// SPDX-License-Identifier: AGPL-3.0-or-later use crate::desktop_modules::{ModuleCoordinate, host_module_platform, publish_renderer_modules}; use crate::paths::{DESKTOP_DIR, DEV_STATE_DIR, ROOT}; use crate::proc::{PNPM_INSTALL_ENV, RunOptions, run_command}; use anyhow::{Context, Result, bail, ensure}; use sha2::{Digest, Sha256}; use std::env; use std::fs; use std::path::{Path, PathBuf}; use std::process::{Command, Stdio}; use std::thread::sleep; use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; pub const DEVELOPMENT_APP_NAME: &str = "Fluxer Development"; pub const DEFAULT_INSTALL_DIR: &str = "/Applications"; const DEVELOPMENT_CHANNEL: &str = "development"; const DEVELOPMENT_BUNDLE_ID: &str = "app.fluxer.development"; const DEVELOPMENT_PROTOCOL_SCHEME: &str = "fluxer-development"; const PRODUCTION_PROTOCOL_SCHEME: &str = "fluxer"; const RENDERER_BUILD_VERSION: &str = "dev"; const RENDERER_SOURCE_PATHS: &[&str] = &["fluxer_app", "packages", "pnpm-lock.yaml"]; const RENDERER_FORBIDDEN_ENTRIES: &[&str] = &["sw.js", "sw.js.map"]; const DEVELOPER_ID_PREFIX: &str = "Developer ID Application: "; const APP_QUIT_TIMEOUT: Duration = Duration::from_secs(15); const LSREGISTER: &str = "/System/Library/Frameworks/CoreServices.framework/Frameworks/LaunchServices.framework/Support/lsregister"; const MACOS_DEV_ELECTRON_USAGE_DESCRIPTIONS: &[(&str, &str)] = &[ ( "NSMicrophoneUsageDescription", "Fluxer needs access to your microphone to enable voice chat features.", ), ( "NSCameraUsageDescription", "Fluxer needs access to your camera to enable video chat features.", ), ( "NSAppleEventsUsageDescription", "Fluxer needs access to Apple Events for automation features.", ), ( "NSAudioCaptureUsageDescription", "Fluxer captures audio from the screen or window you choose to share.", ), ( "NSScreenCaptureUsageDescription", "Fluxer captures the screen or window you choose to share.", ), ]; #[derive(Debug, Clone, PartialEq, Eq)] pub enum MacSigning { DeveloperId(String), AdHoc, } #[derive(Debug, Clone)] pub struct DesktopAppOptions { pub rebuild_renderer: bool, pub ad_hoc: bool, pub install_dir: PathBuf, pub install: bool, pub launch: bool, } fn desktop_app_state_dir() -> PathBuf { DEV_STATE_DIR.join("desktop-app") } fn renderer_cache_dir() -> PathBuf { desktop_app_state_dir().join("renderer") } fn renderer_stamp_path() -> PathBuf { desktop_app_state_dir().join("renderer.stamp") } fn package_output_dir() -> PathBuf { desktop_app_state_dir().join("package") } fn installed_app_path(install_dir: &Path) -> PathBuf { install_dir.join(format!("{DEVELOPMENT_APP_NAME}.app")) } fn app_executable_path(app: &Path) -> PathBuf { app.join("Contents/MacOS").join(DEVELOPMENT_APP_NAME) } pub fn install_desktop_dependencies() -> Result<()> { run_command( &["pnpm", "install", "--frozen-lockfile"], RunOptions { cwd: ROOT.as_path(), env: PNPM_INSTALL_ENV .iter() .map(|(k, v)| ((*k).to_owned(), Some((*v).to_owned()))) .collect(), ..RunOptions::default() }, ) .map(drop) } fn ensure_desktop_dependencies() -> Result<()> { if DESKTOP_DIR .join("node_modules/.bin/electron-builder") .exists() { return Ok(()); } install_desktop_dependencies() } pub fn development_version(now: SystemTime) -> String { let now = chrono::DateTime::::from(now); use chrono::{Datelike, Timelike}; let micro = now.hour() * 10_000 + now.minute() * 100 + now.second(); format!("{}.{}{:02}.{micro}", now.year(), now.month(), now.day()) } fn target_electron_arch() -> Result { if let Ok(arch) = env::var("ELECTRON_ARCH") && !arch.trim().is_empty() { return Ok(arch.trim().to_owned()); } match env::consts::ARCH { "x86_64" => Ok("x64".to_owned()), "aarch64" => Ok("arm64".to_owned()), other => bail!( "cannot build the desktop app on host architecture {other}. Set ELECTRON_ARCH to x64 or arm64" ), } } fn git_output(args: &[&str]) -> Result> { let output = Command::new("git") .args(args) .current_dir(ROOT.as_path()) .env("GIT_OPTIONAL_LOCKS", "0") .stdin(Stdio::null()) .stderr(Stdio::piped()) .output() .with_context(|| format!("failed to run git {}", args.join(" ")))?; if !output.status.success() { bail!( "git {} failed: {}", args.join(" "), String::from_utf8_lossy(&output.stderr).trim_end() ); } Ok(output.stdout) } fn renderer_source_fingerprint() -> Result { let mut hasher = Sha256::new(); hasher.update(format!("{DEVELOPMENT_CHANNEL}\n{RENDERER_BUILD_VERSION}\n")); let mut tree_args = vec!["rev-parse".to_owned()]; tree_args.extend( RENDERER_SOURCE_PATHS .iter() .map(|path| format!("HEAD:{path}")), ); hasher.update(git_output( &tree_args.iter().map(String::as_str).collect::>(), )?); let mut diff_args = vec!["diff", "--no-ext-diff", "--binary", "HEAD", "--"]; diff_args.extend(RENDERER_SOURCE_PATHS); hasher.update(git_output(&diff_args)?); let mut untracked_args = vec!["ls-files", "--others", "--exclude-standard", "-z", "--"]; untracked_args.extend(RENDERER_SOURCE_PATHS); for path in git_output(&untracked_args)? .split(|byte| *byte == 0) .filter(|path| !path.is_empty()) { hasher.update(path); if let Ok(metadata) = fs::metadata(ROOT.join(String::from_utf8_lossy(path).as_ref())) { hasher.update(metadata.len().to_le_bytes()); if let Ok(modified) = metadata.modified() && let Ok(elapsed) = modified.duration_since(UNIX_EPOCH) { hasher.update(elapsed.as_nanos().to_le_bytes()); } } } Ok(hex::encode(hasher.finalize())) } pub fn verify_renderer_tree(root: &Path) -> Result<()> { ensure!( root.join("index.html").is_file(), "renderer at {} has no index.html", root.display() ); ensure!( root.join("assets").is_dir(), "renderer at {} has no assets directory", root.display() ); for entry in RENDERER_FORBIDDEN_ENTRIES { ensure!( !root.join(entry).exists(), "the desktop renderer must not ship a service worker, found {} in {}", entry, root.display() ); } Ok(()) } pub(crate) fn copy_tree( source: &Path, destination: &Path, skip: &dyn Fn(&Path) -> bool, ) -> Result<()> { fs::create_dir_all(destination) .with_context(|| format!("failed to create {}", destination.display()))?; for entry in fs::read_dir(source).with_context(|| format!("failed to read {}", source.display()))? { let entry = entry?; let path = entry.path(); if skip(&path) { continue; } let target = destination.join(entry.file_name()); if entry.file_type()?.is_dir() { copy_tree(&path, &target, skip)?; } else { fs::copy(&path, &target).with_context(|| { format!("failed to copy {} to {}", path.display(), target.display()) })?; } } Ok(()) } pub(crate) fn remove_path(path: &Path) -> Result<()> { match fs::symlink_metadata(path) { Ok(metadata) if metadata.is_dir() => { fs::remove_dir_all(path).with_context(|| format!("failed to remove {}", path.display())) } Ok(_) => { fs::remove_file(path).with_context(|| format!("failed to remove {}", path.display())) } Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()), Err(error) => Err(error).with_context(|| format!("failed to inspect {}", path.display())), } } fn is_source_map(path: &Path) -> bool { path.extension().is_some_and(|extension| extension == "map") } fn ensure_renderer(rebuild: bool) -> Result<(PathBuf, String)> { let cache = renderer_cache_dir(); let stamp_path = renderer_stamp_path(); let fingerprint = renderer_source_fingerprint()?; let cached = fs::read_to_string(&stamp_path).unwrap_or_default(); if !rebuild && cached.trim() == fingerprint && verify_renderer_tree(&cache).is_ok() { println!( "Reusing the renderer built from unchanged sources ({})", &fingerprint[..12] ); return Ok((cache, fingerprint)); } println!("Building the renderer for the development app..."); run_command( &["pnpm", "--filter", "fluxer_app", "build:desktop"], RunOptions { cwd: ROOT.as_path(), env: vec![ ("NODE_ENV".to_owned(), Some("production".to_owned())), ( "PUBLIC_BUILD_VERSION".to_owned(), Some(RENDERER_BUILD_VERSION.to_owned()), ), ( "PUBLIC_RELEASE_CHANNEL".to_owned(), Some(DEVELOPMENT_CHANNEL.to_owned()), ), ], load_default_env: false, ..RunOptions::default() }, )?; let app_dist = ROOT.join("fluxer_app/dist"); verify_renderer_tree(&app_dist)?; remove_path(&stamp_path)?; remove_path(&cache)?; copy_tree(&app_dist, &cache, &is_source_map)?; verify_renderer_tree(&cache)?; fs::write(&stamp_path, format!("{fingerprint}\n")) .with_context(|| format!("failed to write {}", stamp_path.display()))?; Ok((cache, fingerprint)) } struct BuildChannelFileGuard { path: PathBuf, original: Option, } impl BuildChannelFileGuard { fn capture() -> Self { let path = DESKTOP_DIR.join("src/common/BuildChannel.ts"); let original = fs::read_to_string(&path).ok(); Self { path, original } } } impl Drop for BuildChannelFileGuard { fn drop(&mut self) { if let Some(original) = &self.original && fs::read_to_string(&self.path).ok().as_deref() != Some(original.as_str()) { let _ = fs::write(&self.path, original); } } } #[derive(Debug, serde::Deserialize)] #[serde(rename_all = "camelCase")] struct DesktopBuildInfo { build_version: String, build_channel: String, offline_build: bool, modules_enabled: bool, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum RendererDelivery { Offline, Modules, } fn verify_development_build_info( raw: &str, version: &str, delivery: RendererDelivery, ) -> Result<()> { let info: DesktopBuildInfo = serde_json::from_str(raw).context("failed to parse dist/build-info.json")?; ensure!( info.build_channel == DEVELOPMENT_CHANNEL, "the desktop build compiled the {} channel instead of {DEVELOPMENT_CHANNEL}", info.build_channel ); match delivery { RendererDelivery::Offline => { ensure!( info.offline_build && !info.modules_enabled, "the unpackaged development shell must carry its renderer offline" ); } RendererDelivery::Modules => { ensure!( info.modules_enabled && !info.offline_build, "the development app must load its renderer from the module feed like a release" ); } } ensure!( info.build_version == version, "dist/build-info.json records version {} instead of {version}", info.build_version ); Ok(()) } pub fn build_desktop_shell() -> Result<()> { let env = vec![ ( "BUILD_CHANNEL".to_owned(), Some(env::var("BUILD_CHANNEL").unwrap_or_else(|_| "canary".to_owned())), ), ( "PUBLIC_BUILD_VERSION".to_owned(), Some(env::var("PUBLIC_BUILD_VERSION").unwrap_or_else(|_| "dev".to_owned())), ), ( "PUBLIC_RELEASE_CHANNEL".to_owned(), Some(env::var("PUBLIC_RELEASE_CHANNEL").unwrap_or_else(|_| "canary".to_owned())), ), ]; run_command( &["pnpm", "build"], RunOptions { cwd: DESKTOP_DIR.as_path(), env, ..RunOptions::default() }, ) .map(drop) } pub fn build_desktop( rebuild_renderer: bool, skip_native: bool, delivery: RendererDelivery, ) -> Result { ensure_desktop_dependencies()?; let (renderer, fingerprint) = ensure_renderer(rebuild_renderer)?; let version = development_version(SystemTime::now()); let arch = target_electron_arch()?; if delivery == RendererDelivery::Modules { publish_renderer_modules( &renderer, &fingerprint, &version, &ModuleCoordinate { channel: DEVELOPMENT_CHANNEL.to_owned(), platform: host_module_platform()?.to_owned(), arch: arch.clone(), }, )?; } let dist_renderer = DESKTOP_DIR.join("dist/renderer"); remove_path(&dist_renderer)?; copy_tree(&renderer, &dist_renderer, &|_| false)?; if delivery == RendererDelivery::Modules { let version_file = dist_renderer.join("version.json"); fs::write( &version_file, serde_json::to_vec(&serde_json::json!({ "version": version }))?, ) .with_context(|| format!("failed to write {}", version_file.display()))?; } { let _guard = BuildChannelFileGuard::capture(); run_command( &[ "pnpm", "exec", "node", "scripts/build.mjs", "--use-shared-renderer", ], RunOptions { cwd: DESKTOP_DIR.as_path(), env: vec![ ( "BUILD_CHANNEL".to_owned(), Some(DEVELOPMENT_CHANNEL.to_owned()), ), ( "FLUXER_OFFLINE".to_owned(), (delivery == RendererDelivery::Offline).then(|| "1".to_owned()), ), ( "FLUXER_MODULES".to_owned(), (delivery == RendererDelivery::Modules).then(|| "1".to_owned()), ), ( "FLUXER_SKIP_NATIVE".to_owned(), skip_native.then(|| "true".to_owned()), ), ("NODE_ENV".to_owned(), Some("production".to_owned())), ( "FLUXER_DESKTOP_PRODUCTION".to_owned(), Some("true".to_owned()), ), ("PUBLIC_BUILD_VERSION".to_owned(), Some(version.clone())), ("BUILD_VERSION".to_owned(), Some(version.clone())), ( "PUBLIC_RELEASE_CHANNEL".to_owned(), Some(DEVELOPMENT_CHANNEL.to_owned()), ), ( "RELEASE_CHANNEL".to_owned(), Some(DEVELOPMENT_CHANNEL.to_owned()), ), ("ELECTRON_ARCH".to_owned(), Some(arch)), ("WORKDIR".to_owned(), Some(ROOT.display().to_string())), ], load_default_env: false, ..RunOptions::default() }, )?; } let build_info_path = DESKTOP_DIR.join("dist/build-info.json"); let build_info = fs::read_to_string(&build_info_path) .with_context(|| format!("failed to read {}", build_info_path.display()))?; verify_development_build_info(&build_info, &version, delivery)?; Ok(version) } pub fn parse_developer_id_identity(find_identity_output: &str) -> Option { find_identity_output.lines().find_map(|line| { let start = line.find(&format!("\"{DEVELOPER_ID_PREFIX}"))? + 1; let rest = &line[start..]; let name = &rest[..rest.find('"')?]; Some(name.trim_start_matches(DEVELOPER_ID_PREFIX).to_owned()) }) } pub fn resolve_mac_signing(force_ad_hoc: bool) -> MacSigning { if force_ad_hoc { return MacSigning::AdHoc; } if let Ok(name) = env::var("CSC_NAME") && !name.trim().is_empty() { return MacSigning::DeveloperId( name.trim() .trim_start_matches(DEVELOPER_ID_PREFIX) .to_owned(), ); } let output = Command::new("security") .args(["find-identity", "-v", "-p", "codesigning"]) .stdin(Stdio::null()) .stderr(Stdio::null()) .output(); if let Ok(output) = output && let Some(name) = parse_developer_id_identity(&String::from_utf8_lossy(&output.stdout)) { return MacSigning::DeveloperId(name); } MacSigning::AdHoc } fn builder_target_args(arch: &str) -> Vec { if cfg!(target_os = "macos") { vec!["--mac".to_owned(), format!("--{arch}")] } else { vec![format!("--{arch}")] } } pub fn electron_builder_args( output_dir: &Path, arch: &str, signing: &MacSigning, extra_args: &[String], ) -> Vec { let mut args = vec![ "pnpm".to_owned(), "exec".to_owned(), "electron-builder".to_owned(), "--config".to_owned(), "electron-builder.config.cjs".to_owned(), "--dir".to_owned(), format!("-c.directories.output={}", output_dir.display()), "-c.mac.notarize=false".to_owned(), ]; if *signing == MacSigning::AdHoc { args.push("-c.mac.sign.identity=-".to_owned()); } if !extra_args .iter() .any(|arg| arg == "--mac" || arg == "--win" || arg == "--linux") { args.extend(builder_target_args(arch)); } args.extend(extra_args.iter().cloned()); args } pub fn package_desktop( rebuild_renderer: bool, ad_hoc: bool, extra_args: &[String], ) -> Result { let version = build_desktop(rebuild_renderer, false, RendererDelivery::Modules)?; let arch = target_electron_arch()?; let signing = if cfg!(target_os = "macos") { resolve_mac_signing(ad_hoc) } else { MacSigning::AdHoc }; if cfg!(target_os = "macos") { match &signing { MacSigning::DeveloperId(name) => { println!("Signing {DEVELOPMENT_APP_NAME} with Developer ID Application: {name}") } MacSigning::AdHoc => println!( "No Developer ID Application identity found in the keychain, signing {DEVELOPMENT_APP_NAME} ad hoc. macOS will ask for camera, microphone and screen access again after every rebuild. Set CSC_NAME to a Developer ID identity to keep the grants." ), } } let output_dir = package_output_dir(); remove_path(&output_dir)?; let mut env = vec![ ( "BUILD_CHANNEL".to_owned(), Some(DEVELOPMENT_CHANNEL.to_owned()), ), ("VERSION".to_owned(), Some(version.clone())), ("ELECTRON_ARCH".to_owned(), Some(arch.clone())), ("NODE_ENV".to_owned(), Some("production".to_owned())), ]; match &signing { MacSigning::DeveloperId(name) => env.push(("CSC_NAME".to_owned(), Some(name.clone()))), MacSigning::AdHoc => env.push(( "CSC_IDENTITY_AUTO_DISCOVERY".to_owned(), Some("false".to_owned()), )), } let args = electron_builder_args(&output_dir, &arch, &signing, extra_args); let refs: Vec<_> = args.iter().map(String::as_str).collect(); run_command( &refs, RunOptions { cwd: DESKTOP_DIR.as_path(), env, load_default_env: false, ..RunOptions::default() }, )?; if !cfg!(target_os = "macos") { println!( "Packaged {DEVELOPMENT_APP_NAME} {version} into {}", output_dir.display() ); return Ok(output_dir); } let app = find_packaged_app(&output_dir)?; verify_packaged_app(&app)?; println!( "Packaged {DEVELOPMENT_APP_NAME} {version} at {}", app.display() ); Ok(app) } fn find_packaged_app(output_dir: &Path) -> Result { let bundle_name = format!("{DEVELOPMENT_APP_NAME}.app"); if output_dir.is_dir() { for entry in fs::read_dir(output_dir)? { let candidate = entry?.path().join(&bundle_name); if candidate.is_dir() { return Ok(candidate); } } } bail!( "no packaged {bundle_name} under {}. Run `pnpm dev:desktop:package` first", output_dir.display() ) } fn plist_value(info_plist: &Path, key: &str, format: &str) -> Result { let output = Command::new("plutil") .args(["-extract", key, format, "-o", "-"]) .arg(info_plist) .stdin(Stdio::null()) .output() .with_context(|| format!("failed to read {key} from {}", info_plist.display()))?; if !output.status.success() { bail!( "{} has no {key}: {}", info_plist.display(), String::from_utf8_lossy(&output.stderr).trim_end() ); } Ok(String::from_utf8_lossy(&output.stdout).trim().to_owned()) } pub fn verify_url_schemes(url_types_json: &str) -> Result<()> { let url_types: Vec = serde_json::from_str(url_types_json).context("CFBundleURLTypes is not a JSON array")?; let schemes: Vec<&str> = url_types .iter() .filter_map(|entry| entry.get("CFBundleURLSchemes")?.as_array()) .flatten() .filter_map(serde_json::Value::as_str) .collect(); ensure!( !schemes .iter() .any(|scheme| scheme.eq_ignore_ascii_case(PRODUCTION_PROTOCOL_SCHEME)), "{DEVELOPMENT_APP_NAME} must not register {PRODUCTION_PROTOCOL_SCHEME}://, it registers {schemes:?}" ); ensure!( schemes.contains(&DEVELOPMENT_PROTOCOL_SCHEME), "{DEVELOPMENT_APP_NAME} must register {DEVELOPMENT_PROTOCOL_SCHEME}://, it registers {schemes:?}" ); Ok(()) } fn verify_packaged_app(app: &Path) -> Result<()> { let info_plist = app.join("Contents/Info.plist"); let bundle_id = plist_value(&info_plist, "CFBundleIdentifier", "raw")?; ensure!( bundle_id == DEVELOPMENT_BUNDLE_ID, "{} has bundle id {bundle_id}, expected {DEVELOPMENT_BUNDLE_ID}", app.display() ); verify_url_schemes(&plist_value(&info_plist, "CFBundleURLTypes", "json")?)?; ensure!( app_executable_path(app).is_file(), "{} has no {DEVELOPMENT_APP_NAME} executable", app.display() ); let status = Command::new("codesign") .args(["--verify", "--deep", "--strict"]) .arg(app) .status() .context("failed to run codesign --verify")?; ensure!( status.success(), "{} failed code signature verification", app.display() ); Ok(()) } pub fn parse_pids_for_executable(ps_output: &str, executable: &str) -> Vec { ps_output .lines() .filter_map(|line| { let line = line.trim_start(); let (pid, command) = line.split_once(char::is_whitespace)?; (command.trim() == executable) .then(|| pid.parse().ok()) .flatten() }) .collect() } fn running_pids(executable: &Path) -> Result> { let output = Command::new("ps") .args(["-axww", "-o", "pid=,comm="]) .stdin(Stdio::null()) .output() .context("failed to list running processes")?; Ok(parse_pids_for_executable( &String::from_utf8_lossy(&output.stdout), &executable.to_string_lossy(), )) } #[cfg(unix)] fn process_alive(pid: i32) -> bool { unsafe { libc::kill(pid, 0) == 0 } } #[cfg(unix)] fn signal_process(pid: i32, signal: libc::c_int) { unsafe { libc::kill(pid, signal); } } #[cfg(unix)] fn stop_running_app(app: &Path) -> Result { let executable = app_executable_path(app); let pids = running_pids(&executable)?; if pids.is_empty() { return Ok(false); } for pid in &pids { println!("Asking the running {DEVELOPMENT_APP_NAME} (pid {pid}) to quit..."); signal_process(*pid, libc::SIGTERM); } let deadline = Instant::now() + APP_QUIT_TIMEOUT; while pids.iter().any(|pid| process_alive(*pid)) && Instant::now() < deadline { sleep(Duration::from_millis(200)); } for pid in pids.iter().filter(|pid| process_alive(**pid)) { println!("{DEVELOPMENT_APP_NAME} (pid {pid}) did not quit in time, stopping it"); signal_process(*pid, libc::SIGKILL); } Ok(true) } #[cfg(not(unix))] fn stop_running_app(_app: &Path) -> Result { Ok(false) } #[cfg(target_os = "macos")] fn swap_paths(first: &Path, second: &Path) -> Result<()> { use std::ffi::CString; use std::os::unix::ffi::OsStrExt; let first_c = CString::new(first.as_os_str().as_bytes())?; let second_c = CString::new(second.as_os_str().as_bytes())?; let rc = unsafe { libc::renamex_np(first_c.as_ptr(), second_c.as_ptr(), libc::RENAME_SWAP) }; if rc != 0 { return Err(std::io::Error::last_os_error()).with_context(|| { format!( "failed to swap {} with {}", first.display(), second.display() ) }); } Ok(()) } #[cfg(not(target_os = "macos"))] fn swap_paths(first: &Path, second: &Path) -> Result<()> { let parked = second.with_extension("app.previous"); remove_path(&parked)?; fs::rename(second, &parked)?; fs::rename(first, second)?; fs::rename(&parked, first)?; Ok(()) } fn ensure_host_macos(command: &str) -> Result<()> { if !cfg!(target_os = "macos") || Path::new("/.dockerenv").exists() { bail!( "`desktop {command}` installs a macOS app. Run it on the Mac host, not inside the devcontainer." ); } Ok(()) } pub fn install_desktop_app( source: Option<&Path>, install_dir: &Path, launch: bool, ) -> Result { ensure_host_macos("install")?; let source = match source { Some(source) => source.to_path_buf(), None => find_packaged_app(&package_output_dir())?, }; verify_packaged_app(&source)?; let destination = installed_app_path(install_dir); let staging = install_dir.join(format!(".{DEVELOPMENT_APP_NAME}.app.installing")); remove_path(&staging)?; let status = Command::new("ditto") .arg(&source) .arg(&staging) .status() .context("failed to run ditto")?; ensure!( status.success(), "failed to copy {} to {}", source.display(), staging.display() ); stop_running_app(&destination)?; if destination.exists() { swap_paths(&staging, &destination)?; remove_path(&staging)?; } else { fs::rename(&staging, &destination) .with_context(|| format!("failed to move {} into place", staging.display()))?; } let _ = Command::new(LSREGISTER) .args(["-f"]) .arg(&destination) .stdin(Stdio::null()) .stdout(Stdio::null()) .stderr(Stdio::null()) .status(); println!( "Installed {DEVELOPMENT_APP_NAME} at {}", destination.display() ); if launch { let status = Command::new("open") .arg(&destination) .status() .context("failed to open the installed app")?; ensure!(status.success(), "failed to open {}", destination.display()); } Ok(destination) } pub fn desktop_app(options: &DesktopAppOptions) -> Result<()> { if options.install { ensure_host_macos("app")?; } let app = package_desktop(options.rebuild_renderer, options.ad_hoc, &[])?; if options.install { install_desktop_app(Some(&app), &options.install_dir, options.launch)?; } Ok(()) } pub fn electron_args(args: &[String]) -> Vec { let mut runtime_args = Vec::new(); if cfg!(target_os = "linux") && Path::new("/.dockerenv").exists() && env::var("FLUXER_ELECTRON_NO_SANDBOX").as_deref() != Ok("0") { runtime_args.push("--no-sandbox".to_owned()); } if linux_wayland_session() && !args.iter().any(|arg| arg.starts_with("--ozone-platform")) { runtime_args.push("--ozone-platform=wayland".to_owned()); } runtime_args.extend(args.iter().cloned()); runtime_args } fn linux_wayland_session() -> bool { if !cfg!(target_os = "linux") { return false; } let Some(display) = env::var_os("WAYLAND_DISPLAY") else { return false; }; if display.is_empty() { return false; } let display_path = PathBuf::from(&display); let socket = if display_path.is_absolute() { display_path } else { let Some(runtime_dir) = env::var_os("XDG_RUNTIME_DIR") else { return false; }; Path::new(&runtime_dir).join(display_path) }; socket.exists() } pub fn electron_command(args: &[String]) -> Vec { let mut command = base_electron_command(); command.extend(electron_args(args)); command } fn base_electron_command() -> Vec { if cfg!(target_os = "macos") && !Path::new("/.dockerenv").exists() { let electron_binary = dev_electron_binary_path(); if electron_binary.is_file() && let Ok(launcher) = env::current_exe() { return disclaimed_electron_command(&launcher, &electron_binary); } } vec![ "pnpm".to_owned(), "exec".to_owned(), "electron".to_owned(), ".".to_owned(), ] } fn disclaimed_electron_command(launcher: &Path, electron_binary: &Path) -> Vec { vec![ launcher.to_string_lossy().into_owned(), "desktop".to_owned(), "exec-disclaimed".to_owned(), electron_binary.to_string_lossy().into_owned(), ".".to_owned(), ] } pub fn run_desktop( extra_args: &[String], build: bool, rebuild_renderer: bool, skip_native: bool, ) -> Result<()> { if build { build_desktop(rebuild_renderer, skip_native, RendererDelivery::Offline)?; } else { ensure_desktop_dependencies()?; } patch_macos_dev_electron_info_plist()?; let mut args = vec!["--fluxer-log-renderer-console".to_owned()]; args.extend(extra_args.iter().cloned()); let command = electron_command(&args); let refs: Vec<_> = command.iter().map(String::as_str).collect(); println!( "Starting the unpackaged {DEVELOPMENT_APP_NAME} shell. It shares the installed app's profile, so only one of them runs at a time." ); run_command( &refs, RunOptions { cwd: DESKTOP_DIR.as_path(), load_default_env: false, ..RunOptions::default() }, ) .map(drop) } fn patch_macos_dev_electron_info_plist() -> Result<()> { if !cfg!(target_os = "macos") || Path::new("/.dockerenv").exists() { return Ok(()); } let app_bundle = dev_electron_app_bundle_path(); let info_plist = dev_electron_info_plist_path(); if !info_plist.is_file() { bail!( "missing dev Electron Info.plist at {}. Run `pnpm dev:desktop:deps` first", info_plist.display() ); } let mut changed = false; for (key, value) in MACOS_DEV_ELECTRON_USAGE_DESCRIPTIONS { changed |= set_or_add_plist_string(&info_plist, key, value)?; } if changed { println!( "Patched dev Electron Info.plist for macOS capture permissions: {}", info_plist.display() ); } if changed || !codesign_verify(&app_bundle) { codesign_ad_hoc(&app_bundle)?; } Ok(()) } fn dev_electron_app_bundle_path() -> PathBuf { DESKTOP_DIR.join("node_modules/electron/dist/Electron.app") } fn dev_electron_info_plist_path() -> PathBuf { dev_electron_app_bundle_path().join("Contents/Info.plist") } fn dev_electron_binary_path() -> PathBuf { dev_electron_app_bundle_path().join("Contents/MacOS/Electron") } fn set_or_add_plist_string(info_plist: &Path, key: &str, value: &str) -> Result { if read_plist_string(info_plist, key)?.as_deref() == Some(value) { return Ok(false); } if plist_key_exists(info_plist, key)? { run_plist_buddy(info_plist, &format!("Set :{key} {value}"))?; } else { run_plist_buddy(info_plist, &format!("Add :{key} string {value}"))?; } Ok(true) } fn plist_key_exists(info_plist: &Path, key: &str) -> Result { Ok(read_plist_string(info_plist, key)?.is_some()) } fn read_plist_string(info_plist: &Path, key: &str) -> Result> { let output = Command::new("/usr/libexec/PlistBuddy") .args(["-c", &format!("Print :{key}")]) .arg(info_plist) .stdout(Stdio::piped()) .stderr(Stdio::piped()) .output() .with_context(|| format!("failed to read {key} from {}", info_plist.display()))?; if !output.status.success() { return Ok(None); } Ok(Some( String::from_utf8_lossy(&output.stdout) .trim_end() .to_owned(), )) } fn run_plist_buddy(info_plist: &Path, command: &str) -> Result<()> { let output = Command::new("/usr/libexec/PlistBuddy") .args(["-c", command]) .arg(info_plist) .stdout(Stdio::piped()) .stderr(Stdio::piped()) .output() .with_context(|| format!("failed to update {}", info_plist.display()))?; if output.status.success() { return Ok(()); } bail!( "failed to update {}: {}", info_plist.display(), String::from_utf8_lossy(&output.stderr).trim_end() ) } fn codesign_verify(app_bundle: &Path) -> bool { Command::new("codesign") .args(["--verify", "--deep", "--strict"]) .arg(app_bundle) .stdin(Stdio::null()) .stdout(Stdio::null()) .stderr(Stdio::null()) .status() .map(|status| status.success()) .unwrap_or(false) } fn codesign_ad_hoc(app_bundle: &Path) -> Result<()> { println!("Re-signing dev Electron.app after macOS permission plist patch..."); let output = Command::new("codesign") .args(["--force", "--deep", "--sign", "-"]) .arg(app_bundle) .stdout(Stdio::piped()) .stderr(Stdio::piped()) .output() .with_context(|| format!("failed to re-sign {}", app_bundle.display()))?; if output.status.success() { return Ok(()); } bail!( "failed to re-sign {}: {}", app_bundle.display(), String::from_utf8_lossy(&output.stderr).trim_end() ) }