Desktop: Bundle for Mac and Windows (#3297)

* add bundle for mac os and windows

* Fix bundle name

This name gets used as the display name of the app on mac os, shorter name looks better and is more consistent.

* preserve std out by running bin directly

* bundle placeholder on linux

* fix linux
This commit is contained in:
Timon 2025-10-23 10:39:08 +00:00 committed by GitHub
parent 557df6917d
commit 7fbe440e73
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
21 changed files with 429 additions and 15 deletions

View File

@ -10,3 +10,6 @@ rustflags = [
"link-arg=--max-memory=4294967296",
"--cfg=web_sys_unstable_apis",
]
[env]
CARGO_WORKSPACE_DIR = { value = "", relative = true }

56
Cargo.lock generated
View File

@ -2285,7 +2285,15 @@ dependencies = [
"wgpu",
"windows",
"winit",
"winres",
]
[[package]]
name = "graphite-desktop-bundle"
version = "0.0.0"
dependencies = [
"cef-dll-sys",
"plist",
"serde",
]
[[package]]
@ -2295,6 +2303,28 @@ dependencies = [
"include_dir",
]
[[package]]
name = "graphite-desktop-platform-linux"
version = "0.0.0"
dependencies = [
"graphite-desktop",
]
[[package]]
name = "graphite-desktop-platform-mac"
version = "0.0.0"
dependencies = [
"graphite-desktop",
]
[[package]]
name = "graphite-desktop-platform-win"
version = "0.0.0"
dependencies = [
"graphite-desktop",
"winres",
]
[[package]]
name = "graphite-desktop-wrapper"
version = "0.1.0"
@ -4086,6 +4116,19 @@ version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c"
[[package]]
name = "plist"
version = "1.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "740ebea15c5d1428f910cd1a5f52cebf8d25006245ed8ade92702f4943d91e07"
dependencies = [
"base64",
"indexmap",
"quick-xml 0.38.3",
"serde",
"time",
]
[[package]]
name = "plotters"
version = "0.3.7"
@ -4304,6 +4347,15 @@ dependencies = [
"memchr",
]
[[package]]
name = "quick-xml"
version = "0.38.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "42a232e7487fc2ef313d96dde7948e7a3c05101870d8985e4fd8d26aedd27b89"
dependencies = [
"memchr",
]
[[package]]
name = "quinn"
version = "0.11.9"
@ -6523,7 +6575,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "54cb1e9dc49da91950bdfd8b848c49330536d9d1fb03d4bfec8cae50caa50ae3"
dependencies = [
"proc-macro2",
"quick-xml",
"quick-xml 0.37.5",
"quote",
]

View File

@ -3,6 +3,10 @@ members = [
"desktop",
"desktop/wrapper",
"desktop/embedded-resources",
"desktop/bundle",
"desktop/platform/linux",
"desktop/platform/mac",
"desktop/platform/win",
"editor",
"frontend/wasm",
"libraries/dyn-any",

View File

@ -73,6 +73,3 @@ core-foundation = { version = "0.10", optional = true }
# Linux-specific dependencies
[target.'cfg(target_os = "linux")'.dependencies]
libc = { version = "0.2", optional = true }
[target.'cfg(target_os = "windows")'.build-dependencies]
winres = "0.1"

16
desktop/bundle/Cargo.toml Normal file
View File

@ -0,0 +1,16 @@
[package]
name = "graphite-desktop-bundle"
version = "0.0.0"
description = "Graphite Desktop Bundle"
authors = ["Graphite Authors <contact@graphite.rs>"]
license = "Apache-2.0"
repository = ""
edition = "2024"
rust-version = "1.87"
[dependencies]
cef-dll-sys = { workspace = true }
[target.'cfg(target_os = "macos")'.dependencies]
serde = { workspace = true }
plist = { version = "*" }

10
desktop/bundle/build.rs Normal file
View File

@ -0,0 +1,10 @@
fn main() {
println!("cargo:rerun-if-env-changed=CARGO_PROFILE");
println!("cargo:rerun-if-env-changed=PROFILE");
let profile = std::env::var("CARGO_PROFILE").or_else(|_| std::env::var("PROFILE")).unwrap();
println!("cargo:rustc-env=CARGO_PROFILE={profile}");
println!("cargo:rerun-if-env-changed=DEP_CEF_DLL_WRAPPER_CEF_DIR");
let cef_dir = std::env::var("DEP_CEF_DLL_WRAPPER_CEF_DIR").unwrap();
println!("cargo:rustc-env=CEF_PATH={cef_dir}");
}

View File

@ -0,0 +1,66 @@
use std::error::Error;
use std::fs;
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
pub(crate) const APP_NAME: &str = "Graphite";
fn profile_name() -> &'static str {
let mut profile = env!("CARGO_PROFILE");
if profile == "debug" {
profile = "dev";
}
profile
}
pub(crate) fn profile_path() -> PathBuf {
PathBuf::from(env!("CARGO_WORKSPACE_DIR")).join(format!("target/{}", env!("CARGO_PROFILE")))
}
pub(crate) fn cef_path() -> PathBuf {
PathBuf::from(env!("CEF_PATH"))
}
pub(crate) fn build_bin(package: &str, bin: Option<&str>) -> Result<PathBuf, Box<dyn Error>> {
let profile = &profile_name();
let mut args = vec!["build", "--package", package, "--profile", profile];
if let Some(bin) = bin {
args.push("--bin");
args.push(bin);
}
run_command("cargo", &args)?;
let profile_path = profile_path();
let mut bin_path = if let Some(bin) = bin { profile_path.join(bin) } else { profile_path.join(package) };
if cfg!(target_os = "windows") {
bin_path.set_extension("exe");
}
Ok(bin_path)
}
pub(crate) fn run_command(program: &str, args: &[&str]) -> Result<(), Box<dyn std::error::Error>> {
let status = Command::new(program).args(args).stdout(Stdio::inherit()).stderr(Stdio::inherit()).status()?;
if !status.success() {
std::process::exit(1);
}
Ok(())
}
pub(crate) fn clean_dir(dir: &Path) {
if dir.exists() {
fs::remove_dir_all(dir).unwrap();
}
fs::create_dir_all(dir).unwrap();
}
pub(crate) fn copy_dir(src: &Path, dst: &Path) {
fs::create_dir_all(dst).unwrap();
for entry in fs::read_dir(src).unwrap() {
let entry = entry.unwrap();
let dst_path = dst.join(entry.file_name());
if entry.file_type().unwrap().is_dir() {
copy_dir(&entry.path(), &dst_path);
} else {
fs::copy(entry.path(), &dst_path).unwrap();
}
}
}

View File

@ -0,0 +1,23 @@
use std::error::Error;
use crate::common::*;
const PACKAGE: &str = "graphite-desktop-platform-linux";
pub fn main() -> Result<(), Box<dyn Error>> {
let app_bin = build_bin(PACKAGE, None)?;
// TODO: Implement bundling for linux
// TODO: Consider adding more useful cli
if std::env::args().any(|a| a == "open") {
run_command(&app_bin.to_string_lossy(), &[]).expect("failed to open app");
} else {
println!("Binary built and placed at {}", app_bin.to_string_lossy());
eprintln!("Bundling for Linux is not yet implemented.");
eprintln!("You can still start the app with the `open` subcommand. `cargo run -p graphite-desktop-bundle -- open`");
std::process::exit(1);
}
Ok(())
}

135
desktop/bundle/src/mac.rs Normal file
View File

@ -0,0 +1,135 @@
use std::collections::HashMap;
use std::error::Error;
use std::fs;
use std::path::{Path, PathBuf};
use crate::common::*;
const APP_ID: &str = "rs.graphite.GraphiteEditor";
const PACKAGE: &str = "graphite-desktop-platform-mac";
const HELPER_BIN: &str = "graphite-desktop-platform-mac-helper";
const EXEC_PATH: &str = "Contents/MacOS";
const FRAMEWORKS_PATH: &str = "Contents/Frameworks";
const RESOURCES_PATH: &str = "Contents/Resources";
const FRAMEWORK: &str = "Chromium Embedded Framework.framework";
pub fn main() -> Result<(), Box<dyn Error>> {
let app_bin = build_bin(PACKAGE, None)?;
let helper_bin = build_bin(PACKAGE, Some(HELPER_BIN))?;
let profile_path = profile_path();
let app_dir = bundle(&profile_path, &app_bin, &helper_bin);
// TODO: Consider adding more useful cli
if std::env::args().any(|a| a == "open") {
let executable_path = app_dir.join(EXEC_PATH).join(APP_NAME);
run_command(&executable_path.to_string_lossy(), &[]).expect("failed to open app");
}
Ok(())
}
fn bundle(out_dir: &Path, app_bin: &Path, helper_bin: &Path) -> PathBuf {
let app_dir = out_dir.join(APP_NAME).with_extension("app");
clean_dir(&app_dir);
create_app(&app_dir, APP_ID, APP_NAME, app_bin, false);
for helper_type in [None, Some("GPU"), Some("Renderer"), Some("Plugin"), Some("Alerts")] {
let helper_id_suffix = helper_type.map(|t| format!(".{t}")).unwrap_or_default();
let helper_id = format!("{APP_ID}.helper{helper_id_suffix}");
let helper_name_suffix = helper_type.map(|t| format!(" ({t})")).unwrap_or_default();
let helper_name = format!("{APP_NAME} Helper{helper_name_suffix}");
let helper_app_dir = app_dir.join(FRAMEWORKS_PATH).join(&helper_name).with_extension("app");
create_app(&helper_app_dir, &helper_id, &helper_name, helper_bin, true);
}
copy_dir(&cef_path().join(FRAMEWORK), &app_dir.join(FRAMEWORKS_PATH).join(FRAMEWORK));
app_dir
}
fn create_app(app_dir: &Path, id: &str, name: &str, bin: &Path, is_helper: bool) {
fs::create_dir_all(app_dir.join(EXEC_PATH)).unwrap();
let app_contents_dir: &Path = &app_dir.join("Contents");
for p in &[EXEC_PATH, RESOURCES_PATH, FRAMEWORKS_PATH] {
fs::create_dir_all(app_contents_dir.join(p)).unwrap();
}
create_info_plist(app_contents_dir, id, name, is_helper).unwrap();
fs::copy(bin, app_dir.join(EXEC_PATH).join(name)).unwrap();
}
fn create_info_plist(dir: &Path, id: &str, exec_name: &str, is_helper: bool) -> Result<(), Box<dyn std::error::Error>> {
let info = InfoPlist {
cf_bundle_development_region: "en".to_string(),
cf_bundle_display_name: exec_name.to_string(),
cf_bundle_executable: exec_name.to_string(),
cf_bundle_identifier: id.to_string(),
cf_bundle_info_dictionary_version: "6.0".to_string(),
cf_bundle_name: exec_name.to_string(),
cf_bundle_package_type: "APPL".to_string(),
cf_bundle_signature: "????".to_string(),
cf_bundle_version: "0.0.0".to_string(),
cf_bundle_short_version_string: "0.0".to_string(),
ls_environment: [("MallocNanoZone".to_string(), "0".to_string())].iter().cloned().collect(),
ls_file_quarantine_enabled: true,
ls_minimum_system_version: "11.0".to_string(),
ls_ui_element: if is_helper { Some("1".to_string()) } else { None },
ns_bluetooth_always_usage_description: exec_name.to_string(),
ns_supports_automatic_graphics_switching: true,
ns_web_browser_publickey_credential_usage_description: exec_name.to_string(),
ns_camera_usage_description: exec_name.to_string(),
ns_microphone_usage_description: exec_name.to_string(),
};
let plist_file = dir.join("Info.plist");
plist::to_file_xml(plist_file, &info)?;
Ok(())
}
#[derive(serde::Serialize)]
struct InfoPlist {
#[serde(rename = "CFBundleDevelopmentRegion")]
cf_bundle_development_region: String,
#[serde(rename = "CFBundleDisplayName")]
cf_bundle_display_name: String,
#[serde(rename = "CFBundleExecutable")]
cf_bundle_executable: String,
#[serde(rename = "CFBundleIdentifier")]
cf_bundle_identifier: String,
#[serde(rename = "CFBundleInfoDictionaryVersion")]
cf_bundle_info_dictionary_version: String,
#[serde(rename = "CFBundleName")]
cf_bundle_name: String,
#[serde(rename = "CFBundlePackageType")]
cf_bundle_package_type: String,
#[serde(rename = "CFBundleSignature")]
cf_bundle_signature: String,
#[serde(rename = "CFBundleVersion")]
cf_bundle_version: String,
#[serde(rename = "CFBundleShortVersionString")]
cf_bundle_short_version_string: String,
#[serde(rename = "LSEnvironment")]
ls_environment: HashMap<String, String>,
#[serde(rename = "LSFileQuarantineEnabled")]
ls_file_quarantine_enabled: bool,
#[serde(rename = "LSMinimumSystemVersion")]
ls_minimum_system_version: String,
#[serde(rename = "LSUIElement")]
ls_ui_element: Option<String>,
#[serde(rename = "NSBluetoothAlwaysUsageDescription")]
ns_bluetooth_always_usage_description: String,
#[serde(rename = "NSSupportsAutomaticGraphicsSwitching")]
ns_supports_automatic_graphics_switching: bool,
#[serde(rename = "NSWebBrowserPublicKeyCredentialUsageDescription")]
ns_web_browser_publickey_credential_usage_description: String,
#[serde(rename = "NSCameraUsageDescription")]
ns_camera_usage_description: String,
#[serde(rename = "NSMicrophoneUsageDescription")]
ns_microphone_usage_description: String,
}

View File

@ -0,0 +1,17 @@
mod common;
#[cfg(target_os = "linux")]
mod linux;
#[cfg(target_os = "macos")]
mod mac;
#[cfg(target_os = "windows")]
mod win;
fn main() {
#[cfg(target_os = "linux")]
linux::main().unwrap();
#[cfg(target_os = "macos")]
mac::main().unwrap();
#[cfg(target_os = "windows")]
win::main().unwrap();
}

35
desktop/bundle/src/win.rs Normal file
View File

@ -0,0 +1,35 @@
use std::error::Error;
use std::fs;
use std::path::{Path, PathBuf};
use crate::common::*;
const PACKAGE: &str = "graphite-desktop-platform-win";
const EXECUTABLE: &str = "graphite-editor.exe";
pub fn main() -> Result<(), Box<dyn Error>> {
let app_bin = build_bin(PACKAGE, None)?;
let executable = bundle(&profile_path(), &app_bin);
// TODO: Consider adding more useful cli
if std::env::args().any(|a| a == "open") {
let executable_path = executable.to_string_lossy();
run_command(&executable_path, &[]).expect("failed to open app")
}
Ok(())
}
fn bundle(out_dir: &Path, app_bin: &Path) -> PathBuf {
let app_dir = out_dir.join(APP_NAME);
clean_dir(&app_dir);
copy_dir(&cef_path(), &app_dir);
let bin_path = app_dir.join(EXECUTABLE);
fs::copy(app_bin, &bin_path).unwrap();
bin_path
}

View File

@ -0,0 +1,12 @@
[package]
name = "graphite-desktop-platform-linux"
version = "0.0.0"
description = "Graphite Desktop Platform Linux"
authors = ["Graphite Authors <contact@graphite.rs>"]
license = "Apache-2.0"
repository = ""
edition = "2024"
rust-version = "1.87"
[dependencies]
graphite-desktop = { path = "../.." }

View File

@ -0,0 +1,3 @@
fn main() {
graphite_desktop::start();
}

View File

@ -0,0 +1,16 @@
[package]
name = "graphite-desktop-platform-mac"
version = "0.0.0"
description = "Graphite Desktop Platform Mac"
authors = ["Graphite Authors <contact@graphite.rs>"]
license = "Apache-2.0"
repository = ""
edition = "2024"
rust-version = "1.87"
[[bin]]
name = "graphite-desktop-platform-mac-helper"
path = "src/helper.rs"
[dependencies]
graphite-desktop = { path = "../.." }

View File

@ -0,0 +1,3 @@
fn main() {
graphite_desktop::start_helper();
}

View File

@ -0,0 +1,3 @@
fn main() {
graphite_desktop::start();
}

View File

@ -0,0 +1,15 @@
[package]
name = "graphite-desktop-platform-win"
version = "0.0.0"
description = "Graphite Desktop Platform Windows"
authors = ["Graphite Authors <contact@graphite.rs>"]
license = "Apache-2.0"
repository = ""
edition = "2024"
rust-version = "1.87"
[dependencies]
graphite-desktop = { path = "../.." }
[target.'cfg(target_os = "windows")'.build-dependencies]
winres = "0.1"

View File

@ -2,7 +2,7 @@ fn main() {
#[cfg(target_os = "windows")]
{
let mut res = winres::WindowsResource::new();
res.set_icon("assets/graphite-icon-color.ico");
res.set_icon("../../assets/graphite-icon-color.ico");
res.compile().expect("Failed to compile Windows resources");
}
}

View File

@ -0,0 +1,4 @@
#![windows_subsystem = "windows"]
fn main() {
graphite_desktop::start();
}

View File

@ -1,10 +1,10 @@
pub(crate) static APP_NAME: &str = "Graphite";
pub(crate) static APP_ID: &str = "rs.graphite.GraphiteEditor";
pub(crate) const APP_NAME: &str = "Graphite";
pub(crate) const APP_ID: &str = "rs.graphite.GraphiteEditor";
pub(crate) static APP_DIRECTORY_NAME: &str = "graphite-editor";
pub(crate) static APP_STATE_FILE_NAME: &str = "state.ron";
pub(crate) static APP_PREFERENCES_FILE_NAME: &str = "preferences.ron";
pub(crate) static APP_DOCUMENTS_DIRECTORY_NAME: &str = "documents";
pub(crate) const APP_DIRECTORY_NAME: &str = "graphite-editor";
pub(crate) const APP_STATE_FILE_NAME: &str = "state.ron";
pub(crate) const APP_PREFERENCES_FILE_NAME: &str = "preferences.ron";
pub(crate) const APP_DOCUMENTS_DIRECTORY_NAME: &str = "documents";
// CEF configuration constants
pub(crate) const CEF_WINDOWLESS_FRAME_RATE: i32 = 60;

View File

@ -4,15 +4,15 @@
"scripts": {
"---------- DEV SERVER ----------": "",
"start": "cd frontend && npm start",
"start-desktop": "cd frontend && npm run build-native-dev && cargo run -p graphite-desktop",
"start-desktop": "cd frontend && npm run build-native-dev && cargo run -p graphite-desktop-bundle -- open",
"profiling": "cd frontend && npm run profiling",
"production": "cd frontend && npm run production",
"---------- BUILDS ----------": "",
"build-dev": "cd frontend && npm run build-dev",
"build-profiling": "cd frontend && npm run build-profiling",
"build": "cd frontend && npm run build",
"build-desktop": "cd frontend && npm run build-native && cargo build -r -p graphite-desktop",
"build-desktop-dev": "cd frontend && npm run build-native-dev && cargo build -p graphite-desktop",
"build-desktop": "cd frontend && npm run build-native && cargo run -r -p graphite-desktop-bundle",
"build-desktop-dev": "cd frontend && npm run build-native-dev && cargo run -p graphite-desktop-bundle",
"---------- UTILITIES ----------": "",
"lint": "cd frontend && npm run lint",
"lint-fix": "cd frontend && npm run lint-fix"