/
githubmirror
/
deno
Обзор
Документация
Войти
/
githubmirror
/
deno
Код
Запросы
0
Пакеты
0
Релизы
0
Аналитика
Безопасность
main
cli/tools/desktop.rs
7 594 строки
259 KB
Leo Kettmeir
fix(desktop): run Vite-based HMR dev servers inside the desktop runtime (#36488)
08 авг 2026, 11:48
Не верифицирован
08 авг 2026, 11:48
98dc759
Код
Авторство
О чём код?
// Copyright 2018-2026 the Deno authors. MIT license. use std::net::SocketAddr; use std::path::Path; use std::path::PathBuf; use std::sync::Arc; use deno_config::deno_json::DesktopConfig; use deno_core::anyhow::Context; use deno_core::anyhow::bail; use deno_core::error::AnyError; use deno_core::url::Url; use deno_terminal::colors; use sha2::Digest; use crate::args::CliOptions; use crate::args::CompileFlags; use crate::args::DenoSubcommand; use crate::args::DesktopFlags; use crate::args::Flags; use crate::args::TypeCheckMode; use crate::factory::CliFactory; use crate::http_util::HttpClientProvider; use crate::util::progress_bar::ProgressBar; use crate::util::progress_bar::ProgressBarStyle; /// Version of the `laufey` capi crate pinned in the workspace Cargo.lock. /// Populated by `cli/build.rs` and used to resolve matching prebuilt backend /// binaries from `github.com/littledivy/laufey/releases/tag/v{LAUFEY_VERSION}`. const LAUFEY_VERSION: &str = env!("LAUFEY_VERSION"); /// Rustc target triple the deno binary was built for. Used as the default /// target when selecting a prebuilt laufey backend archive. const LAUFEY_NATIVE_TARGET: &str = env!("TARGET"); /// Trust anchor for LAUFEY backend downloads: SHA-256 digests of every archive /// for the pinned `LAUFEY_VERSION`. Checked into the repo so `SHA256SUMS` does /// not need to be fetched (and trusted) at runtime — that file's integrity /// previously rested on TOFU against the GitHub releases page. See /// `cli/laufey_sums.lock` for the format. const LAUFEY_PINNED_SUMS: &str = include_str!("../laufey_sums.lock"); pub async fn desktop( flags: Flags, mut desktop_flags: DesktopFlags, ) -> Result<(), AnyError> { log::warn!( "{}", colors::yellow_bold("⚠ deno desktop is experimental and subject to change") ); let all_targets = desktop_flags.all_targets; let config_flags = flags.clone(); let factory = CliFactory::from_flags(Arc::new(config_flags)); let cli_options = factory.cli_options()?; let desktop_config = cli_options.start_dir.to_desktop_config()?.clone(); let laufey_resolver = Arc::new(LaufeyBackendResolver::new(&factory)?); let deno_dir_root = factory.deno_dir()?.root.clone(); apply_desktop_config_to_flags(&mut desktop_flags, desktop_config); if all_targets { let targets = [ "x86_64-apple-darwin", "aarch64-apple-darwin", "x86_64-unknown-linux-gnu", "aarch64-unknown-linux-gnu", "x86_64-pc-windows-msvc", ]; for target in targets { log::info!("Building for target: {}", target); let mut desktop_flags = desktop_flags.clone(); desktop_flags.target = Some(target.to_string()); Box::pin(compile_desktop( flags.clone(), desktop_flags, cli_options, &laufey_resolver, &deno_dir_root, )) .await?; } Ok(()) } else { Box::pin(compile_desktop( flags, desktop_flags, cli_options, &laufey_resolver, &deno_dir_root, )) .await } } /// Applies `deno.json`'s `desktop` config to the CLI flags. A `deno.json` field /// only fills in a flag that was left unset — CLI flags always win. The webview /// backend remains the final fallback via the `unwrap_or("webview")` call sites /// that consume `desktop_flags.backend`. fn apply_desktop_config_to_flags( desktop_flags: &mut DesktopFlags, desktop_config: DesktopConfig, ) { if let Some(output) = desktop_config.output && desktop_flags.output.is_none() { desktop_flags.output = if cfg!(target_os = "macos") { output.macos } else if cfg!(target_os = "windows") { output.windows } else { output.linux }; } if let Some(app_config) = desktop_config.app { if let Some(icons) = app_config.icons && desktop_flags.icon.is_none() { use deno_config::deno_json::DesktopIconValue; let platform_icon = if cfg!(target_os = "macos") { icons.macos } else if cfg!(target_os = "windows") { icons.windows } else { icons.linux }; desktop_flags.icon = platform_icon.map(|v| match v { DesktopIconValue::Single(s) => crate::args::IconConfig::Single(s), DesktopIconValue::Set(entries) => crate::args::IconConfig::Set( entries .into_iter() .map(|e| crate::args::IconSetEntry { path: e.path, size: e.size, }) .collect(), ), }); } if let Some(name) = app_config.name && desktop_flags.output.is_none() { desktop_flags.output = Some(name); } if let Some(identifier) = app_config.identifier && desktop_flags.identifier.is_none() { desktop_flags.identifier = Some(identifier); } if let Some(deep_links) = app_config.deep_links && desktop_flags.deep_links.is_empty() { desktop_flags.deep_links = deep_links; } } if let Some(backend) = desktop_config.backend && desktop_flags.backend.is_none() { desktop_flags.backend = Some(backend); } if let Some(macos_config) = desktop_config.macos && let Some(identity) = macos_config.codesign_identity && desktop_flags.codesign_identity.is_none() { desktop_flags.codesign_identity = Some(identity); } } async fn compile_desktop( mut flags: Flags, mut desktop_flags: DesktopFlags, cli_options: &Arc<CliOptions>, laufey_resolver: &LaufeyBackendResolver, deno_dir_root: &Path, ) -> Result<(), AnyError> { // If the user asked for a `.dmg` (macOS) installer via `--output`, strip // the extension for the intermediate compile/bundle step and remember the // original so we can wrap the resulting .app in a DMG at the end. let dmg_output = desktop_flags .output .as_ref() .filter(|o| o.to_lowercase().ends_with(".dmg")) .cloned(); if let Some(ref dmg) = dmg_output { if !cfg!(target_os = "macos") { bail!( "Building a .dmg requires a macOS build host (uses hdiutil). \ Requested output: {dmg}. Build on macOS, or choose a different output \ format.", ); } let stem = Path::new(dmg) .file_stem() .map(|s| s.to_string_lossy().into_owned()) .unwrap_or_else(|| "App".to_string()); let parent = Path::new(dmg) .parent() .filter(|p| !p.as_os_str().is_empty()); desktop_flags.output = Some(match parent { Some(p) => p.join(&stem).to_string_lossy().into_owned(), None => stem, }); } // Same for `.AppImage` on Linux — strip extension, wrap app dir in an // AppImage at the end. let appimage_output = desktop_flags .output .as_ref() .filter(|o| o.to_lowercase().ends_with(".appimage")) .cloned(); if let Some(ref appimage) = appimage_output { let stem = Path::new(appimage) .file_stem() .map(|s| s.to_string_lossy().into_owned()) .unwrap_or_else(|| "App".to_string()); let parent = Path::new(appimage) .parent() .filter(|p| !p.as_os_str().is_empty()); desktop_flags.output = Some(match parent { Some(p) => p.join(&stem).to_string_lossy().into_owned(), None => stem, }); } // Same for Linux `.deb` / `.rpm` package installers — strip the extension // for the intermediate compile/bundle step, then wrap the staged app dir in // the chosen package at the end. Both wrap the same tree produced by // `package_linux_app_dir`. let deb_output = desktop_flags .output .as_ref() .filter(|o| o.to_lowercase().ends_with(".deb")) .cloned(); let rpm_output = desktop_flags .output .as_ref() .filter(|o| o.to_lowercase().ends_with(".rpm")) .cloned(); if let Some(ref pkg) = deb_output.as_ref().or(rpm_output.as_ref()) { // `.deb`/`.rpm` wrap the staged Linux app dir, so the build must target // Linux. The package itself is assembled in pure Rust and cross-compiles // from any host — only the target OS matters. (Unlike `.dmg`, which is // gated on a macOS *host* because it shells out to hdiutil.) let targets_linux = match desktop_flags.target.as_deref() { Some(t) => t.contains("linux"), None => cfg!(target_os = "linux"), }; if !targets_linux { bail!( "Building a {ext} requires a Linux target. Requested output: {pkg}. \ Pass --target <linux-triple> (e.g. x86_64-unknown-linux-gnu) or build \ on Linux.", ext = if deb_output.is_some() { ".deb" } else { ".rpm" }, ); } let stem = Path::new(pkg) .file_stem() .map(|s| s.to_string_lossy().into_owned()) .unwrap_or_else(|| "App".to_string()); let parent = Path::new(pkg) .parent() .filter(|p| !p.as_os_str().is_empty()); desktop_flags.output = Some(match parent { Some(p) => p.join(&stem).to_string_lossy().into_owned(), None => stem, }); } // Same for a Windows `.msi` installer — strip the extension for the // intermediate compile/bundle step, then wrap the staged Windows app dir in // an MSI at the end. The MSI is authored entirely in pure Rust (`msi` + // `cab`), so it cross-compiles from any host — only the *target* must be // Windows. (Unlike `.dmg`, which is gated on a macOS host because it shells // out to hdiutil.) let msi_output = desktop_flags .output .as_ref() .filter(|o| o.to_lowercase().ends_with(".msi")) .cloned(); if let Some(ref msi) = msi_output { let targets_windows = match desktop_flags.target.as_deref() { Some(t) => t.contains("windows"), None => cfg!(target_os = "windows"), }; if !targets_windows { bail!( "Building a .msi requires a Windows target. Requested output: {msi}. \ Pass --target <windows-triple> (e.g. x86_64-pc-windows-msvc) or build \ on Windows.", ); } let stem = Path::new(msi) .file_stem() .map(|s| s.to_string_lossy().into_owned()) .unwrap_or_else(|| "App".to_string()); let parent = Path::new(msi) .parent() .filter(|p| !p.as_os_str().is_empty()); desktop_flags.output = Some(match parent { Some(p) => p.join(&stem).to_string_lossy().into_owned(), None => stem, }); } // Desktop framework detection: when --desktop is used and the source is // "." (a directory), detect the framework and generate the entrypoint. // The cwd resolved from CliOptions is reused for the HMR launch below so // framework detection is single-sourced and can't drift between the two. let detection_cwd = cli_options.initial_cwd().to_path_buf(); let detected_framework = if desktop_flags.source_file == "." { super::framework::detect_framework(&detection_cwd)? } else { None }; let desktop_entrypoint_file = if desktop_flags.source_file == "." { let cwd = &detection_cwd; if let Some(detection) = detected_framework.as_ref() { let use_framework_hmr = desktop_flags.hmr && (detection.hmr_entrypoint_code().is_some() || detection.hmr_command.is_some()); let entrypoint_code = if use_framework_hmr { match detection.hmr_entrypoint_code() { Some(code) => { // The generated dev shim imports the framework's dev-server API // (e.g. Vite's); type-checking it would demand the project ship // `@types/node` and checks nothing user-authored, so skip it. flags.type_check_mode = TypeCheckMode::None; // The dev server used to run as an unsandboxed `deno task dev` // subprocess; now that it runs inside the desktop runtime, keep // that behavior — a sandboxed Vite immediately dies on env reads // (e.g. NAPI_RS_WASI_FLAVOR from its napi loader). flags.permissions.allow_all = true; code.to_string() } None => NOOP_ENTRYPOINT.to_string(), } } else { detection.entrypoint_code.clone() }; log::info!("Detected {} framework", detection.name); if !use_framework_hmr { // Run the framework's build step (e.g. `deno task build`) before its // build output (`dist`, `.next`, etc.) is added to the compile includes // below; otherwise the include points at a directory that doesn't exist // yet and the compile fails (#35535). Mirrors `deno compile .`. super::framework::run_build_command(detection, cwd)?; } // Enable CJS detection for Node-based frameworks. flags.unstable_config.detect_cjs = true; if detection.name == "Next.js" && !matches!(flags.type_check_mode, TypeCheckMode::None) { log::info!( "Disabling Deno type checking for Next.js desktop compile; Next handles app compilation itself" ); flags.type_check_mode = TypeCheckMode::None; } // Sweep stale entrypoints leaked by previous interrupted runs. The // NamedTempFile below cleans up on drop, but a Ctrl-C delivers SIGINT // to the whole process group (see `run_desktop_hmr`) and the parent // exits without running destructors — so the dev loop would otherwise // accumulate `.deno_desktop_entry-*.ts` files in the project root. const ENTRY_PREFIX: &str = ".deno_desktop_entry-"; if let Ok(entries) = std::fs::read_dir(cwd) { for entry in entries.flatten() { if entry .file_name() .to_string_lossy() .starts_with(ENTRY_PREFIX) { let _ = std::fs::remove_file(entry.path()); } } } // Write a temporary entrypoint file. tempfile gives us a unique // name (no collision between concurrent `deno desktop` runs in // the same project) and 0600 mode (no symlink-pre-creation // attack); cleanup-on-drop replaces the explicit guard. let entrypoint_temp = tempfile::Builder::new() .prefix(ENTRY_PREFIX) .suffix(".ts") .tempfile_in(cwd) .with_context(|| { format!("failed to create temp entrypoint file in {}", cwd.display()) })?; { use std::io::Write; entrypoint_temp .as_file() .write_all(entrypoint_code.as_bytes())?; } let entrypoint_path = entrypoint_temp.path().to_path_buf(); desktop_flags.source_file = entrypoint_path.display().to_string(); if desktop_flags.output.is_none() && let Some(dir_name) = cwd.file_name() { desktop_flags.output = Some(dir_name.to_string_lossy().into_owned()); } // Add framework build output to includes. Skipped in HMR mode. if !use_framework_hmr { for inc in &detection.include_paths { if !desktop_flags.include.contains(inc) { desktop_flags.include.push(inc.clone()); } } } Some(entrypoint_temp) } else { bail!( "Could not detect a supported framework in the current directory.\nSupported frameworks: Next.js, Astro, Fresh, Remix, React Router, SvelteKit, Nuxt, SolidStart, TanStack Start, Vite\nProvide an explicit entrypoint instead." ); } } else { None }; let self_extracting = desktop_entrypoint_file.is_some(); // `desktop_entrypoint_file` (a NamedTempFile) keeps the file alive while // `compile_binary` reads it. It is explicitly closed right after compilation // (see below) rather than on drop: the long-running `run_desktop_hmr` wait // exits on Ctrl-C without running destructors, so a drop-only guard would // leak the entrypoint for the whole dev session. // No explicit icon, but a framework was detected — try to use its // favicon (e.g. `public/favicon.ico`, `app/icon.png`) as the app icon // so the bundle gets the project's branding for free. if desktop_flags.icon.is_none() && let Some(detection) = detected_framework.as_ref() { let target_os = match desktop_flags.target.as_deref() { Some(t) if t.contains("apple-darwin") => "macos", Some(t) if t.contains("windows") => "windows", Some(_) => "linux", None => { if cfg!(target_os = "macos") { "macos" } else if cfg!(target_os = "windows") { "windows" } else { "linux" } } }; if let Some(path) = super::framework::find_framework_favicon( &detection_cwd, detection, target_os, ) { let display = path .strip_prefix(&detection_cwd) .unwrap_or(&path) .display() .to_string(); log::info!("Using {} favicon as icon: {}", detection.name, display); desktop_flags.icon = Some(crate::args::IconConfig::Single(path.display().to_string())); } } let inspector_requested = flags.inspect.is_some() || flags.inspect_brk.is_some() || flags.inspect_wait.is_some(); // In HMR/inspector mode the compiled dylib is a throwaway dev artifact: we // load it directly rather than packaging it into a `.app`. Writing it into // the cwd litters the project with `<name>.dylib`, its compile temp file // (`<name>.dylib.tmp-*`) and the runtime auto-update sidecars // (`.update-ok`, `.backup`). Redirect it into a stable per-project dir under // `deno_dir` so the cwd stays clean. The path is keyed by the project dir so // it's stable across relaunches (the auto-update / rollback sentinels rely on // a consistent dylib path). let hmr_output_override = if desktop_flags.hmr || inspector_requested { let name = desktop_flags .output .as_deref() .map(Path::new) .and_then(|p| p.file_stem()) .map(|s| s.to_string_lossy().into_owned()) .or_else(|| { detection_cwd .file_name() .map(|s| s.to_string_lossy().into_owned()) }) .filter(|s| !s.is_empty()) .unwrap_or_else(|| "app".to_string()); let key = faster_hex::hex_string(&sha2::Sha256::digest( detection_cwd.to_string_lossy().as_bytes(), )); let dir = deno_dir_root.join("desktop").join(&key[..16]); std::fs::create_dir_all(&dir).with_context(|| { format!("failed to create desktop dev dir {}", dir.display()) })?; Some(dir.join(name).to_string_lossy().into_owned()) } else { None }; let compile_flags = CompileFlags { source_file: desktop_flags.source_file.clone(), output: hmr_output_override .clone() .or_else(|| desktop_flags.output.clone()), app_name: None, args: desktop_flags.args.clone(), target: desktop_flags.target.clone(), no_terminal: false, icon: match &desktop_flags.icon { Some(crate::args::IconConfig::Single(s)) => Some(s.clone()), _ => None, }, include: desktop_flags.include.clone(), exclude: desktop_flags.exclude.clone(), eszip: false, self_extracting, bundle: false, minify: false, exclude_unused_npm: desktop_flags.exclude_unused_npm, engine: desktop_flags.engine, }; let mut temp_flags = flags.clone(); temp_flags.subcommand = DenoSubcommand::Compile(compile_flags.clone()); temp_flags.internal.is_desktop = true; let output_path = super::compile::compile_binary( Arc::new(temp_flags), compile_flags, true, None, ) .await?; // The temp entrypoint is embedded in the compiled dylib's VFS now; nothing // downstream reads it from disk. Remove it deterministically here so the // long-running HMR session (which exits on Ctrl-C without running the // drop guard) can't leave it behind in the project root. if let Some(entrypoint_file) = desktop_entrypoint_file { let _ = entrypoint_file.close(); } if desktop_flags.hmr || inspector_requested { let backend = desktop_flags.backend.as_deref().unwrap_or("webview"); run_desktop_hmr( &output_path, &detection_cwd, detected_framework.as_ref(), backend, laufey_resolver, &flags, &desktop_flags, ) .await?; } else { // Package the dylib into a platform-specific app bundle. let bundle_path = package_desktop_app( &output_path, &desktop_flags, cli_options, laufey_resolver, ) .await?; // Optionally make the bundle self-extracting: the heavy payload is // compressed inside the shipped app and unpacked on first launch. This // shrinks the distributed artifact (the installed footprint is restored // on first run). Done before any .dmg/.deb/.AppImage wrapping so the // installer wraps the compact, self-extracting app. if let Some(format) = desktop_flags.compress.as_deref() { make_self_extracting(&bundle_path, format, &desktop_flags)?; } // If the user requested a .dmg, wrap the .app in one and report the DMG. // If the user requested a .AppImage, wrap the Linux app dir in one. let final_path = if let Some(dmg) = dmg_output.as_deref() { let dmg_abs = cli_options.initial_cwd().join(dmg); create_macos_dmg(&bundle_path, &dmg_abs)?; dmg_abs } else if let Some(appimage) = appimage_output.as_deref() { let appimage_abs = cli_options.initial_cwd().join(appimage); create_linux_appimage( &bundle_path, &appimage_abs, desktop_flags.target.as_deref(), )?; appimage_abs } else if let Some(deb) = deb_output.as_deref() { let deb_abs = cli_options.initial_cwd().join(deb); create_linux_deb( &bundle_path, &deb_abs, &desktop_flags, desktop_flags.target.as_deref(), )?; deb_abs } else if let Some(rpm) = rpm_output.as_deref() { let rpm_abs = cli_options.initial_cwd().join(rpm); create_linux_rpm( &bundle_path, &rpm_abs, &desktop_flags, desktop_flags.target.as_deref(), )?; rpm_abs } else if let Some(msi) = msi_output.as_deref() { let msi_abs = cli_options.initial_cwd().join(msi); create_windows_msi( &bundle_path, &msi_abs, &desktop_flags, desktop_flags.target.as_deref(), )?; msi_abs } else { bundle_path }; let initial_cwd = deno_path_util::url_from_directory_path(cli_options.initial_cwd())?; log::info!( "{} {}", colors::green("Bundle"), if let Ok(bundle_url) = deno_path_util::url_from_file_path(&final_path) { crate::util::path::relative_specifier_path_for_display( &initial_cwd, &bundle_url, ) } else { final_path.display().to_string() } ); } Ok(()) } /// Convert a packaged app bundle into a self-extracting one: the heavy payload /// is compressed inside the shipped bundle and unpacked to a per-user data /// directory on first launch, then the real app is exec'd from there. /// /// This shrinks the distributed artifact (the installed footprint is restored /// on first run, cached and reused on subsequent launches). The transform is /// in place at `bundle_path`. `format` is `"xz"` (LZMA, smallest, decompressed /// everywhere by libarchive `tar`) or `"zstd"` (faster, slightly larger). fn make_self_extracting( bundle_path: &Path, format: &str, desktop_flags: &DesktopFlags, ) -> Result<(), AnyError> { let target_os = match desktop_flags.target.as_deref() { Some(t) if t.contains("apple-darwin") => "macos", Some(t) if t.contains("windows") => "windows", Some(_) => "linux", None => { if cfg!(target_os = "macos") { "macos" } else if cfg!(target_os = "windows") { "windows" } else { "linux" } } }; match target_os { "macos" => make_self_extracting_macos(bundle_path, format, desktop_flags), "windows" => make_self_extracting_dir(bundle_path, format, true), _ => make_self_extracting_dir(bundle_path, format, false), } } /// Validate a deep-link URL scheme. Follows the RFC 3986 `scheme` grammar: /// `ALPHA *( ALPHA / DIGIT / "+" / "-" / "." )`. We additionally reject the /// common reserved schemes (`http`, `https`, `file`, `ftp`, `ws`, `wss`) since /// registering those as app handlers is almost never intended and would hijack /// normal browsing. fn validate_url_scheme(scheme: &str) -> Result<(), AnyError> { let reserved = ["http", "https", "file", "ftp", "ws", "wss"]; let bail = |reason: &str| { Err(deno_core::anyhow::anyhow!( "Invalid deep-link scheme {scheme:?}: {reason}." )) }; match scheme.chars().next() { None => return bail("scheme is empty"), Some(c) if !c.is_ascii_alphabetic() => { return bail("scheme must start with an ASCII letter"); } _ => {} } if !scheme .chars() .all(|c| c.is_ascii_alphanumeric() || matches!(c, '+' | '-' | '.')) { return bail("scheme may only contain letters, digits, '+', '-', and '.'"); } if reserved.contains(&scheme) { return bail("scheme is reserved and cannot be used as a deep link"); } Ok(()) } /// Register the configured deep-link URL schemes with the OS-specific app /// metadata so the system routes `<scheme>://...` links to this app. /// /// First pass: this writes the declarative registration into the bundle /// (macOS `CFBundleURLTypes`, Linux `.desktop` `MimeType` + `Exec %u`, /// Windows `.reg`/`.bat` helper). Delivering the opened URL into the running /// app (single-instance forwarding, the macOS `openURLs` Apple Event, and the /// `open-url` JS event) is tracked separately in the issue. fn register_deep_links( bundle_path: &Path, desktop_flags: &DesktopFlags, ) -> Result<(), AnyError> { let schemes: Vec<String> = desktop_flags .deep_links .iter() .map(|s| s.trim().to_ascii_lowercase()) .filter(|s| !s.is_empty()) .collect(); if schemes.is_empty() { return Ok(()); } for scheme in &schemes { validate_url_scheme(scheme)?; } let target_os = match desktop_flags.target.as_deref() { Some(t) if t.contains("apple-darwin") => "macos", Some(t) if t.contains("windows") => "windows", Some(_) => "linux", None => { if cfg!(target_os = "macos") { "macos" } else if cfg!(target_os = "windows") { "windows" } else { "linux" } } }; match target_os { "macos" => register_deep_links_macos(bundle_path, &schemes)?, "windows" => register_deep_links_windows(bundle_path, &schemes)?, _ => register_deep_links_linux(bundle_path, &schemes)?, } log::info!( "{} {}", colors::green("Deep links"), schemes .iter() .map(|s| format!("{s}://")) .collect::<Vec<_>>() .join(", "), ); Ok(()) } /// macOS: add a single `CFBundleURLTypes` entry carrying every scheme to the /// bundle `Info.plist`. fn register_deep_links_macos( bundle_path: &Path, schemes: &[String], ) -> Result<(), AnyError> { let plist_path = bundle_path.join("Contents").join("Info.plist"); let mut dict: plist::Dictionary = plist::from_file(&plist_path) .with_context(|| format!("failed to parse {}", plist_path.display()))?; let url_name = dict .get("CFBundleIdentifier") .and_then(|v| v.as_string()) .unwrap_or("") .to_string(); let mut url_type = plist::Dictionary::new(); url_type.insert( "CFBundleURLName".to_string(), plist::Value::String(url_name), ); url_type.insert( "CFBundleTypeRole".to_string(), plist::Value::String("Viewer".to_string()), ); url_type.insert( "CFBundleURLSchemes".to_string(), plist::Value::Array( schemes.iter().cloned().map(plist::Value::String).collect(), ), ); dict.insert( "CFBundleURLTypes".to_string(), plist::Value::Array(vec![plist::Value::Dictionary(url_type)]), ); plist::to_file_xml(&plist_path, &dict) .with_context(|| format!("failed to write {}", plist_path.display()))?; Ok(()) } /// Linux: add `x-scheme-handler/<scheme>` MIME types to the `.desktop` entry /// and make sure `Exec=` forwards the opened URL via the `%u` field code. fn register_deep_links_linux( bundle_path: &Path, schemes: &[String], ) -> Result<(), AnyError> { let desktop_file = std::fs::read_dir(bundle_path)? .filter_map(|e| e.ok().map(|e| e.path())) .find(|p| p.extension().is_some_and(|e| e == "desktop")) .ok_or_else(|| { deno_core::anyhow::anyhow!( "no .desktop file found in {}", bundle_path.display() ) })?; let contents = std::fs::read_to_string(&desktop_file)?; let mime = schemes .iter() .map(|s| format!("x-scheme-handler/{s};")) .collect::<String>(); let mut out = String::with_capacity(contents.len() + mime.len() + 16); let mut wrote_mime = false; for line in contents.lines() { if let Some(rest) = line.strip_prefix("Exec=") { // The launcher must receive the URL as an argument, so ensure a `%u` // field code is present exactly once. if rest.contains("%u") || rest.contains("%U") { out.push_str(line); } else { out.push_str(&format!("Exec={} %u", rest.trim_end())); } out.push('\n'); } else if let Some(rest) = line.strip_prefix("MimeType=") { // Merge into the existing MimeType list. out.push_str("MimeType="); out.push_str(rest.trim_end()); if !rest.trim_end().ends_with(';') && !rest.trim_end().is_empty() { out.push(';'); } out.push_str(&mime); out.push('\n'); wrote_mime = true; } else { out.push_str(line); out.push('\n'); } } if !wrote_mime { out.push_str(&format!("MimeType={mime}\n")); } std::fs::write(&desktop_file, out)?; Ok(()) } /// Windows: there is no in-bundle declarative registration for protocol /// handlers, so drop a `register-deep-links.bat` next to the launcher that /// writes the `HKCU\Software\Classes\<scheme>` keys. An installer (or the /// user) runs it once after install; the keys point back at the launcher in /// its install location (`%~dp0`). fn register_deep_links_windows( bundle_path: &Path, schemes: &[String], ) -> Result<(), AnyError> { // The launcher written by the Windows packaging step is `<app>.exe` (the // backend binary renamed to the bundle/app name). let launcher = bundle_path .file_name() .map(|n| format!("{}.exe", n.to_string_lossy())) .unwrap_or_else(|| "launcher.exe".to_string()); let mut script = String::from("@echo off\r\nsetlocal\r\n"); for scheme in schemes { script.push_str(&format!( "reg add \"HKCU\\Software\\Classes\\{scheme}\" /ve /d \"URL:{scheme}\" /f\r\n\ reg add \"HKCU\\Software\\Classes\\{scheme}\" /v \"URL Protocol\" /d \"\" /f\r\n\ reg add \"HKCU\\Software\\Classes\\{scheme}\\shell\\open\\command\" /ve /d \"\\\"%~dp0{launcher}\\\" \\\"%%1\\\"\" /f\r\n", )); } script.push_str("endlocal\r\n"); std::fs::write(bundle_path.join("register-deep-links.bat"), script)?; Ok(()) } /// Tar `parent/entry_name` (preserving symlinks and modes) into `dest_file`, /// compressed with `format`. Returns `(uncompressed, compressed)` byte sizes. fn write_tar_compressed( parent: &Path, entry_name: &str, dest_file: &Path, format: &str, ) -> Result<(u64, u64), AnyError> { use std::io::Write; let mut tar_buf = Vec::new(); { let mut builder = tar::Builder::new(&mut tar_buf); builder.follow_symlinks(false); builder.append_dir_all(entry_name, parent.join(entry_name))?; builder.finish()?; } let raw_len = tar_buf.len() as u64; let out = std::fs::File::create(dest_file).with_context(|| { format!("failed to create payload {}", dest_file.display()) })?; let out = std::io::BufWriter::new(out); match format { "xz" | "lzma" => { // Preset 9 ≈ `xz -9`; PRESET_EXTREME trades a lot of CPU for a few // percent, so stick to plain 9 for build-time sanity. let mut enc = liblzma::write::XzEncoder::new(out, 9); enc.write_all(&tar_buf)?; enc.finish()?.flush()?; } "zstd" => { let mut enc = zstd::stream::write::Encoder::new(out, 19)?; enc.write_all(&tar_buf)?; enc.finish()?.flush()?; } other => bail!("unknown --compress format '{other}' (use xz or zstd)"), } let comp_len = std::fs::metadata(dest_file).map(|m| m.len()).unwrap_or(0); Ok((raw_len, comp_len)) } /// Short, stable cache key derived from the payload bytes — bumps the /// extraction directory whenever the app contents change. fn payload_hash(payload: &Path) -> Result<String, AnyError> { let bytes = std::fs::read(payload)?; let digest = sha2::Sha256::digest(&bytes); Ok(faster_hex::hex_string(&digest)[..16].to_string()) } fn payload_ext(format: &str) -> &'static str { match format { "zstd" => "tar.zst", _ => "tar.xz", } } /// macOS: replace `MyApp.app` with a thin `.app` whose `Resources/` holds the /// compressed real bundle. The `Contents/MacOS/<App>` launcher extracts it to /// `~/Library/Application Support/<bundle-id>/<hash>/` on first run and execs /// the real backend from there. fn make_self_extracting_macos( bundle_path: &Path, format: &str, desktop_flags: &DesktopFlags, ) -> Result<(), AnyError> { let app_name = bundle_path .file_stem() .map(|s| s.to_string_lossy().into_owned()) .unwrap_or_else(|| "App".to_string()); validate_launcher_name(&app_name, "app name")?; let contents = bundle_path.join("Contents"); let bundle_id = read_plist_string(&contents.join("Info.plist"), "CFBundleIdentifier") .unwrap_or_else(|| { format!("com.deno.desktop.{}", app_name.to_lowercase()) }); validate_bundle_identifier(&bundle_id)?; // Capture the bits we re-create in the thin bundle before moving the real // one out of the way. let info_plist = std::fs::read(contents.join("Info.plist"))?; let icon_src = contents.join("Resources").join("AppIcon.icns"); let icon = icon_src .exists() .then(|| std::fs::read(&icon_src)) .transpose()?; // Move the full, signed bundle into a staging dir as `<App>.app`. let parent = bundle_path.parent().unwrap_or_else(|| Path::new(".")); let staging = tempfile::Builder::new() .prefix(".selfextract-") .tempdir_in(parent)?; let inner_name = format!("{app_name}.app"); let inner = staging.path().join(&inner_name); std::fs::rename(bundle_path, &inner)?; // Build the thin bundle in place. let macos_dir = contents.join("MacOS"); let resources_dir = contents.join("Resources"); std::fs::create_dir_all(&macos_dir)?; std::fs::create_dir_all(&resources_dir)?; let payload_name = format!("payload.{}", payload_ext(format)); let payload = resources_dir.join(&payload_name); let (raw, comp) = write_tar_compressed(staging.path(), &inner_name, &payload, format)?; let hash = payload_hash(&payload)?; let launcher = format!( "#!/bin/sh\n\ set -e\n\ DIR=\"$(cd \"$(dirname \"$0\")\" && pwd)\"\n\ DEST=\"$HOME/Library/Application Support/{bundle_id}/{hash}\"\n\ APP=\"$DEST/{inner_name}\"\n\ if [ ! -x \"$APP/Contents/MacOS/{app_name}\" ]; then\n\ \u{20} mkdir -p \"$DEST\"\n\ \u{20} tar -xf \"$DIR/../Resources/{payload_name}\" -C \"$DEST\"\n\ fi\n\ exec \"$APP/Contents/MacOS/{app_name}\" \"$@\"\n", ); let launcher_path = macos_dir.join(&app_name); std::fs::write(&launcher_path, launcher)?; #[cfg(unix)] { use std::os::unix::fs::PermissionsExt; std::fs::set_permissions( &launcher_path, std::fs::Permissions::from_mode(0o755), )?; } std::fs::write(contents.join("Info.plist"), info_plist)?; if let Some(icon) = icon { std::fs::write(resources_dir.join("AppIcon.icns"), icon)?; } // Re-sign the thin bundle (the inner one keeps its own signature inside the // archive). Ad-hoc on a macOS host when no identity was given. let codesign_identity = desktop_flags.codesign_identity.as_deref().or( if cfg!(target_os = "macos") { Some("-") } else { None }, ); if let Some(identity) = codesign_identity { codesign_macos_bundle(bundle_path, identity)?; } log::info!( "{} {} ({} -> {}, {})", colors::green("Self-extract"), app_name, human_size(raw), human_size(comp), format, ); Ok(()) } /// Linux / Windows: replace the app directory with a thin one holding the /// compressed payload plus a launcher that extracts to a per-user data dir /// (`$XDG_DATA_HOME` / `%LOCALAPPDATA%`) on first run and execs the real app. fn make_self_extracting_dir( bundle_path: &Path, format: &str, windows: bool, ) -> Result<(), AnyError> { let app_name = bundle_path .file_name() .map(|s| s.to_string_lossy().into_owned()) .unwrap_or_else(|| "App".to_string()); validate_launcher_name(&app_name, "app name")?; let id = format!("com.deno.desktop.{}", app_name.to_lowercase()); let parent = bundle_path.parent().unwrap_or_else(|| Path::new(".")); let staging = tempfile::Builder::new() .prefix(".selfextract-") .tempdir_in(parent)?; let inner = staging.path().join(&app_name); std::fs::rename(bundle_path, &inner)?; std::fs::create_dir_all(bundle_path)?; let payload_name = format!("payload.{}", payload_ext(format)); let payload = bundle_path.join(&payload_name); let (raw, comp) = write_tar_compressed(staging.path(), &app_name, &payload, format)?; let hash = payload_hash(&payload)?; if windows { let launcher = format!( "@echo off\r\n\ setlocal\r\n\ set \"DIR=%~dp0\"\r\n\ set \"DEST=%LOCALAPPDATA%\\{id}\\{hash}\"\r\n\ if not exist \"%DEST%\\{app_name}\\{app_name}.exe\" (\r\n\ \u{20} mkdir \"%DEST%\" 2>nul\r\n\ \u{20} tar -xf \"%DIR%{payload_name}\" -C \"%DEST%\"\r\n\ )\r\n\ \"%DEST%\\{app_name}\\{app_name}.exe\" %*\r\n", ); std::fs::write(bundle_path.join(format!("{app_name}.bat")), launcher)?; } else { let launcher = format!( "#!/bin/sh\n\ set -e\n\ DIR=\"$(cd \"$(dirname \"$0\")\" && pwd)\"\n\ DEST=\"${{XDG_DATA_HOME:-$HOME/.local/share}}/{id}/{hash}\"\n\ APP=\"$DEST/{app_name}\"\n\ if [ ! -x \"$APP/{app_name}\" ]; then\n\ \u{20} mkdir -p \"$DEST\"\n\ \u{20} tar -xf \"$DIR/{payload_name}\" -C \"$DEST\"\n\ fi\n\ exec \"$APP/{app_name}\" \"$@\"\n", ); let launcher_path = bundle_path.join(&app_name); std::fs::write(&launcher_path, launcher)?; #[cfg(unix)] { use std::os::unix::fs::PermissionsExt; std::fs::set_permissions( &launcher_path, std::fs::Permissions::from_mode(0o755), )?; } } log::info!( "{} {} ({} -> {}, {})", colors::green("Self-extract"), app_name, human_size(raw), human_size(comp), format, ); Ok(()) } /// Format a byte count as a short human-readable size (e.g. `66.0M`). fn human_size(bytes: u64) -> String { const UNITS: [&str; 5] = ["B", "K", "M", "G", "T"]; let mut size = bytes as f64; let mut unit = 0; while size >= 1024.0 && unit < UNITS.len() - 1 { size /= 1024.0; unit += 1; } if unit == 0 { format!("{}{}", bytes, UNITS[unit]) } else { format!("{:.1}{}", size, UNITS[unit]) } } /// Resolve `icon` (a `.png` or `.icns` path, possibly relative to /// `initial_cwd`) into an absolute path suitable for `LAUFEY_APP_ICON`, which /// laufey passes to `-[NSImage initWithContentsOfFile:]` (both formats are /// accepted, so no conversion is needed). #[cfg(target_os = "macos")] fn resolve_hmr_icon_path( icon: &crate::args::IconConfig, initial_cwd: &Path, ) -> Result<PathBuf, AnyError> { let icon_path = match icon { crate::args::IconConfig::Single(p) => initial_cwd.join(p), crate::args::IconConfig::Set(_) => { deno_core::anyhow::bail!("icon sets are not supported in --hmr mode yet") } }; if !icon_path.exists() { deno_core::anyhow::bail!("icon '{}' not found", icon_path.display()); } match icon_path.extension().and_then(|e| e.to_str()) { Some("icns") | Some("png") => {} _ => deno_core::anyhow::bail!( "icon '{}' must be .icns or .png", icon_path.display() ), } Ok(crate::util::fs::canonicalize_path(&icon_path).unwrap_or(icon_path)) } /// Extract the local URL from a line of dev server output. fn parse_dev_server_url(line: &str) -> Option<String> { let line = console_static_text::ansi::strip_ansi_codes(line); // Vite prints ` ➜ Local: http://localhost:5173/` regex::Regex::new(r"Local:\s+(https?://\S+)") .expect("regex to parse local url failed") .captures(&line) .and_then(|c| c.get(1)) .map(|m| m.as_str().to_owned()) } /// Spawns a framework HMR dev server async fn spawn_framework_dev_server( name: &str, cmd_args: &[String], cwd: &Path, ) -> Result<(String, tokio::process::Child), AnyError> { use tokio::io::AsyncBufReadExt; use tokio::io::BufReader; let mut child = tokio::process::Command::new(&cmd_args[0]) .args(&cmd_args[1..]) .current_dir(cwd) .stdout(std::process::Stdio::piped()) .kill_on_drop(true) .spawn() .with_context(|| { format!("failed to spawn HMR dev server: {:?}", cmd_args) })?; let stdout = child.stdout.take().ok_or_else(|| { deno_core::anyhow::anyhow!("failed to capture HMR dev server stdout") })?; let mut lines = BufReader::new(stdout).lines(); let url = tokio::time::timeout(std::time::Duration::from_secs(15), async { while let Ok(Some(line)) = lines.next_line().await { let url = parse_dev_server_url(&line); // Echo the dev server's own startup output (banner, URL, warnings) so // it isn't swallowed while we scan for the URL. log::info!("{line}"); if let Some(url) = url { return Ok(url); } } deno_core::anyhow::bail!("dev server exited without printing a URL") }) .await .map_err(|_| { deno_core::anyhow::anyhow!( "{name} dev server with HMR did not start within 15s" ) })??; // Keep forwarding the dev server's stdout so its HMR/compile logs stay // visible after startup instead of being silently swallowed. The task ends // on its own once the child is killed (on drop) and the pipe closes. tokio::spawn(async move { while let Ok(Some(line)) = lines.next_line().await { log::info!("{line}"); } }); Ok((url, child)) } /// Launch the desktop app with HMR enabled after compilation. /// /// Framework dev servers provide HMR via websocket. Since they run inside /// the Deno desktop runtime, `Deno.desktop` APIs remain available. /// `child_process.fork()` works because forked workers use /// `override_main_module` to run the target script instead of the /// embedded entrypoint. async fn run_desktop_hmr( dylib_path: &Path, source_dir: &Path, framework: Option<&super::framework::FrameworkDetection>, backend: &str, laufey_resolver: &LaufeyBackendResolver, flags: &Flags, desktop_flags: &DesktopFlags, ) -> Result<(), AnyError> { let laufey_backend = laufey_resolver .find_binary(backend, LAUFEY_NATIVE_TARGET) .await?; let dylib_abs = crate::util::fs::canonicalize_path(dylib_path) .unwrap_or(dylib_path.to_path_buf()); let source_abs = crate::util::fs::canonicalize_path(source_dir) .unwrap_or(source_dir.to_path_buf()); // In HMR/inspector mode we launch the prebuilt laufey.app, so a user // `--icon` (or framework-detected favicon) would otherwise be ignored // and the Dock would show laufey's own icon. We can't rely on the bundle's // `CFBundleIconFile` (the dev bundle has none) or on swapping the bundled // `laufey.icns` (LaunchServices caches the icon for an already-registered // bundle id), so instead we pass the icon path to laufey and let it call // `-[NSApp setApplicationIconImage:]` at launch, which bypasses both. #[cfg(target_os = "macos")] let laufey_app_icon = desktop_flags.icon.as_ref().and_then(|icon| { resolve_hmr_icon_path(icon, &source_abs) .map_err(|e| log::warn!("Could not apply custom icon: {e}")) .ok() }); // The prebuilt laufey bundle would otherwise present itself as "laufey" in the // menu bar, Dock and Cmd-Tab switcher. Pass a clearer name (the configured // app name / project directory) so laufey can override the process name at // launch. `desktop_flags.output` is already resolved from `--output`, // deno.json `desktop.app.name`, or the project dir before we get here. let app_name = desktop_flags .output .as_deref() .map(Path::new) .and_then(|p| p.file_stem()) .map(|s| s.to_string_lossy().into_owned()) .or_else(|| { source_abs .file_name() .map(|s| s.to_string_lossy().into_owned()) }) .filter(|s| !s.is_empty()); if let Some(fw) = framework && desktop_flags.hmr { log::info!( "{} {} dev server with HMR in desktop mode", colors::green("Running"), fw.name, ); } if desktop_flags.hmr { log::info!( "{} {}desktop app with HMR (watching {})", colors::green("Running"), framework .map(|f| format!("{} ", f.name)) .unwrap_or_default(), source_abs.display(), ); } else { log::info!("{} desktop app under inspector", colors::green("Running"),); } let mut cmd = std::process::Command::new(&laufey_backend); cmd .arg("--runtime") .arg(&dylib_abs) .env("LAUFEY_RUNTIME_PATH", &dylib_abs) .current_dir(&source_abs); #[cfg(target_os = "macos")] if let Some(icon_path) = laufey_app_icon.as_ref() { cmd.env("LAUFEY_APP_ICON", icon_path); } if let Some(name) = app_name.as_ref() { cmd.env("LAUFEY_APP_NAME", name); } // Only enable the file watcher + setScriptSource pipeline when the user // actually asked for HMR. `deno desktop --inspect` alone used to spin up // both, surprising users (and burning the inspector channel on hot // reloads they didn't request). if desktop_flags.hmr { cmd.env("DENO_DESKTOP_HMR", &source_abs); } // In-runtime framework dev: the compiled entrypoint boots the framework's // dev server inside the desktop runtime (see `hmr_entrypoint_code`), so // server-side code keeps access to `Deno.desktop` (#35899). Signal // rt_desktop to restore CWD to the source dir and leave hot reloading to // the framework; the webview finds the server via the regular // DENO_SERVE_ADDRESS poll, so no external process and no URL scraping. let in_runtime_dev = desktop_flags.hmr && framework.is_some_and(|f| f.hmr_entrypoint_code().is_some()); if in_runtime_dev { cmd.env("DENO_DESKTOP_FRAMEWORK_DEV", "1"); } let _dev_server_child = if desktop_flags.hmr && !in_runtime_dev && let Some(fw) = framework && let Some(dev_cmd) = &fw.hmr_command { let (dev_url, child) = spawn_framework_dev_server(fw.name, dev_cmd, &source_abs).await?; log::info!( "{} {} HMR dev server at {}", colors::green("Running"), fw.name, dev_url, ); cmd.env("DENO_DESKTOP_DEV_URL", &dev_url); Some(child) } else { None }; // Wire up the unified DevTools multiplexer when --inspect is set. // The mux runs in this (parent) process and fronts both the Deno runtime // inspector (in the LAUFEY subprocess) and the CEF renderer's debug port // (in CEF's child process). We allocate two internal ports here, hand // them to the subprocess via env vars, and bind the user-visible port // for DevTools to attach to. let user_inspect = flags.inspect.or(flags.inspect_brk).or(flags.inspect_wait); let mux_handle = if let Some(user_addr) = user_inspect { let deno_internal: SocketAddr = format!( "127.0.0.1:{}", crate::tools::desktop_devtools::allocate_random_port()? ) .parse() .unwrap(); let cef_internal: SocketAddr = match desktop_flags.inspect_renderer { Some(addr) => addr, None => format!( "127.0.0.1:{}", crate::tools::desktop_devtools::allocate_random_port()? ) .parse() .unwrap(), }; let wait_for_debugger = flags.inspect_brk.is_some() || flags.inspect_wait.is_some(); let handle = crate::tools::desktop_devtools::spawn_mux( crate::tools::desktop_devtools::MuxConfig { listen: user_addr, deno_internal, cef_internal, inspect_brk: flags.inspect_brk.is_some(), wait_for_debugger, }, ) .await?; log::info!( "{} DevTools on ws://{} (open chrome://inspect)", colors::green("Inspector"), handle.listen, ); log::debug!( "[desktop] internal upstream ports: deno={} cef={}", deno_internal, cef_internal, ); cmd .env( "DENO_DESKTOP_INSPECT_INTERNAL_PORT", deno_internal.to_string(), ) // Exposed so rt_desktop's `openDevtools()` can launch a browser // pointed at the unified DevTools frontend instead of CEF's // renderer-only native window. .env("DENO_DESKTOP_MUX_WS", handle.listen.to_string()) .env( "LAUFEY_REMOTE_DEBUGGING_PORT", cef_internal.port().to_string(), ); if flags.inspect_brk.is_some() { cmd.env("DENO_DESKTOP_INSPECT_BRK", "1"); } if flags.inspect_wait.is_some() { cmd.env("DENO_DESKTOP_INSPECT_WAIT", "1"); } Some(handle) } else { None }; // `kill_on_drop` is a safety net: if the parent panics or exits via any // path that doesn't reach the explicit `wait` below, the LAUFEY backend // (and its CEF renderer subprocesses) get SIGKILLed on `Child` drop // rather than being orphaned. Normal Ctrl-C delivers SIGINT to the // whole process group so this rarely matters in practice; it covers // the abnormal-exit cases. // // On macOS we go through posix_spawn with TCC responsibility disclaimed // (see `disclaim_spawn`) so the laufey child is its own permission principal. // Without this, the kernel attributes notification/location/etc requests // to whatever started deno (typically the terminal), which has no bundle // id and causes `UNUserNotificationCenter.requestAuthorization` to fail // with UNErrorCodeNotificationsNotAllowed before any user prompt. #[cfg(target_os = "macos")] let status = { let mut child = disclaim_spawn::spawn(&cmd).with_context(|| { format!( "Failed to launch LAUFEY backend: {}", laufey_backend.display() ) })?; child .wait() .await .context("Failed waiting for LAUFEY backend")? }; #[cfg(not(target_os = "macos"))] let status = { let mut child = tokio::process::Command::from(cmd) .kill_on_drop(true) .spawn() .with_context(|| { format!( "Failed to launch LAUFEY backend: {}", laufey_backend.display() ) })?; child .wait() .await .context("Failed waiting for LAUFEY backend")? }; // Keep the mux alive until the subprocess exits, then drop it. drop(mux_handle); if !status.success() { bail!("LAUFEY backend exited with status: {}", status); } Ok(()) } /// Marker file written into every generated desktop app directory/bundle so a /// later build can recognize its own previous output and clear it, while never /// touching unrelated user data that happens to share the inferred app name. const APP_DIR_MARKER: &str = ".deno-desktop-app"; /// Used for `deno desktop --hmr` when a framework runs its own HMR server. /// The compiled app has nothing to serve in this case. const NOOP_ENTRYPOINT: &str = "// @ts-nocheck\nawait new Promise<void>(() => {});\n"; /// Prepare `app_dir` to receive a freshly built bundle. /// /// The app name is inferred from the entrypoint (or, for generic names like /// `main.ts`, the project directory), so the output path can collide with an /// existing user directory of the same name (e.g. `helloworld/helloworld`). /// Blindly `remove_dir_all`-ing that path silently destroys the user's data /// (issue #35510). Instead, only remove a directory we previously created — /// identified by `APP_DIR_MARKER` — or one that is empty. Anything else is /// treated as user data and we bail with instructions rather than delete it. fn reserve_app_dir(app_dir: &Path) -> Result<(), AnyError> { let meta = match std::fs::symlink_metadata(app_dir) { // Nothing there yet (or unreadable) — let the build create it. Err(_) => return Ok(()), Ok(meta) => meta, }; if !meta.is_dir() { bail!( "Refusing to overwrite '{}': a file with that name already exists. \ Pass --output to choose a different name.", app_dir.display() ); } // A bundle we generated carries the marker (at the directory root for // Linux/Windows, or under `Contents/Resources` for a macOS `.app`). let is_ours = app_dir.join(APP_DIR_MARKER).exists() || app_dir .join("Contents") .join("Resources") .join(APP_DIR_MARKER) .exists(); let is_empty = std::fs::read_dir(app_dir) .map(|mut entries| entries.next().is_none()) .unwrap_or(false); if !is_ours && !is_empty { bail!( "Refusing to delete existing directory '{}': it was not created by \ `deno desktop`. The app name was inferred from the entrypoint or the \ project directory and collided with this directory. Pass --output to \ choose a different name, or remove the directory yourself if it is a \ leftover from an older build.", app_dir.display() ); } std::fs::remove_dir_all(app_dir).with_context(|| { format!("failed to clear app directory {}", app_dir.display()) })?; Ok(()) } /// Package a compiled desktop dylib into a platform-specific app bundle. async fn package_desktop_app( dylib_path: &Path, desktop_flags: &DesktopFlags, cli_options: &CliOptions, laufey_resolver: &LaufeyBackendResolver, ) -> Result<PathBuf, AnyError> { let target = desktop_flags.target.as_deref(); let is_darwin = match target { Some(target) => target.contains("darwin"), None => cfg!(target_os = "macos"), }; let is_windows = match target { Some(target) => target.contains("windows"), None => cfg!(target_os = "windows"), }; if is_darwin { package_macos_app_bundle( dylib_path, desktop_flags, cli_options, laufey_resolver, ) .await } else if is_windows { package_windows_app_dir( dylib_path, desktop_flags, cli_options, laufey_resolver, ) .await } else { package_linux_app_dir( dylib_path, desktop_flags, cli_options, laufey_resolver, ) .await } } /// Create a Windows app directory from the compiled desktop dylib. /// /// Directory structure: /// ```text /// AppName/ /// AppName.exe (LAUFEY backend binary, renamed to the app name) /// libcef.dll, ... (CEF support files, if any) /// AppName.dll (compiled Deno runtime + user code) /// AppIcon.ico (optional) /// ``` /// /// The backend binary is renamed to `AppName.exe` so it auto-loads the /// co-located `AppName.dll` runtime — no `.bat` launcher is needed. async fn package_windows_app_dir( dylib_path: &Path, desktop_flags: &DesktopFlags, cli_options: &CliOptions, laufey_resolver: &LaufeyBackendResolver, ) -> Result<PathBuf, AnyError> { let parts = dylib_parts(dylib_path)?; let app_name = parts.app_name; let app_dir = parts.parent.join(&app_name); let backend = desktop_flags.backend.as_deref().unwrap_or("webview"); let target = laufey_target_for(desktop_flags); let laufey_binary = laufey_resolver.find_binary(backend, target).await?; let laufey_dir = laufey_resolver.find_binary_dir(backend, target).await?; let laufey_binary_name = laufey_binary .file_name() .unwrap() .to_string_lossy() .to_string(); reserve_app_dir(&app_dir)?; // Copy LAUFEY backend directory (binary + CEF support files) as the shell. crate::tools::compile::copy_dir_all(&laufey_dir, &app_dir)?; std::fs::write(app_dir.join(APP_DIR_MARKER), b"")?; // Drop any self-extracting runtime cache dir that tagged along. let laufey_exe_stem = Path::new(&laufey_binary_name) .file_stem() .map(|s| s.to_string_lossy().into_owned()) .unwrap_or_else(|| laufey_binary_name.clone()); let cache_dir = app_dir.join(format!(".{}", laufey_exe_stem)); if cache_dir.exists() { let _ = std::fs::remove_dir_all(&cache_dir); } let cache_file = app_dir.join(format!(".{}.cache", laufey_exe_stem)); if cache_file.exists() { let _ = std::fs::remove_file(&cache_file); } // Copy the compiled dylib (denort.dll) alongside the backend binary. let dylib_filename = parts.file_name; let dest_dylib = app_dir.join(dylib_filename); std::fs::copy(dylib_path, &dest_dylib)?; // Rename the LAUFEY backend binary to the app name (`<app>.exe`) so it sits // next to `<app>.dll` and auto-loads it: laufey's LaufeyFindColocatedRuntime // resolves a runtime library whose base name matches the executable's, in the // executable's own directory. A bare double-click of `<app>.exe` therefore // "just works" with no `.bat` wrapper and no `--runtime` argument. // // This also fixes runtime loading from paths containing a space (e.g. the // default MSI target `C:\Program Files\`): laufey resolves the co-located // runtime from its own module path rather than a `--runtime` string, avoiding // the historical `ERROR_MOD_NOT_FOUND` (126) failure on such paths. validate_launcher_name(&app_name, "app name")?; let launcher_path = app_dir.join(format!("{}.exe", app_name)); let staged_backend = app_dir.join(&laufey_binary_name); if staged_backend != launcher_path { std::fs::rename(&staged_backend, &launcher_path)?; } // Handle icon — drop an .ico next to the launcher. Embedding the icon // into the .exe itself requires rcedit or equivalent and is out of scope. if let Some(ref icon) = desktop_flags.icon { let dest = app_dir.join("AppIcon.ico"); match icon { crate::args::IconConfig::Single(path) => { let icon_path = cli_options.initial_cwd().join(path); if icon_path.exists() { match icon_path.extension().and_then(|e| e.to_str()) { Some("ico") => { std::fs::copy(&icon_path, &dest)?; } _ => { log::warn!( "Icon '{}' is not .ico, skipping", icon_path.display() ); } } } else { log::warn!("Icon '{}' not found, skipping", icon_path.display()); } } crate::args::IconConfig::Set(entries) => { convert_icon_set_to_ico(cli_options.initial_cwd(), entries, &dest)?; } } } // Drop the deep-link registration script next to the launcher. register_deep_links(&app_dir, desktop_flags)?; // Remove the standalone dylib (it's now inside the app dir). let _ = std::fs::remove_file(dylib_path); Ok(app_dir) } /// Create a Linux app directory from the compiled desktop dylib. /// /// Directory structure: /// ```text /// AppName/ /// AppName (LAUFEY backend binary, renamed to the app name) /// libcef.so, ... (CEF support files, if any) /// AppName.so (compiled Deno runtime + user code) /// AppIcon.png (optional) /// ``` /// /// The backend binary is renamed to `AppName` so it auto-loads the co-located /// `AppName.so` runtime — no launcher shell script is needed. async fn package_linux_app_dir( dylib_path: &Path, desktop_flags: &DesktopFlags, cli_options: &CliOptions, laufey_resolver: &LaufeyBackendResolver, ) -> Result<PathBuf, AnyError> { let parts = dylib_parts(dylib_path)?; // `file_stem` on "libdenort.so" returns "libdenort" — strip the "lib" prefix // so the app directory is named after the app, not the runtime library. let app_name = parts .app_name .strip_prefix("lib") .map(|s| s.to_string()) .unwrap_or(parts.app_name); let app_dir = parts.parent.join(&app_name); let backend = desktop_flags.backend.as_deref().unwrap_or("webview"); let target = laufey_target_for(desktop_flags); let laufey_binary = laufey_resolver.find_binary(backend, target).await?; let laufey_dir = laufey_resolver.find_binary_dir(backend, target).await?; let laufey_binary_name = laufey_binary .file_name() .unwrap() .to_string_lossy() .to_string(); reserve_app_dir(&app_dir)?; // Copy LAUFEY backend directory (binary + CEF support files) as the shell. crate::tools::compile::copy_dir_all(&laufey_dir, &app_dir)?; std::fs::write(app_dir.join(APP_DIR_MARKER), b"")?; // Drop any self-extracting runtime cache dir that tagged along. let laufey_exe_stem = Path::new(&laufey_binary_name) .file_stem() .map(|s| s.to_string_lossy().into_owned()) .unwrap_or_else(|| laufey_binary_name.clone()); let cache_dir = app_dir.join(format!(".{}", laufey_exe_stem)); if cache_dir.exists() { let _ = std::fs::remove_dir_all(&cache_dir); } let cache_file = app_dir.join(format!(".{}.cache", laufey_exe_stem)); if cache_file.exists() { let _ = std::fs::remove_file(&cache_file); } // Copy the compiled dylib as `<app>.so` so the renamed backend binary // auto-loads it: laufey's LaufeyFindColocatedRuntime resolves `<exe-base>.so` // next to the binary. It reads the real path via `/proc/self/exe`, which // follows the `/usr/bin/<pkg>` -> `/usr/lib/<pkg>/<app>` symlink that // `.deb`/`.rpm` install (issue #35623), so no wrapper script is needed. validate_launcher_name(&app_name, "app name")?; let dest_dylib = app_dir.join(format!("{}.so", app_name)); std::fs::copy(dylib_path, &dest_dylib)?; // Rename the LAUFEY backend binary to the app name so `<app>` is the launcher // the user runs directly — no `--runtime` argument and no shell wrapper. let launcher_path = app_dir.join(&app_name); let staged_backend = app_dir.join(&laufey_binary_name); if staged_backend != launcher_path { std::fs::rename(&staged_backend, &launcher_path)?; } #[cfg(unix)] { use std::os::unix::fs::PermissionsExt; std::fs::set_permissions( &launcher_path, std::fs::Permissions::from_mode(0o755), )?; } // Handle icon — copy a .png next to the launcher. if let Some(ref icon) = desktop_flags.icon { let dest = app_dir.join("AppIcon.png"); match icon { crate::args::IconConfig::Single(path) => { let icon_path = cli_options.initial_cwd().join(path); if icon_path.exists() { match icon_path.extension().and_then(|e| e.to_str()) { Some("png") => { std::fs::copy(&icon_path, &dest)?; } _ => { log::warn!( "Icon '{}' is not .png, skipping", icon_path.display() ); } } } else { log::warn!("Icon '{}' not found, skipping", icon_path.display()); } } crate::args::IconConfig::Set(entries) => { // Pick the largest provided size as the single icon file. if let Some(largest) = entries.iter().max_by_key(|e| e.size) { let src = cli_options.initial_cwd().join(&largest.path); if src.exists() { std::fs::copy(&src, &dest)?; } else { log::warn!("Icon '{}' not found, skipping", src.display()); } } } } } // Write a `.desktop` entry alongside the launcher so a user dropping // the app dir into `~/.local/share/applications/` gets the right // name/icon attribution on notifications and in the taskbar. laufey // doesn't read this file — only the OS does — but libnotify and // GNOME Shell key notification attribution on the desktop file's // `StartupWMClass` and `Icon` fields. let desktop_id = desktop_flags .identifier .clone() .unwrap_or_else(|| format!("com.deno.desktop.{}", app_name.to_lowercase())); if let Err(e) = validate_bundle_identifier(&desktop_id) { log::warn!( "skipping .desktop file: {e} (desktop file IDs follow the same reverse-DNS rules as macOS bundle IDs)" ); } else { let desktop_entry = format!( "[Desktop Entry]\n\ Type=Application\n\ Name={app_name}\n\ Exec={app_name}\n\ Icon=AppIcon\n\ StartupWMClass={desktop_id}\n\ Categories=Utility;\n", ); std::fs::write( app_dir.join(format!("{desktop_id}.desktop")), desktop_entry, )?; } // Merge any deep-link schemes into the `.desktop` entry written above. register_deep_links(&app_dir, desktop_flags)?; // Remove the standalone dylib (it's now inside the app dir). let _ = std::fs::remove_file(dylib_path); Ok(app_dir) } /// Environment variable pointing at a local laufey checkout, used to bypass the /// download path during development. Build-tree subpaths under this directory /// are searched the same way the old sibling-checkout heuristic searched. const LAUFEY_DEV_DIR_ENV: &str = "LAUFEY_DEV_DIR"; /// Resolves LAUFEY backend binaries and `.app` bundles, falling back to /// downloading prebuilt archives from the laufey GitHub releases when /// `LAUFEY_DEV_DIR` is not set. struct LaufeyBackendResolver { http_client_provider: Arc<HttpClientProvider>, /// `<deno_dir>/laufey/<version>/` cache_root: PathBuf, } impl LaufeyBackendResolver { fn new(factory: &CliFactory) -> Result<Self, AnyError> { let cache_root = factory.deno_dir()?.root.join("laufey").join(LAUFEY_VERSION); Ok(Self { http_client_provider: factory.http_client_provider().clone(), cache_root, }) } fn backend_cache_dir(&self, backend: &str, target: &str) -> PathBuf { self.cache_root.join(backend).join(target) } /// Download + verify + extract a backend archive if it isn't already in /// `<deno_dir>/laufey/<version>/<backend>/<target>/`. async fn ensure_downloaded( &self, backend: &str, target: &str, ) -> Result<PathBuf, AnyError> { let dir = self.backend_cache_dir(backend, target); let marker = dir.join(".downloaded"); if marker.exists() { return Ok(dir); } let archive = laufey_archive_name(backend, target); let client = self.http_client_provider.get_or_create()?; // Use the in-tree pinned digests rather than fetching SHA256SUMS from the // release page. The latter is unsigned, so trusting it would let anyone // who can write to the laufey release host swap both archive and sums // together (TOFU). The lock file is reviewed in PRs when LAUFEY_VERSION // bumps, so this is the trust anchor. That the lock file's pinned version // matches LAUFEY_VERSION is asserted at build time (see cli/build.rs). let expected = parse_sha256sum(LAUFEY_PINNED_SUMS, &archive).ok_or_else(|| { deno_core::anyhow::anyhow!( "no pinned SHA-256 for {archive} in cli/laufey_sums.lock \ (regenerate when bumping LAUFEY_VERSION to v{LAUFEY_VERSION}; \ laufey v{LAUFEY_VERSION} release may not include backend '{backend}' for target '{target}')" ) })?; log::info!( "{} laufey {} backend for {} (v{})", colors::green("Downloading"), backend, target, LAUFEY_VERSION, ); let url = Url::parse(&laufey_release_url(&archive))?; let progress_bar = ProgressBar::new(ProgressBarStyle::DownloadBars); let progress = progress_bar.update(&archive); // Send a real User-Agent — some CDNs (incl. parts of GitHub // releases) start rate-limiting empty UAs aggressively. let mut headers = http::HeaderMap::new(); if let Ok(ua) = http::HeaderValue::from_str(&format!( "deno-desktop/{} (+https://deno.com)", env!("CARGO_PKG_VERSION") )) { headers.insert(http::header::USER_AGENT, ua); } let response = client .download_with_progress_and_retries(url.clone(), &headers, &progress) .await .with_context(|| format!("failed to download {url}"))?; let data = response .into_maybe_bytes()? .ok_or_else(|| deno_core::anyhow::anyhow!("empty response from {url}"))?; let actual = faster_hex::hex_string(&sha2::Sha256::digest(&data)).to_lowercase(); let expected_lc = expected.to_lowercase(); if actual != expected_lc { // Include the URL in the bail: an attacker who poisoned a redirect // would otherwise be invisible in the failure log. bail!( "checksum mismatch for {archive} (downloaded from {url})\n expected: {expected_lc}\n actual: {actual}" ); } let parent = dir.parent().ok_or_else(|| { deno_core::anyhow::anyhow!( "LAUFEY cache dir has no parent: {}", dir.display() ) })?; std::fs::create_dir_all(parent)?; // Stage extraction in a sibling tempdir so concurrent `deno desktop` // builds don't see (or stomp on) a half-populated `dir` while one // is mid-extract. tempfile's cleanup-on-drop covers panic / // early-return paths; on the happy path we consume the TempDir via // `into_path` so the rename below sees a real directory. let staging = tempfile::Builder::new() .prefix(".staging-") .tempdir_in(parent) .with_context(|| { format!("failed to stage tempdir in {}", parent.display()) })?; extract_laufey_archive(&archive, &data, staging.path()) .with_context(|| format!("failed to extract {archive}"))?; // Marker is written into the staging dir so it lands atomically with // the rest of the contents — a SIGKILL during extraction can never // leave a marker-without-payload state. std::fs::write( staging.path().join(".downloaded"), format!("v{LAUFEY_VERSION}\n"), )?; // Another process may have raced us and finished its extract while // we were downloading. Use theirs and let staging clean up on drop. if marker.exists() { return Ok(dir); } // Discard any prior failed-extract debris (no marker ⇒ partial). // `rename` refuses a non-empty target. if dir.exists() { std::fs::remove_dir_all(&dir)?; } let staging_path = staging.into_path(); if let Err(e) = std::fs::rename(&staging_path, &dir) { // Lost a rename race with a concurrent process — its dir is at // our target path now. Drop our staged copy and use theirs. let _ = std::fs::remove_dir_all(&staging_path); if marker.exists() { return Ok(dir); } return Err(deno_core::anyhow::anyhow!( "failed to atomic-rename LAUFEY cache to {}: {e}", dir.display(), )); } // The laufey release archive linker-signs the `laufey` binary with // identifier=`laufey` rather than the .app's CFBundleIdentifier. UN // (UNUserNotificationCenter) rejects authorization requests when // the running binary's signed identifier disagrees with the host // bundle's id (UNErrorCodeNotificationsNotAllowed, error code 1). // In HMR mode the user's project bundle isn't involved — we run // laufey.app directly — so we have to fix the cached copy itself. // Re-sign every Mach-O inside the cached laufey.app with the bundle // id so the running identity is internally consistent. No-op on // non-macOS targets / hosts (no `codesign(1)`). #[cfg(target_os = "macos")] if target.contains("apple-darwin") && let Err(e) = harmonize_cached_laufey_identifiers(&dir, backend) { log::warn!( "[desktop] could not re-sign cached laufey backend: {e} \ (notifications may not work in HMR mode until you re-download)" ); } Ok(dir) } /// Locate the LAUFEY backend binary for `backend` on `target`. /// /// Resolution order: `LAUFEY_DEV_DIR` checkout → cached download → /// fresh download. async fn find_binary( &self, backend: &str, target: &str, ) -> Result<PathBuf, AnyError> { if let Some(dev_dir) = laufey_dev_dir() { let binary = locate_dev_backend_binary(&dev_dir, backend).ok_or_else(|| { deno_core::anyhow::anyhow!( "could not find '{backend}' backend binary under {} (set via {})", dev_dir.display(), LAUFEY_DEV_DIR_ENV ) })?; // Re-sign the dev laufey build so its identifier matches the bundle // id — same fix as the download path, applied every launch // because a fresh `cargo build` of laufey restores the linker's // default `Identifier=laufey`. The harmonize call is idempotent: // already-correct binaries are skipped. #[cfg(target_os = "macos")] if let Some(laufey_app) = laufey_app_for_binary(&binary) && let Err(e) = harmonize_laufey_app_identifiers(&laufey_app) { log::warn!( "[desktop] could not re-sign dev laufey backend: {e} \ (notifications may not work in HMR mode)" ); } return Ok(binary); } let dir = self.ensure_downloaded(backend, target).await?; locate_backend_binary(&dir, backend, target).ok_or_else(|| { deno_core::anyhow::anyhow!( "could not find '{backend}' backend binary inside {}", dir.display() ) }) } /// Locate the LAUFEY `.app` bundle for `backend` on a macOS `target`. async fn find_app_bundle( &self, backend: &str, target: &str, ) -> Result<PathBuf, AnyError> { if let Some(dev_dir) = laufey_dev_dir() { return locate_dev_app_bundle(&dev_dir, backend).ok_or_else(|| { deno_core::anyhow::anyhow!( "could not find '{backend}' .app bundle under {} (set via {})", dev_dir.display(), LAUFEY_DEV_DIR_ENV ) }); } let dir = self.ensure_downloaded(backend, target).await?; locate_app_bundle(&dir, backend).ok_or_else(|| { deno_core::anyhow::anyhow!( "could not find '{backend}' .app bundle inside {} (backend may not ship as an app for target '{target}')", dir.display() ) }) } /// Directory containing the backend binary and its support files (used on /// Windows / Linux where support files sit alongside the binary). async fn find_binary_dir( &self, backend: &str, target: &str, ) -> Result<PathBuf, AnyError> { let binary = self.find_binary(backend, target).await?; let parent = binary.parent().ok_or_else(|| { deno_core::anyhow::anyhow!( "LAUFEY backend binary has no parent directory: {}", binary.display() ) })?; Ok(parent.to_path_buf()) } } fn laufey_archive_name(backend: &str, target: &str) -> String { let ext = if target.contains("windows") { "zip" } else { "tar.gz" }; // The `raw` backend ships under the `winit` archive name upstream. let archive_backend = match backend { "raw" => "winit", other => other, }; format!("laufey-{archive_backend}-{target}.{ext}") } fn laufey_release_url(file: &str) -> String { format!( "https://github.com/littledivy/laufey/releases/download/v{LAUFEY_VERSION}/{file}" ) } /// Pick out the hex digest for `file` from a GNU `sha256sum`-style file. Each /// line is `<hex> <filename>` (optionally `<hex> *<filename>` for binary /// mode). fn parse_sha256sum(contents: &str, file: &str) -> Option<String> { for line in contents.lines() { let mut parts = line.split_whitespace(); // Skip blank / whitespace-only lines instead of bailing out of the // whole parse — `?` would terminate the function early on the first // empty line and quietly miss every subsequent entry. let Some(hex) = parts.next() else { continue }; let Some(name) = parts.next() else { continue }; if name.trim_start_matches('*') == file { return Some(hex.to_string()); } } None } fn extract_laufey_archive( name: &str, data: &[u8], dest: &Path, ) -> Result<(), AnyError> { if name.ends_with(".tar.gz") { let decoder = flate2::read::GzDecoder::new(data); let mut archive = tar::Archive::new(decoder); // Strip mode bits from archive entries: a tampered archive could // otherwise ship setuid/setgid binaries into the deno cache. Files keep // the umask-applied default; we re-add execute bits below for entries // that need them. archive.set_preserve_permissions(false); for entry in archive.entries()? { let mut entry = entry?; let entry_path = entry.path()?.into_owned(); // Defence in depth — pre-check `..` / root before handing to // `unpack_in`, since we want a hard error rather than the silent // skip that `unpack_in` does for a rejected entry. if entry_path.components().any(|c| { matches!( c, std::path::Component::ParentDir | std::path::Component::RootDir ) }) { bail!( "refusing tar entry with traversal path: {}", entry_path.display() ); } // `unpack_in` (vs. `unpack(absolute_path)`) makes tar enforce its // symlink + hardlink target containment too: a tar with entry A as // symlink `foo -> ../../etc` followed by entry B writing // `foo/passwd` would otherwise escape `dest`. if !entry.unpack_in(dest)? { bail!( "refusing tar entry that would unpack outside dest: {}", entry_path.display() ); } #[cfg(unix)] { use std::os::unix::fs::PermissionsExt; let dest_path = dest.join(&entry_path); // `symlink_metadata` so we don't follow a just-extracted symlink // and chmod its target. if let Ok(meta) = std::fs::symlink_metadata(&dest_path) && meta.file_type().is_file() { // Was the entry executable? If so, mask to 0o755; otherwise 0o644. let mode = entry.header().mode().unwrap_or(0o644); let safe = if mode & 0o111 != 0 { 0o755 } else { 0o644 }; let mut perms = meta.permissions(); perms.set_mode(safe); let _ = std::fs::set_permissions(&dest_path, perms); } } } } else if name.ends_with(".zip") { let mut archive = zip::ZipArchive::new(std::io::Cursor::new(data))?; // Iterate entries manually rather than `archive.extract(dest)`: that // helper has the same shape as the tar `unpack` we deliberately // avoided (no perm masking; no defence-in-depth against zip-slip // beyond the crate's own checks). Treat the archive as untrusted. for i in 0..archive.len() { let mut entry = archive.by_index(i)?; // `enclosed_name` rejects drive labels, absolute paths and `..` // components. Anything that fails this check is a zip-slip attempt // (or a legitimately weird archive we don't want to handle). let Some(rel_path) = entry.enclosed_name() else { bail!("refusing zip entry with unsafe path: {}", entry.name()); }; // Defence in depth — re-check the components ourselves. if rel_path.components().any(|c| { matches!( c, std::path::Component::ParentDir | std::path::Component::RootDir ) }) { bail!( "refusing zip entry with traversal path: {}", rel_path.display() ); } // Refuse symlinks: with prior entries already extracted, a // symlink-then-write pair is the standard zip-slip-via-symlink // escape, and LAUFEY Windows archives have no legitimate need for // them. if entry.is_symlink() { bail!( "refusing symlink entry in laufey archive: {}", rel_path.display() ); } let dest_path = dest.join(&rel_path); if entry.is_dir() { std::fs::create_dir_all(&dest_path)?; continue; } if let Some(parent) = dest_path.parent() { std::fs::create_dir_all(parent)?; } let mut out = std::fs::File::create(&dest_path)?; std::io::copy(&mut entry, &mut out)?; #[cfg(unix)] { use std::os::unix::fs::PermissionsExt; // Mask to 0o755 / 0o644 — same policy as the tar branch. // setuid/setgid/sticky bits are dropped; world-writable bits too. let mode = entry.unix_mode().unwrap_or(0o644); let safe = if mode & 0o111 != 0 { 0o755 } else { 0o644 }; let _ = std::fs::set_permissions( &dest_path, std::fs::Permissions::from_mode(safe), ); } } } else { bail!("unsupported archive format: {name}"); } Ok(()) } /// Resolve the backend binary path inside an extracted archive directory. fn locate_backend_binary( dir: &Path, backend: &str, target: &str, ) -> Option<PathBuf> { let is_windows = target.contains("windows"); let is_macos = target.contains("apple-darwin"); match backend { "cef" if is_macos => { let p = dir.join("laufey.app/Contents/MacOS/laufey"); p.exists().then_some(p) } "webview" if is_macos => { let p = dir.join("laufey_webview.app/Contents/MacOS/laufey_webview"); p.exists().then_some(p) } _ => { let stem = match backend { "cef" => "laufey", "raw" => "laufey_winit", _ => "laufey_webview", }; let exe = if is_windows { format!("{stem}.exe") } else { stem.to_string() }; let p = dir.join(&exe); p.exists().then_some(p) } } } fn locate_app_bundle(dir: &Path, backend: &str) -> Option<PathBuf> { let name = match backend { "cef" => "laufey.app", _ => "laufey_webview.app", }; let p = dir.join(name); p.exists().then_some(p) } /// Target triple to use when selecting a laufey backend archive. Honors /// `desktop_flags.target` (for cross-target packaging); otherwise defaults to /// the host triple this deno binary was built for. fn laufey_target_for(desktop_flags: &DesktopFlags) -> &str { desktop_flags .target .as_deref() .unwrap_or(LAUFEY_NATIVE_TARGET) } /// Resolve `LAUFEY_DEV_DIR` to a directory path if set and present on disk. fn laufey_dev_dir() -> Option<PathBuf> { let raw = std::env::var(LAUFEY_DEV_DIR_ENV).ok()?; let p = PathBuf::from(raw); p.is_dir().then_some(p) } /// Find a built backend binary inside a laufey checkout. Mirrors the well-known /// build-tree paths produced by laufey's Makefile + Nix flakes. fn locate_dev_backend_binary(laufey: &Path, backend: &str) -> Option<PathBuf> { // The bare build-tree binaries carry the host executable suffix — `.exe` on // Windows, empty elsewhere — so e.g. `cargo build` on Windows emits // `laufey_winit.exe`. The macOS `.app` bundle paths never apply on Windows, // so leaving them unsuffixed is fine. let exe = |rel: &str| laufey.join(format!("{rel}{}", std::env::consts::EXE_SUFFIX)); let candidates: Vec<PathBuf> = match backend { "cef" => vec![ laufey.join("result-cef/Applications/laufey.app/Contents/MacOS/laufey"), laufey.join("result/Applications/laufey.app/Contents/MacOS/laufey"), laufey.join("cef/build/Release/laufey.app/Contents/MacOS/laufey"), laufey.join("cef/build/laufey.app/Contents/MacOS/laufey"), exe("cef/build/Release/laufey"), exe("cef/build/laufey"), ], "raw" => vec![ exe("target/release/laufey_winit"), exe("target/debug/laufey_winit"), ], _ => vec![ laufey.join( "result-1/Applications/laufey_webview.app/Contents/MacOS/laufey_webview", ), laufey .join("result/Applications/laufey_webview.app/Contents/MacOS/laufey_webview"), laufey.join("webview/build/laufey_webview.app/Contents/MacOS/laufey_webview"), exe("webview/build/laufey_webview"), ], }; candidates.into_iter().find(|p| p.exists()) } /// Find a built backend `.app` bundle inside a laufey checkout. fn locate_dev_app_bundle(laufey: &Path, backend: &str) -> Option<PathBuf> { let candidates: Vec<PathBuf> = match backend { "cef" => vec![ laufey.join("result-cef/Applications/laufey.app"), laufey.join("result/Applications/laufey.app"), laufey.join("cef/build/Release/laufey.app"), laufey.join("cef/build/laufey.app"), ], "raw" => return None, _ => vec![ laufey.join("result-1/Applications/laufey_webview.app"), laufey.join("result/Applications/laufey_webview.app"), laufey.join("webview/build/laufey_webview.app"), ], }; candidates.into_iter().find(|p| p.exists()) } /// Read a top-level string from a plist file (XML or binary). /// /// Uses the `plist` crate so a hostile or just-non-trivial Info.plist /// (CDATA, entities, binary plist format, key reordered, etc.) parses /// correctly — the previous string-scan implementation could be tricked /// or silently mis-extract. Returns `None` on any read or parse failure. fn read_plist_string(path: &Path, key: &str) -> Option<String> { let dict: plist::Dictionary = plist::from_file(path).ok()?; dict.get(key)?.as_string().map(|s| s.to_string()) } /// Validate a reverse-DNS bundle identifier (Apple `CFBundleIdentifier`, /// also used for Linux `.desktop` filenames and Windows AppUserModelID). /// /// Apple's rules: ASCII alphanumerics, hyphens, and dots; must have at /// least one dot (so it looks like reverse DNS); each dot-separated /// segment must be non-empty and not start with a digit. We don't /// enforce the segment-leading-letter rule strictly (some legacy apps /// use digits) but we do reject empty segments and obvious shell /// metacharacters — the identifier ends up as a `codesign` argument and /// a path component of the helper bundles. fn validate_bundle_identifier(id: &str) -> Result<(), AnyError> { if id.is_empty() { bail!("bundle identifier is empty"); } if id.len() > 155 { // Apple's documented limit for CFBundleIdentifier on receipts is // 155 chars; bigger values quietly truncate elsewhere in the // toolchain. bail!("bundle identifier {id:?} is longer than 155 characters"); } if !id.contains('.') { bail!( "bundle identifier {id:?} must be in reverse-DNS form (e.g. com.acme.foo)" ); } for c in id.chars() { if !(c.is_ascii_alphanumeric() || c == '.' || c == '-') { bail!( "bundle identifier {id:?} must match [A-Za-z0-9.-]+, but contains {c:?}", ); } } if id.split('.').any(|seg| seg.is_empty()) { bail!("bundle identifier {id:?} has an empty segment"); } Ok(()) } /// Walk every `.app` under `Contents/Frameworks/` and rewrite its /// `CFBundleIdentifier` so it's a strict suffix of `main_bundle_id`. /// /// CEF's process model: when the browser process spawns a helper for a /// child role (gpu, renderer, plugin, …), the helper inspects its own /// `CFBundleIdentifier` and refuses to attach to a parent whose id /// doesn't match it as a prefix. laufey's default helper plists ship with /// `com.example.laufey.helper.*` — which is inconsistent with whatever id /// we wrote into the main bundle, so we'd get a launch-time refusal /// (the helper exits silently and the browser hangs waiting for it). /// /// We compute the new id by extracting the "kind" suffix from the /// existing id (everything from the last `helper` segment onward) and /// concatenating it onto the main id. So `com.example.laufey.helper` → /// `<main>.helper`, `com.example.laufey.helper.gpu` → `<main>.helper.gpu`. fn rewrite_cef_helper_bundle_ids( contents_dir: &Path, main_bundle_id: &str, ) -> Result<(), AnyError> { let frameworks = contents_dir.join("Frameworks"); if !frameworks.exists() { return Ok(()); } let entries = match std::fs::read_dir(&frameworks) { Ok(e) => e, Err(_) => return Ok(()), }; for entry in entries.flatten() { let path = entry.path(); let name = path .file_name() .map(|s| s.to_string_lossy().into_owned()) .unwrap_or_default(); // Only touch helper apps. The CEF framework directory // (`Chromium Embedded Framework.framework`) also lives here and // has its own bundle id that we must not rewrite — touching it // would invalidate its embedded code signature. if !path.is_dir() || !name.ends_with(".app") || !name.contains("Helper") { continue; } let plist_path = path.join("Contents/Info.plist"); if !plist_path.exists() { continue; } rewrite_helper_plist_identifier(&plist_path, main_bundle_id).with_context( || format!("failed to rewrite helper plist at {}", plist_path.display()), )?; } Ok(()) } /// Rewrite a single helper's `CFBundleIdentifier` based on the existing /// value's "kind" suffix. fn rewrite_helper_plist_identifier( plist_path: &Path, main_bundle_id: &str, ) -> Result<(), AnyError> { let mut dict: plist::Dictionary = plist::from_file(plist_path) .with_context(|| format!("failed to parse {}", plist_path.display()))?; let existing = dict .get("CFBundleIdentifier") .and_then(|v| v.as_string()) .ok_or_else(|| { deno_core::anyhow::anyhow!( "helper plist {} has no CFBundleIdentifier", plist_path.display() ) })?; // Extract the suffix from the last `helper` segment onward. Falls back // to a bare `helper` if the existing id doesn't contain that token // (defensive — every laufey helper plist has it today). let suffix = existing .find("helper") .map(|i| &existing[i..]) .unwrap_or("helper"); let new_id = format!("{main_bundle_id}.{suffix}"); dict.insert( "CFBundleIdentifier".to_string(), plist::Value::String(new_id), ); // Write XML format for stability and human-diffability. Helper plists // are tiny so we don't gain anything by switching to binary plist // format; XML is what laufey ships and what `codesign` expects to find. plist::to_file_xml(plist_path, &dict) .with_context(|| format!("failed to write {}", plist_path.display()))?; Ok(()) } /// Codesign the macOS bundle in place. Signs every helper `.app` and /// the embedded CEF framework first, then the main bundle (signatures /// nest: the outer signature's CodeDirectory hashes the inner ones, so /// outer-last is the only order that works). /// /// Re-uses the JIT entitlements that ship with the laufey CEF bundle /// (`Contents/Frameworks/<helper>.app/Contents/Resources/...entitlements...` /// or, more robustly, the per-helper entitlements laufey bundles next to /// each helper). When entitlements aren't present we fall back to /// signing without them — the binary will still launch but V8 won't /// get JIT permission. fn codesign_macos_bundle( app_bundle: &Path, identity: &str, ) -> Result<(), AnyError> { if !cfg!(target_os = "macos") { bail!( "codesigning requires a macOS build host (uses `codesign(1)`). \ Run `deno desktop` on macOS, or drop `macos.codesignIdentity` \ from your deno.json when cross-building." ); } if identity.is_empty() { bail!("macos.codesignIdentity is empty"); } log::info!( "{} bundle with identity {:?}", colors::green("Codesigning"), identity, ); // Read the bundle id from the main Info.plist so we can override the // signed identifier on `Contents/MacOS/laufey`. The default identifier // codesign infers from a bare Mach-O binary is `laufey` (the basename), // which doesn't match the .app's CFBundleIdentifier — and UN refuses // notification authorization when the running binary's signed id // doesn't match the bundle's id. Forcing `--identifier=<bundle_id>` // makes them match. let bundle_id = read_bundle_identifier(app_bundle)?; // Sign helpers first (inside → outside). The order within helpers // doesn't matter — they don't nest into each other. let frameworks = app_bundle.join("Contents/Frameworks"); if frameworks.exists() && let Ok(entries) = std::fs::read_dir(&frameworks) { for entry in entries.flatten() { let path = entry.path(); let Some(name) = path.file_name().map(|s| s.to_string_lossy().into_owned()) else { continue; }; if path.is_dir() && name.ends_with(".app") { let helper_entitlements = locate_helper_entitlements(&path); codesign_one(&path, identity, helper_entitlements.as_deref(), None)?; } else if path.is_dir() && name.ends_with(".framework") { // Frameworks (notably the CEF framework) have an embedded // signature already — `codesign --force` re-signs the // versioned directory in place. codesign_one(&path, identity, None, None)?; } } } // Sign `Contents/MacOS/laufey` (and the launcher shim, the dylib) with // the bundle's id as the signed identifier. This is what UN keys // notification permission to — without it, UN sees `laufey` requesting // permission for `com.example.app` and rejects with // `UNErrorCodeNotificationsNotAllowed`. let macos_dir = app_bundle.join("Contents/MacOS"); if let Ok(entries) = std::fs::read_dir(&macos_dir) { for entry in entries.flatten() { let path = entry.path(); if !path.is_file() { continue; } // Skip non-Mach-O files: the auto-update sentinel (`*.update-ok`), // staged updates (`*.update`, `*.backup`), and the POSIX shell // launcher (`Contents/MacOS/<app>` is a shell script that execs // laufey). codesign can't sign a text file and would fail the // whole signing pass. if !is_macho_file(&path) { continue; } codesign_one(&path, identity, None, Some(&bundle_id))?; } } // Finally the main bundle. Use the browser-process entitlements if // laufey shipped them; otherwise sign with no entitlements (still // launches, just no JIT for V8 in the browser process — which doesn't // host V8 anyway, so this is fine). let browser_entitlements = locate_browser_entitlements(app_bundle); codesign_one(app_bundle, identity, browser_entitlements.as_deref(), None)?; Ok(()) } /// Read `CFBundleIdentifier` out of `Contents/Info.plist` via `plutil`. fn read_bundle_identifier(app_bundle: &Path) -> Result<String, AnyError> { let plist = app_bundle.join("Contents/Info.plist"); let output = std::process::Command::new("plutil") .arg("-extract") .arg("CFBundleIdentifier") .arg("raw") .arg("-o") .arg("-") .arg(&plist) .output() .context("failed to invoke plutil(1) to read CFBundleIdentifier")?; if !output.status.success() { bail!( "plutil could not read CFBundleIdentifier from {}: {}", plist.display(), String::from_utf8_lossy(&output.stderr).trim(), ); } let id = String::from_utf8_lossy(&output.stdout).trim().to_string(); if id.is_empty() { bail!("CFBundleIdentifier is empty in {}", plist.display()); } Ok(id) } /// Cheap Mach-O sniff: a Mach-O file starts with one of the well-known /// magic numbers (32-bit, 64-bit, fat — both endians). Used to skip /// non-binaries inside `Contents/MacOS/` (shell launchers, update /// sentinels) before passing them to `codesign(1)`, which would reject /// them and abort the signing pass. fn is_macho_file(path: &Path) -> bool { let Ok(mut f) = std::fs::File::open(path) else { return false; }; use std::io::Read; let mut magic = [0u8; 4]; if f.read_exact(&mut magic).is_err() { return false; } matches!( u32::from_be_bytes(magic), // FAT_MAGIC / FAT_CIGAM / FAT_MAGIC_64 / FAT_CIGAM_64 0xcafebabe | 0xbebafeca | 0xcafebabf | 0xbfbafeca // MH_MAGIC / MH_CIGAM (32-bit) / MH_MAGIC_64 / MH_CIGAM_64 | 0xfeedface | 0xcefaedfe | 0xfeedfacf | 0xcffaedfe ) } /// Search for a per-helper entitlements plist that laufey bundled with this /// helper. Returns the path if found, `None` otherwise. fn locate_helper_entitlements(helper_app: &Path) -> Option<PathBuf> { // laufey ships these alongside the helper binaries; the exact filename // pattern depends on the laufey build. Probe the well-known names; fall // back to the generic `entitlements-helper.plist` next to Contents. let candidates = [ helper_app.join("Contents/Resources/entitlements-helper.plist"), helper_app .parent() .map(|p| p.join("entitlements-helper.plist")) .unwrap_or_default(), ]; candidates.into_iter().find(|p| p.exists()) } /// Locate the browser-process entitlements plist for the main bundle. fn locate_browser_entitlements(app_bundle: &Path) -> Option<PathBuf> { let candidates = [ app_bundle.join("Contents/Resources/entitlements-browser.plist"), app_bundle.join("Contents/Resources/entitlements.plist"), ]; candidates.into_iter().find(|p| p.exists()) } /// Run `codesign --force [--timestamp --options runtime] --sign <identity> [--entitlements <plist>] <path>`. /// /// `--options runtime` enables the Hardened Runtime, which is required /// for notarization. `--force` overwrites any existing signature; the /// helpers come pre-signed by laufey with an ad-hoc signature that we /// always need to replace. /// /// Ad-hoc identity (`-`) skips `--timestamp` (no cert to anchor a /// timestamp to) and `--options runtime` (Hardened Runtime + ad-hoc is /// a Gatekeeper-rejected combo). Ad-hoc is what we use when the user /// hasn't configured a real signing identity — it's enough for macOS /// to grant the bundle a stable code identity, which UN requires /// before it will hand out notification permission. fn codesign_one( target: &Path, identity: &str, entitlements: Option<&Path>, signing_identifier: Option<&str>, ) -> Result<(), AnyError> { let adhoc = identity == "-"; let mut cmd = std::process::Command::new("codesign"); cmd.arg("--force"); if !adhoc { cmd.arg("--timestamp").arg("--options").arg("runtime"); } if let Some(ident) = signing_identifier { cmd.arg("--identifier").arg(ident); } cmd.arg("--sign").arg(identity); if let Some(ent) = entitlements { cmd.arg("--entitlements").arg(ent); } cmd.arg(target); let status = cmd .stdout(std::process::Stdio::null()) .stderr(std::process::Stdio::inherit()) .status() .context("failed to invoke codesign(1)")?; if !status.success() { bail!( "codesign failed for {} (identity {:?})", target.display(), identity, ); } Ok(()) } /// Re-sign the cached laufey.app's binaries so the running binary's /// identifier matches its host bundle's `CFBundleIdentifier`. Run once /// per fresh download; HMR mode runs laufey.app directly (no per-project /// wrapper), so without this UN sees `Identifier=laufey` / /// `CFBundleIdentifier=com.deno.desktop` and refuses notification /// authorization. Best-effort: failures here are logged but don't /// abort the install, since most desktop features still work without /// notifications. #[cfg(target_os = "macos")] fn harmonize_cached_laufey_identifiers( install_dir: &Path, backend: &str, ) -> Result<(), AnyError> { let laufey_app = locate_laufey_app_in_install(install_dir, backend) .ok_or_else(|| { deno_core::anyhow::anyhow!( "could not find laufey.app under {} to re-sign", install_dir.display() ) })?; harmonize_laufey_app_identifiers(&laufey_app) } /// Idempotently re-sign every Mach-O in `<laufey.app>/Contents/MacOS/` so /// its code-signing identifier matches the bundle's `CFBundleIdentifier`. /// macOS `usernoted` rejects UN authorization with /// `UNErrorCodeNotificationsNotAllowed` when the running binary's /// signed identifier disagrees with the bundle id ("Legacy client X /// connecting to modern client" in the daemon log). The linker-signed /// default for the laufey binary is `Identifier=laufey`, which never matches. /// Safe to call on every launch — binaries already at the correct /// identifier are skipped without a re-sign. #[cfg(target_os = "macos")] fn harmonize_laufey_app_identifiers(laufey_app: &Path) -> Result<(), AnyError> { let bundle_id = read_bundle_identifier(laufey_app)?; let macos_dir = laufey_app.join("Contents/MacOS"); // laufey writes a `.laufey/` runtime-data cache directly inside the bundle's // `Contents/MacOS/` at runtime. When codesign(1) signs the main // executable (`Contents/MacOS/laufey`) it seals the *whole* bundle and trips // over that stray directory with "bundle format unrecognized", aborting // the re-sign — which silently breaks notification permission on every // launch after the first run created the cache. Park it just outside the // bundle while we sign and move it back afterwards (it would regenerate // anyway, but preserving it avoids a cold-start penalty). The guard // restores it even if signing errors out below. let _parked = park_bundle_cache_dir(&macos_dir); for entry in std::fs::read_dir(&macos_dir)?.flatten() { let path = entry.path(); // Skip non-Mach-O files (`Contents/MacOS/` legitimately contains // shell launchers and update sentinels). `is_file` filters both // directories and shell scripts; `is_macho_file` rejects the rest. // Skipping these is critical: handing them to codesign would fail // with "bundle format unrecognized" and abort the run. if !path.is_file() || !is_macho_file(&path) { continue; } if signed_identifier_matches(&path, &bundle_id) { continue; } codesign_one(&path, "-", None, Some(&bundle_id))?; } Ok(()) } /// RAII guard that moves a directory parked by [`park_bundle_cache_dir`] /// back into the bundle on drop. Best-effort: a failed restore is ignored /// (the cache regenerates on next launch). #[cfg(target_os = "macos")] struct ParkedCacheDir { parked_at: PathBuf, restore_to: PathBuf, } #[cfg(target_os = "macos")] impl Drop for ParkedCacheDir { fn drop(&mut self) { if self.parked_at.exists() { let _ = std::fs::rename(&self.parked_at, &self.restore_to); } } } /// If `<macos_dir>/.laufey` exists, move it just outside the `.app` bundle so /// codesign's bundle sealing doesn't choke on it, returning a guard that /// restores it on drop. Returns `None` when there's nothing to park or it /// can't be relocated — in which case signing proceeds as before (and may /// fail loudly, same as the previous behaviour). #[cfg(target_os = "macos")] fn park_bundle_cache_dir(macos_dir: &Path) -> Option<ParkedCacheDir> { let cache = macos_dir.join(".laufey"); if !cache.exists() { return None; } // Park it next to the `.app` (outside `Contents/`, so it's not part of // what codesign seals). `macos_dir` is `<app>/Contents/MacOS`, so three // parents up is the directory containing the `.app`. Staying on the same // volume keeps the rename atomic and cheap. let app_parent = macos_dir.parent()?.parent()?.parent()?; let parked_at = app_parent.join(".laufey-harmonize-parked"); // Clear any debris from a previously interrupted run. if parked_at.exists() { let _ = std::fs::remove_dir_all(&parked_at); } match std::fs::rename(&cache, &parked_at) { Ok(()) => Some(ParkedCacheDir { parked_at, restore_to: cache, }), Err(_) => None, } } /// Read the code-signing identifier of `path` (`codesign -dv` → /// `Identifier=...`). Returns true if it equals `expected`. False if /// we can't read it (unsigned binary, codesign missing) — that means /// "needs a re-sign," which is the correct fall-through for the /// caller. #[cfg(target_os = "macos")] fn signed_identifier_matches(path: &Path, expected: &str) -> bool { let Ok(output) = std::process::Command::new("codesign") .arg("-dv") .arg(path) .output() else { return false; }; // codesign writes its display info to stderr, not stdout. let stderr = String::from_utf8_lossy(&output.stderr); stderr .lines() .filter_map(|l| l.strip_prefix("Identifier=")) .any(|id| id.trim() == expected) } /// Given a path to the laufey Mach-O binary (`laufey.app/Contents/MacOS/laufey`), /// return the containing `.app` bundle. Returns `None` if the path /// doesn't sit inside a `.app` bundle in the expected layout. #[cfg(target_os = "macos")] fn laufey_app_for_binary(binary: &Path) -> Option<PathBuf> { let app = binary.parent()?.parent()?.parent()?; if app.extension().and_then(|s| s.to_str()) == Some("app") { Some(app.to_path_buf()) } else { None } } /// Locate the laufey backend's `.app` bundle within an extracted install /// dir. The release layout varies by backend (`cef/Release/laufey.app`, /// `webview/Release/laufey_webview.app`, etc.) so probe the well-known /// names rather than hardcoding one. #[cfg(target_os = "macos")] fn locate_laufey_app_in_install(dir: &Path, backend: &str) -> Option<PathBuf> { let candidates = match backend { "cef" => vec![ dir.join("laufey.app"), dir.join("Release/laufey.app"), dir.join("cef/Release/laufey.app"), ], "webview" => vec![ dir.join("laufey_webview.app"), dir.join("Release/laufey_webview.app"), dir.join("webview/Release/laufey_webview.app"), ], _ => vec![ dir.join(format!("laufey_{backend}.app")), dir.join("laufey.app"), ], }; candidates.into_iter().find(|p| p.exists()) } /// Reject any name we'd interpolate into a generated launcher script /// (POSIX shell on macOS/Linux, `.bat` on Windows). Even the /// double-quoted positions take expansions: `$`, backticks, `\` in /// bash; `%` and `^` in cmd.exe. The launcher kind context (`kind`) /// is included in the error to make the failure easy to act on. fn validate_launcher_name(name: &str, kind: &str) -> Result<(), AnyError> { if name.is_empty() { bail!("invalid {kind}: name is empty"); } // ASCII-only, alphanumerics + a small whitelist of harmless // punctuation. Spaces are allowed because real macOS .app bundles // commonly have spaces in their executable names. let bad = name.chars().find(|c| { !(c.is_ascii_alphanumeric() || matches!(c, ' ' | '.' | '_' | '-')) }); if let Some(c) = bad { bail!( "invalid {kind} {name:?}: must match [A-Za-z0-9 ._-]+, but contains {c:?}", ); } Ok(()) } /// The pieces of `dylib_path` we feed into the bundlers, with proper /// error messages instead of `unwrap` panics on degenerate inputs like /// `--output /` or `--output .`. struct DylibParts<'a> { parent: &'a Path, file_name: &'a std::ffi::OsStr, app_name: String, } fn dylib_parts(dylib_path: &Path) -> Result<DylibParts<'_>, AnyError> { let parent = dylib_path.parent().ok_or_else(|| { deno_core::anyhow::anyhow!( "invalid --output: dylib path has no parent dir: {}", dylib_path.display() ) })?; let file_name = dylib_path.file_name().ok_or_else(|| { deno_core::anyhow::anyhow!( "invalid --output: dylib path has no file name: {}", dylib_path.display() ) })?; let app_name = dylib_path .file_stem() .ok_or_else(|| { deno_core::anyhow::anyhow!( "invalid --output: dylib path has no file stem: {}", dylib_path.display() ) })? .to_string_lossy() .into_owned(); Ok(DylibParts { parent, file_name, app_name, }) } /// The runtime dylib filename each macOS LAUFEY backend resolves when the /// backend binary is the bundle's CFBundleExecutable (i.e. no `--runtime` /// argument is passed). The two macOS backends use different conventions: /// - `webview` searches a hardcoded `libruntime.dylib` via [NSBundle mainBundle] /// (laufey `webview/src/main_mac.mm`). /// - `cef` derives `<backend-executable-basename>.dylib` next to the binary /// (laufey `LaufeyFindColocatedRuntime`, `cef/src/runtime_loader.cc`). fn macos_runtime_dylib_name(backend: &str, laufey_exe_stem: &str) -> String { if backend == "cef" { format!("{laufey_exe_stem}.dylib") } else { "libruntime.dylib".to_string() } } /// Create a macOS .app bundle from the compiled desktop dylib. /// /// Bundle structure: /// ```text /// AppName.app/ /// Contents/ /// Info.plist (CFBundleExecutable = the LAUFEY backend binary) /// MacOS/ /// laufey_webview (LAUFEY backend binary; the bundle executable) /// libruntime.dylib (compiled Deno runtime + user code; the `cef` /// backend uses `<backend-executable>.dylib` instead) /// Resources/ /// AppIcon.icns (optional) /// ``` async fn package_macos_app_bundle( dylib_path: &Path, desktop_flags: &DesktopFlags, cli_options: &CliOptions, laufey_resolver: &LaufeyBackendResolver, ) -> Result<PathBuf, AnyError> { let parts = dylib_parts(dylib_path)?; let app_name = parts.app_name.clone(); // app_name (from the --output stem) flows unescaped into the Info.plist XML // and the synthesized bundle id, so reject anything outside the safe charset // (the same guard the removed shell launcher applied) instead of emitting a // malformed plist that silently fails to launch. validate_launcher_name(&app_name, "app name")?; let app_bundle = parts.parent.join(format!("{}.app", app_name)); // Find the LAUFEY backend .app and its main executable. let backend = desktop_flags.backend.as_deref().unwrap_or("webview"); let target = laufey_target_for(desktop_flags); let laufey_app = laufey_resolver.find_app_bundle(backend, target).await?; let laufey_executable_name = read_plist_string( &laufey_app.join("Contents/Info.plist"), "CFBundleExecutable", ) .unwrap_or_else(|| "laufey_webview".to_string()); let laufey_binary = laufey_app .join("Contents/MacOS") .join(&laufey_executable_name); if !laufey_binary.exists() { bail!( "LAUFEY backend executable not found at '{}'", laufey_binary.display() ); } // Remove an existing bundle we previously built; never delete unrelated // user data that collided with the inferred `<name>.app` (issue #35510). reserve_app_dir(&app_bundle)?; // Copy the entire LAUFEY .app as the shell (CEF needs Frameworks/, Resources/, etc.). crate::tools::compile::copy_dir_all(&laufey_app, &app_bundle)?; let contents_dir = app_bundle.join("Contents"); let macos_dir = contents_dir.join("MacOS"); let resources_dir = contents_dir.join("Resources"); std::fs::create_dir_all(&resources_dir)?; // Marker lives under Resources/ (not the bundle root) so it is sealed as a // normal resource when the bundle is codesigned below. std::fs::write(resources_dir.join(APP_DIR_MARKER), b"")?; // The backend binary extracts its self-extracting VFS to a sibling // `.<exe>` dir on first run. If the source laufey.app was ever run, that dir // gets copied along with it — drop any such runtime caches. let laufey_exe_stem = Path::new(&laufey_executable_name) .file_stem() .map(|s| s.to_string_lossy().into_owned()) .unwrap_or_else(|| laufey_executable_name.clone()); let cache_dir = macos_dir.join(format!(".{}", laufey_exe_stem)); if cache_dir.exists() { let _ = std::fs::remove_dir_all(&cache_dir); } let cache_file = macos_dir.join(format!(".{}.cache", laufey_exe_stem)); if cache_file.exists() { let _ = std::fs::remove_file(&cache_file); } // Strip unnecessary bulk from the CEF framework. strip_cef_bloat(&contents_dir); // Copy the compiled dylib under the name the active backend resolves without // a `--runtime` argument (see `macos_runtime_dylib_name`). We deliberately do // NOT write a shell-script launcher that execs the backend with `--runtime`: // under LaunchServices (open / Finder / double-click) that `exec` breaks the // app's foreground-GUI registration, so an `NSStatusItem` (Deno.Tray) is // created but never attaches to the menu bar. Instead the backend binary is // the bundle's CFBundleExecutable directly (see Info.plist below), so the // process LaunchServices launches IS the GUI process. See denoland/deno#35619. let dest_dylib = macos_dir.join(macos_runtime_dylib_name(backend, &laufey_exe_stem)); std::fs::copy(dylib_path, &dest_dylib)?; // Resolve the bundle identifier. The user-configured `identifier` is // preferred; otherwise we synthesize one from the app name. The // synthetic form is fine for `deno run`-like local use, but real // distribution wants a stable reverse-DNS identifier — notification // permission (and other tcc-keyed permissions) are decided per // (bundle id, code signature), so a synthetic id changes the user's // grant whenever they rename the app. let bundle_id = match desktop_flags.identifier.as_deref() { Some(id) => { validate_bundle_identifier(id)?; id.to_string() } None => { let slug = app_name.to_lowercase().replace(' ', "-"); format!("com.deno.desktop.{slug}") } }; // Generate Info.plist. The backend binary is the CFBundleExecutable so // there is no shell-script `exec` between LaunchServices and the GUI // process (which would break tray registration — see above). let info_plist = render_macos_info_plist( &app_name, &bundle_id, &laufey_executable_name, desktop_flags.icon.is_some(), ); std::fs::write(contents_dir.join("Info.plist"), info_plist)?; // Rewrite each CEF helper's CFBundleIdentifier to be a strict suffix // of the main bundle id. CEF's process model requires this — the // browser process matches a helper's bundle id prefix against its own // to verify the helper is part of the same app, and the laufey defaults // (`com.example.laufey.helper.*`) don't share a prefix with whatever // identifier we just wrote into the main plist. rewrite_cef_helper_bundle_ids(&contents_dir, &bundle_id)?; // Codesign the assembled bundle. Helpers must be signed before the // main bundle (Gatekeeper / CEF verify them in that order), and the // main bundle's signature seals the helpers' signatures into its // CodeDirectory. // // When the user provides an identity we use it (path to a real // Developer ID for distribution). Otherwise — on a macOS host — we // ad-hoc sign with `-`. Ad-hoc is required for the bundle to receive // a stable code identity from the OS, which gates: // - UNUserNotificationCenter authorization (without signing, // `requestAuthorization` silently fails and the user sees // "denied" with no prompt — this is why Notification.permission // stays "denied" for unsigned dev builds); // - LaunchServices registration under a stable bundle id; // - TCC entries (microphone/camera/automation) attaching to a // persistent identity rather than re-prompting on every rebuild. // We skip on non-macOS hosts (cross-build) since `codesign(1)` only // exists on macOS. // // Register any deep-link URL schemes before signing: codesign seals the // bundle contents, so mutating `Info.plist` afterwards would invalidate // the signature and the app would be rejected on launch. register_deep_links(&app_bundle, desktop_flags)?; let codesign_identity = desktop_flags.codesign_identity.as_deref().or( if cfg!(target_os = "macos") { Some("-") } else { None }, ); if let Some(identity) = codesign_identity { codesign_macos_bundle(&app_bundle, identity)?; } // Handle icon. if let Some(ref icon) = desktop_flags.icon { let dest = resources_dir.join("AppIcon.icns"); match icon { crate::args::IconConfig::Single(path) => { let icon_path = cli_options.initial_cwd().join(path); if icon_path.exists() { match icon_path.extension().and_then(|e| e.to_str()) { Some("icns") => { std::fs::copy(&icon_path, &dest)?; } Some("png") => { crate::tools::compile::convert_png_to_icns(&icon_path, &dest)?; } _ => { log::warn!( "Icon '{}' is not .icns or .png, skipping", icon_path.display() ); } } } else { log::warn!("Icon '{}' not found, skipping", icon_path.display()); } } crate::args::IconConfig::Set(entries) => { convert_icon_set_to_icns(cli_options.initial_cwd(), entries, &dest)?; } } } // Remove the standalone dylib (it's now inside the .app). let _ = std::fs::remove_file(dylib_path); Ok(app_bundle) } // Keep the generated bundle metadata aligned with laufey's macOS app plist, // while carrying the TCC usage-description keys desktop apps need for // microphone/camera/audio-capture prompts. fn render_macos_info_plist( app_name: &str, bundle_id: &str, executable_name: &str, has_icon: bool, ) -> String { format!( r#"<?xml version="1.0" encoding="UTF-8"?> <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> <plist version="1.0"> <dict> <key>CFBundleDevelopmentRegion</key> <string>en</string> <key>CFBundleExecutable</key> <string>{executable_name}</string> <key>CFBundleIconFile</key> <string>{icon_file}</string> <key>CFBundleIdentifier</key> <string>{bundle_id}</string> <key>CFBundleInfoDictionaryVersion</key> <string>6.0</string> <key>CFBundleName</key> <string>{app_name}</string> <key>CFBundlePackageType</key> <string>APPL</string> <key>CFBundleShortVersionString</key> <string>1.0</string> <key>CFBundleVersion</key> <string>1.0.0</string> <key>LSMinimumSystemVersion</key> <string>10.15</string> <key>NSHighResolutionCapable</key> <true/> <key>NSPrincipalClass</key> <string>LaufeyApplication</string> <key>NSSupportsAutomaticGraphicsSwitching</key> <true/> <key>NSAppTransportSecurity</key> <dict> <key>NSAllowsLocalNetworking</key> <true/> </dict> <key>NSMicrophoneUsageDescription</key> <string>{app_name} requires access to the microphone</string> <key>NSCameraUsageDescription</key> <string>{app_name} requires access to the camera</string> <key>NSAudioCaptureUsageDescription</key> <string>{app_name} requires access to audio capture</string> <key>NSBluetoothAlwaysUsageDescription</key> <string>{app_name} requires access to Bluetooth</string> <key>NSBluetoothPeripheralUsageDescription</key> <string>{app_name} requires access to Bluetooth</string> </dict> </plist> "#, app_name = app_name, bundle_id = bundle_id, executable_name = executable_name, icon_file = if has_icon { "AppIcon" } else { "" }, ) } /// Wrap a macOS `.app` bundle in a drag-to-Applications `.dmg` installer. /// /// Builds a staging directory containing the `.app` plus a symlink to /// `/Applications`, then invokes `hdiutil` to create a compressed read-only /// disk image. fn create_macos_dmg( app_bundle: &Path, dmg_path: &Path, ) -> Result<(), AnyError> { let app_name = app_bundle .file_stem() .map(|s| s.to_string_lossy().into_owned()) .unwrap_or_else(|| "App".to_string()); // Stage in a sibling temp directory so hdiutil doesn't traverse // unrelated files. tempfile gives us a unique name (no collision // with concurrent builds) and 0700 mode (no other-user racing or // pre-creating it as a symlink), and cleans up on drop. let parent = dmg_path .parent() .filter(|p| !p.as_os_str().is_empty()) .unwrap_or_else(|| Path::new(".")); std::fs::create_dir_all(parent)?; let staging = tempfile::Builder::new() .prefix(".dmg-staging-") .tempdir_in(parent) .with_context(|| { format!("failed to stage DMG tempdir in {}", parent.display()) })?; // Copy the .app into the staging dir and add an /Applications symlink so // users can drag the app across in the mounted DMG window. let staged_app = staging.path().join( app_bundle .file_name() .ok_or_else(|| deno_core::anyhow::anyhow!("app bundle has no name"))?, ); crate::tools::compile::copy_dir_all(app_bundle, &staged_app)?; #[cfg(unix)] { let _ = std::os::unix::fs::symlink( "/Applications", staging.path().join("Applications"), ); } if dmg_path.exists() { std::fs::remove_file(dmg_path)?; } let status = std::process::Command::new("hdiutil") .args([ "create", "-volname", &app_name, "-srcfolder", &staging.path().display().to_string(), "-ov", "-format", "UDZO", &dmg_path.display().to_string(), ]) .stdout(std::process::Stdio::null()) .stderr(std::process::Stdio::inherit()) .status() .context("Failed to run hdiutil")?; // staging tempdir is removed by its Drop impl when this fn returns. if !status.success() { bail!("hdiutil failed to create DMG at {}", dmg_path.display()); } Ok(()) } /// Zstd-compressed AppImage Type-2 runtime ELF stubs, vendored from /// github.com/AppImage/type2-runtime at tag `20251108`. The selected runtime /// is decompressed and prepended to the SquashFS payload when creating an /// AppImage. const APPIMAGE_RUNTIME_X86_64: &[u8] = include_bytes!(concat!( env!("OUT_DIR"), "/appimage_runtime/runtime-x86_64.zstd" )); const APPIMAGE_RUNTIME_AARCH64: &[u8] = include_bytes!(concat!( env!("OUT_DIR"), "/appimage_runtime/runtime-aarch64.zstd" )); /// 1×1 transparent PNG, used when the caller didn't supply an icon. /// appimagetool-built AppImages expect a top-level `<Name>.png` to exist. const STUB_ICON_PNG: &[u8] = &[ 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d, 0x49, 0x48, 0x44, 0x52, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x08, 0x06, 0x00, 0x00, 0x00, 0x1f, 0x15, 0xc4, 0x89, 0x00, 0x00, 0x00, 0x0d, 0x49, 0x44, 0x41, 0x54, 0x78, 0x9c, 0x63, 0x00, 0x01, 0x00, 0x00, 0x05, 0x00, 0x01, 0x0d, 0x0a, 0x2d, 0xb4, 0x00, 0x00, 0x00, 0x00, 0x49, 0x45, 0x4e, 0x44, 0xae, 0x42, 0x60, 0x82, ]; /// Pick the Type-2 runtime stub for the requested target. Falls back to the /// host arch when `target` is None. The triple's leading component is the /// arch (e.g. `x86_64-unknown-linux-gnu` → `x86_64`). fn appimage_runtime_for_target( target: Option<&str>, ) -> Result<Vec<u8>, AnyError> { let arch = target .and_then(|t| t.split('-').next()) .unwrap_or(std::env::consts::ARCH); let compressed = match arch { "x86_64" => APPIMAGE_RUNTIME_X86_64, "aarch64" => APPIMAGE_RUNTIME_AARCH64, other => bail!( "No bundled AppImage runtime for arch '{other}'; supported: x86_64, aarch64" ), }; let uncompressed_len = u32::from_le_bytes(compressed[..4].try_into().unwrap()); zstd::bulk::decompress(&compressed[4..], uncompressed_len as usize) .with_context(|| { format!("Failed to decompress AppImage runtime for {arch}") }) } /// Unix mode bits for a filesystem entry. On non-Unix hosts (cross-compiling /// a Linux AppImage from Windows/macOS) we don't have real mode bits, so fall /// back to a reasonable default: 0o755 for dirs, 0o644 for files. fn unix_mode_of(meta: &std::fs::Metadata) -> u16 { #[cfg(unix)] { use std::os::unix::fs::PermissionsExt; (meta.permissions().mode() & 0o7777) as u16 } #[cfg(not(unix))] { if meta.is_dir() { 0o755 } else { 0o644 } } } fn node_header(mode: u16) -> backhand::NodeHeader { backhand::NodeHeader { permissions: mode, uid: 0, gid: 0, mtime: 0, } } /// Walk `fs_root` and push every entry into `writer` at the SquashFS root. /// Directories are pushed before their contents (required by backhand). fn push_dir_contents_to_squashfs( writer: &mut backhand::FilesystemWriter<'_, '_, '_>, fs_root: &Path, ) -> Result<(), AnyError> { let mut stack: Vec<PathBuf> = vec![fs_root.to_path_buf()]; while let Some(dir) = stack.pop() { let mut entries: Vec<_> = std::fs::read_dir(&dir)?.collect::<Result<_, _>>()?; entries.sort_by_key(|e| e.file_name()); for entry in entries { let path = entry.path(); let rel = path.strip_prefix(fs_root)?; let arc_path = Path::new("/").join(rel); let meta = std::fs::symlink_metadata(&path)?; let mode = unix_mode_of(&meta); let ft = meta.file_type(); if ft.is_symlink() { let target = std::fs::read_link(&path)?; writer.push_symlink(target, &arc_path, node_header(mode))?; } else if ft.is_dir() { writer.push_dir(&arc_path, node_header(mode))?; stack.push(path); } else { let f = std::fs::File::open(&path)?; writer.push_file(f, &arc_path, node_header(mode))?; } } } Ok(()) } /// Wrap a Linux app directory in an `.AppImage` single-file executable. /// /// Packs the app dir into a SquashFS image via the `backhand` crate, adds the /// AppDir-required entries (`AppRun`, `.desktop`, top-level icon), then /// prepends the vendored AppImage Type-2 runtime ELF for the target arch. /// Pure Rust; works on any build host. fn create_linux_appimage( app_dir: &Path, appimage_path: &Path, target: Option<&str>, ) -> Result<(), AnyError> { use std::io::Cursor; use std::io::Write as _; let app_name = app_dir .file_name() .map(|s| s.to_string_lossy().into_owned()) .unwrap_or_else(|| "App".to_string()); let runtime_elf = appimage_runtime_for_target(target)?; let mut writer = backhand::FilesystemWriter::default(); let compressor = backhand::FilesystemCompressor::new( backhand::compression::Compressor::Zstd, None, ) .context("Failed to configure zstd SquashFS compressor")?; writer.set_compressor(compressor); // Pack everything from the staged app dir into the SquashFS root. push_dir_contents_to_squashfs(&mut writer, app_dir)?; // AppRun is what the AppImage invokes on launch. Thin shell shim that // delegates to the existing launcher (which already sets $DIR and execs // the backend with the right args). let apprun = format!( "#!/bin/sh\n\ DIR=\"$(cd \"$(dirname \"$0\")\" && pwd)\"\n\ exec \"$DIR/{app_name}\" \"$@\"\n", ); writer.push_file( Cursor::new(apprun.into_bytes()), "/AppRun", node_header(0o755), )?; // .desktop entry at the AppDir root. let desktop_entry = format!( "[Desktop Entry]\n\ Type=Application\n\ Name={app_name}\n\ Exec={app_name}\n\ Icon={app_name}\n\ Categories=Utility;\n", ); writer.push_file( Cursor::new(desktop_entry.into_bytes()), format!("/{app_name}.desktop"), node_header(0o644), )?; // Icon at AppDir root named after the app. package_linux_app_dir writes the // user icon as AppIcon.png; if absent, fall back to a 1×1 transparent PNG. let icon_src = app_dir.join("AppIcon.png"); let icon_bytes = if icon_src.exists() { std::fs::read(&icon_src)? } else { STUB_ICON_PNG.to_vec() }; writer.push_file( Cursor::new(icon_bytes), format!("/{app_name}.png"), node_header(0o644), )?; // Serialize the SquashFS to memory. let mut squashfs = Cursor::new(Vec::<u8>::new()); writer .write(&mut squashfs) .context("Failed to write SquashFS image")?; drop(writer); // Assemble: runtime ELF + SquashFS, then mark executable. if let Some(parent) = appimage_path.parent() && !parent.as_os_str().is_empty() { std::fs::create_dir_all(parent)?; } let mut out = std::fs::File::create(appimage_path).with_context(|| { format!("Failed to create AppImage at {}", appimage_path.display()) })?; out.write_all(&runtime_elf)?; out.write_all(&squashfs.into_inner())?; drop(out); #[cfg(unix)] { use std::os::unix::fs::PermissionsExt; std::fs::set_permissions( appimage_path, std::fs::Permissions::from_mode(0o755), )?; } Ok(()) } /// CEF/Chromium shared-library runtime dependencies, as /// `(soname, debian package)` pairs. /// /// The Debian package names go into the `.deb` `Depends` field. The sonames go /// into the `.rpm` `Requires` field: every RPM auto-`Provides` the sonames of /// the shared libraries it ships, so a soname `Requires` resolves correctly on /// Fedora, openSUSE, etc. without hard-coding each distro's divergent package /// names. Too loose a list crashes the app on launch with a missing `.so`; too /// strict blocks install on otherwise-fine systems — this is the curated middle /// covering CEF's GTK/X11/NSS/audio needs. const CEF_RUNTIME_DEPS: &[(&str, &str)] = &[ ("libgtk-3.so.0", "libgtk-3-0"), ("libnss3.so", "libnss3"), ("libasound.so.2", "libasound2"), ("libX11.so.6", "libx11-6"), ("libXcomposite.so.1", "libxcomposite1"), ("libXdamage.so.1", "libxdamage1"), ("libXext.so.6", "libxext6"), ("libXfixes.so.3", "libxfixes3"), ("libXrandr.so.2", "libxrandr2"), ("libgbm.so.1", "libgbm1"), ("libxkbcommon.so.0", "libxkbcommon0"), ("libpango-1.0.so.0", "libpango-1.0-0"), ("libcairo.so.2", "libcairo2"), ("libatk-1.0.so.0", "libatk1.0-0"), ("libdbus-1.so.3", "libdbus-1-3"), ("libexpat.so.1", "libexpat1"), ("libxcb.so.1", "libxcb1"), ("libdrm.so.2", "libdrm2"), ]; /// Default package version when no version is configured. Matches the macOS /// bundle's hard-coded `CFBundleVersion`. const LINUX_PACKAGE_VERSION: &str = "1.0.0"; /// Metadata shared by the `.deb` and `.rpm` builders, derived from the staged /// app dir name and the desktop flags. Avoids new config fields — the package /// name comes from the app dir, the identifier from `--identifier` (or the same /// synthetic `com.deno.desktop.<slug>` default the `.desktop` writer uses). struct LinuxPackageMeta { /// Sanitized lowercase package name (Debian rules: `[a-z0-9][a-z0-9+.-]+`). /// Used as the package name, the `/usr/bin` symlink, and the icon/`.desktop` /// basenames. package: String, /// Human display name (the staged app dir's name), used for `Name=` in the /// `.desktop` entry and the package summary. app_name: String, version: String, maintainer: String, summary: String, /// Reverse-DNS identifier for the `.desktop` `StartupWMClass`. identifier: String, } /// Sanitize an app name into a Debian-style package name: lowercase, only /// `[a-z0-9+.-]`, and a leading alphanumeric (Debian forbids a leading `+`/`-`/ /// `.`). Falls back to `app` when nothing usable remains. fn debian_package_name(app_name: &str) -> String { let mut out = String::with_capacity(app_name.len()); for c in app_name.to_lowercase().chars() { if c.is_ascii_alphanumeric() || matches!(c, '+' | '.' | '-') { out.push(c); } else { out.push('-'); } } let trimmed = out.trim_start_matches(|c: char| !c.is_ascii_alphanumeric()); let trimmed = trimmed.trim_end_matches('-'); if trimmed.len() < 2 { "app".to_string() } else { trimmed.to_string() } } fn linux_package_meta( app_dir: &Path, desktop_flags: &DesktopFlags, ) -> LinuxPackageMeta { let app_name = app_dir .file_name() .map(|s| s.to_string_lossy().into_owned()) .unwrap_or_else(|| "App".to_string()); let package = debian_package_name(&app_name); let identifier = desktop_flags .identifier .clone() .unwrap_or_else(|| format!("com.deno.desktop.{package}")); LinuxPackageMeta { summary: format!("{app_name} desktop application"), maintainer: format!("{app_name} <noreply@deno.com>"), version: LINUX_PACKAGE_VERSION.to_string(), package, app_name, identifier, } } /// Map a target triple (or the host arch when `target` is None) to a Debian /// architecture name. Debian arch names differ from the triple's leading /// component (`x86_64` → `amd64`, `aarch64` → `arm64`). fn debian_arch_for_target( target: Option<&str>, ) -> Result<&'static str, AnyError> { let arch = target .and_then(|t| t.split('-').next()) .unwrap_or(std::env::consts::ARCH); match arch { "x86_64" => Ok("amd64"), "aarch64" => Ok("arm64"), other => bail!( "No Debian architecture mapping for arch '{other}'; supported: x86_64, aarch64" ), } } /// Map a target triple (or the host arch) to an RPM architecture name. RPM /// keeps the triple's arch names (`x86_64`, `aarch64`). fn rpm_arch_for_target(target: Option<&str>) -> Result<&'static str, AnyError> { let arch = target .and_then(|t| t.split('-').next()) .unwrap_or(std::env::consts::ARCH); match arch { "x86_64" => Ok("x86_64"), "aarch64" => Ok("aarch64"), other => bail!( "No RPM architecture mapping for arch '{other}'; supported: x86_64, aarch64" ), } } /// `.desktop` entry installed at `/usr/share/applications/<pkg>.desktop`. /// /// Unlike the in-app-dir `.desktop` (whose `Exec`/`Icon` are relative), this /// one points `Exec` at the package name (resolved via PATH from the /// `/usr/bin/<pkg>` symlink) and `Icon` at the installed hicolor icon name. fn system_desktop_entry(meta: &LinuxPackageMeta) -> String { format!( "[Desktop Entry]\n\ Type=Application\n\ Name={app_name}\n\ Exec={package}\n\ Icon={package}\n\ StartupWMClass={identifier}\n\ Categories=Utility;\n", app_name = meta.app_name, package = meta.package, identifier = meta.identifier, ) } /// Wrap a Linux app directory in a Debian `.deb` package. /// /// A `.deb` is an `ar` archive of three members: `debian-binary` (`2.0\n`), /// `control.tar.gz` (metadata) and `data.tar.gz` (the install tree at absolute /// paths). Built entirely in Rust (`tar` + `flate2`, plus a hand-written `ar` /// header) so it cross-compiles from any build host. Install layout: /// /// ```text /// /usr/lib/<pkg>/ ← staged app dir contents /// /usr/bin/<pkg> ← symlink → ../lib/<pkg>/<launcher> /// /usr/share/applications/<pkg>.desktop /// /usr/share/icons/hicolor/512x512/apps/<pkg>.png /// ``` fn create_linux_deb( app_dir: &Path, deb_path: &Path, desktop_flags: &DesktopFlags, target: Option<&str>, ) -> Result<(), AnyError> { let meta = linux_package_meta(app_dir, desktop_flags); let arch = debian_arch_for_target(target)?; let data_tar_gz = build_deb_data_tar(app_dir, &meta)?; let installed_size_kib = data_tar_gz.installed_size_kib; let depends = CEF_RUNTIME_DEPS .iter() .map(|(_, pkg)| *pkg) .collect::<Vec<_>>() .join(", "); let control = format!( "Package: {package}\n\ Version: {version}\n\ Architecture: {arch}\n\ Maintainer: {maintainer}\n\ Installed-Size: {size}\n\ Depends: {depends}\n\ Section: utils\n\ Priority: optional\n\ Description: {summary}\n", package = meta.package, version = meta.version, maintainer = meta.maintainer, size = installed_size_kib, summary = meta.summary, ); let control_tar_gz = build_deb_control_tar(&control)?; // Assemble the ar archive: global header then the three members in the // order dpkg expects (debian-binary, control, data). let mut out = Vec::new(); out.extend_from_slice(b"!<arch>\n"); ar_append_member(&mut out, "debian-binary", b"2.0\n"); ar_append_member(&mut out, "control.tar.gz", &control_tar_gz); ar_append_member(&mut out, "data.tar.gz", &data_tar_gz.bytes); if let Some(parent) = deb_path.parent() && !parent.as_os_str().is_empty() { std::fs::create_dir_all(parent)?; } std::fs::write(deb_path, &out).with_context(|| { format!("Failed to write .deb at {}", deb_path.display()) })?; Ok(()) } /// Append one member to an `ar` archive. The 60-byte header is the common /// `ar` format dpkg uses: name (space-padded), mtime, uid/gid, mode, decimal /// size, then the `` `\n `` magic. Member data is padded to an even length /// with a trailing newline (per the `ar` spec). fn ar_append_member(out: &mut Vec<u8>, name: &str, data: &[u8]) { let mut header = [b' '; 60]; let name_bytes = name.as_bytes(); header[..name_bytes.len()].copy_from_slice(name_bytes); // mtime (16..28), uid (28..34), gid (34..40) header[16..16 + 1].copy_from_slice(b"0"); header[28..28 + 1].copy_from_slice(b"0"); header[34..34 + 1].copy_from_slice(b"0"); // mode (40..48), octal let mode = b"100644"; header[40..40 + mode.len()].copy_from_slice(mode); // size (48..58), decimal let size = data.len().to_string(); header[48..48 + size.len()].copy_from_slice(size.as_bytes()); // magic (58..60) header[58] = b'`'; header[59] = b'\n'; out.extend_from_slice(&header); out.extend_from_slice(data); if data.len() % 2 == 1 { out.push(b'\n'); } } struct DebDataTar { bytes: Vec<u8>, /// Sum of regular-file sizes rounded up to KiB, for the control file's /// `Installed-Size:` field. installed_size_kib: u64, } /// Build the `data.tar.gz`: the install tree at absolute (`./`-prefixed) paths. fn build_deb_data_tar( app_dir: &Path, meta: &LinuxPackageMeta, ) -> Result<DebDataTar, AnyError> { use flate2::Compression; use flate2::write::GzEncoder; let mut tar_buf: Vec<u8> = Vec::new(); let mut installed_size: u64 = 0; { let mut builder = tar::Builder::new(&mut tar_buf); let push_dir = |b: &mut tar::Builder<&mut Vec<u8>>, path: &str| -> Result<(), AnyError> { let mut h = tar::Header::new_gnu(); h.set_entry_type(tar::EntryType::Directory); h.set_mode(0o755); h.set_uid(0); h.set_gid(0); h.set_mtime(0); h.set_size(0); h.set_cksum(); b.append_data(&mut h, path, std::io::empty())?; Ok(()) }; // Intermediate directories the package owns or shares. The `./` prefix is // conventional for `data.tar`; the `tar` crate strips it (it skips // `CurDir` components), leaving root-relative `usr/...` entries, which dpkg // installs to `/usr/...` identically. for dir in [ "./usr/", "./usr/bin/", "./usr/lib/", &format!("./usr/lib/{}/", meta.package), "./usr/share/", "./usr/share/applications/", "./usr/share/icons/", "./usr/share/icons/hicolor/", "./usr/share/icons/hicolor/512x512/", "./usr/share/icons/hicolor/512x512/apps/", ] { push_dir(&mut builder, dir)?; } // Copy the staged app dir under /usr/lib/<pkg>/. let lib_prefix = format!("./usr/lib/{}", meta.package); let mut stack = vec![app_dir.to_path_buf()]; let mut files: Vec<PathBuf> = Vec::new(); while let Some(dir) = stack.pop() { let mut entries: Vec<_> = std::fs::read_dir(&dir)?.collect::<Result<_, _>>()?; entries.sort_by_key(|e| e.file_name()); for entry in entries { files.push(entry.path()); if std::fs::symlink_metadata(entry.path())?.is_dir() { stack.push(entry.path()); } } } files.sort(); for path in files { let rel = path.strip_prefix(app_dir)?; let arc_path = format!("{}/{}", lib_prefix, rel.to_string_lossy()); let meta_fs = std::fs::symlink_metadata(&path)?; let mode = unix_mode_of(&meta_fs) as u32; let mut h = tar::Header::new_gnu(); h.set_uid(0); h.set_gid(0); h.set_mtime(0); if meta_fs.is_dir() { h.set_entry_type(tar::EntryType::Directory); h.set_mode(if mode == 0 { 0o755 } else { mode }); h.set_size(0); h.set_cksum(); builder.append_data( &mut h, format!("{arc_path}/"), std::io::empty(), )?; } else if meta_fs.file_type().is_symlink() { let link = std::fs::read_link(&path)?; h.set_entry_type(tar::EntryType::Symlink); h.set_mode(0o777); h.set_size(0); h.set_link_name(&link)?; h.set_cksum(); builder.append_data(&mut h, &arc_path, std::io::empty())?; } else { let data = std::fs::read(&path)?; installed_size += data.len() as u64; h.set_entry_type(tar::EntryType::Regular); h.set_mode(if mode == 0 { 0o644 } else { mode }); h.set_size(data.len() as u64); h.set_cksum(); builder.append_data(&mut h, &arc_path, &data[..])?; } } // /usr/bin/<pkg> → ../lib/<pkg>/<launcher> { let mut h = tar::Header::new_gnu(); h.set_entry_type(tar::EntryType::Symlink); h.set_mode(0o777); h.set_uid(0); h.set_gid(0); h.set_mtime(0); h.set_size(0); h.set_link_name(format!("../lib/{}/{}", meta.package, meta.app_name))?; h.set_cksum(); builder.append_data( &mut h, format!("./usr/bin/{}", meta.package), std::io::empty(), )?; } // /usr/share/applications/<pkg>.desktop { let desktop = system_desktop_entry(meta).into_bytes(); installed_size += desktop.len() as u64; let mut h = tar::Header::new_gnu(); h.set_entry_type(tar::EntryType::Regular); h.set_mode(0o644); h.set_uid(0); h.set_gid(0); h.set_mtime(0); h.set_size(desktop.len() as u64); h.set_cksum(); builder.append_data( &mut h, format!("./usr/share/applications/{}.desktop", meta.package), &desktop[..], )?; } // /usr/share/icons/.../<pkg>.png (if the app dir carries an icon) let icon_src = app_dir.join("AppIcon.png"); if icon_src.exists() { let data = std::fs::read(&icon_src)?; installed_size += data.len() as u64; let mut h = tar::Header::new_gnu(); h.set_entry_type(tar::EntryType::Regular); h.set_mode(0o644); h.set_uid(0); h.set_gid(0); h.set_mtime(0); h.set_size(data.len() as u64); h.set_cksum(); builder.append_data( &mut h, format!( "./usr/share/icons/hicolor/512x512/apps/{}.png", meta.package ), &data[..], )?; } builder.finish()?; } let mut gz = Vec::new(); let mut enc = GzEncoder::new(&mut gz, Compression::default()); std::io::Write::write_all(&mut enc, &tar_buf)?; enc.finish()?; Ok(DebDataTar { bytes: gz, installed_size_kib: installed_size.div_ceil(1024).max(1), }) } /// Build the `control.tar.gz` carrying the single `./control` file. fn build_deb_control_tar(control: &str) -> Result<Vec<u8>, AnyError> { use flate2::Compression; use flate2::write::GzEncoder; let mut tar_buf: Vec<u8> = Vec::new(); { let mut builder = tar::Builder::new(&mut tar_buf); let body = control.as_bytes(); let mut h = tar::Header::new_gnu(); h.set_entry_type(tar::EntryType::Regular); h.set_mode(0o644); h.set_uid(0); h.set_gid(0); h.set_mtime(0); h.set_size(body.len() as u64); h.set_cksum(); builder.append_data(&mut h, "./control", body)?; builder.finish()?; } let mut gz = Vec::new(); let mut enc = GzEncoder::new(&mut gz, Compression::default()); std::io::Write::write_all(&mut enc, &tar_buf)?; enc.finish()?; Ok(gz) } /// Wrap a Linux app directory in an RPM `.rpm` package via the pure-Rust `rpm` /// crate (no `rpmbuild`, so it cross-compiles). Same install layout as the /// `.deb`. `Requires` is expressed as CEF shared-library sonames, which resolve /// across RPM distros without hard-coding each one's package names. fn create_linux_rpm( app_dir: &Path, rpm_path: &Path, desktop_flags: &DesktopFlags, target: Option<&str>, ) -> Result<(), AnyError> { let meta = linux_package_meta(app_dir, desktop_flags); let arch = rpm_arch_for_target(target)?; // Zstd payload (see Cargo.toml: gzip would pull flate2's zlib-rs shim, which // link-clashes with deno's zlib-ng), plus a pinned source date for // reproducible builds — mirrors the `mtime: 0` used in the AppImage/`.deb` // paths so identical inputs yield identical packages. let config = rpm::BuildConfig::default() .compression(rpm::CompressionType::Zstd) .source_date(0u32); let mut builder = rpm::PackageBuilder::new( &meta.package, &meta.version, "MIT", arch, &meta.summary, ); builder.using_config(config); builder.description(&meta.summary); builder.vendor(&meta.maintainer); // Own /usr/lib/<pkg>/** (the staged app dir). Standard dirs (/usr, /usr/bin, // /usr/share, …) are deliberately not owned — they belong to the filesystem // package. builder .with_dir(app_dir, format!("/usr/lib/{}", meta.package), |o| o) .context("failed to add app directory to rpm")?; // /usr/bin/<pkg> → ../lib/<pkg>/<launcher> builder .with_symlink(rpm::FileOptions::symlink( format!("/usr/bin/{}", meta.package), format!("../lib/{}/{}", meta.package, meta.app_name), )) .context("failed to add launcher symlink to rpm")?; // /usr/share/applications/<pkg>.desktop — staged to a temp file because the // rpm builder reads file content from disk. let staging = tempfile::Builder::new() .prefix(".deno-desktop-rpm-") .tempdir()?; let desktop_path = staging.path().join("app.desktop"); std::fs::write(&desktop_path, system_desktop_entry(&meta))?; builder .with_file( &desktop_path, rpm::FileOptions::new(format!( "/usr/share/applications/{}.desktop", meta.package )), ) .context("failed to add .desktop file to rpm")?; // /usr/share/icons/.../<pkg>.png (if present) let icon_src = app_dir.join("AppIcon.png"); if icon_src.exists() { builder .with_file( &icon_src, rpm::FileOptions::new(format!( "/usr/share/icons/hicolor/512x512/apps/{}.png", meta.package )), ) .context("failed to add icon to rpm")?; } // RPM auto-`Provides` 64-bit ELF sonames with an `()(64bit)` class suffix // (e.g. `libgtk-3.so.0()(64bit)`), so a bare-soname `Requires` would not // match. Both supported arches (x86_64, aarch64) are 64-bit ELF, so always // append the suffix. for (soname, _) in CEF_RUNTIME_DEPS { builder.requires(rpm::Dependency::any(format!("{soname}()(64bit)"))); } let package = builder.build().context("failed to build rpm package")?; if let Some(parent) = rpm_path.parent() && !parent.as_os_str().is_empty() { std::fs::create_dir_all(parent)?; } let mut out = std::fs::File::create(rpm_path).with_context(|| { format!("Failed to create .rpm at {}", rpm_path.display()) })?; package.write(&mut out).with_context(|| { format!("Failed to write .rpm at {}", rpm_path.display()) })?; Ok(()) } // ===================== Windows .msi installer =========================== // /// Fixed namespace for deriving deterministic MSI GUIDs (ProductCode, /// UpgradeCode, package code, component GUIDs) from the app identity via /// UUIDv5. Deterministic GUIDs keep `.msi` builds reproducible (an /// identical app produces an identical installer) and give a stable /// `UpgradeCode` across versions so newer installers can detect and replace /// older ones — mirroring the `source_date(0)` / `mtime: 0` reproducibility of /// the `.rpm` / `.deb` / AppImage paths. const MSI_GUID_NAMESPACE: uuid::Uuid = uuid::Uuid::from_u128(0x6f1d3c8a_4b2e_4f5a_9c7d_8e0f1a2b3c4d); /// Format a UUID as an MSI registry-format GUID: braced, uppercase, hyphenated /// (e.g. `{6F1D3C8A-4B2E-4F5A-9C7D-8E0F1A2B3C4D}`). This is what the `GUID` /// column category requires (38 chars, no lowercase). fn msi_guid(uuid: uuid::Uuid) -> String { format!("{{{}}}", uuid.as_hyphenated().to_string().to_uppercase()) } /// Derive a deterministic GUID for a given role (e.g. "product:1.0.0", /// "upgrade", "package", "component:c0") of the named app. fn msi_derive_guid(identifier: &str, role: &str) -> String { let name = format!("{identifier}\0{role}"); msi_guid(uuid::Uuid::new_v5(&MSI_GUID_NAMESPACE, name.as_bytes())) } /// Map a target triple (or the host arch) to the MSI summary-info architecture /// string used in the `Template` field. Both supported arches are 64-bit. fn msi_arch_for_target(target: Option<&str>) -> Result<&'static str, AnyError> { let arch = target .and_then(|t| t.split('-').next()) .unwrap_or(std::env::consts::ARCH); match arch { "x86_64" => Ok("x64"), "aarch64" => Ok("Arm64"), other => bail!( "No MSI architecture mapping for arch '{other}'; supported: x86_64, aarch64" ), } } /// Lowercase base36 encoding of a counter, used to mint unique 8.3 short names. fn base36(mut n: u32) -> String { if n == 0 { return "0".to_string(); } const DIGITS: &[u8; 36] = b"0123456789abcdefghijklmnopqrstuvwxyz"; let mut out = Vec::new(); while n > 0 { out.push(DIGITS[(n % 36) as usize]); n /= 36; } out.reverse(); String::from_utf8(out).unwrap() } /// Mint a unique DOS 8.3 short name for the MSI `DefaultDir` / `File.FileName` /// "short|long" syntax. The long name carries the real (possibly long) name; /// the short name only has to be a unique, valid 8.3 token within its /// directory. We derive it from a monotonically increasing counter (globally /// unique ⇒ unique within any directory) so we never have to reconcile /// collisions: `F<base36>` (files, ≤8 chars) plus the real extension truncated /// to 3 uppercased alphanumerics, or `D<base36>` (directories, no extension). fn msi_short_name(counter: u32, long: &str, is_dir: bool) -> String { let token = base36(counter).to_uppercase(); if is_dir { format!("D{token}") } else { let ext: String = long .rsplit_once('.') .map(|(_, e)| e) .unwrap_or("") .chars() .filter(|c| c.is_ascii_alphanumeric()) .take(3) .collect::<String>() .to_uppercase(); if ext.is_empty() { format!("F{token}") } else { format!("F{token}.{ext}") } } } /// A staged file destined for both the embedded cabinet and the MSI `File` /// table. struct MsiFile { /// MSI `File` primary key (also the file's name inside the cabinet). key: String, /// Component the file belongs to (one per install directory). component: String, /// `short|long` `FileName` value. file_name: String, size: u64, abs_path: PathBuf, } /// Wrap a Windows app directory in a Windows Installer `.msi` package. /// /// The MSI database is authored entirely in pure Rust via the `msi` crate, with /// the file payload stored in an embedded MSZIP cabinet (`cab` crate), so it /// cross-compiles from any host — only the *target* must be Windows. The app is /// installed per-machine under `ProgramFiles64Folder\<AppName>\`, mirroring the /// staged app-dir tree exactly; uninstall removes it. Layout: /// /// ```text /// %ProgramFiles%\<AppName>\ /// <AppName>.bat (launcher) /// laufey.exe (CEF backend) /// libcef.dll, ... (CEF support files) /// denort.dll (compiled runtime + user code) /// ... (nested dirs preserved) /// ``` fn create_windows_msi( app_dir: &Path, msi_path: &Path, desktop_flags: &DesktopFlags, target: Option<&str>, ) -> Result<(), AnyError> { use msi::CodePage; use msi::Column; use msi::Insert; use msi::Package; use msi::PackageType; use msi::Value; let app_name = app_dir .file_name() .map(|s| s.to_string_lossy().into_owned()) .unwrap_or_else(|| "App".to_string()); // Version default matches the macOS bundle's CFBundleVersion and the // Linux package default. let version = "1.0.0"; let identifier = desktop_flags .identifier .clone() .unwrap_or_else(|| format!("com.deno.desktop.{}", app_name.to_lowercase())); let manufacturer = "Deno"; let arch = msi_arch_for_target(target)?; // --- Walk the staged tree: register every directory, then every file. ---- // Directories are keyed by their path relative to `app_dir` ("" = the // install root, INSTALLDIR). Sorted traversal keeps IDs deterministic. let mut rel_files: Vec<(PathBuf, u64)> = Vec::new(); let mut rel_dirs: std::collections::BTreeSet<PathBuf> = std::collections::BTreeSet::new(); let mut stack = vec![app_dir.to_path_buf()]; while let Some(dir) = stack.pop() { let mut entries: Vec<_> = std::fs::read_dir(&dir)?.collect::<Result<_, _>>()?; entries.sort_by_key(|e| e.file_name()); for entry in entries { let path = entry.path(); let md = std::fs::symlink_metadata(&path)?; if md.is_dir() { rel_dirs.insert(path.strip_prefix(app_dir)?.to_path_buf()); stack.push(path); } else if md.is_file() { let rel = path.strip_prefix(app_dir)?.to_path_buf(); rel_files.push((rel, md.len())); } // Symlinks are not expected in a Windows app dir (copy_dir_all // dereferences) — skip anything else. } } rel_files.sort(); // Assign a Directory id to every directory. Root → INSTALLDIR; nested dirs → // d0, d1, … in sorted order. let mut dir_ids: std::collections::BTreeMap<PathBuf, String> = std::collections::BTreeMap::new(); dir_ids.insert(PathBuf::new(), "INSTALLDIR".to_string()); for (i, dir) in rel_dirs.iter().enumerate() { dir_ids.insert(dir.clone(), format!("d{i}")); } // The Program Files (64-bit) root that hosts the install dir. Both supported // arches are 64-bit, so we always target the 64-bit Program Files. let pf_folder = "ProgramFiles64Folder"; // --- Directory table rows. ---------------------------------------------- let mut short_counter: u32 = 0; let mut directory_rows: Vec<Vec<Value>> = vec![ vec![ Value::Str("TARGETDIR".to_string()), Value::Null, Value::Str("SourceDir".to_string()), ], vec![ Value::Str(pf_folder.to_string()), Value::Str("TARGETDIR".to_string()), Value::Str(".".to_string()), ], vec![ Value::Str("INSTALLDIR".to_string()), Value::Str(pf_folder.to_string()), Value::Str(format!( "{}|{}", msi_short_name( { short_counter += 1; short_counter }, &app_name, true ), app_name )), ], ]; for dir in &rel_dirs { let id = dir_ids[dir].clone(); let parent = dir_ids[dir.parent().unwrap_or(Path::new(""))].clone(); let name = dir .file_name() .map(|s| s.to_string_lossy().into_owned()) .unwrap_or_else(|| id.clone()); short_counter += 1; directory_rows.push(vec![ Value::Str(id), Value::Str(parent), Value::Str(format!( "{}|{}", msi_short_name(short_counter, &name, true), name )), ]); } // --- Components: one per directory that directly contains files. --------- // A component's KeyPath is its first (sorted) file. Files inherit their // directory's component, so files always install next to their siblings. let mut comp_for_dir: std::collections::BTreeMap<PathBuf, String> = std::collections::BTreeMap::new(); let mut files: Vec<MsiFile> = Vec::new(); for (rel, size) in &rel_files { let dir = rel.parent().unwrap_or(Path::new("")).to_path_buf(); let next_comp = format!("c{}", comp_for_dir.len()); let component = comp_for_dir.entry(dir.clone()).or_insert(next_comp).clone(); let key = format!("f{}", files.len()); let long = rel .file_name() .map(|s| s.to_string_lossy().into_owned()) .unwrap_or_else(|| key.clone()); short_counter += 1; files.push(MsiFile { key, component, file_name: format!( "{}|{}", msi_short_name(short_counter, &long, false), long ), size: *size, abs_path: app_dir.join(rel), }); } if files.is_empty() { bail!("Cannot build a .msi from an empty app directory"); } // Locate the laufey launcher in the install root so we can author a Start Menu // shortcut to it (otherwise the installed app is not discoverable — there is no // icon anywhere, only files under Program Files). The launcher is the backend // binary renamed to `<app>.exe`; it auto-loads the co-located `<app>.dll` // runtime, so the shortcut targets it directly with no arguments. let launcher_exe = format!("{app_name}.exe").to_ascii_lowercase(); let shortcut_target = rel_files .iter() .zip(files.iter()) .find(|((rel, _), _)| { rel.parent() == Some(Path::new("")) && rel .file_name() .and_then(|n| n.to_str()) .is_some_and(|n| n.to_ascii_lowercase() == launcher_exe) }) .map(|(_, f)| (f.key.clone(), f.component.clone())); // The all-users Start Menu folder that hosts the app shortcut. Only added when // we found a launcher to point at. if shortcut_target.is_some() { directory_rows.push(vec![ Value::Str("ProgramMenuFolder".to_string()), Value::Str("TARGETDIR".to_string()), Value::Str(".".to_string()), ]); } // msidbComponentAttributes64bit (256): mark components 64-bit so they // resolve ProgramFiles64Folder and the 64-bit registry view. const COMPONENT_64BIT: i32 = 256; let component_rows: Vec<Vec<Value>> = comp_for_dir .iter() .map(|(dir, comp)| { let dir_id = dir_ids[dir].clone(); let keypath = files .iter() .find(|f| &f.component == comp) .map(|f| f.key.clone()) .unwrap(); vec![ Value::Str(comp.clone()), Value::Str(msi_derive_guid(&identifier, &format!("component:{comp}"))), Value::Str(dir_id), Value::Int(COMPONENT_64BIT), Value::Null, Value::Str(keypath), ] }) .collect(); // --- File table + cabinet payload (shared 1-based sequence). ------------- // msidbFileAttributesVital (512): a failed file install aborts the // transaction. The summary Word Count marks the source compressed, so no // per-file Compressed attribute is needed. const FILE_VITAL: i32 = 512; let file_rows: Vec<Vec<Value>> = files .iter() .enumerate() .map(|(i, f)| { vec![ Value::Str(f.key.clone()), Value::Str(f.component.clone()), Value::Str(f.file_name.clone()), Value::Int(f.size as i32), Value::Null, // Version (not a tracked-version file) Value::Null, // Language Value::Int(FILE_VITAL), Value::Int(1 + i as i32), // Sequence ] }) .collect(); // Build the embedded cabinet: a single MSZIP folder holding every file, // named by its MSI `File` key, in sequence order. let cab_bytes = build_msi_cabinet(&files)?; // --- Author the MSI database. ------------------------------------------- let mut cursor = std::io::Cursor::new(Vec::<u8>::new()); let mut package = Package::create(PackageType::Installer, &mut cursor)?; // The `msi` crate defaults both the database string pool and the summary // info to the UTF-8 codepage (65001). Windows Installer (msiexec) rejects a // UTF-8 database outright — "This installation package could not be opened" // — because an MSI codepage must be a valid ANSI codepage (or neutral). All // our strings are ASCII (file names, GUIDs, ids), so Windows-1252 encodes // them identically and is universally accepted. Set the database codepage // here and the summary-info codepage below. package.set_database_codepage(CodePage::Windows1252); { let summary = package.summary_info_mut(); summary.set_codepage(CodePage::Windows1252); summary.set_title(format!("{app_name} Installer")); summary.set_subject(app_name.clone()); summary.set_author(manufacturer.to_string()); summary.set_comments(format!("{app_name} desktop application")); summary.set_arch(arch); summary.set_languages(&[msi::Language::from_code(1033)]); summary.set_creating_application("deno desktop"); // Package code: a fresh GUID identifying this exact package build. summary.set_uuid(uuid::Uuid::new_v5( &MSI_GUID_NAMESPACE, format!("{identifier}\0package:{version}").as_bytes(), )); // Word Count bit 1 (2) = source files are compressed (in cabinets); bit 0 // clear = long file names allowed. summary.set_word_count(2); // Page Count = minimum Windows Installer version (2.00). summary.set_page_count(200); // Fixed creation time (2020-01-01) for reproducible output. summary.set_creation_time( std::time::UNIX_EPOCH + std::time::Duration::from_secs(1_577_836_800), ); } // Table schemas (subset of the Windows Installer schema needed to install // and uninstall a per-machine app). package.create_table( "Directory", vec![ Column::build("Directory").primary_key().id_string(72), Column::build("Directory_Parent").nullable().id_string(72), Column::build("DefaultDir") .category(msi::Category::DefaultDir) .string(255), ], )?; package.create_table( "Component", vec![ Column::build("Component").primary_key().id_string(72), Column::build("ComponentId") .nullable() .category(msi::Category::Guid) .string(38), Column::build("Directory_").id_string(72), Column::build("Attributes").int16(), Column::build("Condition") .nullable() .category(msi::Category::Condition) .string(255), Column::build("KeyPath").nullable().id_string(72), ], )?; package.create_table( "Feature", vec![ Column::build("Feature").primary_key().id_string(38), Column::build("Feature_Parent").nullable().id_string(38), Column::build("Title").nullable().text_string(64), Column::build("Description").nullable().text_string(255), Column::build("Display").nullable().int16(), Column::build("Level").int16(), Column::build("Directory_").nullable().id_string(72), Column::build("Attributes").int16(), ], )?; package.create_table( "FeatureComponents", vec![ Column::build("Feature_").primary_key().id_string(38), Column::build("Component_").primary_key().id_string(72), ], )?; package.create_table( "File", vec![ Column::build("File").primary_key().id_string(72), Column::build("Component_").id_string(72), Column::build("FileName") .category(msi::Category::Filename) .string(255), Column::build("FileSize").int32(), Column::build("Version") .nullable() .category(msi::Category::Version) .string(72), Column::build("Language").nullable().string(20), Column::build("Attributes").nullable().int16(), Column::build("Sequence").int16(), ], )?; package.create_table( "Media", vec![ Column::build("DiskId").primary_key().int16(), Column::build("LastSequence").int16(), Column::build("DiskPrompt").nullable().text_string(64), Column::build("Cabinet") .nullable() .category(msi::Category::Cabinet) .string(255), Column::build("VolumeLabel").nullable().text_string(32), Column::build("Source") .nullable() .category(msi::Category::Property) .string(72), ], )?; package.create_table( "Property", vec![ Column::build("Property").primary_key().id_string(72), Column::build("Value").text_string(0), ], )?; if shortcut_target.is_some() { package.create_table( "Shortcut", vec![ Column::build("Shortcut").primary_key().id_string(72), Column::build("Directory_").id_string(72), Column::build("Name") .category(msi::Category::Filename) .string(128), Column::build("Component_").id_string(72), Column::build("Target") .category(msi::Category::Shortcut) .string(72), Column::build("Arguments") .nullable() .category(msi::Category::Formatted) .string(255), Column::build("Description").nullable().text_string(255), Column::build("Hotkey").nullable().int16(), Column::build("Icon_").nullable().id_string(72), Column::build("IconIndex").nullable().int16(), Column::build("ShowCmd").nullable().int16(), Column::build("WkDir").nullable().id_string(72), ], )?; } for table in ["InstallExecuteSequence", "InstallUISequence"] { package.create_table( table, vec![ Column::build("Action").primary_key().id_string(72), Column::build("Condition") .nullable() .category(msi::Category::Condition) .string(255), Column::build("Sequence").nullable().int16(), ], )?; } // --- Populate tables. ---------------------------------------------------- package.insert_rows(Insert::into("Directory").rows(directory_rows))?; package.insert_rows(Insert::into("Component").rows(component_rows))?; package.insert_rows(Insert::into("Feature").row(vec![ Value::Str("MainFeature".to_string()), Value::Null, Value::Str(app_name.clone()), Value::Null, Value::Int(1), Value::Int(1), Value::Str("INSTALLDIR".to_string()), Value::Int(0), ]))?; package.insert_rows( Insert::into("FeatureComponents").rows( comp_for_dir .values() .map(|c| { vec![Value::Str("MainFeature".to_string()), Value::Str(c.clone())] }) .collect(), ), )?; package.insert_rows(Insert::into("File").rows(file_rows))?; package.insert_rows(Insert::into("Media").row(vec![ Value::Int(1), Value::Int(files.len() as i32), Value::Null, Value::Str("#appcab".to_string()), Value::Null, Value::Null, ]))?; if let Some((launcher_key, launcher_comp)) = &shortcut_target { short_counter += 1; let short_name = msi_short_name(short_counter, &app_name, false); package.insert_rows(Insert::into("Shortcut").row(vec![ Value::Str("AppShortcut".to_string()), Value::Str("ProgramMenuFolder".to_string()), Value::Str(format!("{short_name}|{app_name}")), Value::Str(launcher_comp.clone()), // Non-advertised shortcut: `[#key]` resolves to the installed exe's path. Value::Str(format!("[#{launcher_key}]")), Value::Null, // Arguments (co-located auto-load) Value::Null, // Description Value::Null, // Hotkey Value::Null, // Icon_ Value::Null, // IconIndex Value::Null, // ShowCmd Value::Str("INSTALLDIR".to_string()), // WkDir ]))?; } let product_code = msi_derive_guid(&identifier, &format!("product:{version}")); let upgrade_code = msi_derive_guid(&identifier, "upgrade"); package.insert_rows(Insert::into("Property").rows(vec![ vec![ Value::Str("ProductCode".to_string()), Value::Str(product_code), ], vec![ Value::Str("ProductName".to_string()), Value::Str(app_name.clone()), ], vec![ Value::Str("ProductVersion".to_string()), Value::Str(version.to_string()), ], vec![ Value::Str("ProductLanguage".to_string()), Value::Str("1033".to_string()), ], vec![ Value::Str("Manufacturer".to_string()), Value::Str(manufacturer.to_string()), ], vec![ Value::Str("UpgradeCode".to_string()), Value::Str(upgrade_code), ], // Per-machine install (into Program Files). vec![ Value::Str("ALLUSERS".to_string()), Value::Str("1".to_string()), ], ]))?; // Standard action sequences for a basic per-machine install + uninstall. let mut exec_seq: Vec<(&str, i32)> = vec![ ("CostInitialize", 800), ("FileCost", 900), ("CostFinalize", 1000), ("InstallValidate", 1400), ("InstallInitialize", 1500), ("ProcessComponents", 1600), ("UnpublishFeatures", 1800), ("RemoveFiles", 3500), ("InstallFiles", 4000), ("RegisterProduct", 6100), ("PublishFeatures", 6300), ("PublishProduct", 6400), ("InstallFinalize", 6600), ]; if shortcut_target.is_some() { // RemoveShortcuts on uninstall; CreateShortcuts after the files land. exec_seq.push(("RemoveShortcuts", 3800)); exec_seq.push(("CreateShortcuts", 4500)); } package.insert_rows( Insert::into("InstallExecuteSequence").rows( exec_seq .iter() .map(|(a, s)| { vec![Value::Str(a.to_string()), Value::Null, Value::Int(*s)] }) .collect(), ), )?; let ui_seq: &[(&str, i32)] = &[ ("CostInitialize", 800), ("FileCost", 900), ("CostFinalize", 1000), ("ExecuteAction", 1300), ]; package.insert_rows( Insert::into("InstallUISequence").rows( ui_seq .iter() .map(|(a, s)| { vec![Value::Str(a.to_string()), Value::Null, Value::Int(*s)] }) .collect(), ), )?; // Embed the cabinet as a stream named to match Media.Cabinet ("#appcab"). { use std::io::Write as _; let mut stream = package.write_stream("appcab")?; stream.write_all(&cab_bytes)?; } package.flush()?; drop(package); if let Some(parent) = msi_path.parent() && !parent.as_os_str().is_empty() { std::fs::create_dir_all(parent)?; } std::fs::write(msi_path, cursor.into_inner()).with_context(|| { format!("Failed to write .msi at {}", msi_path.display()) })?; // The `msi` crate emits each table's rows in the order they were inserted (and // the auto-generated system tables `_Tables`/`_Columns`/`_Validation` in // table-name order), but Windows Installer requires every table's rows to be // sorted ascending by primary key — and for string-typed keys that ordering is // by the row's *string-pool id*, not the string's text. When the two orders // disagree (e.g. `_Validation` has a low string id yet sorts late // alphabetically) real `msiexec` rejects the database at open time with error // 2219 "Invalid Installer database format", even though the file is a valid // compound document and round-trips through the `msi` crate's own reader. Fix // this up in place by re-sorting every persistent table by its primary-key // columns in string-id order. See `msi_sort_tables_by_string_id`. msi_sort_tables_by_string_id(msi_path).with_context(|| { format!("Failed to finalize .msi at {}", msi_path.display()) })?; Ok(()) } /// Decode a Windows Installer stream name back to its logical table name. /// /// MSI stores each table in a compound-file stream whose name is encoded with a /// custom base-64 scheme: code points in `0x3800..0x4800` carry two base-64 /// digits and `0x4800..0x4840` carry one, over the alphabet /// `0-9 A-Z a-z . _`. Table-data streams are additionally prefixed with the /// sentinel `0x4840`, which falls outside both ranges; we return that prefix as /// a leading NUL so callers can tell a real table stream apart from the special /// `\u{5}SummaryInformation` stream. fn msi_demangle_stream_name(name: &str) -> String { const B64: &[u8] = b"0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz._"; let mut out = String::new(); for c in name.chars() { let v = c as u32; if (0x3800..0x4800).contains(&v) { let n = v - 0x3800; out.push(B64[(n & 0x3f) as usize] as char); out.push(B64[((n >> 6) & 0x3f) as usize] as char); } else if (0x4800..0x4840).contains(&v) { let n = v - 0x4800; out.push(B64[(n & 0x3f) as usize] as char); } else if v == 0x4840 { out.push('\0'); } else { out.push(c); } } out } /// Re-sort the rows of a column-major MSI table stream by the given key columns. /// /// `widths` is the byte width of every column (a stream is laid out column by /// column: all of column 0's values, then all of column 1's, …). `keys` lists /// the column indices to sort by, in priority order. Stored values are compared /// as little-endian unsigned integers, which matches MSI's ordering for both /// integer columns and string columns (whose stored value is the string-pool /// id). fn msi_resort_stream(data: &[u8], widths: &[usize], keys: &[usize]) -> Vec<u8> { let row_width: usize = widths.iter().sum(); if row_width == 0 { return data.to_vec(); } let rows = data.len() / row_width; if rows <= 1 { return data.to_vec(); } // Byte offset where each column's run of values begins. let mut col_off = vec![0usize; widths.len()]; let mut acc = 0; for (c, &w) in widths.iter().enumerate() { col_off[c] = acc; acc += rows * w; } let read = |row: usize, col: usize| -> u64 { let base = col_off[col] + row * widths[col]; let mut v = 0u64; for k in 0..widths[col] { v |= (data[base + k] as u64) << (8 * k); } v }; let mut order: Vec<usize> = (0..rows).collect(); order.sort_by(|&a, &b| { for &k in keys { match read(a, k).cmp(&read(b, k)) { std::cmp::Ordering::Equal => continue, ord => return ord, } } a.cmp(&b) }); let mut out = vec![0u8; data.len()]; for (c, &w) in widths.iter().enumerate() { for (new_row, &old_row) in order.iter().enumerate() { let src = col_off[c] + old_row * w; let dst = col_off[c] + new_row * w; out[dst..dst + w].copy_from_slice(&data[src..src + w]); } } out } /// Sort every persistent table in a freshly written `.msi` by primary key in /// string-pool-id order, as Windows Installer requires (see the call site for /// why the `msi` crate's output needs this). Operates in place on the compound /// file, rewriting only the small metadata/table streams — the embedded cabinet /// stream is left untouched. fn msi_sort_tables_by_string_id(msi_path: &Path) -> Result<(), AnyError> { use std::io::Read; use std::io::Write; let mut comp = cfb::open_rw(msi_path)?; let names: Vec<String> = comp .read_storage("/")? .map(|e| e.name().to_string()) .collect(); let read_stream = |comp: &mut cfb::CompoundFile<std::fs::File>, raw: &str| -> Result<Vec<u8>, AnyError> { let mut s = comp.open_stream(format!("/{raw}"))?; let mut b = Vec::new(); s.read_to_end(&mut b)?; Ok(b) }; let write_stream = |comp: &mut cfb::CompoundFile<std::fs::File>, raw: &str, bytes: &[u8]| -> Result<(), AnyError> { let mut s = comp.open_stream(format!("/{raw}"))?; s.write_all(bytes)?; Ok(()) }; let find_raw = |suffix: &str| -> Option<String> { names .iter() .find(|n| msi_demangle_stream_name(n).ends_with(suffix)) .cloned() }; // The string-pool header's top bit selects 3-byte string references; tiny // databases use 2-byte refs, but honor the flag to stay correct at any size. let pool_raw = find_raw("_StringPool").ok_or_else(|| { deno_core::anyhow::anyhow!("malformed .msi: no _StringPool stream") })?; let pool = read_stream(&mut comp, &pool_raw)?; let str_w: usize = if pool.len() >= 4 && (pool[3] & 0x80) != 0 { 3 } else { 2 }; // Map every string-pool id to its text so data-table streams (named by table // text) can be matched to their column schema (keyed by table string id). let data_raw = find_raw("_StringData").ok_or_else(|| { deno_core::anyhow::anyhow!("malformed .msi: no _StringData stream") })?; let str_data = read_stream(&mut comp, &data_raw)?; let mut strings = vec![String::new()]; // 1-based; index 0 is unused. { let mut off = 0usize; let mut i = 4; while i + 4 <= pool.len() { let len = u16::from_le_bytes([pool[i], pool[i + 1]]) as usize; let end = (off + len).min(str_data.len()); strings.push(String::from_utf8_lossy(&str_data[off..end]).into_owned()); off += len; i += 4; } } let id_of = |text: &str| -> Option<u64> { strings.iter().position(|s| s == text).map(|i| i as u64) }; // System tables have a fixed schema not described in `_Columns`; their // string-typed columns use the same `str_w` reference width. let system_tables: &[(&str, Vec<usize>, Vec<usize>)] = &[ ("_Tables", vec![str_w], vec![0]), ("_Columns", vec![str_w, 2, str_w, 2], vec![0, 1]), ( "_Validation", vec![str_w, str_w, str_w, 4, 4, str_w, 2, str_w, str_w, str_w], vec![0, 1], ), ]; for (name, widths, keys) in system_tables { if let Some(raw) = find_raw(name) { let bytes = read_stream(&mut comp, &raw)?; let sorted = msi_resort_stream(&bytes, widths, keys); write_stream(&mut comp, &raw, &sorted)?; } } // Parse the (now sorted) `_Columns` table to recover every persistent table's // column widths and which columns are primary keys. let columns_raw = find_raw("_Columns").ok_or_else(|| { deno_core::anyhow::anyhow!("malformed .msi: no _Columns stream") })?; let columns = read_stream(&mut comp, &columns_raw)?; let col_row_w = str_w + 2 + str_w + 2; // Table, Number, Name, Type let ncol = columns.len() / col_row_w; let read_col = |arr_off: usize, row: usize, w: usize| -> u64 { let base = arr_off + row * w; let mut v = 0u64; for k in 0..w { v |= (columns[base + k] as u64) << (8 * k); } v }; let off_table = 0usize; let off_number = ncol * str_w; let off_type = off_number + ncol * 2 + ncol * str_w; // A column Type word: 0x8000 marks the stored value present (always set here); // 0x0800 marks a string type; 0x2000 marks a primary-key column; for integer // columns the low byte is the storage size (2 or 4 bytes). let width_of = |ty: u64| -> usize { let t = (ty ^ 0x8000) & 0xffff; if (t & 0x0800) != 0 { str_w } else if (t & 0xff) == 4 { 4 } else { 2 } }; let is_key = |ty: u64| -> bool { ((ty ^ 0x8000) & 0x2000) != 0 }; let mut table_columns: std::collections::BTreeMap<u64, Vec<(u64, u64)>> = std::collections::BTreeMap::new(); for r in 0..ncol { let table_id = read_col(off_table, r, str_w); let number = read_col(off_number, r, 2) ^ 0x8000; let ty = read_col(off_type, r, 2); table_columns .entry(table_id) .or_default() .push((number, ty)); } for cols in table_columns.values_mut() { cols.sort_by_key(|&(number, _)| number); } // Re-sort each persistent data-table stream by its primary-key columns. for raw in &names { let demangled = msi_demangle_stream_name(raw); // Table-data streams carry the 0x4840 sentinel (decoded as a leading NUL); // skip the summary-information stream and the already-handled system tables. let Some(table_name) = demangled.strip_prefix('\0') else { continue; }; if table_name.starts_with('_') { continue; } let Some(table_id) = id_of(table_name) else { continue; }; let Some(cols) = table_columns.get(&table_id) else { continue; }; let widths: Vec<usize> = cols.iter().map(|&(_, ty)| width_of(ty)).collect(); let keys: Vec<usize> = cols .iter() .enumerate() .filter(|&(_, &(_, ty))| is_key(ty)) .map(|(i, _)| i) .collect(); if keys.is_empty() { continue; } let bytes = read_stream(&mut comp, raw)?; let sorted = msi_resort_stream(&bytes, &widths, &keys); write_stream(&mut comp, raw, &sorted)?; } comp.flush()?; Ok(()) } /// Build the embedded MSZIP cabinet carrying every install file, named by its /// MSI `File` key, in sequence order. fn build_msi_cabinet(files: &[MsiFile]) -> Result<Vec<u8>, AnyError> { use std::io::Write as _; let mut builder = cab::CabinetBuilder::new(); { let folder = builder.add_folder(cab::CompressionType::MsZip); for f in files { folder.add_file(f.key.clone()); } } let cursor = std::io::Cursor::new(Vec::<u8>::new()); let mut writer = builder .build(cursor) .context("failed to start cabinet for .msi")?; // Files come back in the order they were added; read each one's bytes by its // cab name (the MSI File key) to stay robust to ordering. let by_key: std::collections::HashMap<&str, &MsiFile> = files.iter().map(|f| (f.key.as_str(), f)).collect(); while let Some(mut file_writer) = writer .next_file() .context("failed to advance cabinet writer")? { let name = file_writer.file_name().to_string(); let f = by_key.get(name.as_str()).ok_or_else(|| { deno_core::anyhow::anyhow!("cabinet file {name} missing") })?; let data = std::fs::read(&f.abs_path).with_context(|| { format!("failed to read {} for .msi cabinet", f.abs_path.display()) })?; file_writer.write_all(&data)?; } let cursor = writer.finish().context("failed to finish cabinet")?; Ok(cursor.into_inner()) } /// Recursively copy a directory tree, ensuring writable permissions on the /// destination. This is needed because source files from the Nix store are /// read-only. /// Strip unnecessary files from a CEF-based app bundle to reduce size. /// /// Removes: /// - Non-English locale packs (~47MB) /// - SwiftShader software Vulkan renderer (~16MB, not needed on macOS with Metal) /// - OpenGL ES emulation library (~6.5MB, not needed on macOS with Metal) fn strip_cef_bloat(contents_dir: &Path) { let frameworks_dir = contents_dir.join("Frameworks"); let cef_framework = frameworks_dir .join("Chromium Embedded Framework.framework") .join("Versions") .join("A"); if !cef_framework.exists() { return; } let cef_resources = cef_framework.join("Resources"); let cef_libraries = cef_framework.join("Libraries"); // Remove non-English locale packs. if let Ok(entries) = std::fs::read_dir(&cef_resources) { for entry in entries.flatten() { let name = entry.file_name(); let name = name.to_string_lossy(); if name.ends_with(".lproj") && name != "en.lproj" { let _ = std::fs::remove_dir_all(entry.path()); } } } // Remove SwiftShader (software Vulkan fallback, not needed on macOS with Metal). let _ = std::fs::remove_file(cef_libraries.join("libvk_swiftshader.dylib")); let _ = std::fs::remove_file(cef_libraries.join("vk_swiftshader_icd.json")); } /// Build a macOS `.icns` from an icon set (multiple PNGs at specified sizes). /// /// Writes the ICNS container format directly (macOS 10.7+ accepts PNG bytes /// as the payload for every modern OSType code, so no re-encoding needed). /// Each pixel size maps to one or two OSType codes — the 1x and 2x slots /// that share that pixel dimension: /// 16px → icp4 /// 32px → ic11 (16×16@2x) + icp5 (32×32) /// 64px → ic12 (32×32@2x) /// 128px → ic07 /// 256px → ic13 (128×128@2x) + ic08 (256×256) /// 512px → ic14 (256×256@2x) + ic09 (512×512) /// 1024px→ ic10 (512×512@2x) fn convert_icon_set_to_icns( cwd: &Path, entries: &[crate::args::IconSetEntry], icns_path: &Path, ) -> Result<(), deno_core::error::AnyError> { use std::io::Write; let mut icons: Vec<(&'static [u8; 4], Vec<u8>)> = Vec::new(); for entry in entries { let src = cwd.join(&entry.path); if !src.exists() { log::warn!("Icon '{}' not found, skipping", src.display()); continue; } let data = std::fs::read(&src)?; for code in icns_ostypes_for_size(entry.size) { icons.push((*code, data.clone())); } } if icons.is_empty() { deno_core::anyhow::bail!("No valid icon images found for .icns"); } // File header (8 bytes) + for each icon: 4-byte OSType + 4-byte length + payload. let total_size: u32 = icons .iter() .map(|(_, data)| 8 + data.len() as u32) .sum::<u32>() + 8; let mut buf = Vec::with_capacity(total_size as usize); buf.write_all(b"icns")?; buf.write_all(&total_size.to_be_bytes())?; for (code, data) in &icons { buf.write_all(*code)?; let entry_len = 8 + data.len() as u32; buf.write_all(&entry_len.to_be_bytes())?; buf.write_all(data)?; } std::fs::write(icns_path, &buf)?; Ok(()) } /// Returns the ICNS OSType codes a given pixel size maps to. Multiple codes /// mean the same PNG is embedded under each (matching how the staged /// `.iconset` approach duplicated files across 1x/2x filename slots). fn icns_ostypes_for_size(size: u32) -> &'static [&'static [u8; 4]] { match size { 16 => &[b"icp4"], 32 => &[b"ic11", b"icp5"], 64 => &[b"ic12"], 128 => &[b"ic07"], 256 => &[b"ic13", b"ic08"], 512 => &[b"ic14", b"ic09"], 1024 => &[b"ic10"], _ => { log::warn!( "Icon size {}px doesn't map to a standard macOS iconset slot, skipping", size ); &[] } } } /// Build a Windows `.ico` from an icon set (multiple PNGs at specified sizes). /// /// The ICO format stores PNG images directly (Vista+ supports PNG-compressed /// entries). We write the ICO header, one directory entry per image, then the /// raw PNG data for each. pub fn convert_icon_set_to_ico( cwd: &Path, entries: &[crate::args::IconSetEntry], ico_path: &Path, ) -> Result<(), deno_core::error::AnyError> { use std::io::Write; let mut images: Vec<(u32, Vec<u8>)> = Vec::new(); for entry in entries { let src = cwd.join(&entry.path); if !src.exists() { log::warn!("Icon '{}' not found, skipping", src.display()); continue; } let data = std::fs::read(&src)?; images.push((entry.size, data)); } if images.is_empty() { deno_core::anyhow::bail!("No valid icon images found for .ico"); } let count = images.len() as u16; // ICO header: 6 bytes // Each directory entry: 16 bytes let header_size = 6 + (count as u32) * 16; let mut buf = Vec::new(); // ICO header buf.write_all(&0u16.to_le_bytes())?; // reserved buf.write_all(&1u16.to_le_bytes())?; // type: 1 = ICO buf.write_all(&count.to_le_bytes())?; // image count // Directory entries let mut data_offset = header_size; for (size, data) in &images { // Width/height: 0 means 256 in ICO format let dim = if *size >= 256 { 0u8 } else { *size as u8 }; buf.push(dim); // width buf.push(dim); // height buf.push(0); // color palette count buf.push(0); // reserved buf.write_all(&1u16.to_le_bytes())?; // color planes buf.write_all(&32u16.to_le_bytes())?; // bits per pixel buf.write_all(&(data.len() as u32).to_le_bytes())?; // image data size buf.write_all(&data_offset.to_le_bytes())?; // offset to image data data_offset += data.len() as u32; } // Image data for (_, data) in &images { buf.write_all(data)?; } std::fs::write(ico_path, &buf)?; Ok(()) } /// Spawn a child with macOS TCC "responsibility" disclaimed, so the child /// is its own permission principal instead of inheriting attribution from /// the calling chain (terminal → deno → laufey). /// /// Without this, requests like `UNUserNotificationCenter.requestAuthorization` /// fail immediately with `UNErrorCodeNotificationsNotAllowed` because TCC /// resolves "who's asking" to a process that has no notification bundle id. /// `responsibility_spawnattrs_setdisclaim` is the same SPI `open(1)` uses /// internally — it tells the kernel "this child decides its own permissions". #[cfg(target_os = "macos")] mod disclaim_spawn { use std::ffi::CString; use std::ffi::OsString; use std::os::unix::ffi::OsStrExt; use std::process::ExitStatus; // SPI in libsystem (10.14+). Not in libc's bindings. unsafe extern "C" { fn responsibility_spawnattrs_setdisclaim( attrs: *mut libc::posix_spawnattr_t, disclaim: libc::c_int, ) -> libc::c_int; // macOS 10.15+. libc has the *_addclose family but not *_addchdir_np yet. fn posix_spawn_file_actions_addchdir_np( actions: *mut libc::posix_spawn_file_actions_t, path: *const libc::c_char, ) -> libc::c_int; } pub struct Child { pid: libc::pid_t, exited: bool, } impl Child { pub async fn wait(&mut self) -> std::io::Result<ExitStatus> { use std::os::unix::process::ExitStatusExt; let pid = self.pid; let status_raw = tokio::task::spawn_blocking(move || { let mut status: libc::c_int = 0; loop { // Safety: waiting on our own child pid. let rc = unsafe { libc::waitpid(pid, &mut status, 0) }; if rc < 0 { let err = std::io::Error::last_os_error(); if err.raw_os_error() == Some(libc::EINTR) { continue; } return Err(err); } return Ok(status); } }) .await .map_err(std::io::Error::other)??; self.exited = true; Ok(ExitStatus::from_raw(status_raw)) } } impl Drop for Child { fn drop(&mut self) { // Mirrors tokio's kill_on_drop: if we didn't observe an exit, SIGKILL // the orphan. Polite escalation (TERM-then-KILL) isn't possible inside // sync Drop, and the existing tokio path uses SIGKILL too. if !self.exited { // SAFETY: `kill(2)` with a pid we spawned is always safe to call; a // stale pid simply returns ESRCH, which we ignore. unsafe { libc::kill(self.pid, libc::SIGKILL); } } } } /// Flattened `(program, argv, envp, cwd)` for `posix_spawn`. type SpawnArgs = (CString, Vec<CString>, Vec<CString>, Option<CString>); /// Convert a std::process::Command into the argv/envp/cwd tuple posix_spawn /// needs. Inherits the parent's env, then applies Command::env() overrides /// (matching what std::process::Command does internally). fn flatten(cmd: &std::process::Command) -> std::io::Result<SpawnArgs> { let program = CString::new(cmd.get_program().as_bytes()).map_err(|_| { std::io::Error::new( std::io::ErrorKind::InvalidInput, "program path contains NUL", ) })?; let mut argv: Vec<CString> = Vec::with_capacity(cmd.get_args().len() + 1); argv.push(program.clone()); for a in cmd.get_args() { argv.push(CString::new(a.as_bytes()).map_err(|_| { std::io::Error::new( std::io::ErrorKind::InvalidInput, "argv contains NUL", ) })?); } let mut env_map: std::collections::BTreeMap<OsString, OsString> = std::env::vars_os().collect(); for (k, v) in cmd.get_envs() { match v { Some(v) => { env_map.insert(k.to_os_string(), v.to_os_string()); } None => { env_map.remove(k); } } } let envp: Vec<CString> = env_map .into_iter() .map(|(k, v)| { let mut s = Vec::with_capacity(k.as_bytes().len() + 1 + v.as_bytes().len()); s.extend_from_slice(k.as_bytes()); s.push(b'='); s.extend_from_slice(v.as_bytes()); CString::new(s).map_err(|_| { std::io::Error::new( std::io::ErrorKind::InvalidInput, "env contains NUL", ) }) }) .collect::<std::io::Result<_>>()?; let cwd = match cmd.get_current_dir() { Some(p) => { Some(CString::new(p.as_os_str().as_bytes()).map_err(|_| { std::io::Error::new(std::io::ErrorKind::InvalidInput, "cwd has NUL") })?) } None => None, }; Ok((program, argv, envp, cwd)) } pub fn spawn(cmd: &std::process::Command) -> std::io::Result<Child> { let (program, argv, envp, cwd) = flatten(cmd)?; let mut argv_ptrs: Vec<*mut libc::c_char> = argv.iter().map(|c| c.as_ptr() as *mut _).collect(); argv_ptrs.push(std::ptr::null_mut()); let mut envp_ptrs: Vec<*mut libc::c_char> = envp.iter().map(|c| c.as_ptr() as *mut _).collect(); envp_ptrs.push(std::ptr::null_mut()); // SAFETY: posix_spawn FFI. We initialize attrs/actions before use, // destroy them on every exit path, and keep argv/envp CString backing // alive until posix_spawn returns. Spawn inherits fds 0/1/2 by default // (no file action redirects them), which is the stdio behavior the // caller expects. unsafe { let mut attrs: libc::posix_spawnattr_t = std::mem::zeroed(); if libc::posix_spawnattr_init(&mut attrs) != 0 { return Err(std::io::Error::last_os_error()); } let res = (|| -> std::io::Result<libc::pid_t> { let mut actions: libc::posix_spawn_file_actions_t = std::mem::zeroed(); if libc::posix_spawn_file_actions_init(&mut actions) != 0 { return Err(std::io::Error::last_os_error()); } let inner = (|| -> std::io::Result<libc::pid_t> { // The disclaim. Ignored if the SPI is missing (hypothetical // older system) — caller just falls back to inherited TCC. let _ = responsibility_spawnattrs_setdisclaim(&mut attrs, 1); if let Some(cwd) = cwd.as_ref() { let rc = posix_spawn_file_actions_addchdir_np(&mut actions, cwd.as_ptr()); if rc != 0 { return Err(std::io::Error::from_raw_os_error(rc)); } } let mut pid: libc::pid_t = 0; let rc = libc::posix_spawn( &mut pid, program.as_ptr(), &actions, &attrs, argv_ptrs.as_mut_ptr(), envp_ptrs.as_mut_ptr(), ); if rc != 0 { return Err(std::io::Error::from_raw_os_error(rc)); } Ok(pid) })(); libc::posix_spawn_file_actions_destroy(&mut actions); inner })(); libc::posix_spawnattr_destroy(&mut attrs); let pid = res?; Ok(Child { pid, exited: false }) } } } #[cfg(test)] mod tests { use super::*; // --- macOS Info.plist --- #[test] fn macos_info_plist_includes_principal_class_and_tcc_usage_strings() { let plist = render_macos_info_plist( "Deno Demo", "com.deno.demo", "laufey_webview", true, ); assert!(plist.contains("<string>LaufeyApplication</string>")); // The backend binary must be the CFBundleExecutable (not a shell-script // launcher named after the app) so LaunchServices launches the GUI // process directly — otherwise the tray icon never attaches. #35619. assert!(plist.contains( "<key>CFBundleExecutable</key>\n <string>laufey_webview</string>" )); assert!( plist.contains("<key>NSMicrophoneUsageDescription</key>") && plist.contains("Deno Demo requires access to the microphone") ); assert!( plist.contains("<key>NSCameraUsageDescription</key>") && plist.contains("Deno Demo requires access to the camera") ); assert!( plist.contains("<key>NSAudioCaptureUsageDescription</key>") && plist.contains("Deno Demo requires access to audio capture") ); assert!( plist.contains("<key>NSBluetoothAlwaysUsageDescription</key>") && plist.contains("Deno Demo requires access to Bluetooth") ); assert!(plist.contains("<string>AppIcon</string>")); } #[test] fn macos_runtime_dylib_name_is_backend_specific() { // webview resolves a hardcoded libruntime.dylib via [NSBundle mainBundle]; // cef resolves <executable-basename>.dylib next to the backend binary. assert_eq!( macos_runtime_dylib_name("webview", "laufey_webview"), "libruntime.dylib" ); assert_eq!( macos_runtime_dylib_name("cef", "laufey_cef"), "laufey_cef.dylib" ); } // --- laufey_archive_name / laufey_release_url --- #[test] fn archive_name_extensions() { assert_eq!( laufey_archive_name("cef", "aarch64-apple-darwin"), "laufey-cef-aarch64-apple-darwin.tar.gz" ); assert_eq!( laufey_archive_name("webview", "x86_64-pc-windows-msvc"), "laufey-webview-x86_64-pc-windows-msvc.zip", "windows targets must use zip, not tar.gz — the bundled 7z step assumes it" ); } #[test] fn archive_name_raw_aliases_to_winit() { // `raw` is the public name; the GitHub releases ship under `winit`. // A regression here would 404 every backend download for raw users. assert_eq!( laufey_archive_name("raw", "x86_64-unknown-linux-gnu"), "laufey-winit-x86_64-unknown-linux-gnu.tar.gz" ); } #[test] fn release_url_uses_v_prefix() { let url = laufey_release_url("laufey-cef-aarch64-apple-darwin.tar.gz"); assert!( url.starts_with( "https://github.com/littledivy/laufey/releases/download/v" ) ); assert!(url.ends_with("/laufey-cef-aarch64-apple-darwin.tar.gz")); // No spaces, no shell metachars — this string is fed to `curl` and to // log messages. assert!(!url.contains(' ')); } // --- parse_sha256sum --- #[test] fn parse_sha256sum_basic() { let contents = "\ abc123 file-a.tar.gz def456 file-b.zip "; assert_eq!( parse_sha256sum(contents, "file-a.tar.gz").as_deref(), Some("abc123") ); assert_eq!( parse_sha256sum(contents, "file-b.zip").as_deref(), Some("def456") ); assert_eq!(parse_sha256sum(contents, "missing").as_deref(), None); } #[test] fn parse_sha256sum_handles_binary_mode_star() { // GNU sha256sum's binary mode emits `<hex> *filename`. We must // strip the leading star or we'll fail to match every Windows // artifact line. let contents = "abc123 *file-a.zip\n"; assert_eq!( parse_sha256sum(contents, "file-a.zip").as_deref(), Some("abc123") ); } #[test] fn parse_sha256sum_tolerates_blank_and_extra_whitespace() { let contents = "abc123 file.tar.gz \t def456 other.zip "; assert_eq!( parse_sha256sum(contents, "file.tar.gz").as_deref(), Some("abc123") ); assert_eq!( parse_sha256sum(contents, "other.zip").as_deref(), Some("def456") ); } // --- parse_dev_server_url --- #[test] fn dev_server_url_matches() { assert_eq!( parse_dev_server_url(" ➜ Local: http://localhost:5173/").as_deref(), Some("http://localhost:5173/") ); assert_eq!( parse_dev_server_url(" Local: https://localhost:5173/").as_deref(), Some("https://localhost:5173/") ); } #[test] fn dev_server_url_matches_colored_output() { assert_eq!( parse_dev_server_url( "\x1b[32m➜\x1b[39m \x1b[1mLocal\x1b[22m: \ \x1b[36mhttp://127.0.0.1:\x1b[1m5173\x1b[22m/\x1b[39m" ) .as_deref(), Some("http://127.0.0.1:5173/") ); } #[test] fn dev_server_url_matches_with_subpath() { assert_eq!( parse_dev_server_url(" Local: http://localhost:5173/app").as_deref(), Some("http://localhost:5173/app") ); assert_eq!( parse_dev_server_url(" Local: http://192.168.1.1:5173").as_deref(), Some("http://192.168.1.1:5173") ); assert_eq!( parse_dev_server_url(" Local: https://localhost:5173/app").as_deref(), Some("https://localhost:5173/app") ); } #[test] fn dev_server_url_no_match() { assert_eq!(parse_dev_server_url("Watching for file changes..."), None); assert_eq!(parse_dev_server_url(""), None); assert_eq!( parse_dev_server_url(" Network: http://192.168.1.1:5173/"), None ); } // --- validate_bundle_identifier --- #[test] fn bundle_id_accepts_canonical() { for ok in &[ "com.deno.app", "com.deno.my-app", "com.deno.app.v2", "A.B", "com.deno.app.helper", ] { assert!( validate_bundle_identifier(ok).is_ok(), "{ok:?} should be accepted" ); } } #[test] fn bundle_id_rejects_bad_shapes() { let cases = &[ ("", "empty"), ("noseparator", "no dot"), ("com.deno.", "trailing empty segment"), (".com.deno", "leading empty segment"), ("com..deno", "doubled dot"), ("com.deno.app/foo", "slash"), ("com.deno.app foo", "space"), ("com.deno.app$bar", "shell metachar"), ("com.deno.app;rm", "semicolon"), ("com.deno.app\nx", "newline"), ]; for (bad, why) in cases { assert!( validate_bundle_identifier(bad).is_err(), "{bad:?} should be rejected ({why})" ); } } #[test] fn bundle_id_rejects_too_long() { let long = format!("com.deno.{}", "x".repeat(200)); assert!(validate_bundle_identifier(&long).is_err()); // Right at the boundary. let just_too_long = format!("com.deno.{}", "x".repeat(155)); assert!( validate_bundle_identifier(&just_too_long).is_err(), "ids longer than 155 chars must be rejected (Apple receipt limit)" ); } // --- validate_launcher_name --- #[test] fn launcher_name_accepts_canonical() { for ok in &["my-app", "My App", "app_1.0", "FooBar", "a"] { assert!( validate_launcher_name(ok, "test").is_ok(), "{ok:?} should be accepted" ); } } #[test] fn launcher_name_rejects_shell_metachars() { // Every char that could let a value escape an unquoted .bat / .sh // launcher line must be rejected. let cases = &[ ("", "empty"), ("a&b", "ampersand"), ("a;b", "semicolon"), ("a|b", "pipe"), ("a$b", "dollar"), ("a`b", "backtick"), ("a'b", "single quote"), ("a\"b", "double quote"), ("a\\b", "backslash"), ("a/b", "slash"), ("a\nb", "newline"), ("a\rb", "carriage return"), ("a\tb", "tab"), ("a\0b", "NUL"), ("café", "non-ascii"), ]; for (bad, why) in cases { assert!( validate_launcher_name(bad, "test").is_err(), "{bad:?} should be rejected ({why})" ); } } #[test] fn launcher_name_error_message_includes_kind() { let err = validate_launcher_name("bad/name", "LAUFEY backend binary name") .unwrap_err() .to_string(); assert!( err.contains("LAUFEY backend binary name"), "error should label what was invalid; got: {err}" ); assert!( err.contains("/"), "error should name the bad char; got: {err}" ); } // --- dylib_parts --- #[test] fn dylib_parts_basic() { let p = std::path::PathBuf::from("/tmp/app/myapp.dylib"); let parts = dylib_parts(&p).expect("parts"); assert_eq!(parts.parent, std::path::Path::new("/tmp/app")); assert_eq!(parts.file_name, "myapp.dylib"); assert_eq!(parts.app_name, "myapp"); } #[test] fn dylib_parts_strips_libdenort_prefix() { // .so on Linux carries the `lib` prefix in the file stem; the // resulting bundle name shouldn't include it (the user typed // `--output myapp`, not `libmyapp`). let p = std::path::PathBuf::from("/tmp/libdenort.so"); let parts = dylib_parts(&p).expect("parts"); // dylib_parts itself doesn't strip; that's downstream. But the // pieces should at least round-trip cleanly. assert_eq!(parts.file_name, "libdenort.so"); assert_eq!(parts.app_name, "libdenort"); } #[test] fn dylib_parts_rejects_degenerate_inputs() { // The whole point of this helper is to surface friendly errors // instead of the `file_stem().unwrap()` panic the old code had // on `--output /` and `--output .`. assert!(dylib_parts(std::path::Path::new("/")).is_err()); // `dylib_parts` doesn't reject "" by itself — Path::new("") still // has a parent (None on some platforms, Some("") on others). Spot // a degenerate case that previously panicked. let empty = std::path::Path::new(""); let _ = dylib_parts(empty); // not panicking is the regression test } // --- appimage_runtime_for_target --- #[test] fn appimage_runtime_target_arch_lookup() { let x86_64 = appimage_runtime_for_target(Some("x86_64-unknown-linux-gnu")).unwrap(); assert_eq!(x86_64.len(), 944_632); assert_eq!(&x86_64[..4], b"\x7fELF"); let aarch64 = appimage_runtime_for_target(Some("aarch64-unknown-linux-gnu")).unwrap(); assert_eq!(aarch64.len(), 936_456); assert_eq!(&aarch64[..4], b"\x7fELF"); } #[test] fn appimage_runtime_rejects_unknown_arch() { let err = appimage_runtime_for_target(Some("powerpc64-unknown-linux-gnu")) .unwrap_err() .to_string(); assert!( err.contains("powerpc64"), "error should name the arch: {err}" ); assert!( err.contains("x86_64") && err.contains("aarch64"), "error should list supported arches: {err}" ); } // --- icns_ostypes_for_size --- #[test] fn icns_ostypes_known_sizes() { // These OSType codes are part of the macOS iconset spec; a change // here means Finder will silently skip the icon. Pin them. assert_eq!(icns_ostypes_for_size(16), &[b"icp4"]); assert_eq!(icns_ostypes_for_size(32), &[b"ic11", b"icp5"]); assert_eq!(icns_ostypes_for_size(64), &[b"ic12"]); assert_eq!(icns_ostypes_for_size(128), &[b"ic07"]); assert_eq!(icns_ostypes_for_size(256), &[b"ic13", b"ic08"]); assert_eq!(icns_ostypes_for_size(512), &[b"ic14", b"ic09"]); assert_eq!(icns_ostypes_for_size(1024), &[b"ic10"]); } #[test] fn icns_ostypes_unknown_size_returns_empty() { assert!(icns_ostypes_for_size(0).is_empty()); assert!(icns_ostypes_for_size(48).is_empty()); assert!(icns_ostypes_for_size(999).is_empty()); } // --- extract_laufey_archive --- // // These tests build tar.gz fixtures in-memory and feed them through // extract_laufey_archive. They mirror the manual checklist items 1.33–1.37: // a malicious archive must be rejected, a normal one must extract, // and setuid/world-writable bits must never reach disk. use std::io::Read; use std::io::Write; fn make_tar_gz(entries: &[(&str, tar::EntryType, &[u8], u32)]) -> Vec<u8> { let mut tar_buf: Vec<u8> = Vec::new(); { let mut builder = tar::Builder::new(&mut tar_buf); for (name, ty, data, mode) in entries { let mut header = tar::Header::new_gnu(); header.set_size(data.len() as u64); header.set_mode(*mode); header.set_mtime(0); header.set_entry_type(*ty); header.set_cksum(); builder .append_data(&mut header, name, &data[..]) .expect("tar append"); } builder.finish().expect("tar finish"); } let mut gz = Vec::new(); let mut enc = flate2::write::GzEncoder::new(&mut gz, flate2::Compression::default()); enc.write_all(&tar_buf).expect("gz write"); enc.finish().expect("gz finish"); gz } fn make_tar_gz_symlink(name: &str, target: &str) -> Vec<u8> { let mut tar_buf: Vec<u8> = Vec::new(); { let mut builder = tar::Builder::new(&mut tar_buf); let mut header = tar::Header::new_gnu(); header.set_size(0); header.set_mode(0o777); header.set_mtime(0); header.set_entry_type(tar::EntryType::Symlink); header.set_link_name(target).expect("set_link_name"); header.set_cksum(); builder .append_data(&mut header, name, std::io::empty()) .expect("tar append symlink"); builder.finish().expect("tar finish"); } let mut gz = Vec::new(); let mut enc = flate2::write::GzEncoder::new(&mut gz, flate2::Compression::default()); enc.write_all(&tar_buf).expect("gz write"); enc.finish().expect("gz finish"); gz } #[test] fn extract_tar_normal_succeeds() { let tmp = tempfile::tempdir().unwrap(); let gz = make_tar_gz(&[ ("greet", tar::EntryType::Regular, b"hello", 0o755), ("notes/readme.txt", tar::EntryType::Regular, b"docs", 0o644), ]); extract_laufey_archive("ok.tar.gz", &gz, tmp.path()).expect("extract"); let mut out = String::new(); std::fs::File::open(tmp.path().join("greet")) .unwrap() .read_to_string(&mut out) .unwrap(); assert_eq!(out, "hello"); assert!(tmp.path().join("notes/readme.txt").exists()); } #[test] fn extract_tar_rejects_parent_dir_traversal() { // Hand-craft a tar block with `../escape.txt` as the name: the // `tar` crate's `Builder` refuses to *write* such a path (a safety // feature in the producer), so a unit test that goes through it // wouldn't actually exercise extract_laufey_archive's reader-side // check. We construct the 512-byte ustar header directly to get a // genuinely-malicious archive on the wire. fn ustar_block(name: &str, body: &[u8]) -> Vec<u8> { let mut hdr = [0u8; 512]; // name (offset 0..100) let nb = name.as_bytes(); hdr[..nb.len()].copy_from_slice(nb); // mode "000644 \0" octal (offset 100..108) hdr[100..108].copy_from_slice(b"000644 \0"); // uid/gid zeroed via spaces+NUL hdr[108..116].copy_from_slice(b"000000 \0"); hdr[116..124].copy_from_slice(b"000000 \0"); // size in octal let sz = format!("{:011o} ", body.len()); hdr[124..136].copy_from_slice(sz.as_bytes()); // mtime hdr[136..148].copy_from_slice(b"00000000000 "); // checksum placeholder (8 spaces) hdr[148..156].copy_from_slice(b" "); // typeflag '0' = regular file hdr[156] = b'0'; // magic "ustar" then null then version "00" hdr[257..263].copy_from_slice(b"ustar\0"); hdr[263..265].copy_from_slice(b"00"); // checksum: unsigned sum of all bytes, written as 6 octal digits // + NUL + space at offset 148..156. let sum: u32 = hdr.iter().map(|&b| b as u32).sum(); let cs = format!("{:06o}\0 ", sum); hdr[148..156].copy_from_slice(cs.as_bytes()); let mut out = Vec::with_capacity(512 + body.len().div_ceil(512) * 512); out.extend_from_slice(&hdr); out.extend_from_slice(body); // pad body to 512. let pad = (512 - body.len() % 512) % 512; out.extend(std::iter::repeat_n(0u8, pad)); // two zero blocks for end-of-archive marker. out.extend(std::iter::repeat_n(0u8, 1024)); out } let raw_tar = ustar_block("../escape.txt", b"oops"); let mut gz = Vec::new(); let mut enc = flate2::write::GzEncoder::new(&mut gz, flate2::Compression::default()); enc.write_all(&raw_tar).unwrap(); enc.finish().unwrap(); let tmp = tempfile::tempdir().unwrap(); let err = extract_laufey_archive("evil.tar.gz", &gz, tmp.path()) .expect_err("malicious `..` path must be rejected"); let msg = err.to_string(); assert!( msg.contains("traversal") || msg.contains("outside"), "error must indicate the rejection reason; got: {msg}" ); // The sibling file must not exist — defence-in-depth check passed. assert!(!tmp.path().parent().unwrap().join("escape.txt").exists()); } #[test] fn extract_tar_rejects_symlink_escape() { let tmp = tempfile::tempdir().unwrap(); // A bare symlink whose target escapes the dest. unpack_in must // refuse: the test name documents the canonical zip-slip-via-symlink // pattern (entry A = symlink escape, entry B writes through it). let gz = make_tar_gz_symlink("foo", "../../etc/passwd"); let _ = extract_laufey_archive("evil.tar.gz", &gz, tmp.path()); // Tar's `unpack_in` is allowed to either error or skip the entry — // both behaviours mean the symlink didn't land in dest. Either is // acceptable; what matters is that nothing escaped. assert!( !tmp.path().join("foo").exists() || std::fs::symlink_metadata(tmp.path().join("foo")) .map(|m| m.file_type().is_symlink()) .unwrap_or(false), "if a symlink was extracted it must live inside dest" ); // The target itself never came into existence under dest. assert!(!tmp.path().join("etc/passwd").exists()); } #[cfg(unix)] #[test] fn extract_tar_strips_setuid_bits() { use std::os::unix::fs::PermissionsExt; let tmp = tempfile::tempdir().unwrap(); // Setuid + setgid + world-writable + executable. All the bad bits // we never want extracted to disk. let gz = make_tar_gz(&[( "exe", tar::EntryType::Regular, b"#!/bin/sh\necho gotcha\n", 0o7777, )]); extract_laufey_archive("perm.tar.gz", &gz, tmp.path()).expect("extract"); let meta = std::fs::metadata(tmp.path().join("exe")).unwrap(); let mode = meta.permissions().mode() & 0o7777; // We normalize execute-bit-set files to 0o755. setuid/setgid/sticky // bits must be gone, world-writable must be gone. assert_eq!( mode, 0o755, "extracted mode must be exactly 0o755 (was {:o})", mode ); } #[cfg(unix)] #[test] fn extract_tar_keeps_non_executable_as_0644() { use std::os::unix::fs::PermissionsExt; let tmp = tempfile::tempdir().unwrap(); // A doc file with overly permissive 0o666 mode — must be downgraded // to 0o644 (no world-writable). let gz = make_tar_gz(&[("readme.txt", tar::EntryType::Regular, b"hello", 0o666)]); extract_laufey_archive("doc.tar.gz", &gz, tmp.path()).expect("extract"); let mode = std::fs::metadata(tmp.path().join("readme.txt")) .unwrap() .permissions() .mode() & 0o7777; assert_eq!( mode, 0o644, "non-executable mode must be 0o644 (was {:o})", mode ); } #[test] fn extract_unknown_format_errors() { let tmp = tempfile::tempdir().unwrap(); let err = extract_laufey_archive("evil.rar", b"PK\x03\x04", tmp.path()) .expect_err("unknown extensions must error"); assert!(err.to_string().contains("unsupported archive format")); } // --- rewrite_helper_plist_identifier --- fn write_helper_plist(path: &std::path::Path, bundle_id: &str) { let xml = format!( r#"<?xml version="1.0" encoding="UTF-8"?> <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> <plist version="1.0"> <dict> <key>CFBundleIdentifier</key> <string>{bundle_id}</string> </dict> </plist> "# ); std::fs::write(path, xml).unwrap(); } fn read_bundle_id(path: &std::path::Path) -> String { let d: plist::Dictionary = plist::from_file(path).unwrap(); d.get("CFBundleIdentifier") .and_then(|v| v.as_string()) .unwrap() .to_string() } #[test] fn rewrite_helper_plist_keeps_helper_suffix() { let tmp = tempfile::tempdir().unwrap(); let p = tmp.path().join("Info.plist"); write_helper_plist(&p, "com.example.laufey.helper"); rewrite_helper_plist_identifier(&p, "com.acme.myapp").unwrap(); assert_eq!(read_bundle_id(&p), "com.acme.myapp.helper"); } #[test] fn rewrite_helper_plist_keeps_subhelper_suffix() { // CEF spawns multiple helper variants (Renderer, GPU, Plugin, // Alerts). Each Info.plist's existing id has the role baked in // after `helper`; we must preserve everything from `helper` onward. let tmp = tempfile::tempdir().unwrap(); let p = tmp.path().join("Info.plist"); write_helper_plist(&p, "com.example.laufey.helper.gpu"); rewrite_helper_plist_identifier(&p, "com.acme.myapp").unwrap(); assert_eq!(read_bundle_id(&p), "com.acme.myapp.helper.gpu"); } #[test] fn rewrite_helper_plist_falls_back_when_no_helper_token() { // Defensive path: every laufey helper plist today has `helper` in its // id, but if a hypothetical future bundle drops it we still emit // something reasonable. let tmp = tempfile::tempdir().unwrap(); let p = tmp.path().join("Info.plist"); write_helper_plist(&p, "com.example.laufey.misc"); rewrite_helper_plist_identifier(&p, "com.acme.myapp").unwrap(); assert_eq!(read_bundle_id(&p), "com.acme.myapp.helper"); } // --- extract_laufey_archive ZIP path --- fn build_zip< F: FnOnce(&mut zip::ZipWriter<std::io::Cursor<&mut Vec<u8>>>), >( f: F, ) -> Vec<u8> { let mut buf: Vec<u8> = Vec::new(); { let cursor = std::io::Cursor::new(&mut buf); let mut writer = zip::ZipWriter::new(cursor); f(&mut writer); writer.finish().unwrap(); } buf } #[test] fn extract_zip_normal_succeeds() { use zip::write::SimpleFileOptions; let zip = build_zip(|w| { w.start_file("greet.txt", SimpleFileOptions::default()) .unwrap(); w.write_all(b"hello zip").unwrap(); w.start_file("nested/readme.md", SimpleFileOptions::default()) .unwrap(); w.write_all(b"docs").unwrap(); }); let tmp = tempfile::tempdir().unwrap(); extract_laufey_archive("ok.zip", &zip, tmp.path()).expect("extract"); assert_eq!( std::fs::read(tmp.path().join("greet.txt")).unwrap(), b"hello zip" ); assert!(tmp.path().join("nested/readme.md").exists()); } #[test] fn extract_zip_rejects_absolute_path() { use zip::write::SimpleFileOptions; let zip = build_zip(|w| { // `enclosed_name` rejects absolute paths. w.start_file("/etc/escape.txt", SimpleFileOptions::default()) .unwrap(); w.write_all(b"oops").unwrap(); }); let tmp = tempfile::tempdir().unwrap(); let err = extract_laufey_archive("evil.zip", &zip, tmp.path()) .expect_err("absolute path in zip must be rejected"); let msg = err.to_string(); assert!( msg.contains("unsafe") || msg.contains("traversal"), "error must indicate rejection; got: {msg}" ); // /etc/escape.txt mustn't exist on the host. assert!(!std::path::Path::new("/etc/escape.txt").exists()); } #[test] fn extract_zip_rejects_parent_traversal() { use zip::write::SimpleFileOptions; // ZIP without "/" prefix but with `..` segments — must also be // refused by enclosed_name OR the defence-in-depth components check. let zip = build_zip(|w| { w.start_file("../escape.txt", SimpleFileOptions::default()) .unwrap(); w.write_all(b"oops").unwrap(); }); let tmp = tempfile::tempdir().unwrap(); let err = extract_laufey_archive("evil.zip", &zip, tmp.path()) .expect_err("parent-dir traversal in zip must be rejected"); let msg = err.to_string(); assert!( msg.contains("unsafe") || msg.contains("traversal"), "error must indicate rejection; got: {msg}" ); assert!(!tmp.path().parent().unwrap().join("escape.txt").exists()); } #[test] fn extract_zip_rejects_symlink_entries() { use zip::write::SimpleFileOptions; let zip = build_zip(|w| { // Use `add_symlink` so the entry is genuinely typed as a symlink // in the ZIP central directory. `entry.is_symlink()` in the // reader keys off that — a regression that removed our `if // entry.is_symlink() { bail!(...) }` check would let a follow-on // entry write through the symlink to escape `dest`. w.add_symlink("link", "../../etc", SimpleFileOptions::default()) .unwrap(); }); let tmp = tempfile::tempdir().unwrap(); let err = extract_laufey_archive("evil.zip", &zip, tmp.path()) .expect_err("symlink in zip must be rejected"); let msg = err.to_string(); assert!( msg.contains("symlink"), "error must name symlink rejection; got: {msg}" ); // And nothing landed at the symlink path. assert!(!tmp.path().join("link").exists()); } #[cfg(unix)] #[test] fn extract_zip_strips_setuid_bits() { use std::os::unix::fs::PermissionsExt; use zip::write::SimpleFileOptions; let zip = build_zip(|w| { let opts = SimpleFileOptions::default().unix_permissions(0o7777); w.start_file("exe", opts).unwrap(); w.write_all(b"#!/bin/sh\necho gotcha\n").unwrap(); }); let tmp = tempfile::tempdir().unwrap(); extract_laufey_archive("perm.zip", &zip, tmp.path()).expect("extract"); let mode = std::fs::metadata(tmp.path().join("exe")) .unwrap() .permissions() .mode() & 0o7777; assert_eq!(mode, 0o755, "mode must be exactly 0o755 (was {:o})", mode); } #[cfg(unix)] #[test] fn extract_zip_keeps_non_exec_at_0644() { use std::os::unix::fs::PermissionsExt; use zip::write::SimpleFileOptions; let zip = build_zip(|w| { let opts = SimpleFileOptions::default().unix_permissions(0o666); w.start_file("doc.txt", opts).unwrap(); w.write_all(b"hello").unwrap(); }); let tmp = tempfile::tempdir().unwrap(); extract_laufey_archive("perm.zip", &zip, tmp.path()).expect("extract"); let mode = std::fs::metadata(tmp.path().join("doc.txt")) .unwrap() .permissions() .mode() & 0o7777; assert_eq!(mode, 0o644); } #[test] fn extract_zip_creates_nested_directories() { use zip::write::SimpleFileOptions; let zip = build_zip(|w| { w.start_file("a/b/c/leaf.txt", SimpleFileOptions::default()) .unwrap(); w.write_all(b"deep").unwrap(); }); let tmp = tempfile::tempdir().unwrap(); extract_laufey_archive("nest.zip", &zip, tmp.path()).expect("extract"); assert_eq!( std::fs::read(tmp.path().join("a/b/c/leaf.txt")).unwrap(), b"deep" ); } // --- read_plist_string --- #[test] fn read_plist_string_returns_value_for_existing_key() { let tmp = tempfile::tempdir().unwrap(); let p = tmp.path().join("Info.plist"); std::fs::write( &p, r#"<?xml version="1.0" encoding="UTF-8"?> <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> <plist version="1.0"><dict> <key>CFBundleExecutable</key><string>my_app</string> <key>CFBundleIdentifier</key><string>com.example.foo</string> </dict></plist> "#, ) .unwrap(); assert_eq!( read_plist_string(&p, "CFBundleExecutable").as_deref(), Some("my_app") ); assert_eq!( read_plist_string(&p, "CFBundleIdentifier").as_deref(), Some("com.example.foo") ); } #[test] fn read_plist_string_none_for_missing_key() { let tmp = tempfile::tempdir().unwrap(); let p = tmp.path().join("Info.plist"); std::fs::write( &p, r#"<?xml version="1.0" encoding="UTF-8"?> <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> <plist version="1.0"><dict><key>X</key><string>y</string></dict></plist> "#, ) .unwrap(); assert_eq!(read_plist_string(&p, "CFBundleExecutable"), None); } #[test] fn read_plist_string_none_for_non_string_value() { // A key with a non-string value (e.g. an array) must yield None // rather than panic or return some stringified form. let tmp = tempfile::tempdir().unwrap(); let p = tmp.path().join("Info.plist"); std::fs::write( &p, r#"<?xml version="1.0" encoding="UTF-8"?> <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> <plist version="1.0"><dict><key>K</key><array><string>x</string></array></dict></plist> "#, ) .unwrap(); assert_eq!(read_plist_string(&p, "K"), None); } #[test] fn read_plist_string_none_for_unreadable_or_corrupt_file() { let tmp = tempfile::tempdir().unwrap(); // Non-existent file. assert_eq!( read_plist_string(&tmp.path().join("missing.plist"), "K"), None ); // Garbage contents. let p = tmp.path().join("bad.plist"); std::fs::write(&p, b"this is not a plist").unwrap(); assert_eq!(read_plist_string(&p, "K"), None); } // --- rewrite_cef_helper_bundle_ids --- fn setup_cef_frameworks(tmp: &std::path::Path) -> std::path::PathBuf { let contents = tmp.join("Contents"); let fw = contents.join("Frameworks"); std::fs::create_dir_all(&fw).unwrap(); let make_helper_app = |name: &str, id: &str| { let app = fw.join(format!("{name}.app")); let plist_dir = app.join("Contents"); std::fs::create_dir_all(&plist_dir).unwrap(); write_helper_plist(&plist_dir.join("Info.plist"), id); }; make_helper_app("laufey Helper", "com.example.laufey.helper"); make_helper_app("laufey Helper (GPU)", "com.example.laufey.helper.gpu"); make_helper_app( "laufey Helper (Renderer)", "com.example.laufey.helper.renderer", ); // A non-helper .app — must NOT be rewritten. let other = fw.join("Other.app/Contents"); std::fs::create_dir_all(&other).unwrap(); write_helper_plist(&other.join("Info.plist"), "com.example.other"); // The CEF framework directory itself — also must NOT be touched // (rewriting it invalidates its embedded code signature). let cef_fw = fw.join("Chromium Embedded Framework.framework/Versions/A/Resources"); std::fs::create_dir_all(&cef_fw).unwrap(); write_helper_plist( &cef_fw.join("Info.plist"), "org.chromium.embedded.framework", ); contents } #[test] fn rewrite_helpers_rewrites_only_helper_apps() { let tmp = tempfile::tempdir().unwrap(); let contents = setup_cef_frameworks(tmp.path()); rewrite_cef_helper_bundle_ids(&contents, "com.acme.myapp").unwrap(); let fw = contents.join("Frameworks"); assert_eq!( read_bundle_id(&fw.join("laufey Helper.app/Contents/Info.plist")), "com.acme.myapp.helper" ); assert_eq!( read_bundle_id(&fw.join("laufey Helper (GPU).app/Contents/Info.plist")), "com.acme.myapp.helper.gpu" ); assert_eq!( read_bundle_id( &fw.join("laufey Helper (Renderer).app/Contents/Info.plist") ), "com.acme.myapp.helper.renderer" ); // Other.app does not contain "Helper" → must NOT be rewritten. assert_eq!( read_bundle_id(&fw.join("Other.app/Contents/Info.plist")), "com.example.other" ); // CEF framework: also untouched. assert_eq!( read_bundle_id(&fw.join( "Chromium Embedded Framework.framework/Versions/A/Resources/Info.plist" )), "org.chromium.embedded.framework" ); } #[test] fn rewrite_helpers_is_noop_when_frameworks_missing() { // Some backends (winit) don't ship a Frameworks/ subdir. The // helper-rewriter must tolerate that without erroring. let tmp = tempfile::tempdir().unwrap(); std::fs::create_dir_all(tmp.path().join("Contents")).unwrap(); rewrite_cef_helper_bundle_ids( &tmp.path().join("Contents"), "com.acme.myapp", ) .expect("absent Frameworks must not error"); } // --- locate_dev_backend_binary / locate_dev_app_bundle --- #[test] fn locate_dev_webview_returns_first_existing_candidate() { let tmp = tempfile::tempdir().unwrap(); // Set up TWO candidates; the function should return whichever it // finds first in its candidate list. We create the SECOND one to // prove the function actually walks the list (rather than blindly // returning candidate[0] which would not exist). let p = tmp .path() .join("webview/build/laufey_webview.app/Contents/MacOS/laufey_webview"); std::fs::create_dir_all(p.parent().unwrap()).unwrap(); std::fs::write(&p, b"binary").unwrap(); let found = locate_dev_backend_binary(tmp.path(), "webview"); assert_eq!(found.as_deref(), Some(p.as_path())); } #[test] fn locate_dev_returns_none_when_nothing_exists() { let tmp = tempfile::tempdir().unwrap(); assert!(locate_dev_backend_binary(tmp.path(), "cef").is_none()); assert!(locate_dev_backend_binary(tmp.path(), "webview").is_none()); assert!(locate_dev_backend_binary(tmp.path(), "raw").is_none()); } #[test] fn locate_dev_backend_winit_target_paths() { let tmp = tempfile::tempdir().unwrap(); // `raw` is the public backend name; the dev binary is `laufey_winit`, // carrying the host executable suffix (`.exe` on Windows) — without it // LAUFEY_DEV_DIR could never resolve a backend on Windows. let p = tmp.path().join(format!( "target/release/laufey_winit{}", std::env::consts::EXE_SUFFIX )); std::fs::create_dir_all(p.parent().unwrap()).unwrap(); std::fs::write(&p, b"binary").unwrap(); let found = locate_dev_backend_binary(tmp.path(), "raw"); assert_eq!(found.as_deref(), Some(p.as_path())); } #[test] fn locate_dev_app_bundle_skips_winit() { // winit don't ship as .app bundles. locate_dev_app_bundle // must short-circuit to None for those, never touching the // filesystem (so a misleading directory with the right name can't // accidentally match). let tmp = tempfile::tempdir().unwrap(); assert!(locate_dev_app_bundle(tmp.path(), "raw").is_none()); } #[test] fn locate_dev_app_bundle_finds_webview() { let tmp = tempfile::tempdir().unwrap(); let p = tmp.path().join("webview/build/laufey_webview.app"); std::fs::create_dir_all(&p).unwrap(); let found = locate_dev_app_bundle(tmp.path(), "webview"); assert_eq!(found.as_deref(), Some(p.as_path())); } // --- convert_icon_set_to_ico --- // // ICO is a binary container with a strict header layout. A regression // that flips a width/height byte, or computes data_offset wrong, // produces a visually-fine file that Windows refuses to display. fn fake_png(label: &[u8]) -> Vec<u8> { // Not a real PNG, just bytes the converter reads verbatim into the // ICO entry body. The converter doesn't validate the PNG contents. let mut v = Vec::with_capacity(8 + label.len()); v.extend_from_slice(b"\x89PNG\r\n\x1a\n"); // PNG magic v.extend_from_slice(label); v } fn read_u16(buf: &[u8], off: usize) -> u16 { u16::from_le_bytes([buf[off], buf[off + 1]]) } fn read_u32(buf: &[u8], off: usize) -> u32 { u32::from_le_bytes([buf[off], buf[off + 1], buf[off + 2], buf[off + 3]]) } #[test] fn ico_writes_correct_header_for_multiple_sizes() { let cwd = tempfile::tempdir().unwrap(); let png16 = fake_png(b"size16"); let png32 = fake_png(b"size32"); let png256 = fake_png(b"size256"); std::fs::write(cwd.path().join("a.png"), &png16).unwrap(); std::fs::write(cwd.path().join("b.png"), &png32).unwrap(); std::fs::write(cwd.path().join("c.png"), &png256).unwrap(); let entries = vec![ crate::args::IconSetEntry { path: "a.png".into(), size: 16, }, crate::args::IconSetEntry { path: "b.png".into(), size: 32, }, crate::args::IconSetEntry { path: "c.png".into(), size: 256, }, ]; let out = cwd.path().join("icon.ico"); convert_icon_set_to_ico(cwd.path(), &entries, &out).expect("ok"); let buf = std::fs::read(&out).unwrap(); // ICO header: reserved=0, type=1 (ICO), count=3. assert_eq!(read_u16(&buf, 0), 0); assert_eq!(read_u16(&buf, 2), 1); assert_eq!(read_u16(&buf, 4), 3); // Three directory entries follow, 16 bytes each. // Entry 0: 16x16 PNG. dim=16, planes=1, bpp=32, size, offset. assert_eq!(buf[6], 16); assert_eq!(buf[7], 16); assert_eq!(read_u16(&buf, 6 + 4), 1, "planes"); assert_eq!(read_u16(&buf, 6 + 6), 32, "bits per pixel"); let sz0 = read_u32(&buf, 6 + 8) as usize; let off0 = read_u32(&buf, 6 + 12) as usize; assert_eq!(sz0, png16.len()); // First entry's data starts right after the header + 3 directory // entries (6 + 48 = 54). assert_eq!(off0, 54); // Entry 2: 256 is encoded as 0 in the dim byte per ICO convention. assert_eq!(buf[6 + 32], 0, "256px width must be encoded as 0"); assert_eq!(buf[6 + 33], 0, "256px height must be encoded as 0"); // The three PNG bodies appear at the offsets the directory entries // claim, in the order they were listed. assert_eq!(&buf[off0..off0 + sz0], png16.as_slice()); let off1 = read_u32(&buf, 6 + 16 + 12) as usize; let sz1 = read_u32(&buf, 6 + 16 + 8) as usize; assert_eq!(&buf[off1..off1 + sz1], png32.as_slice()); let off2 = read_u32(&buf, 6 + 32 + 12) as usize; let sz2 = read_u32(&buf, 6 + 32 + 8) as usize; assert_eq!(&buf[off2..off2 + sz2], png256.as_slice()); } #[test] fn ico_skips_missing_files_with_warning() { let cwd = tempfile::tempdir().unwrap(); let png = fake_png(b"only"); std::fs::write(cwd.path().join("there.png"), &png).unwrap(); let entries = vec![ crate::args::IconSetEntry { path: "missing.png".into(), size: 16, }, crate::args::IconSetEntry { path: "there.png".into(), size: 32, }, ]; let out = cwd.path().join("icon.ico"); convert_icon_set_to_ico(cwd.path(), &entries, &out).expect("ok"); let buf = std::fs::read(&out).unwrap(); // Only one image survived; the count must reflect that, otherwise // the directory would name an entry with bogus offset/size. assert_eq!(read_u16(&buf, 4), 1, "count must skip missing entries"); } #[test] fn ico_errors_when_no_inputs_resolve() { let cwd = tempfile::tempdir().unwrap(); let entries = vec![ crate::args::IconSetEntry { path: "absent1.png".into(), size: 16, }, crate::args::IconSetEntry { path: "absent2.png".into(), size: 32, }, ]; let out = cwd.path().join("icon.ico"); let err = convert_icon_set_to_ico(cwd.path(), &entries, &out).unwrap_err(); assert!(err.to_string().contains("No valid icon")); // Output file must not exist if we never wrote anything. assert!(!out.exists()); } #[test] fn ico_data_offsets_are_monotonically_increasing() { // The data_offset accumulator in the producer increments by each // image's byte length. A regression that forgot to advance the // offset would cause later entries to alias earlier ones. let cwd = tempfile::tempdir().unwrap(); let a = fake_png(b"aaaaaaaaaa"); // distinct lengths let b = fake_png(b"bbbbbbbbbbbbbbbb"); let c = fake_png(b"cccc"); std::fs::write(cwd.path().join("a.png"), &a).unwrap(); std::fs::write(cwd.path().join("b.png"), &b).unwrap(); std::fs::write(cwd.path().join("c.png"), &c).unwrap(); let entries = vec![ crate::args::IconSetEntry { path: "a.png".into(), size: 16, }, crate::args::IconSetEntry { path: "b.png".into(), size: 32, }, crate::args::IconSetEntry { path: "c.png".into(), size: 64, }, ]; let out = cwd.path().join("icon.ico"); convert_icon_set_to_ico(cwd.path(), &entries, &out).expect("ok"); let buf = std::fs::read(&out).unwrap(); let off0 = read_u32(&buf, 6 + 12) as usize; let off1 = read_u32(&buf, 6 + 16 + 12) as usize; let off2 = read_u32(&buf, 6 + 32 + 12) as usize; let sz0 = read_u32(&buf, 6 + 8) as usize; let sz1 = read_u32(&buf, 6 + 16 + 8) as usize; assert_eq!(off1, off0 + sz0, "second offset = first off + first size"); assert_eq!(off2, off1 + sz1, "third offset = second off + second size"); // And the file must be at least long enough to hold all images. let total = read_u32(&buf, 6 + 32 + 8) as usize + off2; assert_eq!(buf.len(), total); } #[test] fn rewrite_helper_plist_errors_on_missing_key() { let tmp = tempfile::tempdir().unwrap(); let p = tmp.path().join("Info.plist"); // Plist without CFBundleIdentifier at all — the rewrite must fail // loudly rather than silently insert a wrong id. std::fs::write( &p, r#"<?xml version="1.0" encoding="UTF-8"?> <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> <plist version="1.0"><dict><key>Other</key><string>x</string></dict></plist> "#, ) .unwrap(); let err = rewrite_helper_plist_identifier(&p, "com.acme.myapp").unwrap_err(); assert!(err.to_string().contains("CFBundleIdentifier")); } // --- Linux packaging --- #[test] fn debian_package_name_sanitizes() { assert_eq!(debian_package_name("MyApp"), "myapp"); assert_eq!(debian_package_name("My App!"), "my-app"); // Leading non-alphanumerics are stripped (Debian forbids leading -/+/.). assert_eq!(debian_package_name("--foo"), "foo"); assert_eq!(debian_package_name("a"), "app", "too short falls back"); assert_eq!(debian_package_name("___"), "app", "nothing usable"); // Allowed punctuation is preserved. assert_eq!(debian_package_name("a.b+c-d"), "a.b+c-d"); } #[test] fn package_arch_mappings() { assert_eq!( debian_arch_for_target(Some("x86_64-unknown-linux-gnu")).unwrap(), "amd64" ); assert_eq!( debian_arch_for_target(Some("aarch64-unknown-linux-gnu")).unwrap(), "arm64" ); assert!(debian_arch_for_target(Some("riscv64-unknown-linux-gnu")).is_err()); assert_eq!( rpm_arch_for_target(Some("x86_64-unknown-linux-gnu")).unwrap(), "x86_64" ); assert_eq!( rpm_arch_for_target(Some("aarch64-unknown-linux-gnu")).unwrap(), "aarch64" ); assert!(rpm_arch_for_target(Some("mips-unknown-linux-gnu")).is_err()); } /// Build a minimal staged Linux app dir like `package_linux_app_dir` does: /// a launcher script named after the app, a fake runtime lib, and an icon. fn fake_linux_app_dir(parent: &Path, app_name: &str) -> PathBuf { let app_dir = parent.join(app_name); std::fs::create_dir_all(&app_dir).unwrap(); // The backend binary is renamed to `<app>` and auto-loads the co-located // `<app>.so`; there is no launcher shell script. std::fs::write(app_dir.join(app_name), b"\x7fELFfake-bin").unwrap(); std::fs::write(app_dir.join(format!("{app_name}.so")), b"\x7fELFfake") .unwrap(); std::fs::write(app_dir.join("AppIcon.png"), STUB_ICON_PNG).unwrap(); app_dir } fn empty_desktop_flags() -> DesktopFlags { DesktopFlags { source_file: String::new(), output: None, args: vec![], target: None, icon: None, include: vec![], exclude: vec![], hmr: false, backend: None, engine: Default::default(), all_targets: false, identifier: None, deep_links: Vec::new(), codesign_identity: None, inspect_renderer: None, compress: None, exclude_unused_npm: false, } } /// Read a gzip-compressed buffer to bytes. fn gunzip(data: &[u8]) -> Vec<u8> { let mut dec = flate2::read::GzDecoder::new(data); let mut out = Vec::new(); dec.read_to_end(&mut out).unwrap(); out } #[test] fn appimage_uses_runtime_supported_zstd_squashfs() { let tmp = tempfile::tempdir().unwrap(); let app_dir = fake_linux_app_dir(tmp.path(), "MyApp"); let appimage_path = tmp.path().join("MyApp.AppImage"); let target = Some("x86_64-unknown-linux-gnu"); create_linux_appimage(&app_dir, &appimage_path, target).unwrap(); let runtime_offset = appimage_runtime_for_target(target).unwrap().len() as u64; let appimage = std::io::BufReader::new(std::fs::File::open(&appimage_path).unwrap()); let filesystem = backhand::FilesystemReader::from_reader_with_offset( appimage, runtime_offset, ) .unwrap(); assert_eq!( filesystem.compressor, backhand::compression::Compressor::Zstd ); } #[test] fn deb_has_ar_members_and_control_fields() { let tmp = tempfile::tempdir().unwrap(); let app_dir = fake_linux_app_dir(tmp.path(), "MyApp"); let deb = tmp.path().join("MyApp.deb"); let flags = empty_desktop_flags(); create_linux_deb(&app_dir, &deb, &flags, Some("x86_64-unknown-linux-gnu")) .unwrap(); let bytes = std::fs::read(&deb).unwrap(); assert!(bytes.starts_with(b"!<arch>\n"), "missing ar global header"); // Walk the ar members in order and collect (name, data). let mut members: Vec<(String, Vec<u8>)> = Vec::new(); let mut pos = 8; // after "!<arch>\n" while pos + 60 <= bytes.len() { let header = &bytes[pos..pos + 60]; let name = String::from_utf8_lossy(&header[..16]).trim().to_string(); let size: usize = String::from_utf8_lossy(&header[48..58]) .trim() .parse() .unwrap(); assert_eq!(&header[58..60], b"`\n", "bad ar member magic"); let data_start = pos + 60; members.push((name, bytes[data_start..data_start + size].to_vec())); // members are padded to an even length pos = data_start + size + (size % 2); } let names: Vec<&str> = members.iter().map(|(n, _)| n.as_str()).collect(); assert_eq!( names, vec!["debian-binary", "control.tar.gz", "data.tar.gz"], "members must appear in dpkg's expected order" ); assert_eq!(members[0].1, b"2.0\n"); // control.tar.gz → ./control with the required fields. let control_tar = gunzip(&members[1].1); let mut archive = tar::Archive::new(&control_tar[..]); let mut control = String::new(); for entry in archive.entries().unwrap() { let mut entry = entry.unwrap(); if entry.path().unwrap().ends_with("control") { entry.read_to_string(&mut control).unwrap(); } } assert!(control.contains("Package: myapp\n"), "control:\n{control}"); assert!(control.contains("Architecture: amd64\n")); assert!(control.contains("Version: 1.0.0\n")); assert!(control.contains("Depends: libgtk-3-0,")); assert!(control.contains("Installed-Size: ")); // data.tar.gz install layout. The `tar` crate strips the conventional // leading `./` (it skips `CurDir` path components), leaving root-relative // `usr/...` paths — which dpkg unpacks to `/usr/...` all the same. let data_tar = gunzip(&members[2].1); let mut archive = tar::Archive::new(&data_tar[..]); let mut paths: Vec<String> = Vec::new(); let mut bin_link_target: Option<String> = None; for entry in archive.entries().unwrap() { let entry = entry.unwrap(); let p = entry.path().unwrap().to_string_lossy().into_owned(); if p == "usr/bin/myapp" { bin_link_target = entry .link_name() .unwrap() .map(|l| l.to_string_lossy().into_owned()); } paths.push(p); } assert!(paths.iter().any(|p| p == "usr/lib/myapp/MyApp")); assert!(paths.iter().any(|p| p == "usr/lib/myapp/MyApp.so")); assert!( paths .iter() .any(|p| p == "usr/share/applications/myapp.desktop") ); assert!( paths .iter() .any(|p| p == "usr/share/icons/hicolor/512x512/apps/myapp.png") ); assert_eq!( bin_link_target.as_deref(), Some("../lib/myapp/MyApp"), "/usr/bin symlink must point at the staged launcher" ); } #[test] fn rpm_parses_with_expected_metadata() { let tmp = tempfile::tempdir().unwrap(); let app_dir = fake_linux_app_dir(tmp.path(), "MyApp"); let rpm_path = tmp.path().join("MyApp.rpm"); let flags = empty_desktop_flags(); create_linux_rpm( &app_dir, &rpm_path, &flags, Some("aarch64-unknown-linux-gnu"), ) .unwrap(); let bytes = std::fs::read(&rpm_path).unwrap(); let pkg = rpm::Package::parse(&mut &bytes[..]).unwrap(); assert_eq!(pkg.metadata.get_name().unwrap(), "myapp"); assert_eq!(pkg.metadata.get_version().unwrap(), "1.0.0"); assert_eq!(pkg.metadata.get_arch().unwrap(), "aarch64"); let requires: Vec<String> = pkg .metadata .get_requires() .unwrap() .into_iter() .map(|d| d.name) .collect(); assert!( requires.iter().any(|r| r == "libgtk-3.so.0()(64bit)"), "rpm Requires must carry CEF sonames with the 64-bit ELF class suffix, got: {requires:?}" ); let files: Vec<String> = pkg .metadata .get_file_paths() .unwrap() .into_iter() .map(|p| p.to_string_lossy().into_owned()) .collect(); assert!(files.iter().any(|f| f == "/usr/lib/myapp/MyApp")); assert!(files.iter().any(|f| f == "/usr/bin/myapp")); assert!( files .iter() .any(|f| f == "/usr/share/applications/myapp.desktop") ); } // --- Windows .msi packaging --- #[test] fn msi_arch_mappings() { assert_eq!( msi_arch_for_target(Some("x86_64-pc-windows-msvc")).unwrap(), "x64" ); assert_eq!( msi_arch_for_target(Some("aarch64-pc-windows-msvc")).unwrap(), "Arm64" ); assert!(msi_arch_for_target(Some("i686-pc-windows-msvc")).is_err()); } #[test] fn msi_guids_are_deterministic_and_valid() { let a = msi_derive_guid("com.acme.myapp", "product:1.0.0"); let b = msi_derive_guid("com.acme.myapp", "product:1.0.0"); let c = msi_derive_guid("com.acme.myapp", "upgrade"); assert_eq!(a, b, "same inputs must yield the same GUID"); assert_ne!(a, c, "different roles must yield different GUIDs"); // Registry-format GUID: 38 chars, braced, uppercase. assert_eq!(a.len(), 38); assert!(a.starts_with('{') && a.ends_with('}')); assert!( msi::Category::Guid.validate(&a), "must satisfy GUID category" ); } #[test] fn msi_short_names_are_valid_8_3() { let d = msi_short_name(1, "Some Long Folder Name", true); assert_eq!(d, "D1"); let f = msi_short_name(42, "libcef.dll", false); assert_eq!(f, "F16.DLL"); // No extension → no dot. assert_eq!(msi_short_name(2, "LICENSE", false), "F2"); } /// Build a minimal staged Windows app dir with a nested subdirectory, like /// `package_windows_app_dir` would produce. fn fake_windows_app_dir(parent: &Path, app_name: &str) -> PathBuf { let app_dir = parent.join(app_name); std::fs::create_dir_all(&app_dir).unwrap(); // The backend binary is renamed to `<app>.exe` and auto-loads the // co-located `<app>.dll`; there is no `.bat` wrapper. std::fs::write(app_dir.join(format!("{app_name}.exe")), b"MZfake-exe") .unwrap(); std::fs::write(app_dir.join(format!("{app_name}.dll")), b"MZfake-dll") .unwrap(); let locales = app_dir.join("locales"); std::fs::create_dir_all(&locales).unwrap(); std::fs::write(locales.join("en-US.pak"), b"pakdata").unwrap(); app_dir } #[test] fn msi_parses_with_expected_tables_and_payload() { use std::io::Read as _; let tmp = tempfile::tempdir().unwrap(); let app_dir = fake_windows_app_dir(tmp.path(), "MyApp"); let msi_path = tmp.path().join("MyApp.msi"); let flags = empty_desktop_flags(); create_windows_msi( &app_dir, &msi_path, &flags, Some("x86_64-pc-windows-msvc"), ) .unwrap(); let mut package = msi::open(&msi_path).unwrap(); assert_eq!(package.summary_info().arch(), Some("x64")); // Windows Installer rejects a UTF-8 (65001) database codepage with "could // not be opened"; both the database and summary-info codepage must be a // valid ANSI codepage. We author Windows-1252. assert_eq!(package.database_codepage(), msi::CodePage::Windows1252); assert_eq!( package.summary_info().codepage(), msi::CodePage::Windows1252 ); // Property table: product identity. let mut props = std::collections::HashMap::new(); for row in package.select_rows(msi::Select::table("Property")).unwrap() { props.insert( row["Property"].as_str().unwrap().to_string(), row["Value"].as_str().unwrap().to_string(), ); } assert_eq!(props.get("ProductName").map(String::as_str), Some("MyApp")); assert_eq!( props.get("ProductVersion").map(String::as_str), Some("1.0.0") ); assert_eq!(props.get("ALLUSERS").map(String::as_str), Some("1")); assert!( msi::Category::Guid.validate(props.get("ProductCode").unwrap()), "ProductCode must be a valid GUID" ); assert!(msi::Category::Guid.validate(props.get("UpgradeCode").unwrap())); // Directory table: the install root and the nested locales dir. let dirs: Vec<String> = package .select_rows(msi::Select::table("Directory")) .unwrap() .map(|r| r["Directory"].as_str().unwrap().to_string()) .collect(); assert!(dirs.iter().any(|d| d == "INSTALLDIR")); assert!(dirs.iter().any(|d| d == "ProgramFiles64Folder")); // File table: every staged file, with a 1..N sequence. let mut files: Vec<(String, String, i32)> = package .select_rows(msi::Select::table("File")) .unwrap() .map(|r| { ( r["File"].as_str().unwrap().to_string(), r["FileName"].as_str().unwrap().to_string(), r["Sequence"].as_int().unwrap(), ) }) .collect(); files.sort_by_key(|f| f.2); assert_eq!(files.len(), 3, "3 files staged: {files:?}"); let long_names: Vec<&str> = files .iter() .map(|(_, name, _)| name.rsplit('|').next().unwrap()) .collect(); assert!(long_names.contains(&"MyApp.exe")); assert!(long_names.contains(&"MyApp.dll")); assert!(long_names.contains(&"en-US.pak")); let seqs: Vec<i32> = files.iter().map(|f| f.2).collect(); assert_eq!(seqs, vec![1, 2, 3], "sequence must be contiguous 1..N"); // Two components: one for the root dir, one for locales/. let comps: Vec<String> = package .select_rows(msi::Select::table("Component")) .unwrap() .map(|r| r["Component"].as_str().unwrap().to_string()) .collect(); assert_eq!(comps.len(), 2, "root + locales components: {comps:?}"); // The embedded cabinet referenced by Media.Cabinet. let cabinet = package .select_rows(msi::Select::table("Media")) .unwrap() .map(|r| r["Cabinet"].as_str().unwrap().to_string()) .next() .unwrap(); assert_eq!(cabinet, "#appcab"); // Read the cabinet back and confirm a file's bytes round-trip. The cab // member name is the MSI File key. let key_for_dll = files .iter() .find(|(_, name, _)| name.ends_with("MyApp.dll")) .map(|(key, _, _)| key.clone()) .unwrap(); let mut cab_bytes = Vec::new(); package .read_stream("appcab") .unwrap() .read_to_end(&mut cab_bytes) .unwrap(); let mut cabinet = cab::Cabinet::new(std::io::Cursor::new(cab_bytes)).unwrap(); let mut content = Vec::new(); cabinet .read_file(&key_for_dll) .unwrap() .read_to_end(&mut content) .unwrap(); assert_eq!(content, b"MZfake-dll"); } #[test] fn msi_tables_sorted_by_string_id() { use std::io::Read as _; let tmp = tempfile::tempdir().unwrap(); let app_dir = fake_windows_app_dir(tmp.path(), "SortApp"); let msi_path = tmp.path().join("SortApp.msi"); create_windows_msi( &app_dir, &msi_path, &empty_desktop_flags(), Some("x86_64-pc-windows-msvc"), ) .unwrap(); // Windows Installer requires every table's rows to be ordered ascending by // primary key, and for string-typed keys that ordering is by the row's // string-pool id (not the string's text). The `msi` crate does not emit the // system tables in that order, so `create_windows_msi` re-sorts them; real // `msiexec` rejects the database with error 2219 otherwise. Verify the // invariant on the `_Tables` stream, whose single column is the table name's // string id, and on the `Directory` data table's primary-key column. let mut comp = cfb::open(&msi_path).unwrap(); let names: Vec<String> = comp .read_storage("/") .unwrap() .map(|e| e.name().to_string()) .collect(); let read_ids = |comp: &mut cfb::CompoundFile<std::fs::File>, suffix: &str| -> Vec<u16> { let raw = names .iter() .find(|n| msi_demangle_stream_name(n).ends_with(suffix)) .cloned() .unwrap(); let mut s = comp.open_stream(format!("/{raw}")).unwrap(); let mut b = Vec::new(); s.read_to_end(&mut b).unwrap(); b.chunks_exact(2) .map(|c| u16::from_le_bytes([c[0], c[1]])) .collect() }; let table_ids = read_ids(&mut comp, "_Tables"); assert!(table_ids.len() > 1, "expected multiple tables"); assert!( table_ids.windows(2).all(|w| w[0] < w[1]), "_Tables must be strictly ascending by string id: {table_ids:?}" ); // The Directory table's first column is its primary key (the directory id). // Its run of values (the table is stored column-major, key column first) // must be ascending by string id. let dir_keys = read_ids(&mut comp, "\0Directory"); let dir_rows = dir_keys.len() / 3; // Directory, Directory_Parent, DefaultDir let pk: Vec<u16> = dir_keys.into_iter().take(dir_rows).collect(); assert!( pk.windows(2).all(|w| w[0] < w[1]), "Directory primary key must be ascending by string id: {pk:?}" ); } #[test] fn msi_authors_start_menu_shortcut() { let tmp = tempfile::tempdir().unwrap(); // fake_windows_app_dir stages `MyApp.exe`, the renamed backend binary the // shortcut points at. let app_dir = fake_windows_app_dir(tmp.path(), "MyApp"); let msi_path = tmp.path().join("MyApp.msi"); create_windows_msi( &app_dir, &msi_path, &empty_desktop_flags(), Some("x86_64-pc-windows-msvc"), ) .unwrap(); let mut package = msi::open(&msi_path).unwrap(); // The all-users Start Menu folder must exist for the shortcut to land in it. let dirs: Vec<String> = package .select_rows(msi::Select::table("Directory")) .unwrap() .map(|r| r["Directory"].as_str().unwrap().to_string()) .collect(); assert!(dirs.iter().any(|d| d == "ProgramMenuFolder")); // Exactly one shortcut, targeting `<app>.exe` (`[#fkey]`) with no arguments // (it auto-loads the co-located `<app>.dll`) and running from INSTALLDIR — // no console window. let shortcuts: Vec<_> = package .select_rows(msi::Select::table("Shortcut")) .unwrap() .collect(); assert_eq!(shortcuts.len(), 1); let s = &shortcuts[0]; assert_eq!(s["Directory_"].as_str(), Some("ProgramMenuFolder")); assert_eq!(s["Arguments"].as_str(), None); assert_eq!(s["WkDir"].as_str(), Some("INSTALLDIR")); assert!(s["Target"].as_str().unwrap().starts_with("[#")); // CreateShortcuts/RemoveShortcuts must be sequenced or the table is ignored. let actions: Vec<String> = package .select_rows(msi::Select::table("InstallExecuteSequence")) .unwrap() .map(|r| r["Action"].as_str().unwrap().to_string()) .collect(); assert!(actions.iter().any(|a| a == "CreateShortcuts")); assert!(actions.iter().any(|a| a == "RemoveShortcuts")); } // --- deep links --- #[test] fn validate_url_scheme_accepts_canonical() { for ok in &["acme", "my-app", "x", "com.acme.app", "a1+2.3-4", "App"] { assert!(validate_url_scheme(ok).is_ok(), "{ok:?} should be accepted"); } } #[test] fn validate_url_scheme_rejects_bad_shapes() { let cases = &[ ("", "empty"), ("1acme", "leading digit"), ("-acme", "leading hyphen"), (".acme", "leading dot"), ("acme app", "space"), ("acme/app", "slash"), ("acme://", "colon and slashes"), ("acme_app", "underscore"), ("acmé", "non-ascii"), ]; for (bad, why) in cases { assert!( validate_url_scheme(bad).is_err(), "{bad:?} should be rejected ({why})" ); } } #[test] fn validate_url_scheme_rejects_reserved() { // Registering these as app handlers would hijack normal browsing. for reserved in &["http", "https", "file", "ftp", "ws", "wss"] { assert!( validate_url_scheme(reserved).is_err(), "{reserved:?} must be rejected as reserved" ); } } /// Build a minimal macOS `.app` skeleton with an `Info.plist` carrying the /// given bundle id, returning the bundle root. fn fake_macos_bundle( parent: &std::path::Path, bundle_id: &str, ) -> std::path::PathBuf { let bundle = parent.join("MyApp.app"); let contents = bundle.join("Contents"); std::fs::create_dir_all(&contents).unwrap(); write_helper_plist(&contents.join("Info.plist"), bundle_id); bundle } #[test] fn deep_links_macos_writes_cfbundleurltypes() { let tmp = tempfile::tempdir().unwrap(); let bundle = fake_macos_bundle(tmp.path(), "com.acme.myapp"); register_deep_links_macos( &bundle, &["acme".to_string(), "acme-beta".to_string()], ) .unwrap(); let dict: plist::Dictionary = plist::from_file(bundle.join("Contents/Info.plist")).unwrap(); let url_types = dict .get("CFBundleURLTypes") .and_then(|v| v.as_array()) .expect("CFBundleURLTypes array"); assert_eq!(url_types.len(), 1); let url_type = url_types[0].as_dictionary().unwrap(); // The URL name is keyed off the bundle id so multiple apps don't collide. assert_eq!( url_type.get("CFBundleURLName").and_then(|v| v.as_string()), Some("com.acme.myapp") ); assert_eq!( url_type.get("CFBundleTypeRole").and_then(|v| v.as_string()), Some("Viewer") ); let schemes: Vec<&str> = url_type .get("CFBundleURLSchemes") .and_then(|v| v.as_array()) .unwrap() .iter() .map(|v| v.as_string().unwrap()) .collect(); assert_eq!(schemes, ["acme", "acme-beta"]); } /// Write a minimal Linux `.desktop` entry into `dir` and return its path. fn fake_desktop_file( dir: &std::path::Path, body: &str, ) -> std::path::PathBuf { let path = dir.join("myapp.desktop"); std::fs::write(&path, body).unwrap(); path } #[test] fn deep_links_linux_adds_mimetype_and_url_field_code() { let tmp = tempfile::tempdir().unwrap(); let path = fake_desktop_file( tmp.path(), "[Desktop Entry]\nName=MyApp\nExec=/opt/myapp/myapp\nType=Application\n", ); register_deep_links_linux( tmp.path(), &["acme".to_string(), "acme-beta".to_string()], ) .unwrap(); let out = std::fs::read_to_string(&path).unwrap(); // The launcher must receive the URL, so `%u` is appended exactly once. assert!( out.contains("Exec=/opt/myapp/myapp %u"), "Exec should gain a %u field code; got:\n{out}" ); assert!( out .contains("MimeType=x-scheme-handler/acme;x-scheme-handler/acme-beta;"), "MimeType should list every scheme handler; got:\n{out}" ); } #[test] fn deep_links_linux_does_not_duplicate_field_code() { let tmp = tempfile::tempdir().unwrap(); let path = fake_desktop_file( tmp.path(), "[Desktop Entry]\nExec=/opt/myapp/myapp %U\nType=Application\n", ); register_deep_links_linux(tmp.path(), &["acme".to_string()]).unwrap(); let out = std::fs::read_to_string(&path).unwrap(); // An existing `%U` (or `%u`) already forwards the URL — don't add another. assert!( out.contains("Exec=/opt/myapp/myapp %U\n"), "existing field code should be left untouched; got:\n{out}" ); assert!(!out.contains("%U %u") && !out.contains("%u %U")); } #[test] fn deep_links_linux_merges_into_existing_mimetype() { let tmp = tempfile::tempdir().unwrap(); let path = fake_desktop_file( tmp.path(), "[Desktop Entry]\nExec=/opt/myapp/myapp\nMimeType=text/html\n", ); register_deep_links_linux(tmp.path(), &["acme".to_string()]).unwrap(); let out = std::fs::read_to_string(&path).unwrap(); assert!( out.contains("MimeType=text/html;x-scheme-handler/acme;"), "existing MimeType entries should be preserved; got:\n{out}" ); } #[test] fn deep_links_windows_writes_registry_script() { let tmp = tempfile::tempdir().unwrap(); // The launcher is `<bundle-dir-name>.exe`, so the bundle dir must be named // after the app. let bundle = tmp.path().join("MyApp"); std::fs::create_dir_all(&bundle).unwrap(); register_deep_links_windows( &bundle, &["acme".to_string(), "acme-beta".to_string()], ) .unwrap(); let script = std::fs::read_to_string(bundle.join("register-deep-links.bat")).unwrap(); for scheme in &["acme", "acme-beta"] { assert!( script .contains(&format!("reg add \"HKCU\\Software\\Classes\\{scheme}\"")), "script should register {scheme}; got:\n{script}" ); } // The protocol handler must point back at the renamed launcher exe. assert!( script.contains("MyApp.exe"), "handler should invoke the launcher; got:\n{script}" ); } #[test] fn register_deep_links_lowercases_and_dispatches_by_target() { // Drives the top-level entry point with an explicit target so the test // exercises the macOS path on any host. Mixed-case input must be // normalized to lowercase before it reaches the plist. let tmp = tempfile::tempdir().unwrap(); let bundle = fake_macos_bundle(tmp.path(), "com.acme.myapp"); let mut flags = empty_desktop_flags(); flags.target = Some("aarch64-apple-darwin".to_string()); flags.deep_links = vec![" ACME ".to_string(), "".to_string()]; register_deep_links(&bundle, &flags).unwrap(); let dict: plist::Dictionary = plist::from_file(bundle.join("Contents/Info.plist")).unwrap(); let schemes: Vec<&str> = dict .get("CFBundleURLTypes") .and_then(|v| v.as_array()) .unwrap()[0] .as_dictionary() .unwrap() .get("CFBundleURLSchemes") .and_then(|v| v.as_array()) .unwrap() .iter() .map(|v| v.as_string().unwrap()) .collect(); assert_eq!(schemes, ["acme"]); } #[test] fn register_deep_links_rejects_reserved_scheme() { let tmp = tempfile::tempdir().unwrap(); let bundle = fake_macos_bundle(tmp.path(), "com.acme.myapp"); let mut flags = empty_desktop_flags(); flags.target = Some("aarch64-apple-darwin".to_string()); flags.deep_links = vec!["https".to_string()]; assert!(register_deep_links(&bundle, &flags).is_err()); } #[test] fn reserve_app_dir_ok_when_missing() { let tmp = tempfile::tempdir().unwrap(); let app_dir = tmp.path().join("app"); reserve_app_dir(&app_dir).unwrap(); assert!(!app_dir.exists()); } #[test] fn reserve_app_dir_clears_empty_dir() { let tmp = tempfile::tempdir().unwrap(); let app_dir = tmp.path().join("app"); std::fs::create_dir(&app_dir).unwrap(); reserve_app_dir(&app_dir).unwrap(); assert!(!app_dir.exists()); } #[test] fn reserve_app_dir_clears_previous_build() { // A directory carrying our marker is a prior `deno desktop` output and // may be replaced. let tmp = tempfile::tempdir().unwrap(); let app_dir = tmp.path().join("app"); std::fs::create_dir(&app_dir).unwrap(); std::fs::write(app_dir.join("laufey"), b"binary").unwrap(); std::fs::write(app_dir.join(APP_DIR_MARKER), b"").unwrap(); reserve_app_dir(&app_dir).unwrap(); assert!(!app_dir.exists()); } #[test] fn reserve_app_dir_clears_previous_macos_bundle() { // A `.app` bundle's marker lives under Contents/Resources/. let tmp = tempfile::tempdir().unwrap(); let app_dir = tmp.path().join("App.app"); let resources = app_dir.join("Contents").join("Resources"); std::fs::create_dir_all(&resources).unwrap(); std::fs::write(resources.join(APP_DIR_MARKER), b"").unwrap(); reserve_app_dir(&app_dir).unwrap(); assert!(!app_dir.exists()); } #[test] fn reserve_app_dir_refuses_user_data() { // The crux of issue #35510: an inferred app name collides with an // existing user directory. We must error, not delete it. let tmp = tempfile::tempdir().unwrap(); let app_dir = tmp.path().join("helloworld"); std::fs::create_dir(&app_dir).unwrap(); let precious = app_dir.join("precious.txt"); std::fs::write(&precious, b"do not delete").unwrap(); let err = reserve_app_dir(&app_dir).unwrap_err(); assert!(err.to_string().contains("not created by")); // The user's data survives untouched. assert!(precious.exists()); assert_eq!(std::fs::read(&precious).unwrap(), b"do not delete"); } #[test] fn reserve_app_dir_refuses_existing_file() { let tmp = tempfile::tempdir().unwrap(); let path = tmp.path().join("app"); std::fs::write(&path, b"i am a file").unwrap(); let err = reserve_app_dir(&path).unwrap_err(); assert!(err.to_string().contains("a file with that name")); assert!(path.exists()); } // --- desktop.backend config merge (CLI flag > deno.json > webview) --- #[test] fn backend_from_deno_json_when_flag_absent() { let mut flags = DesktopFlags { source_file: "main.ts".to_string(), backend: None, ..Default::default() }; let config = DesktopConfig { backend: Some("cef".to_string()), ..Default::default() }; apply_desktop_config_to_flags(&mut flags, config); assert_eq!(flags.backend.as_deref(), Some("cef")); } #[test] fn cli_flag_overrides_deno_json_backend() { let mut flags = DesktopFlags { source_file: "main.ts".to_string(), backend: Some("webview".to_string()), ..Default::default() }; let config = DesktopConfig { backend: Some("cef".to_string()), ..Default::default() }; apply_desktop_config_to_flags(&mut flags, config); assert_eq!(flags.backend.as_deref(), Some("webview")); } #[test] fn backend_defaults_to_none_when_unset() { let mut flags = DesktopFlags { source_file: "main.ts".to_string(), backend: None, ..Default::default() }; let config = DesktopConfig::default(); apply_desktop_config_to_flags(&mut flags, config); // Left unset; callers fall back to "webview" via unwrap_or("webview"). assert_eq!(flags.backend.as_deref(), None); } }