From 3fda863616bd5c72e81538df32927c88c64aea2e Mon Sep 17 00:00:00 2001 From: upstream Date: Tue, 29 Sep 2026 11:59:56 +0200 Subject: [PATCH] Upstream denoland/deno state of the touched files before 72a20ccf5a --- cli/tools/desktop.rs | 8363 ++++++++++++++++++++++++++++++++ libs/cli_parser/src/convert.rs | 3461 +++++++++++++ libs/cli_parser/src/flags.rs | 1071 ++++ 3 files changed, 12895 insertions(+) create mode 100644 cli/tools/desktop.rs create mode 100644 libs/cli_parser/src/convert.rs create mode 100644 libs/cli_parser/src/flags.rs diff --git a/cli/tools/desktop.rs b/cli/tools/desktop.rs new file mode 100644 index 0000000..1ccf781 --- /dev/null +++ b/cli/tools/desktop.rs @@ -0,0 +1,8363 @@ +// 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, + 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 (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 (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 `.dylib`, its compile temp file + // (`.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(), + config_package_version(cli_options).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(), + config_package_version(cli_options).as_deref(), + config_package_license(cli_options).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(), + config_package_version(cli_options).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 `://...` 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 = 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::>() + .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/` 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::(); + + 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\` 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 `.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 { + 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/` launcher extracts it to +/// `~/Library/Application Support///` 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`. + 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 { + 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 { + 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(() => {});\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 { + 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 { + 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. + // + // No `linux_colocated_runtime_name` equivalent is needed here: the Windows + // launcher is `.exe`, so the same chop-at-the-last-dot lookup removes a + // real `.exe` extension and lands on `.dll` for any app name, dotted or + // not. Linux launchers have no extension for the chop to consume, which is + // why only that side has to pre-truncate. + let AppDirTargets { + dest_dylib, + launcher_path, + staged_backend, + } = resolve_app_dir_targets( + &app_dir, + &app_name, + &parts.file_name.to_string_lossy(), + &format!("{}.exe", app_name), + &laufey_binary_name, + )?; + std::fs::copy(dylib_path, &dest_dylib)?; + + // Rename the LAUFEY backend binary to the app name (`.exe`) so it sits + // next to `.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 `.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. + 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 +/// runtime — no launcher shell script is needed. The runtime file name is the +/// one the launcher resolves (see [`linux_colocated_runtime_name`]), which is +/// `AppName.so` unless the app name itself contains a dot. +async fn package_linux_app_dir( + dylib_path: &Path, + desktop_flags: &DesktopFlags, + cli_options: &CliOptions, + laufey_resolver: &LaufeyBackendResolver, +) -> Result { + 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 under the name the renamed backend binary will + // auto-load: laufey's LaufeyFindColocatedRuntime resolves `.so` + // next to the binary. It reads the real path via `/proc/self/exe`, which + // follows the `/usr/bin/` -> `/usr/lib//` symlink that + // `.deb`/`.rpm` install (issue #35623), so no wrapper script is needed. + let runtime_name = linux_colocated_runtime_name(&app_name); + let AppDirTargets { + dest_dylib, + launcher_path, + staged_backend, + } = resolve_app_dir_targets( + &app_dir, + &app_name, + &runtime_name, + &app_name, + &laufey_binary_name, + )?; + std::fs::copy(dylib_path, &dest_dylib)?; + + // Rename the LAUFEY backend binary to the app name so `` is the launcher + // the user runs directly — no `--runtime` argument and no shell wrapper. + 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, + /// `/laufey//` + cache_root: PathBuf, +} + +impl LaufeyBackendResolver { + fn new(factory: &CliFactory) -> Result { + 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 + /// `/laufey////`. + async fn ensure_downloaded( + &self, + backend: &str, + target: &str, + ) -> Result { + 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 { + 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 { + 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 { + 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 ` ` (optionally ` *` for binary +/// mode). +fn parse_sha256sum(contents: &str, file: &str) -> Option { + 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 { + 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 { + 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 { + 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 { + // 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 = 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 { + let candidates: Vec = 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 { + 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` → +/// `
.helper`, `com.example.laufey.helper.gpu` → `
.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/.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=` + // 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/` 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 { + 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 { + // 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 { + 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 [--entitlements ] `. +/// +/// `--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 `/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 `/.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 { + 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 `/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 { + 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 { + 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, 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 library filename the Linux launcher resolves for an app named +/// `app_name`. +/// +/// laufey's `LaufeyFindColocatedRuntime` chops everything after the last dot of +/// the executable's file name — an extension a Linux launcher doesn't have — +/// and appends `.so`. So a launcher named `MyApp-2.9.2` opens `MyApp-2.9.so`, +/// not `MyApp-2.9.2.so` (issue #35971). Mirror that here so the library we ship +/// is the one the launcher actually opens. +/// +/// # This is coupled to laufey's behaviour, across a repo boundary +/// +/// Mirrored against **laufey 0.7.0** (see `cli/rt_desktop/Cargo.toml`), whose +/// lookup this reproduces; it is verified only by apps starting, since the +/// chop lives in laufey's C++ backend and nothing here can assert on it. +/// +/// The coupling is two-way and silent. If laufey changes +/// `LaufeyFindColocatedRuntime` to strip only a known extension, every app +/// built by this version of Deno stops starting: the launcher would begin +/// looking for `MyApp-2.9.2.so`, which we deliberately do not ship. **Re-check +/// this function when bumping laufey**, and if the lookup has changed, ship +/// the library under the name the new lookup resolves. +fn linux_colocated_runtime_name(app_name: &str) -> String { + // A dot at index 0 is treated as part of the name (`.hidden`) rather than an + // extension. Unlike the truncation itself, this carve-out has NOT been + // verified against the backend — a plain `strrchr(name, '.')` there would + // find the dot at index 0 and look for `.so` instead. Reaching it requires + // `--output .hidden`, so it is theoretical, but if a dotfile-named app ever + // fails to start this is the first thing to check. + let base = match app_name.rfind('.') { + Some(dot) if dot > 0 => &app_name[..dot], + _ => app_name, + }; + format!("{base}.so") +} + +/// The three app-dir paths a packager writes, resolved and checked together. +#[derive(Debug)] +struct AppDirTargets { + /// Where the compiled runtime library is copied to. + dest_dylib: PathBuf, + /// Where the backend binary ends up after being renamed to the app name. + launcher_path: PathBuf, + /// Where the backend binary currently sits, under its own name. + staged_backend: PathBuf, +} + +/// Resolve the app-named files a packager is about to write, refusing any that +/// would clobber something already in the app dir. +/// +/// Both the runtime library and the launcher take their names from the app +/// name, so either can land on a file the backend shipped (`libcef.so`, +/// `libcef.dll`, `d3dcompiler_47.dll`, the helper executables) or — for a Linux +/// app name like `myapp.so`, whose library truncates back onto the launcher — +/// on each other. +/// +/// `fs::copy` and `fs::rename` both replace silently, so every check has to run +/// before either does. Returning the paths only on success is what keeps that +/// ordering intact: a caller cannot reach the paths without having passed the +/// checks, so a later refactor can't reorder them apart. +fn resolve_app_dir_targets( + app_dir: &Path, + app_name: &str, + runtime_name: &str, + launcher_name: &str, + laufey_binary_name: &str, +) -> Result { + validate_launcher_name(app_name, "app name")?; + let dest_dylib = app_dir.join(runtime_name); + let launcher_path = app_dir.join(launcher_name); + let staged_backend = app_dir.join(laufey_binary_name); + + reject_backend_file_collision(&dest_dylib, app_name, "runtime library")?; + if dest_dylib == launcher_path { + bail!( + "app name {app_name:?} resolves its runtime library to {runtime_name}, \ + which is the launcher itself. Choose a different --output name.", + ); + } + // The staged backend is the file we are about to rename *into* the launcher + // path, so it colliding with itself is the normal case, not a clash. + if launcher_path != staged_backend { + reject_backend_file_collision(&launcher_path, app_name, "launcher")?; + } + Ok(AppDirTargets { + dest_dylib, + launcher_path, + staged_backend, + }) +} + +/// Refuse an app-derived file name that lands on a file the backend shipped. +/// +/// The app dir starts life as a copy of the LAUFEY backend directory, so it +/// already holds the backend's own files — `libcef.so` and friends on Linux, +/// `libcef.dll` / `d3dcompiler_47.dll` / the helper executables on Windows. +/// Both the runtime library and the launcher are named after the app, so an app +/// name that resolves onto one of those would overwrite it (`fs::copy` and +/// `fs::rename` both replace silently) and ship an app that can't start. +fn reject_backend_file_collision( + path: &Path, + app_name: &str, + kind: &str, +) -> Result<(), AnyError> { + if !path.exists() { + return Ok(()); + } + let name = path.file_name().unwrap_or_default().to_string_lossy(); + bail!( + "app name {app_name:?} resolves its {kind} to {name}, which is already \ + part of the app. Choose a different --output 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 `.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 `.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 { + 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 `.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 + // `.` 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). + // Warn here rather than inside the renderer, which is otherwise a pure + // string builder (and is called repeatedly from tests). The plist keys + // accept only period-separated integers, so a non-numeric version leaves + // the defaults in place — say so, since the deb/rpm path rejects the same + // `deno.json` outright. + warn_about_unusable_version( + config_package_version(cli_options).as_deref(), + "the bundle will carry CFBundleShortVersionString 1.0 / CFBundleVersion \ + 1.0.0 (both accept only period-separated integers)", + ); + let info_plist = render_macos_info_plist( + &app_name, + &bundle_id, + &laufey_executable_name, + desktop_flags.icon.is_some(), + config_package_version(cli_options).as_deref(), + ); + 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)?; + + // Handle icon. Must land before codesigning: the bundle signature seals + // `Contents/Resources`, so an icon copied in afterwards makes a fresh + // build fail `codesign --verify --strict` on arrival (#36418). + 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)?; + } + } + } + + // 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)?; + + // 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. + 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)?; + } + + // 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, + config_version: Option<&str>, +) -> String { + // deno.json `version` when configured (#36503), reduced to the numeric + // `major.minor.build` form both version keys accept — a semver prerelease + // carried through verbatim is rejected by notarization and App Store + // validation (macOS only tolerates it for locally-distributed apps). The + // defaults match the Linux/Windows packaging defaults. + let numeric_version = + config_version + .and_then(numeric_version_fields) + .map(|fields| { + fields + .iter() + .map(u64::to_string) + .collect::>() + .join(".") + }); + let short_version = numeric_version.as_deref().unwrap_or("1.0"); + let bundle_version = numeric_version.as_deref().unwrap_or("1.0.0"); + format!( + r#" + + + + CFBundleDevelopmentRegion + en + CFBundleExecutable + {executable_name} + CFBundleIconFile + {icon_file} + CFBundleIdentifier + {bundle_id} + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + {app_name} + CFBundlePackageType + APPL + CFBundleShortVersionString + {short_version} + CFBundleVersion + {bundle_version} + LSMinimumSystemVersion + 10.15 + NSHighResolutionCapable + + NSPrincipalClass + LaufeyApplication + NSSupportsAutomaticGraphicsSwitching + + NSAppTransportSecurity + + NSAllowsLocalNetworking + + + NSMicrophoneUsageDescription + {app_name} requires access to the microphone + NSCameraUsageDescription + {app_name} requires access to the camera + NSAudioCaptureUsageDescription + {app_name} requires access to audio capture + NSBluetoothAlwaysUsageDescription + {app_name} requires access to Bluetooth + NSBluetoothPeripheralUsageDescription + {app_name} requires access to Bluetooth + + +"#, + 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 `.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, 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 = vec![fs_root.to_path_buf()]; + while let Some(dir) = stack.pop() { + let mut entries: Vec<_> = + std::fs::read_dir(&dir)?.collect::>()?; + 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::::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.` 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() + } +} + +/// The workspace root deno.json `version` field, used as the package version +/// across the macOS bundle and the Linux/Windows installers (#36503). +fn config_package_version( + cli_options: &crate::args::CliOptions, +) -> Option { + cli_options + .workspace() + .root_deno_json() + .and_then(|c| c.json.version.clone()) + .filter(|v| !v.is_empty()) +} + +/// The workspace root deno.json `license` field (when it is a string), used +/// for the `.rpm` License tag (#36503). +fn config_package_license( + cli_options: &crate::args::CliOptions, +) -> Option { + cli_options + .workspace() + .root_deno_json() + .and_then(|c| c.json.license.as_ref()) + .and_then(|v| v.as_str().map(str::to_string)) + .filter(|v| !v.is_empty()) +} + +fn linux_package_meta( + app_dir: &Path, + desktop_flags: &DesktopFlags, + config_version: Option<&str>, +) -> Result { + 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}")); + Ok(LinuxPackageMeta { + summary: format!("{app_name} desktop application"), + maintainer: format!("{app_name} "), + version: linux_package_version(config_version)?, + package, + app_name, + identifier, + }) +} + +/// The `.deb`/`.rpm` version string for a configured deno.json `version`. +/// +/// Both formats constrain the version tag, and outside of publishing +/// `version` is a free-form string, so an unusable value has to be rejected +/// here: the package would build fine and only fail at `dpkg -i`/`rpm -i` +/// time with an opaque "invalid version" error. Debian policy 5.6.12 requires +/// the upstream version to start with a digit and restricts it to +/// alphanumerics plus `. + - ~` (`:` and `-` being reserved as the epoch and +/// revision separators, neither of which we emit); RPM's Version tag is +/// similarly restricted and additionally forbids `-`. +/// +/// The one transformation applied is `-` → `~`: neither format reads `-` in +/// the upstream-version position with the meaning users expect from a semver +/// prerelease, while both order `~` as "less than the release" — the semver +/// intent. +fn linux_package_version( + config_version: Option<&str>, +) -> Result { + let Some(version) = config_version else { + return Ok(LINUX_PACKAGE_VERSION.to_string()); + }; + if !version.starts_with(|c: char| c.is_ascii_digit()) { + bail!( + "deno.json `version` \"{version}\" cannot be used as a Debian/RPM package version: it must start with a digit" + ); + } + if let Some(c) = version.chars().find(|c| { + !(c.is_ascii_alphanumeric() || matches!(c, '.' | '+' | '-' | '~')) + }) { + bail!( + "deno.json `version` \"{version}\" cannot be used as a Debian/RPM package version: the character '{c}' is not allowed (only alphanumerics, '.', '+', '-' and '~')" + ); + } + Ok(version.replace('-', "~")) +} + +/// 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/.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/` 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// ← staged app dir contents +/// /usr/bin/ ← symlink → ../lib// +/// /usr/share/applications/.desktop +/// /usr/share/icons/hicolor/512x512/apps/.png +/// ``` +fn create_linux_deb( + app_dir: &Path, + deb_path: &Path, + desktop_flags: &DesktopFlags, + target: Option<&str>, + config_version: Option<&str>, +) -> Result<(), AnyError> { + let meta = linux_package_meta(app_dir, desktop_flags, config_version)?; + 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::>() + .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"!\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, 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, + /// 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 { + use flate2::Compression; + use flate2::write::GzEncoder; + + let mut tar_buf: Vec = 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>, + 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//. + let lib_prefix = format!("./usr/lib/{}", meta.package); + let mut stack = vec![app_dir.to_path_buf()]; + let mut files: Vec = Vec::new(); + while let Some(dir) = stack.pop() { + let mut entries: Vec<_> = + std::fs::read_dir(&dir)?.collect::>()?; + 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/ → ../lib// + { + 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/.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/.../.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, AnyError> { + use flate2::Compression; + use flate2::write::GzEncoder; + + let mut tar_buf: Vec = 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>, + config_version: Option<&str>, + config_license: Option<&str>, +) -> Result<(), AnyError> { + let meta = linux_package_meta(app_dir, desktop_flags, config_version)?; + 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, + // deno.json `license` when configured; the previous hard-coded "MIT" + // mislabeled every package built from a differently-licensed project + // (#36503). + config_license.unwrap_or("MIT"), + arch, + &meta.summary, + ); + builder.using_config(config); + builder.description(&meta.summary); + builder.vendor(&meta.maintainer); + + // Own /usr/lib//** (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/ → ../lib// + 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/.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/.../.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` (files, ≤8 chars) plus the real extension truncated +/// to 3 uppercased alphanumerics, or `D` (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::() + .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, +} + +/// Reduce a configured version to the numeric `major.minor.build` form MSI's +/// ProductVersion accepts, dropping semver prerelease/build suffixes. Returns +/// the packaging default when nothing numeric is configured, and errors when +/// a field exceeds the range MSI encodes. +fn msi_product_version( + config_version: Option<&str>, +) -> Result { + let Some(version) = config_version else { + return Ok("1.0.0".to_string()); + }; + let Some(fields) = numeric_version_fields(version) else { + // MSI's ProductVersion can't express a non-numeric version at all, so + // the default is the only way through. `create_windows_msi` warns about + // it; this stays a pure reduction so tests and the plist path can call + // it without logging. + return Ok("1.0.0".to_string()); + }; + // Windows Installer packs ProductVersion into 8/8/16 bits, so a field that + // overflows either makes the package fail validation or, worse, silently + // compares wrong against an installed build. Truncating would ship a + // version the user didn't write, so a CalVer-style `2026.8.26` has to be + // reported rather than mangled. + const LIMITS: [(u64, &str); 3] = + [(255, "major"), (255, "minor"), (65535, "build")]; + for (field, (limit, name)) in fields.iter().zip(LIMITS) { + if *field > limit { + bail!( + "deno.json `version` \"{version}\" cannot be used as an MSI \ + ProductVersion: the {name} field {field} exceeds the maximum of \ + {limit}. Windows Installer packs ProductVersion into \ + major(0-255).minor(0-255).build(0-65535), so a CalVer-style version \ + has no valid encoding. Either use a version within those bounds, or \ + build a non-MSI format (.deb/.rpm/.appimage/.app carry the version \ + verbatim)." + ); + } + } + Ok( + fields + .iter() + .map(u64::to_string) + .collect::>() + .join("."), + ) +} + +/// Warn when a configured version can't be carried into a format that needs +/// a numeric one, and something other than what the user wrote will ship. +/// +/// Covers both ways that happens — not numeric at all, or more fields than +/// the format holds. Neither is an error: MSI's ProductVersion and the plist +/// version keys simply can't express those versions, so falling back or +/// truncating is the only way through. But the deb/rpm path rejects the very +/// same `deno.json`, and quietly shipping a version the user didn't write is +/// the failure #36503 is about. `consequence` names what will ship instead. +/// +/// Call this once per build, from the packager rather than from a helper: +/// keeping it out of `numeric_version_fields` is what stops every caller of +/// that function logging, and what stops a version being warned about twice +/// on a path that parses it more than once. +fn warn_about_unusable_version( + config_version: Option<&str>, + consequence: &str, +) { + let Some(version) = config_version else { + return; + }; + if numeric_version_fields(version).is_none() { + log::warn!("deno.json `version` {version:?} is not numeric; {consequence}"); + } else if version_core_fields(version).len() > 3 { + // Three is all either target can hold (MSI's ProductVersion has three + // fields, `CFBundleVersion` accepts at most three integers), so the + // output is right — this is only about saying so. + log::warn!( + "deno.json `version` {version:?} has more than three numeric fields; \ + only the leading major.minor.build are used" + ); + } +} + +/// The leading numeric `major[.minor[.build]]` fields of a version, dropping +/// any semver prerelease/build suffix. `None` when the version isn't numeric +/// at all (`weird`, `1.beta.2`) — the packagers that need a numeric version +/// fall back to their default in that case. +/// +/// Fields are returned parsed, so callers re-emit them normalised: a padded +/// `01.02.03` becomes `1.2.3`. Both target formats compare these as integers, +/// so the padding carries no meaning to drop. +fn numeric_version_fields(version: &str) -> Option> { + version_core_fields(version) + .iter() + .take(3) + .map(|p| p.parse::().ok()) + .collect::>>() +} + +/// The dot-separated fields of a version's numeric core, i.e. everything +/// before a semver prerelease/build suffix. Kept separate from +/// [`numeric_version_fields`] so the field *count* is available without +/// re-deriving the split — `numeric_version_fields` only ever returns the +/// leading three. +fn version_core_fields(version: &str) -> Vec<&str> { + let core = version.split(['-', '+']).next().unwrap_or(version); + core.split('.').collect() +} + +/// 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\\`, mirroring the +/// staged app-dir tree exactly; uninstall removes it. Layout: +/// +/// ```text +/// %ProgramFiles%\\ +/// .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>, + config_version: 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()); + // deno.json `version` when configured, reduced to the numeric + // `major.minor.build` form MSI's ProductVersion requires (a semver + // prerelease/build suffix would fail MSI validation); the default matches + // the macOS bundle's CFBundleVersion and the Linux package default + // (#36503). + warn_about_unusable_version( + config_version, + "the .msi will be built with ProductVersion 1.0.0 (MSI accepts only \ + major.minor.build)", + ); + let version = msi_product_version(config_version)?; + let version = version.as_str(); + 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 = + 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::>()?; + 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 = + 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![ + 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 = + std::collections::BTreeMap::new(); + let mut files: Vec = 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 `.exe`; it auto-loads the co-located `.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> = 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> = 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::::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 { + 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 = (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 = comp + .read_storage("/")? + .map(|e| e.name().to_string()) + .collect(); + let read_stream = |comp: &mut cfb::CompoundFile, + raw: &str| + -> Result, 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, + 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 { + 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 { + 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, Vec)] = &[ + ("_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> = + 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 = cols.iter().map(|&(_, ty)| width_of(ty)).collect(); + let keys: Vec = 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, 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::::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)> = 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::() + + 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)> = 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 { + 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, Vec, Option); + + /// 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 { + 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 = 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 = + 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 = 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::>()?; + 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 { + 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 { + 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 { + // 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, + None, + ); + assert!(plist.contains("LaufeyApplication")); + // 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( + "CFBundleExecutable\n laufey_webview" + )); + assert!( + plist.contains("NSMicrophoneUsageDescription") + && plist.contains("Deno Demo requires access to the microphone") + ); + assert!( + plist.contains("NSCameraUsageDescription") + && plist.contains("Deno Demo requires access to the camera") + ); + assert!( + plist.contains("NSAudioCaptureUsageDescription") + && plist.contains("Deno Demo requires access to audio capture") + ); + assert!( + plist.contains("NSBluetoothAlwaysUsageDescription") + && plist.contains("Deno Demo requires access to Bluetooth") + ); + assert!(plist.contains("AppIcon")); + } + + #[test] + fn macos_runtime_dylib_name_is_backend_specific() { + // webview resolves a hardcoded libruntime.dylib via [NSBundle mainBundle]; + // cef resolves .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 ` *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 + } + + // --- linux_colocated_runtime_name --- + + #[test] + fn linux_colocated_runtime_name_plain() { + assert_eq!(linux_colocated_runtime_name("MyApp"), "MyApp.so"); + } + + #[test] + fn linux_colocated_runtime_name_matches_launcher_lookup() { + // The launcher strips everything after the last dot of its own file name + // and appends `.so`, so a dotted app name must ship the library under the + // truncated name — otherwise it starts up with "No runtime library found" + // (issue #35971). + assert_eq!( + linux_colocated_runtime_name("template-deno2.9.2-desktop-vue3-vite8"), + "template-deno2.9.so" + ); + assert_eq!( + linux_colocated_runtime_name("myapp.v1.2.3"), + "myapp.v1.2.so" + ); + } + + #[test] + fn linux_colocated_runtime_name_leading_dot_is_not_an_extension() { + // The launcher only treats a dot past position 0 as an extension, so a + // dotfile name keeps its whole name. + assert_eq!(linux_colocated_runtime_name(".hidden"), ".hidden.so"); + } + + // --- resolve_app_dir_targets --- + + /// An app dir as the packagers find it: a copy of the backend directory, + /// holding the backend's own files and its binary, and nothing else. + fn fake_backend_app_dir( + root: &std::path::Path, + backend_files: &[&str], + ) -> PathBuf { + let app_dir = root.join("app"); + std::fs::create_dir_all(&app_dir).unwrap(); + for f in backend_files { + std::fs::write(app_dir.join(f), b"backend").unwrap(); + } + app_dir + } + + #[test] + fn resolve_app_dir_targets_linux_happy_path() { + let tmp = tempfile::tempdir().unwrap(); + let app_dir = fake_backend_app_dir(tmp.path(), &["libcef.so", "laufey"]); + let targets = resolve_app_dir_targets( + &app_dir, + "MyApp-2.9.2", + &linux_colocated_runtime_name("MyApp-2.9.2"), + "MyApp-2.9.2", + "laufey", + ) + .unwrap(); + // The dotted name keeps every dot in the launcher, while the library is + // pre-truncated to what the launcher's own chop will look for (#35971). + assert_eq!(targets.launcher_path, app_dir.join("MyApp-2.9.2")); + assert_eq!(targets.dest_dylib, app_dir.join("MyApp-2.9.so")); + assert_eq!(targets.staged_backend, app_dir.join("laufey")); + } + + #[test] + fn resolve_app_dir_targets_rejects_library_landing_on_the_launcher() { + // `--output myapp.so`: the library truncates back to `myapp.so`, which is + // the launcher path. Covers the `dest_dylib == launcher_path` bail that + // `package_linux_app_dir` reaches. + let tmp = tempfile::tempdir().unwrap(); + let app_dir = fake_backend_app_dir(tmp.path(), &["libcef.so", "laufey"]); + let err = resolve_app_dir_targets( + &app_dir, + "myapp.so", + &linux_colocated_runtime_name("myapp.so"), + "myapp.so", + "laufey", + ) + .unwrap_err() + .to_string(); + assert!(err.contains("which is the launcher itself"), "got: {err}"); + } + + #[test] + fn resolve_app_dir_targets_rejects_clobbering_a_backend_library() { + // `deno desktop --output libcef` on Linux, `--output libcef` on Windows: + // the runtime library copy would replace a file CEF ships. + let tmp = tempfile::tempdir().unwrap(); + + let linux_dir = fake_backend_app_dir(tmp.path(), &["libcef.so", "laufey"]); + let err = resolve_app_dir_targets( + &linux_dir, + "libcef", + &linux_colocated_runtime_name("libcef"), + "libcef", + "laufey", + ) + .unwrap_err() + .to_string(); + assert!(err.contains("runtime library"), "got: {err}"); + assert!(err.contains("libcef.so"), "got: {err}"); + + let win_root = tmp.path().join("win"); + std::fs::create_dir_all(&win_root).unwrap(); + let win_dir = fake_backend_app_dir( + &win_root, + &["libcef.dll", "d3dcompiler_47.dll", "laufey.exe"], + ); + let err = resolve_app_dir_targets( + &win_dir, + "d3dcompiler_47", + "d3dcompiler_47.dll", + "d3dcompiler_47.exe", + "laufey.exe", + ) + .unwrap_err() + .to_string(); + assert!(err.contains("runtime library"), "got: {err}"); + assert!(err.contains("d3dcompiler_47.dll"), "got: {err}"); + } + + #[test] + fn resolve_app_dir_targets_rejects_clobbering_a_backend_executable() { + // The launcher rename is the other write. An app named after one of the + // backend's helper executables would replace it — Windows-shaped here, + // since that is the path #36006 added the guard to. + let tmp = tempfile::tempdir().unwrap(); + let app_dir = fake_backend_app_dir( + tmp.path(), + &["libcef.dll", "laufey_helper.exe", "laufey.exe"], + ); + let err = resolve_app_dir_targets( + &app_dir, + "laufey_helper", + "laufey_helper.dll", + "laufey_helper.exe", + "laufey.exe", + ) + .unwrap_err() + .to_string(); + assert!(err.contains("launcher"), "got: {err}"); + assert!(err.contains("laufey_helper.exe"), "got: {err}"); + } + + #[test] + fn resolve_app_dir_targets_allows_the_backend_binary_as_launcher() { + // When the app name already matches the backend binary, the rename is a + // no-op rather than a clobber — the staged backend IS the launcher. + let tmp = tempfile::tempdir().unwrap(); + let app_dir = fake_backend_app_dir(tmp.path(), &["libcef.dll", "app.exe"]); + let targets = + resolve_app_dir_targets(&app_dir, "app", "app.dll", "app.exe", "app.exe") + .unwrap(); + assert_eq!(targets.launcher_path, targets.staged_backend); + } + + #[test] + fn resolve_app_dir_targets_checks_before_returning_any_path() { + // The guards exist to run before `fs::copy`/`fs::rename`. Returning the + // paths only on success is what enforces that, so a rejected app name + // must yield no paths at all for a caller to write through. + let tmp = tempfile::tempdir().unwrap(); + let app_dir = fake_backend_app_dir(tmp.path(), &["libcef.so", "laufey"]); + assert!( + resolve_app_dir_targets( + &app_dir, + "libcef", + &linux_colocated_runtime_name("libcef"), + "libcef", + "laufey", + ) + .is_err() + ); + // Nothing was written or renamed on the way to that error. + assert_eq!( + std::fs::read(app_dir.join("libcef.so")).unwrap(), + b"backend", + "the rejected build must not have touched the backend file" + ); + assert!( + app_dir.join("laufey").exists(), + "backend binary still staged" + ); + } + + #[test] + fn resolve_app_dir_targets_rejects_an_invalid_app_name() { + // `validate_launcher_name` runs first, so a name that could never be a + // safe file name is refused before any collision check. + let tmp = tempfile::tempdir().unwrap(); + let app_dir = fake_backend_app_dir(tmp.path(), &["laufey"]); + let err = resolve_app_dir_targets( + &app_dir, + "my/app", + "my/app.so", + "my/app", + "laufey", + ) + .unwrap_err() + .to_string(); + assert!(err.contains("invalid app name"), "got: {err}"); + } + + // --- reject_backend_file_collision --- + + #[test] + fn reject_backend_file_collision_allows_a_free_name() { + let tmp = tempfile::tempdir().unwrap(); + reject_backend_file_collision( + &tmp.path().join("MyApp.so"), + "MyApp", + "runtime library", + ) + .unwrap(); + } + + #[test] + fn reject_backend_file_collision_rejects_a_backend_file() { + // `deno desktop --output libcef` on Linux, or `--output d3dcompiler_47` on + // Windows: the copy would replace a file CEF ships and the app would no + // longer start, with nothing in the build output to say why. + let tmp = tempfile::tempdir().unwrap(); + for backend_file in ["libcef.so", "d3dcompiler_47.dll"] { + let path = tmp.path().join(backend_file); + std::fs::write(&path, b"backend").unwrap(); + let err = + reject_backend_file_collision(&path, "libcef", "runtime library") + .unwrap_err() + .to_string(); + assert!(err.contains(backend_file), "{err}"); + assert!(err.contains("already"), "{err}"); + // The guard must not have touched the file it refused. + assert_eq!(std::fs::read(&path).unwrap(), b"backend"); + } + } + + #[test] + fn reject_backend_file_collision_reports_the_kind() { + let tmp = tempfile::tempdir().unwrap(); + let path = tmp.path().join("chrome-sandbox"); + std::fs::write(&path, b"backend").unwrap(); + let err = + reject_backend_file_collision(&path, "chrome-sandbox", "launcher") + .unwrap_err() + .to_string(); + assert!(err.contains("launcher"), "{err}"); + } + + // --- 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 { + let mut tar_buf: Vec = 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 { + let mut tar_buf: Vec = 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 { + 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#" + + + + CFBundleIdentifier + {bundle_id} + + +"# + ); + 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>>), + >( + f: F, + ) -> Vec { + let mut buf: Vec = 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#" + + + CFBundleExecutablemy_app + CFBundleIdentifiercom.example.foo + +"#, + ) + .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#" + +Xy +"#, + ) + .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#" + +Kx +"#, + ) + .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 { + // 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#" + +Otherx +"#, + ) + .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 `` and auto-loads the co-located + // `.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 { + 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"), + None, + ) + .unwrap(); + + let bytes = std::fs::read(&deb).unwrap(); + assert!(bytes.starts_with(b"!\n"), "missing ar global header"); + + // Walk the ar members in order and collect (name, data). + let mut members: Vec<(String, Vec)> = Vec::new(); + let mut pos = 8; // after "!\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 = Vec::new(); + let mut bin_link_target: Option = 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"), + None, + None, + ) + .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 = 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 = 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") + ); + } + + #[test] + fn packaging_honors_config_version_and_license() { + // deno.json `version`/`license` must reach the installers instead of the + // hard-coded 1.0.0 / MIT defaults (#36503). Semver prereleases map to the + // `~` ordering both deb and rpm use for "before the release". + let tmp = tempfile::tempdir().unwrap(); + let app_dir = fake_linux_app_dir(tmp.path(), "MyApp"); + let rpm_path = tmp.path().join("MyApp.rpm"); + create_linux_rpm( + &app_dir, + &rpm_path, + &empty_desktop_flags(), + Some("aarch64-unknown-linux-gnu"), + Some("2.3.4-beta.1"), + Some("Apache-2.0"), + ) + .unwrap(); + let bytes = std::fs::read(&rpm_path).unwrap(); + let pkg = rpm::Package::parse(&mut &bytes[..]).unwrap(); + assert_eq!(pkg.metadata.get_version().unwrap(), "2.3.4~beta.1"); + assert_eq!(pkg.metadata.get_license().unwrap(), "Apache-2.0"); + + // The plist keys accept period-separated integers only, so the prerelease + // is dropped there rather than carried through to notarization. + let plist = render_macos_info_plist( + "A", + "com.a", + "laufey_webview", + false, + Some("2.3.4-beta.1"), + ); + assert!(plist.contains( + "CFBundleShortVersionString + 2.3.4" + )); + assert!(plist.contains( + "CFBundleVersion + 2.3.4" + )); + + // A version that isn't numeric at all leaves the defaults in place. + let plist = + render_macos_info_plist("A", "com.a", "laufey_webview", false, None); + assert!(plist.contains( + "CFBundleShortVersionString + 1.0" + )); + assert!(plist.contains( + "CFBundleVersion + 1.0.0" + )); + } + + #[test] + fn linux_package_version_rejects_unusable_versions() { + // `version` is free-form outside of publishing, and a value deb/rpm can't + // parse builds fine but fails at install time — so it has to be caught + // here (#36503). + assert_eq!(linux_package_version(None).unwrap(), "1.0.0"); + assert_eq!(linux_package_version(Some("2.3.4")).unwrap(), "2.3.4"); + assert_eq!( + linux_package_version(Some("2.3.4-beta.1")).unwrap(), + "2.3.4~beta.1", + "semver prereleases map to the `~` both formats order before the release" + ); + + let err = linux_package_version(Some("v1.2.3")) + .unwrap_err() + .to_string(); + assert!( + err.contains("must start with a digit"), + "a leading `v` is the common case Debian policy 5.6.12 rejects, got: {err}" + ); + for bad in ["1.0.0_alpha", "1.0.0 rc1", "1.0.0:x"] { + let err = linux_package_version(Some(bad)).unwrap_err().to_string(); + assert!( + err.contains("is not allowed"), + "`{bad}` must be rejected, got: {err}" + ); + } + } + + #[test] + fn version_parsing_helpers_do_not_log() { + // `numeric_version_fields` is on every version path, including inside + // `render_macos_info_plist`, so a warning here would fire from the + // renderer and fire twice on any path that parses the same version + // more than once. The warning belongs at the packager call site; these + // two stay pure parses. + // + // Pinned structurally rather than by capturing the log, which would need + // a global logger and race every other test in this binary. The guard is + // narrower than the invariant, so it also rejects a call to the warning + // helper next door — a parser reaching for that rather than `log::` + // directly is the likelier shape of the next slip. + let src = include_str!("desktop.rs"); + for name in ["fn numeric_version_fields", "fn version_core_fields"] { + let start = src.find(name).expect(name); + let body_end = start + src[start..].find("\n}\n").expect("fn end"); + let body = &src[start..body_end]; + for banned in ["log::", "warn_about_unusable_version"] { + assert!( + !body.contains(banned), + "{name} must stay a pure parse (found {banned:?}) - warn from the \ + packager call site instead, or it fires from the renderer and \ + twice on any path that parses the version more than once" + ); + } + } + } + + #[test] + fn version_core_fields_splits_on_the_numeric_core() { + assert_eq!(version_core_fields("1.2.3"), vec!["1", "2", "3"]); + assert_eq!(version_core_fields("1.2.3.4"), vec!["1", "2", "3", "4"]); + // The prerelease/build suffix isn't part of the core, so it can't make + // a three-field version look like a four-field one. + assert_eq!(version_core_fields("1.2.3-beta.1"), vec!["1", "2", "3"]); + assert_eq!(version_core_fields("1.2.3+build.5"), vec!["1", "2", "3"]); + assert_eq!(version_core_fields("weird"), vec!["weird"]); + } + + #[test] + fn msi_product_version_reduces_to_numeric_fields() { + assert_eq!(msi_product_version(Some("2.3.4-beta.1")).unwrap(), "2.3.4"); + assert_eq!(msi_product_version(Some("2.3")).unwrap(), "2.3"); + assert_eq!(msi_product_version(Some("1.2.3.4")).unwrap(), "1.2.3"); + // Fields are re-emitted from parsed integers, so padding is dropped. + // Both formats compare these numerically, so nothing is lost. + assert_eq!(msi_product_version(Some("01.02.03")).unwrap(), "1.2.3"); + assert_eq!(msi_product_version(None).unwrap(), "1.0.0"); + // Not numeric at all: fall back rather than emit a partial version. + assert_eq!(msi_product_version(Some("weird")).unwrap(), "1.0.0"); + assert_eq!( + msi_product_version(Some("1.beta.2")).unwrap(), + "1.0.0", + "a non-numeric field must fall back, not truncate to the numeric prefix" + ); + + // ProductVersion is packed into 8/8/16 bits; truncating a CalVer-style + // version would silently ship one the user didn't write. + let err = msi_product_version(Some("2026.8.26")) + .unwrap_err() + .to_string(); + assert!(err.contains("major field 2026"), "got: {err}"); + let err = msi_product_version(Some("1.256.0")) + .unwrap_err() + .to_string(); + assert!(err.contains("minor field 256"), "got: {err}"); + let err = msi_product_version(Some("1.0.65536")) + .unwrap_err() + .to_string(); + assert!(err.contains("build field 65536"), "got: {err}"); + } + + // --- 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 `.exe` and auto-loads the + // co-located `.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"), + None, + ) + .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 = 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 = 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 = 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"), + None, + ) + .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 = comp + .read_storage("/") + .unwrap() + .map(|e| e.name().to_string()) + .collect(); + let read_ids = + |comp: &mut cfb::CompoundFile, suffix: &str| -> Vec { + 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 = 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"), + None, + ) + .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 = 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 `.exe` (`[#fkey]`) with no arguments + // (it auto-loads the co-located `.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 = 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 `.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); + } +} diff --git a/libs/cli_parser/src/convert.rs b/libs/cli_parser/src/convert.rs new file mode 100644 index 0000000..8ab0551 --- /dev/null +++ b/libs/cli_parser/src/convert.rs @@ -0,0 +1,3461 @@ +// Copyright 2018-2026 the Deno authors. MIT license. +#![allow(dead_code, reason = "conversion functions used by the deno CLI crate")] + +//! Converts a raw `ParseResult` into the typed `Flags` struct. +//! +//! Each subcommand has a dedicated conversion function that extracts +//! the relevant parsed arguments and builds the corresponding +//! `DenoSubcommand` variant plus any shared `Flags` fields. + +use std::net::IpAddr; +use std::net::SocketAddr; +use std::net::ToSocketAddrs; +use std::num::NonZeroU8; +use std::num::NonZeroU32; +use std::num::NonZeroUsize; + +use deno_permissions::SysDescriptor; +use deno_semver::jsr::JsrDepPackageReq; +use deno_semver::package::PackageKind; + +use crate::error::CliError; +use crate::error::CliErrorKind; +use crate::flags::*; +use crate::types::ParseResult; + +type UnstableFeatureEntry = (&'static str, Option); + +/// Parse an `--inspect*` address. +/// +/// Accepts a bare port (`9229`), a bare `:port`, a bare IP (`192.168.0.1`), a +/// full `host:port`, and hostnames (`localhost`, `localhost:0`). Hostnames are +/// resolved via DNS so Node's `--inspect=localhost:0` syntax works; IPv4 +/// results are preferred when the resolver returns both families, matching +/// Node. +fn parse_socket_addr(s: &str) -> Result { + const DEFAULT_HOST: IpAddr = + IpAddr::V4(std::net::Ipv4Addr::new(127, 0, 0, 1)); + const DEFAULT_PORT: u16 = 9229; + + fn invalid(message: String) -> CliError { + CliError::new(CliErrorKind::InvalidValue, message) + } + + fn parse_port(port: &str) -> Result { + port + .parse::() + .map_err(|_| invalid(format!("Invalid inspector port '{port}'"))) + } + + fn resolve_host(host: &str) -> Result { + if let Ok(ip) = host.parse::() { + return Ok(ip); + } + let addrs = (host, 0u16) + .to_socket_addrs() + .map_err(|e| invalid(format!("Invalid inspector host '{host}': {e}")))? + .collect::>(); + addrs + .iter() + .find(|a| a.is_ipv4()) + .or_else(|| addrs.first()) + .map(|a| a.ip()) + .ok_or_else(|| { + invalid(format!("Could not resolve inspector host '{host}'")) + }) + } + + if s.is_empty() { + return Err(invalid("Inspector address cannot be empty".to_string())); + } + + if let Some(port_part) = s.strip_prefix(':') { + let port = if port_part.is_empty() { + DEFAULT_PORT + } else { + parse_port(port_part)? + }; + return Ok(SocketAddr::new(DEFAULT_HOST, port)); + } + + if s.contains(':') { + // A bare IPv6 address (`::1`) or a full `host:port`. + if let Ok(addr) = s.parse::() { + return Ok(addr); + } + if let Ok(ip) = s.parse::() { + return Ok(SocketAddr::new(ip, DEFAULT_PORT)); + } + + let (host_part, port_part) = s + .rsplit_once(':') + .ok_or_else(|| invalid(format!("Invalid inspector address '{s}'")))?; + let port = if port_part.is_empty() { + DEFAULT_PORT + } else { + parse_port(port_part)? + }; + return Ok(SocketAddr::new(resolve_host(host_part)?, port)); + } + + if s.chars().all(|c| c.is_ascii_digit()) { + return Ok(SocketAddr::new(DEFAULT_HOST, parse_port(s)?)); + } + + Ok(SocketAddr::new(resolve_host(s)?, DEFAULT_PORT)) +} + +// ============================================================ +// High-level API +// ============================================================ + +/// Parse command-line arguments and convert directly to `Flags`. +/// This is the main entry point matching Deno's `flags_from_vec`. +pub fn flags_from_vec(args: Vec) -> Result { + // Strip a trailing `\r` from each argument so that a shebang in a script + // saved with CRLF line endings (e.g. `#!/usr/bin/env -S deno run -A\r`) + // doesn't poison the final shebang argument with a stray carriage return. + let args: Vec = args + .into_iter() + .map(|mut a| { + if a.ends_with('\r') { + a.pop(); + } + a + }) + .collect(); + let parsed = crate::parse::parse(&crate::defs::DENO_ROOT, &args)?; + + // If --version was set at the root level (not as a subcommand flag like + // `upgrade --version `), return DisplayVersion. + // Check: version is present, no subcommand OR subcommand doesn't have its + // own --version flag that takes a value. + if parsed.get_bool("version") && parsed.get_one("version").is_none() { + return Err(CliError::new(CliErrorKind::DisplayVersion, "version")); + } + + // If --help was set, generate help text + if parsed.contains("help") { + let root = &crate::defs::DENO_ROOT; + let cmd = if let Some(ref sub) = parsed.subcommand { + root.find_subcommand(sub).unwrap_or(root) + } else { + root + }; + let help_text = crate::help::render_help(cmd); + let mut flags = Flags::default(); + global_args_parse(&parsed, &mut flags); + flags.subcommand = DenoSubcommand::Help(HelpFlags { help: help_text }); + return Ok(flags); + } + + let mut flags = convert(parsed)?; + apply_node_options(&mut flags); + Ok(flags) +} + +// ============================================================ +// Main entry point +// ============================================================ + +/// Convert a raw `ParseResult` into a fully typed `Flags`. +pub fn convert(result: ParseResult) -> Result { + let mut flags = Flags::default(); + + // Global flags (log-level, quiet) + global_args_parse(&result, &mut flags); + + // --env-file is accepted globally; parse it here so every subcommand that + // supports it picks it up (no-op when absent). + env_file_arg_parse(&result, &mut flags); + + // Unstable feature flags (applies to all subcommands) + unstable_args_parse(&result, &mut flags); + + match result.subcommand.as_deref() { + Some("run") => run_parse(&result, &mut flags, false)?, + Some("watch") => run_parse(&result, &mut flags, true)?, + Some("serve") => serve_parse(&result, &mut flags)?, + Some("eval") => eval_parse(&result, &mut flags)?, + Some("fmt") => fmt_parse(&result, &mut flags), + Some("lint") => lint_parse(&result, &mut flags), + Some("test") => test_parse(&result, &mut flags)?, + Some("upgrade") => upgrade_parse(&result, &mut flags)?, + Some("cache") => cache_parse(&result, &mut flags), + Some("check") => check_parse(&result, &mut flags), + Some("info") => info_parse(&result, &mut flags), + Some("doc") => doc_parse(&result, &mut flags)?, + Some("task") => task_parse(&result, &mut flags)?, + Some("bench") => bench_parse(&result, &mut flags)?, + Some("compile") => compile_parse(&result, &mut flags)?, + Some("coverage") => coverage_parse(&result, &mut flags)?, + Some("repl") => repl_parse(&result, &mut flags, false)?, + Some("install" | "i") => install_parse(&result, &mut flags)?, + Some("uninstall") => uninstall_parse(&result, &mut flags), + Some("completions") => completions_parse(&result, &mut flags), + Some("init") => init_parse(&result, &mut flags)?, + Some("create") => create_parse(&result, &mut flags)?, + Some("jupyter") => jupyter_parse(&result, &mut flags), + Some("publish") => publish_parse(&result, &mut flags), + Some("add") => add_parse(&result, &mut flags), + Some("remove" | "rm") => remove_parse(&result, &mut flags), + Some("outdated") => outdated_parse(&result, &mut flags, false), + Some("update") => outdated_parse(&result, &mut flags, true), + Some("clean") => clean_parse(&result, &mut flags), + Some("list") => list_parse(&result, &mut flags), + Some("link") => link_parse(&result, &mut flags), + Some("unlink") => unlink_parse(&result, &mut flags), + Some("sync-types") => sync_types_parse(&result, &mut flags), + Some("approve-scripts" | "approve-builds") => { + approve_scripts_parse(&result, &mut flags) + } + Some("types") => types_parse(&mut flags), + Some("lsp") => lsp_parse(&mut flags), + Some("vendor") => vendor_parse(&mut flags), + Some("deploy") => deploy_parse(&mut flags, false), + Some("sandbox") => deploy_parse(&mut flags, true), + Some("bundle") => bundle_parse(&result, &mut flags), + Some("audit") => audit_parse(&result, &mut flags)?, + Some("why") => why_parse(&result, &mut flags), + Some("transpile") => transpile_parse(&result, &mut flags), + Some("bump-version") => bump_version_parse(&result, &mut flags)?, + Some("ci") => ci_parse(&result, &mut flags), + Some("desktop") => desktop_parse(&result, &mut flags)?, + Some("pack") => pack_parse(&result, &mut flags), + Some("x") => x_parse(&result, &mut flags)?, + Some("json_reference") => json_reference_parse(&mut flags), + Some("help") => help_subcommand_parse(&result, &mut flags), + None => default_parse(&result, &mut flags)?, + Some(other) => { + return Err(CliError::new( + CliErrorKind::InvalidValue, + format!("Unknown subcommand: {other}"), + )); + } + } + + // Validate --location scheme (must be http/https), mirroring clap's + // location value_parser. No-op when `--location` was not provided. + if let Some(loc) = result.get_one("location") { + let url = deno_core::url::Url::parse(loc).map_err(|_| { + CliError::new(CliErrorKind::InvalidValue, "Failed to parse URL") + })?; + if !["http", "https"].contains(&url.scheme()) { + return Err(CliError::new( + CliErrorKind::InvalidValue, + "Expected protocol \"http\" or \"https\"", + )); + } + } + + // Validate and apply `--allow-scripts` (no-op when the subcommand's def does + // not include the flag, so it is only set where it is accepted). + allow_scripts_arg_parse(&result, &mut flags)?; + + // Validate permission args + validate_permission_args(&result, &flags)?; + + // Trailing args go into flags.argv (unless already handled by the subcommand) + let handled_trailing = matches!( + result.subcommand.as_deref(), + Some("create") + | Some("init") + | Some("compile") + | Some("install") + | Some("i") + ); + if !result.trailing.is_empty() && !handled_trailing { + flags.argv.extend(result.trailing.iter().cloned()); + } + + Ok(flags) +} + +// ============================================================ +// Default (no subcommand) +// ============================================================ + +/// When no subcommand is provided: if there is a script arg, treat as +/// bare `run`; otherwise enter REPL. +fn default_parse( + result: &ParseResult, + flags: &mut Flags, +) -> Result<(), CliError> { + // Check if there's a script_arg (from default subcommand = run) + let script = result.get_one("script_arg"); + if let Some(script) = script { + // Check if the script name is actually a known subcommand name. + // If so, a non-global flag was placed before it and we should error. + let known_subcommands = [ + "run", + "watch", + "serve", + "eval", + "fmt", + "lint", + "test", + "upgrade", + "cache", + "check", + "info", + "doc", + "task", + "bench", + "compile", + "coverage", + "repl", + "install", + "uninstall", + "completions", + "init", + "create", + "jupyter", + "publish", + "add", + "remove", + "outdated", + "update", + "clean", + "approve-scripts", + "types", + "lsp", + "vendor", + "deploy", + "sandbox", + "bundle", + "audit", + "x", + ]; + if known_subcommands.contains(&script) { + // Check if any non-global flags were parsed before this + // subcommand name. If so, the user likely put flags before + // the subcommand. + let has_non_global_flags = result.args.iter().any(|a| { + a.is_present + && a.name != "script_arg" + && a.name != "log-level" + && a.name != "quiet" + && a.name != "unstable" + }); + if has_non_global_flags { + // Find which flags were set + let flag_names: Vec = result + .args + .iter() + .filter(|a| { + a.is_present + && a.name != "script_arg" + && a.name != "log-level" + && a.name != "quiet" + && a.name != "unstable" + }) + .filter_map(|a| { + // Try to find the long flag name + let root = &crate::defs::DENO_ROOT; + let cmd = root.find_subcommand("run").unwrap_or(root); + cmd + .all_args() + .find(|ad| ad.name == a.name) + .and_then(|ad| ad.long) + .map(|l| format!("--{l}")) + }) + .collect(); + let first_flag = flag_names + .first() + .cloned() + .unwrap_or_else(|| "unknown".to_string()); + let mut err = CliError::new( + CliErrorKind::UnknownFlag, + format!("unexpected argument '{first_flag}' found"), + ); + // Only claim ` ` exists when that subcommand + // actually accepts the flag (see issue #27336). + let accepts_flag = crate::defs::DENO_ROOT + .find_subcommand(script) + .is_some_and(|sub| { + sub + .find_arg_long(first_flag.trim_start_matches('-')) + .is_some() + }); + if accepts_flag { + err = err.with_suggestion(format!("'{script} {first_flag}' exists")); + } + return Err(err); + } + } + + // Bare run - same as `deno run` but with bare=true + runtime_args_parse(result, flags, true, true)?; + ext_arg_parse(result, flags); + flags.tunnel = result.get_bool("tunnel"); + flags.code_cache_enabled = !result.get_bool("no-code-cache"); + let coverage_dir = if result.contains("coverage") { + Some(result.get_one("coverage").unwrap_or("coverage").to_string()) + } else { + None + }; + cpu_prof_parse(result, flags); + + let watch = watch_arg_parse_with_paths(result); + if script == "-" && watch.is_some() { + return Err(CliError::new( + CliErrorKind::InvalidValue, + "--watch and --watch-hmr cannot be used while reading from stdin.", + )); + } + flags.watch = watch; + flags.subcommand = DenoSubcommand::Run(RunFlags { + script: script.to_string(), + bare: true, + coverage_dir, + print_task_list: false, + }); + } else { + // Check if any run-specific flags were provided without a script. + // If so, the user likely meant to run a script but forgot it. + let has_v8_help = result + .get_many("v8-flags") + .is_some_and(|v| v.iter().any(|f| f == "--help")); + + // Check for flags that indicate run intent (not REPL) + let has_run_flags = result.get_bool("no-config") + || result.contains("config") + || result.contains("import-map") + || result.contains("no-remote") + || result.contains("no-npm") + || result.contains("lock") + || result.contains("no-lock") + || result.contains("reload") + || result.contains("frozen-lockfile") + || result.contains("cert") + || result.contains("check") + || result.contains("no-check"); + + if has_run_flags && !has_v8_help { + return Err(CliError::new( + CliErrorKind::MissingRequired, + "[SCRIPT_ARG] may only be omitted with --v8-flags=--help, else to use the repl with arguments, please use the `deno repl` subcommand\n\nUsage: deno [OPTIONS] [COMMAND] [SCRIPT_ARG]...", + )); + } + + // No script -> REPL with default command + repl_parse(result, flags, true)?; + } + Ok(()) +} + +// ============================================================ +// Global args +// ============================================================ + +fn global_args_parse(result: &ParseResult, flags: &mut Flags) { + if let Some(level) = result.get_one("log-level") { + flags.log_level = match level { + "trace" => Some(log::Level::Trace), + "debug" => Some(log::Level::Debug), + "info" => Some(log::Level::Info), + "warn" => Some(log::Level::Warn), + "error" => Some(log::Level::Error), + _ => None, + }; + } + if result.get_bool("quiet") { + flags.log_level = Some(log::Level::Error); + } +} + +// ============================================================ +// Shared arg group parsers +// ============================================================ + +fn compile_args_parse(result: &ParseResult, flags: &mut Flags) { + compile_args_without_check_parse(result, flags); + no_check_arg_parse(result, flags); + check_arg_parse(result, flags); +} + +fn compile_args_without_check_parse(result: &ParseResult, flags: &mut Flags) { + import_map_arg_parse(result, flags); + no_remote_arg_parse(result, flags); + no_npm_arg_parse(result, flags); + node_modules_and_vendor_dir_arg_parse(result, flags); + node_conditions_args_parse(result, flags); + config_args_parse(result, flags); + reload_arg_parse(result, flags); + lock_args_parse(result, flags); + ca_file_arg_parse(result, flags); + unsafely_ignore_certificate_errors_parse(result, flags); + min_dep_age_arg_parse(result, flags); +} + +fn runtime_args_parse( + result: &ParseResult, + flags: &mut Flags, + include_perms: bool, + include_inspector: bool, +) -> Result<(), CliError> { + compile_args_parse(result, flags); + cached_only_arg_parse(result, flags); + if include_perms { + permission_args_parse(result, flags); + } + if include_inspector { + inspect_arg_parse(result, flags)?; + } + // `--allow-scripts` is validated and applied centrally in `convert()` so the + // fallible parsing lives in one place (see `allow_scripts_arg_parse`). + location_arg_parse(result, flags); + v8_flags_arg_parse(result, flags); + seed_arg_parse(result, flags); + enable_testing_features_arg_parse(result, flags); + env_file_arg_parse(result, flags); + trace_ops_parse(result, flags); + eszip_arg_parse(result, flags); + preload_arg_parse(result, flags); + require_arg_parse(result, flags); + Ok(()) +} + +/// Expand bare port entries (e.g. ":8080") into full host:port entries +/// for 0.0.0.0, 127.0.0.1, and localhost. +fn expand_net_list(entries: Vec) -> Vec { + let mut out = Vec::new(); + for entry in entries { + if let Some(port_str) = entry.strip_prefix(':') + && port_str.parse::().is_ok() + { + for host in &["0.0.0.0", "127.0.0.1", "localhost"] { + out.push(format!("{}:{}", host, port_str)); + } + continue; + } + out.push(entry); + } + out +} + +fn permission_args_parse(result: &ParseResult, flags: &mut Flags) { + if result.contains("permission-set") { + flags.permission_set = + Some(result.get_one("permission-set").unwrap_or("").to_string()); + } + + // Helper: for permission flags that use Append + ZeroOrMore + value_delimiter + // get_many returns Some(&[]) when flag is present without value, + // Some(&[values...]) when flag has values, None when absent. + macro_rules! perm_flag { + ($name:expr, $field:expr) => { + if let Some(values) = result.get_many($name) { + $field = Some(values.iter().map(|s| s.to_string()).collect()); + } + }; + } + + perm_flag!("allow-read", flags.permissions.allow_read); + perm_flag!("deny-read", flags.permissions.deny_read); + perm_flag!("ignore-read", flags.permissions.ignore_read); + perm_flag!("allow-write", flags.permissions.allow_write); + perm_flag!("deny-write", flags.permissions.deny_write); + perm_flag!("allow-net", flags.permissions.allow_net); + perm_flag!("deny-net", flags.permissions.deny_net); + + // Expand bare port entries in net lists + if let Some(ref mut net) = flags.permissions.allow_net { + *net = expand_net_list(std::mem::take(net)); + } + if let Some(ref mut net) = flags.permissions.deny_net { + *net = expand_net_list(std::mem::take(net)); + } + perm_flag!("allow-env", flags.permissions.allow_env); + perm_flag!("deny-env", flags.permissions.deny_env); + perm_flag!("ignore-env", flags.permissions.ignore_env); + perm_flag!("allow-run", flags.permissions.allow_run); + perm_flag!("deny-run", flags.permissions.deny_run); + perm_flag!("allow-sys", flags.permissions.allow_sys); + perm_flag!("deny-sys", flags.permissions.deny_sys); + perm_flag!("allow-ffi", flags.permissions.allow_ffi); + perm_flag!("deny-ffi", flags.permissions.deny_ffi); + perm_flag!("allow-import", flags.permissions.allow_import); + perm_flag!("deny-import", flags.permissions.deny_import); + + if result.get_bool("allow-all") { + flags.permissions.allow_all = true; + } + + if result.get_bool("no-prompt") { + flags.permissions.no_prompt = true; + } + + if result.get_bool("allow-hrtime") || result.get_bool("deny-hrtime") { + // use eprintln instead of log::warn because logging hasn't been initialized yet + #[allow(clippy::print_stderr, reason = "can't use log crate yet")] + { + eprintln!( + "{} `allow-hrtime` and `deny-hrtime` have been removed in Deno 2, as high resolution time is now always allowed", + deno_runtime::colors::yellow("Warning") + ); + } + } +} + +fn inspect_arg_parse( + result: &ParseResult, + flags: &mut Flags, +) -> Result<(), CliError> { + const DEFAULT: &str = "127.0.0.1:9229"; + if result.contains("inspect") { + flags.inspect = Some(parse_socket_addr( + result.get_one("inspect").unwrap_or(DEFAULT), + )?); + } + if result.contains("inspect-brk") { + flags.inspect_brk = Some(parse_socket_addr( + result.get_one("inspect-brk").unwrap_or(DEFAULT), + )?); + } + if result.contains("inspect-wait") { + flags.inspect_wait = Some(parse_socket_addr( + result.get_one("inspect-wait").unwrap_or(DEFAULT), + )?); + } + if let Some(uid_str) = result.get_one("inspect-publish-uid") { + let mut uid = InspectPublishUid { + console: false, + http: false, + }; + for part in uid_str.split(',') { + match part.trim() { + "stderr" | "console" => uid.console = true, + "http" => uid.http = true, + _ => {} + } + } + flags.inspect_publish_uid = Some(uid); + } + Ok(()) +} + +fn config_args_parse(result: &ParseResult, flags: &mut Flags) { + flags.config_flag = if result.get_bool("no-config") { + ConfigFlag::Disabled + } else if let Some(config) = result.get_one("config") { + ConfigFlag::Path(config.to_string()) + } else { + ConfigFlag::Discover + }; +} + +fn import_map_arg_parse(result: &ParseResult, flags: &mut Flags) { + if let Some(path) = result.get_one("import-map") { + flags.import_map_path = Some(path.to_string()); + } +} + +fn no_remote_arg_parse(result: &ParseResult, flags: &mut Flags) { + if result.get_bool("no-remote") { + flags.no_remote = true; + } +} + +fn no_npm_arg_parse(result: &ParseResult, flags: &mut Flags) { + if result.get_bool("no-npm") { + flags.no_npm = true; + } +} + +fn node_modules_arg_parse(result: &ParseResult, flags: &mut Flags) { + if result.contains("node-modules-dir") { + let value = result.get_one("node-modules-dir"); + flags.node_modules_dir = Some(match value { + Some("auto" | "true") | None => NodeModulesDirMode::Auto, + Some("manual") => NodeModulesDirMode::Manual, + Some("none" | "false") => NodeModulesDirMode::None, + Some(_) => NodeModulesDirMode::Auto, + }); + } +} + +fn vendor_dir_arg_parse(result: &ParseResult, flags: &mut Flags) { + if result.contains("vendor") { + let value = result.get_one("vendor"); + flags.vendor = Some(!matches!(value, Some("false"))); + } +} + +fn node_modules_and_vendor_dir_arg_parse( + result: &ParseResult, + flags: &mut Flags, +) { + node_modules_arg_parse(result, flags); + node_modules_linker_arg_parse(result, flags); + vendor_dir_arg_parse(result, flags); +} + +fn node_modules_linker_arg_parse(result: &ParseResult, flags: &mut Flags) { + flags.node_modules_linker = match result.get_one("node-modules-linker") { + Some("isolated") => Some(NodeModulesLinkerMode::Isolated), + Some("hoisted") => Some(NodeModulesLinkerMode::Hoisted), + // Invalid values are left as clap would reject them; validation is a + // separate (Tier 2) concern. + _ => None, + }; +} + +fn node_conditions_args_parse(result: &ParseResult, flags: &mut Flags) { + if let Some(values) = result.get_many("node-conditions") { + flags.node_conditions = values.iter().map(|s| s.to_string()).collect(); + } +} + +fn reload_arg_parse(result: &ParseResult, flags: &mut Flags) { + if let Some(values) = result.get_many("reload") { + if values.is_empty() { + flags.reload = true; + } else { + flags.cache_blocklist = values.iter().map(|s| s.to_string()).collect(); + flags.reload = false; + } + } +} + +fn lock_args_parse(result: &ParseResult, flags: &mut Flags) { + if result.contains("lock") { + flags.lock = + Some(result.get_one("lock").unwrap_or("deno.lock").to_string()); + } + if result.get_bool("no-lock") { + flags.no_lock = true; + } + frozen_lockfile_arg_parse(result, flags); +} + +fn frozen_lockfile_arg_parse(result: &ParseResult, flags: &mut Flags) { + if result.contains("frozen-lockfile") { + let value = result.get_one("frozen-lockfile"); + flags.frozen_lockfile = Some(match value { + Some("false") => false, + _ => true, // --frozen-lockfile without value or with any other value + }); + } +} + +fn ca_file_arg_parse(result: &ParseResult, flags: &mut Flags) { + if let Some(cert) = result.get_one("cert") { + if let Some(b64) = cert.strip_prefix("base64:") { + if let Some(bytes) = base64_decode(b64) { + flags.ca_data = Some(CaData::Bytes(bytes)); + } else { + flags.ca_data = Some(CaData::File(cert.to_string())); + } + } else { + flags.ca_data = Some(CaData::File(cert.to_string())); + } + } +} + +/// Simple base64 decoder (standard alphabet, with optional padding). +fn base64_decode(input: &str) -> Option> { + const TABLE: [u8; 128] = { + let mut t = [255u8; 128]; + let mut i = 0u8; + while i < 26 { + t[(b'A' + i) as usize] = i; + t[(b'a' + i) as usize] = i + 26; + i += 1; + } + let mut i = 0u8; + while i < 10 { + t[(b'0' + i) as usize] = i + 52; + i += 1; + } + t[b'+' as usize] = 62; + t[b'/' as usize] = 63; + t + }; + + let input = input.trim_end_matches('='); + let mut out = Vec::with_capacity(input.len() * 3 / 4); + let mut buf: u32 = 0; + let mut bits: u32 = 0; + + for &b in input.as_bytes() { + if b >= 128 { + return None; + } + let val = TABLE[b as usize]; + if val == 255 { + return None; + } + buf = (buf << 6) | val as u32; + bits += 6; + if bits >= 8 { + bits -= 8; + out.push((buf >> bits) as u8); + buf &= (1 << bits) - 1; + } + } + + Some(out) +} + +fn unsafely_ignore_certificate_errors_parse( + result: &ParseResult, + flags: &mut Flags, +) { + if let Some(values) = result.get_many("unsafely-ignore-certificate-errors") { + flags.unsafely_ignore_certificate_errors = + Some(values.iter().map(|s| s.to_string()).collect()); + } +} + +fn min_dep_age_arg_parse(result: &ParseResult, flags: &mut Flags) { + if let Some(age) = result.get_one("min-dep-age") { + flags.minimum_dependency_age = deno_config::parse_minutes_duration_or_date( + &sys_traits::impls::RealSys, + age, + ) + .ok(); + } +} + +fn cached_only_arg_parse(result: &ParseResult, flags: &mut Flags) { + if result.get_bool("cached-only") { + flags.cached_only = true; + } +} + +fn location_arg_parse(result: &ParseResult, flags: &mut Flags) { + if let Some(loc) = result.get_one("location") { + flags.location = deno_core::url::Url::parse(loc).ok().map(|mut url| { + // Mirror clap's location value_parser, which strips any credentials. + let _ = url.set_username(""); + let _ = url.set_password(None); + url + }); + } +} + +fn v8_flags_arg_parse(result: &ParseResult, flags: &mut Flags) { + if let Some(values) = result.get_many("v8-flags") { + flags.v8_flags = values.iter().map(|s| s.to_string()).collect(); + } +} + +fn seed_arg_parse(result: &ParseResult, flags: &mut Flags) { + if let Some(seed_str) = result.get_one("seed") + && let Ok(seed) = seed_str.parse::() + { + flags.seed = Some(seed); + flags.v8_flags.push(format!("--random-seed={seed}")); + } +} + +fn enable_testing_features_arg_parse(result: &ParseResult, flags: &mut Flags) { + if result.get_bool("enable-testing-features") { + flags.enable_testing_features = true; + } +} + +fn env_file_arg_parse(result: &ParseResult, flags: &mut Flags) { + if result.contains("env-file") { + // Get the parsed arg directly to check count vs values + let parsed_arg = result.args.iter().find(|a| a.name == "env-file"); + if let Some(pa) = parsed_arg { + let mut files = Vec::new(); + // Each occurrence where count > values.len() was a bare --env-file + // (no value), which defaults to ".env" + let bare_count = pa.count.saturating_sub(pa.values.len()); + for _ in 0..bare_count { + files.push(".env".to_string()); + } + files.extend(pa.values.iter().cloned()); + if files.is_empty() { + files.push(".env".to_string()); + } + flags.env_file = Some(files); + } else { + flags.env_file = Some(vec![".env".to_string()]); + } + } +} + +fn trace_ops_parse(result: &ParseResult, flags: &mut Flags) { + if let Some(values) = result.get_many("trace-ops") { + flags.trace_ops = Some(values.iter().map(|s| s.to_string()).collect()); + } +} + +fn eszip_arg_parse(result: &ParseResult, flags: &mut Flags) { + if result.get_bool("eszip-internal-do-not-use") { + flags.eszip = true; + } +} + +fn preload_arg_parse(result: &ParseResult, flags: &mut Flags) { + if let Some(values) = result.get_many("preload") { + flags.preload = values.iter().map(|s| s.to_string()).collect(); + } +} + +fn require_arg_parse(result: &ParseResult, flags: &mut Flags) { + if let Some(values) = result.get_many("require") { + flags.require = values.iter().map(|s| s.to_string()).collect(); + } +} + +fn ext_arg_parse(result: &ParseResult, flags: &mut Flags) { + if let Some(ext) = result.get_one("ext") { + flags.ext = Some(ext.to_string()); + } +} + +fn unstable_args_parse(result: &ParseResult, flags: &mut Flags) { + if result.get_bool("unstable") { + flags.unstable_config.legacy_flag_enabled = true; + } + + // Map each unstable feature flag to features list and individual bools + let unstable_features: &[UnstableFeatureEntry] = &[ + ("unstable-bare-node-builtins", None), + ( + "unstable-detect-cjs", + Some(|c: &mut UnstableConfig| c.detect_cjs = true), + ), + ( + "unstable-lazy-dynamic-imports", + Some(|c: &mut UnstableConfig| c.lazy_dynamic_imports = true), + ), + ( + "unstable-sloppy-imports", + Some(|c: &mut UnstableConfig| c.sloppy_imports = true), + ), + ( + "unstable-npm-lazy-caching", + Some(|c: &mut UnstableConfig| c.npm_lazy_caching = true), + ), + ( + "unstable-raw-imports", + Some(|c: &mut UnstableConfig| c.raw_imports = true), + ), + ("unstable-ffi", None), + ("unstable-worker-options", None), + ("unstable-broadcast-channel", None), + ("unstable-bundle", None), + ("unstable-byonm", None), + ("unstable-cron", None), + ("unstable-fs", None), + ("unstable-http", None), + ("unstable-kv", None), + ("unstable-lockfile-v5", None), + ("unstable-net", None), + ("unstable-no-legacy-abort", None), + ("unstable-node-globals", None), + ("unstable-otel", None), + ("unstable-process", None), + ("unstable-subdomain-wildcards", None), + ("unstable-temporal", None), + ("unstable-unsafe-proto", None), + ("unstable-vsock", None), + ("unstable-webgpu", None), + ]; + + for (flag_name, setter) in unstable_features { + if result.get_bool(flag_name) { + // Extract feature name (strip "unstable-" prefix) + let feature = flag_name.strip_prefix("unstable-").unwrap_or(flag_name); + flags.unstable_config.features.push(feature.to_string()); + if let Some(setter) = setter { + setter(&mut flags.unstable_config); + } + } + } + + // Sort features for deterministic output + flags.unstable_config.features.sort(); +} + +fn allow_scripts_arg_parse( + result: &ParseResult, + flags: &mut Flags, +) -> Result<(), CliError> { + let Some(values) = result.get_many("allow-scripts") else { + return Ok(()); + }; + if values.is_empty() { + flags.allow_scripts = PackagesAllowedScripts::All; + return Ok(()); + } + let mut packages = Vec::new(); + for value in values { + // The `--allow-scripts` value parser requires an explicit `npm:` prefix + // (unlike `--ignore-scripts`, which auto-prepends it). + if !value.starts_with("npm:") { + return Err(CliError::new( + CliErrorKind::InvalidValue, + format!( + "Invalid package for --allow-scripts: '{value}'. An 'npm:' specifier is required" + ), + )); + } + for value in escape_and_split_commas(value.to_string())? { + let dep = JsrDepPackageReq::from_str_loose(&value).map_err(|e| { + CliError::new(CliErrorKind::InvalidValue, e.to_string()) + })?; + if dep.kind != PackageKind::Npm { + return Err(CliError::new( + CliErrorKind::InvalidValue, + format!("Only npm package constraints are supported: {value}"), + )); + } + if dep.req.version_req.tag().is_some() { + return Err(CliError::new( + CliErrorKind::InvalidValue, + format!("Tags are not supported in --allow-scripts: {value}"), + )); + } + packages.push(dep.req); + } + } + flags.allow_scripts = PackagesAllowedScripts::Some(packages); + Ok(()) +} + +fn ignore_scripts_value( + result: &ParseResult, +) -> Result { + let Some(values) = result.get_many("ignore-scripts") else { + return Ok(PackagesAllowedScripts::None); + }; + if values.is_empty() { + return Ok(PackagesAllowedScripts::All); + } + + let mut packages = Vec::new(); + for value in values { + for value in escape_and_split_commas(value.to_string())? { + let value = if value.starts_with("npm:") || value.starts_with("jsr:") { + value + } else { + format!("npm:{value}") + }; + let dep = JsrDepPackageReq::from_str_loose(&value).map_err(|e| { + CliError::new(CliErrorKind::InvalidValue, e.to_string()) + })?; + if dep.kind != PackageKind::Npm { + return Err(CliError::new( + CliErrorKind::InvalidValue, + format!("Only npm package constraints are supported: {value}"), + )); + } + if dep.req.version_req.tag().is_some() { + return Err(CliError::new( + CliErrorKind::InvalidValue, + format!("Tags are not supported in --ignore-scripts: {value}"), + )); + } + packages.push(dep.req); + } + } + + Ok(PackagesAllowedScripts::Some(packages)) +} + +fn no_check_arg_parse(result: &ParseResult, flags: &mut Flags) { + if result.contains("no-check") { + if let Some(value) = result.get_one("no-check") { + match value { + "remote" => flags.type_check_mode = TypeCheckMode::Local, + _ => { + // Invalid value for --no-check - keep default + } + } + } else { + flags.type_check_mode = TypeCheckMode::None; + } + } +} + +fn check_arg_parse(result: &ParseResult, flags: &mut Flags) { + if result.contains("check") { + if let Some(value) = result.get_one("check") { + match value { + "all" => flags.type_check_mode = TypeCheckMode::All, + _ => { + // Invalid value for --check - keep default (no change) + } + } + } else { + flags.type_check_mode = TypeCheckMode::Local; + } + } +} + +fn cpu_prof_parse(result: &ParseResult, flags: &mut Flags) { + let enabled = result.get_bool("cpu-prof"); + let dir = result.get_one("cpu-prof-dir").map(|s| s.to_string()); + let name = result.get_one("cpu-prof-name").map(|s| s.to_string()); + let interval = result + .get_one("cpu-prof-interval") + .and_then(|s| s.parse::().ok()); + // md and flamegraph flags do not exist in our defs currently, + // but handle them gracefully + let md = result.get_bool("cpu-prof-md"); + let flamegraph = result.get_bool("cpu-prof-flamegraph"); + + if enabled + || dir.is_some() + || name.is_some() + || interval.is_some() + || md + || flamegraph + { + flags.cpu_prof = Some(CpuProfFlags { + dir, + name, + interval, + md, + flamegraph, + }); + } +} + +fn allow_and_deny_import_parse(result: &ParseResult, flags: &mut Flags) { + if let Some(values) = result.get_many("allow-import") { + flags.permissions.allow_import = + Some(values.iter().map(|s| s.to_string()).collect()); + } + if let Some(values) = result.get_many("deny-import") { + flags.permissions.deny_import = + Some(values.iter().map(|s| s.to_string()).collect()); + } +} + +// ============================================================ +// Watch helpers +// ============================================================ + +/// Parse watch flags without paths (for check, compile). +fn watch_arg_parse(result: &ParseResult) -> Option { + if result.contains("watch") { + Some(WatchFlagsWithPaths { + hmr: false, + paths: vec![], + no_clear_screen: result.get_bool("no-clear-screen"), + exclude: result + .get_many("watch-exclude") + .map(|v| v.iter().map(|s| s.to_string()).collect()) + .unwrap_or_default(), + }) + } else { + None + } +} + +/// Parse watch flags with paths (for run, serve, test). +fn watch_arg_parse_with_paths( + result: &ParseResult, +) -> Option { + if result.contains("watch") { + let paths = result + .get_many("watch") + .map(|v| v.iter().map(|s| s.to_string()).collect()) + .unwrap_or_default(); + return Some(WatchFlagsWithPaths { + paths, + hmr: false, + no_clear_screen: result.get_bool("no-clear-screen"), + exclude: result + .get_many("watch-exclude") + .map(|v| v.iter().map(|s| s.to_string()).collect()) + .unwrap_or_default(), + }); + } + + if result.contains("hmr") { + let paths = result + .get_many("hmr") + .map(|v| v.iter().map(|s| s.to_string()).collect()) + .unwrap_or_default(); + return Some(WatchFlagsWithPaths { + paths, + hmr: true, + no_clear_screen: result.get_bool("no-clear-screen"), + exclude: result + .get_many("watch-exclude") + .map(|v| v.iter().map(|s| s.to_string()).collect()) + .unwrap_or_default(), + }); + } + + None +} + +// ============================================================ +// Subcommand conversion functions +// ============================================================ + +fn validate_permission_args( + _result: &ParseResult, + flags: &Flags, +) -> Result<(), CliError> { + // Validate env var names: reject names containing '=' or '\0' + if let Some(ref envs) = flags.permissions.allow_env { + for name in envs { + if name.contains('=') || name.contains('\0') { + return Err(CliError::new( + CliErrorKind::InvalidValue, + format!("invalid env var name: '{name}'"), + )); + } + } + } + if let Some(ref envs) = flags.permissions.deny_env { + for name in envs { + if name.contains('=') || name.contains('\0') { + return Err(CliError::new( + CliErrorKind::InvalidValue, + format!("invalid env var name: '{name}'"), + )); + } + } + } + + // Validate sys descriptor names + if let Some(ref sys) = flags.permissions.allow_sys { + for name in sys { + if !name.is_empty() && SysDescriptor::parse(name.to_string()).is_err() { + return Err(CliError::new( + CliErrorKind::InvalidValue, + format!("unknown sys descriptor: '{name}'"), + )); + } + } + } + if let Some(ref sys) = flags.permissions.deny_sys { + for name in sys { + if !name.is_empty() && SysDescriptor::parse(name.to_string()).is_err() { + return Err(CliError::new( + CliErrorKind::InvalidValue, + format!("unknown sys descriptor: '{name}'"), + )); + } + } + } + + // Validate reload values are valid URLs or specifier prefixes (npm:, jsr:) + for url in &flags.cache_blocklist { + if url.is_empty() || url.starts_with("./") || url.starts_with('/') { + return Err(CliError::new( + CliErrorKind::InvalidValue, + format!("invalid reload URL: '{url}'"), + )); + } + // Must contain "://" (http://, https://, file://) or be a known + // specifier prefix (npm:, jsr:) + if !url.contains("://") + && !url.starts_with("npm:") + && !url.starts_with("jsr:") + { + return Err(CliError::new( + CliErrorKind::InvalidValue, + format!("invalid reload URL: '{url}'"), + )); + } + } + + // Validate net/import flags don't contain URLs (must be domains/IPs only) + fn check_no_url_scheme( + values: &Option>, + _flag_name: &str, + ) -> Result<(), CliError> { + if let Some(vals) = values { + for val in vals { + if val.contains("://") { + return Err(CliError::new( + CliErrorKind::InvalidValue, + format!( + "invalid value '{val}': URLs are not supported, only domains and IPs" + ), + )); + } + } + } + Ok(()) + } + check_no_url_scheme(&flags.permissions.allow_net, "--allow-net")?; + check_no_url_scheme(&flags.permissions.deny_net, "--deny-net")?; + check_no_url_scheme(&flags.permissions.allow_import, "--allow-import")?; + check_no_url_scheme(&flags.permissions.deny_import, "--deny-import")?; + check_no_url_scheme( + &flags.unsafely_ignore_certificate_errors, + "--unsafely-ignore-certificate-errors", + )?; + + // Validate --allow-all conflicts with specific allow flags + if flags.permissions.allow_all { + let conflicting_flags: &[(&str, &Option>)] = &[ + ("--allow-read", &flags.permissions.allow_read), + ("--allow-write", &flags.permissions.allow_write), + ("--allow-net", &flags.permissions.allow_net), + ("--allow-env", &flags.permissions.allow_env), + ("--allow-run", &flags.permissions.allow_run), + ("--allow-sys", &flags.permissions.allow_sys), + ("--allow-ffi", &flags.permissions.allow_ffi), + ("--allow-import", &flags.permissions.allow_import), + ]; + + // Only check if this is not the REPL default (where allow_all is auto-set) + if !matches!( + flags.subcommand, + DenoSubcommand::Repl(ReplFlags { + is_default_command: true, + .. + }) + ) { + for (flag_name, value) in conflicting_flags { + if value.is_some() { + return Err(CliError::new( + CliErrorKind::InvalidValue, + format!("--allow-all conflicts with {flag_name}"), + )); + } + } + } + } + + Ok(()) +} + +/// Split a comma-separated value string, where `,,` is an escaped literal comma. +/// Returns an error if any empty values are produced. +pub fn escape_and_split_commas(s: String) -> Result, CliError> { + let mut result = vec![]; + let mut current = String::new(); + let mut chars = s.chars(); + + while let Some(c) = chars.next() { + if c == ',' { + if let Some(next) = chars.next() { + if next == ',' { + current.push(','); + } else { + if current.is_empty() { + return Err(CliError::new( + CliErrorKind::InvalidValue, + "Empty values are not allowed", + )); + } + + result.push(current.clone()); + current.clear(); + current.push(next); + } + } else { + return Err(CliError::new( + CliErrorKind::InvalidValue, + "Empty values are not allowed", + )); + } + } else { + current.push(c); + } + } + + if current.is_empty() { + return Err(CliError::new( + CliErrorKind::InvalidValue, + "Empty values are not allowed", + )); + } + + result.push(current); + + Ok(result) +} + +fn run_parse( + result: &ParseResult, + flags: &mut Flags, + force_hmr: bool, +) -> Result<(), CliError> { + runtime_args_parse(result, flags, true, true)?; + ext_arg_parse(result, flags); + + flags.tunnel = result.get_bool("tunnel"); + if result.get_bool("use-env-proxy") { + // Node's --use-env-proxy is process-wide. Deno's node polyfills read the + // same env variable as the Node tests when selecting global proxy config. + // SAFETY: CLI parsing runs before worker startup and before Deno starts + // any threads that could concurrently read the process environment. + unsafe { std::env::set_var("NODE_USE_ENV_PROXY", "1") }; + } else if result.get_bool("no-use-env-proxy") { + // SAFETY: see the --use-env-proxy branch above. + unsafe { std::env::set_var("NODE_USE_ENV_PROXY", "0") }; + } + flags.code_cache_enabled = !result.get_bool("no-code-cache"); + let coverage_dir = if result.contains("coverage") { + Some(result.get_one("coverage").unwrap_or("coverage").to_string()) + } else { + None + }; + cpu_prof_parse(result, flags); + + if let Some(script) = result.get_one("script_arg") { + let mut watch = watch_arg_parse_with_paths(result); + // `deno watch` is an alias for `deno run --watch-hmr`, so enable hot + // module replacement watching by default when no explicit watch flag + // was passed. + if force_hmr { + match &mut watch { + Some(watch) => watch.hmr = true, + None => { + watch = Some(WatchFlagsWithPaths { + hmr: true, + paths: vec![], + no_clear_screen: result.get_bool("no-clear-screen"), + exclude: result + .get_many("watch-exclude") + .map(|v| v.iter().map(|s| s.to_string()).collect()) + .unwrap_or_default(), + }); + } + } + } + if script == "-" && watch.is_some() { + return Err(CliError::new( + CliErrorKind::InvalidValue, + "--watch and --watch-hmr cannot be used while reading from stdin.", + )); + } + flags.watch = watch; + flags.subcommand = DenoSubcommand::Run(RunFlags { + script: script.to_string(), + bare: false, + coverage_dir, + print_task_list: false, + }); + } else if !flags.v8_flags.is_empty() { + // `deno run --v8-flags=--help` with no script + flags.subcommand = DenoSubcommand::Run(RunFlags { + script: "_".to_string(), + bare: false, + coverage_dir: None, + print_task_list: false, + }); + } else { + // `deno run` with no script - show available tasks + flags.subcommand = DenoSubcommand::Run(RunFlags { + script: String::new(), + bare: false, + coverage_dir: None, + print_task_list: true, + }); + } + Ok(()) +} + +fn serve_parse( + result: &ParseResult, + flags: &mut Flags, +) -> Result<(), CliError> { + let port = result + .get_one("port") + .and_then(|s| s.parse::().ok()) + .unwrap_or(8000); + let host = result + .get_one("host") + .map(|s| s.to_string()) + .unwrap_or_else(|| "0.0.0.0".to_string()); + + runtime_args_parse(result, flags, true, true)?; + flags.code_cache_enabled = !result.get_bool("no-code-cache"); + flags.tunnel = result.get_bool("tunnel"); + + let script = result + .get_one("script_arg") + .map(|s| s.to_string()) + .unwrap_or_default(); + + ext_arg_parse(result, flags); + cpu_prof_parse(result, flags); + + let parallel = result.get_bool("parallel"); + + flags.watch = watch_arg_parse_with_paths(result); + flags.subcommand = DenoSubcommand::Serve(ServeFlags { + script, + port, + host, + parallel, + open_site: result.get_bool("open"), + }); + Ok(()) +} + +fn eval_parse(result: &ParseResult, flags: &mut Flags) -> Result<(), CliError> { + runtime_args_parse(result, flags, false, true)?; + // eval implies allow all permissions + flags.permissions.allow_all = true; + + ext_arg_parse(result, flags); + cpu_prof_parse(result, flags); + + let print = result.get_bool("print"); + let code = result + .get_one("code_arg") + .map(|s| s.to_string()) + .unwrap_or_default(); + + flags.subcommand = DenoSubcommand::Eval(EvalFlags { print, code }); + Ok(()) +} + +fn fmt_parse(result: &ParseResult, flags: &mut Flags) { + config_args_parse(result, flags); + ext_arg_parse(result, flags); + + let include = result + .get_many("files") + .map(|v| v.iter().map(|s| s.to_string()).collect()) + .unwrap_or_default(); + let ignore = result + .get_many("ignore") + .map(|v| v.iter().map(|s| s.to_string()).collect()) + .unwrap_or_default(); + + let use_tabs = if result.contains("use-tabs") { + Some(!matches!(result.get_one("use-tabs"), Some("false"))) + } else { + None + }; + + let line_width = result + .get_one("line-width") + .and_then(|s| s.parse::().ok()) + .and_then(NonZeroU32::new); + let indent_width = result + .get_one("indent-width") + .and_then(|s| s.parse::().ok()) + .and_then(NonZeroU8::new); + + let single_quote = if result.contains("single-quote") { + Some(!matches!(result.get_one("single-quote"), Some("false"))) + } else { + None + }; + + let prose_wrap = result.get_one("prose-wrap").map(|s| s.to_string()); + + let no_semicolons = if result.contains("no-semicolons") { + Some(!matches!(result.get_one("no-semicolons"), Some("false"))) + } else { + None + }; + + let unstable_component = result.get_bool("unstable-component"); + let unstable_sql = result.get_bool("unstable-sql"); + + flags.watch = watch_arg_parse_with_paths(result); + flags.subcommand = DenoSubcommand::Fmt(FmtFlags { + check: result.get_bool("check"), + fail_fast: result.get_bool("fail-fast"), + files: FileFlags { include, ignore }, + permit_no_files: result.get_bool("permit-no-files"), + use_tabs, + line_width, + indent_width, + single_quote, + prose_wrap, + no_semicolons, + no_editorconfig: result.get_bool("no-editorconfig"), + unstable_component, + unstable_sql, + }); +} + +fn lint_parse(result: &ParseResult, flags: &mut Flags) { + config_args_parse(result, flags); + ext_arg_parse(result, flags); + allow_and_deny_import_parse(result, flags); + + let files = result + .get_many("files") + .map(|v| v.iter().map(|s| s.to_string()).collect()) + .unwrap_or_default(); + let ignore = result + .get_many("ignore") + .map(|v| v.iter().map(|s| s.to_string()).collect()) + .unwrap_or_default(); + + let fix = result.get_bool("fix"); + let rules = result.get_bool("rules"); + let maybe_rules_tags = result + .get_many("rules-tags") + .map(|v| v.iter().map(|s| s.to_string()).collect()); + let maybe_rules_include = result + .get_many("rules-include") + .map(|v| v.iter().map(|s| s.to_string()).collect()); + let maybe_rules_exclude = result + .get_many("rules-exclude") + .map(|v| v.iter().map(|s| s.to_string()).collect()); + let json = result.get_bool("json"); + let compact = result.get_bool("compact"); + + flags.watch = watch_arg_parse_with_paths(result); + flags.subcommand = DenoSubcommand::Lint(LintFlags { + files: FileFlags { + include: files, + ignore, + }, + fix, + rules, + maybe_rules_tags, + maybe_rules_include, + maybe_rules_exclude, + permit_no_files: result.get_bool("permit-no-files"), + json, + compact, + }); +} + +fn test_parse(result: &ParseResult, flags: &mut Flags) -> Result<(), CliError> { + flags.type_check_mode = TypeCheckMode::Local; + runtime_args_parse(result, flags, true, true)?; + ext_arg_parse(result, flags); + + // deno test always uses --no-prompt + flags.permissions.no_prompt = true; + + let ignore = result + .get_many("ignore") + .map(|v| v.iter().map(|s| s.to_string()).collect()) + .unwrap_or_default(); + + let no_run = result.get_bool("no-run"); + let trace_leaks = result.get_bool("trace-leaks"); + let doc = result.get_bool("doc"); + let filter = result.get_one("filter").map(|s| s.to_string()); + let clean = result.get_bool("clean"); + + let fail_fast = if result.contains("fail-fast") { + let val = result + .get_one("fail-fast") + .and_then(|s| s.parse::().ok()) + .unwrap_or(1); + match NonZeroUsize::new(val) { + Some(n) => Some(n), + None => { + return Err(CliError::new( + CliErrorKind::InvalidValue, + "the value of --fail-fast must be greater than 0", + )); + } + } + } else { + None + }; + + let shard = match result.get_one("shard") { + Some(s) => match parse_shard(s) { + Some((index, count)) if index >= 1 && count >= 1 && index <= count => { + Some((index, count)) + } + _ => { + return Err(CliError::new( + CliErrorKind::InvalidValue, + format!( + "invalid value '{s}' for '--shard': expected INDEX/COUNT with 1 <= INDEX <= COUNT" + ), + )); + } + }, + None => None, + }; + + let shuffle = if result.contains("shuffle") { + let val = result + .get_one("shuffle") + .and_then(|s| s.parse::().ok()) + .unwrap_or(0); + Some(val) + } else { + None + }; + + let include = result + .get_many("files") + .map(|v| v.iter().map(|s| s.to_string()).collect()) + .unwrap_or_default(); + + let junit_path = result.get_one("junit-path").map(|s| s.to_string()); + + let reporter = if let Some(reporter_str) = result.get_one("reporter") { + match reporter_str { + "pretty" => TestReporterConfig::Pretty, + "junit" => TestReporterConfig::Junit, + "dot" => TestReporterConfig::Dot, + "tap" => TestReporterConfig::Tap, + _ => TestReporterConfig::Pretty, + } + } else { + TestReporterConfig::Pretty + }; + + if matches!(reporter, TestReporterConfig::Dot | TestReporterConfig::Tap) { + flags.log_level = Some(log::Level::Error); + } + + let hide_stacktraces = result.get_bool("hide-stacktraces"); + + let coverage_dir = if result.contains("coverage") { + Some(result.get_one("coverage").unwrap_or("coverage").to_string()) + } else { + None + }; + + flags.watch = watch_arg_parse_with_paths(result); + flags.subcommand = DenoSubcommand::Test(TestFlags { + no_run, + doc, + coverage_dir, + coverage_raw_data_only: result.get_bool("coverage-raw-data-only"), + clean, + fail_fast, + files: FileFlags { include, ignore }, + filter, + shuffle, + permit_no_files: result.get_bool("permit-no-files"), + parallel: result.get_bool("parallel"), + trace_leaks, + sanitize_ops: result.get_bool("sanitize-ops"), + sanitize_resources: result.get_bool("sanitize-resources"), + coverage_threshold: result + .get_one("coverage-threshold") + .and_then(|s| s.parse().ok()), + retry: result + .get_one("retry") + .and_then(|s| s.parse().ok()) + .unwrap_or(0), + repeats: result + .get_one("repeats") + .and_then(|s| s.parse().ok()) + .unwrap_or(0), + shard, + reporter, + junit_path, + hide_stacktraces, + changed: if result.contains("changed") { + Some(result.get_one("changed").map(|s| s.to_string())) + } else { + None + }, + related: match result.get_many("related") { + Some(v) => v + .iter() + .flat_map(|s| match escape_and_split_commas(s.to_string()) { + Ok(parts) => parts.into_iter().map(Ok).collect(), + Err(e) => vec![Err(e)], + }) + .collect::>()?, + None => vec![], + }, + update_snapshots: result.get_bool("update-snapshots"), + }); + Ok(()) +} + +/// Parse a `--shard` value of the form `index/count` (1-based index). +fn parse_shard(s: &str) -> Option<(usize, usize)> { + let (index, count) = s.split_once('/')?; + Some((index.parse().ok()?, count.parse().ok()?)) +} + +fn upgrade_parse( + result: &ParseResult, + flags: &mut Flags, +) -> Result<(), CliError> { + ca_file_arg_parse(result, flags); + unsafely_ignore_certificate_errors_parse(result, flags); + + let dry_run = result.get_bool("dry-run"); + let force = result.get_bool("force"); + let canary = result.get_bool("canary"); + let release_candidate = result.get_bool("release-candidate"); + let version = result.get_one("version").map(|s| s.to_string()); + let output = result.get_one("output").map(|s| s.to_string()); + let checksum = result.get_one("checksum").map(|s| s.to_string()); + + let positional = result + .get_one("version-or-hash-or-channel") + .map(|s| s.to_string()); + + // Handle "pr " and "compass" special positional patterns + let pr_number_positional = result + .get_one("pr-number-positional") + .map(|s| s.to_string()); + + let (version_or_hash_or_channel, pr, branch) = + if positional.as_deref() == Some("pr") { + // "deno upgrade pr " - second positional is the PR number + let pr_str = pr_number_positional + .as_deref() + .or_else(|| result.get_one("pr")); + match pr_str { + None => { + return Err(CliError::new( + CliErrorKind::MissingRequired, + "missing PR number for 'deno upgrade pr'", + )); + } + Some(s) => { + let s = s.strip_prefix('#').unwrap_or(s); + match s.parse::() { + Ok(n) => (None, Some(n), None), + Err(_) => { + return Err(CliError::new( + CliErrorKind::InvalidValue, + format!("invalid PR number: '{s}'"), + )); + } + } + } + } + } else if positional.as_deref() == Some("compass") { + (None, None, Some("compass".to_string())) + } else { + let pr_flag = result.get_one("pr").and_then(|s| s.parse::().ok()); + let branch_flag = result.get_one("branch").map(|s| s.to_string()); + if pr_flag.is_some() { + (None, pr_flag, None) + } else if branch_flag.is_some() { + (None, None, branch_flag) + } else { + (positional, None, None) + } + }; + + flags.subcommand = DenoSubcommand::Upgrade(UpgradeFlags { + dry_run, + force, + release_candidate, + canary, + version, + output, + version_or_hash_or_channel, + checksum, + pr, + branch, + no_delta: result.get_bool("no-delta"), + }); + Ok(()) +} + +fn cache_parse(result: &ParseResult, flags: &mut Flags) { + compile_args_parse(result, flags); + allow_and_deny_import_parse(result, flags); + env_file_arg_parse(result, flags); + ext_arg_parse(result, flags); + + let files = result + .get_many("files") + .map(|v| v.iter().map(|s| s.to_string()).collect()) + .unwrap_or_default(); + flags.subcommand = DenoSubcommand::Cache(CacheFlags { files }); +} + +fn check_parse(result: &ParseResult, flags: &mut Flags) { + flags.type_check_mode = TypeCheckMode::Local; + compile_args_without_check_parse(result, flags); + v8_flags_arg_parse(result, flags); + + let files = result + .get_many("files") + .map(|v| v.iter().map(|s| s.to_string()).collect()) + .unwrap_or_else(|| vec![".".to_string()]); + + if result.get_bool("all") || result.get_bool("remote") { + flags.type_check_mode = TypeCheckMode::All; + } + if result.get_bool("desktop") { + flags.internal.is_desktop = true; + } + + flags.watch = watch_arg_parse(result); + flags.subcommand = DenoSubcommand::Check(CheckFlags { + files, + doc: result.get_bool("doc"), + doc_only: result.get_bool("doc-only"), + check_js: result.get_bool("check-js"), + }); + flags.code_cache_enabled = !result.get_bool("no-code-cache"); + allow_and_deny_import_parse(result, flags); +} + +fn info_parse(result: &ParseResult, flags: &mut Flags) { + reload_arg_parse(result, flags); + min_dep_age_arg_parse(result, flags); + config_args_parse(result, flags); + import_map_arg_parse(result, flags); + location_arg_parse(result, flags); + ca_file_arg_parse(result, flags); + unsafely_ignore_certificate_errors_parse(result, flags); + node_modules_and_vendor_dir_arg_parse(result, flags); + lock_args_parse(result, flags); + no_remote_arg_parse(result, flags); + no_npm_arg_parse(result, flags); + allow_and_deny_import_parse(result, flags); + + let json = result.get_bool("json"); + flags.subcommand = DenoSubcommand::Info(InfoFlags { + file: result.get_one("file").map(|s| s.to_string()), + json, + }); +} + +fn doc_parse(result: &ParseResult, flags: &mut Flags) -> Result<(), CliError> { + import_map_arg_parse(result, flags); + reload_arg_parse(result, flags); + lock_args_parse(result, flags); + no_npm_arg_parse(result, flags); + no_remote_arg_parse(result, flags); + allow_and_deny_import_parse(result, flags); + + let has_builtin = result.get_bool("builtin"); + + // clap marks `source_file` as `required_if_eq_any(html|lint)`: when either + // `--html` or `--lint` is set, at least one source file (or `--builtin`) + // must be provided. + let has_source_file = result + .get_many("source_file") + .map(|v| !v.is_empty()) + .unwrap_or(false) + || has_builtin; + if (result.get_bool("html") || result.get_bool("lint")) && !has_source_file { + return Err(CliError::new( + CliErrorKind::MissingRequired, + "the following required arguments were not provided:\n [source_file]...", + )); + } + let source_files = if let Some(values) = result.get_many("source_file") { + let paths: Vec = values + .iter() + .filter(|v| v.as_str() != "--builtin") + .map(|s| s.to_string()) + .collect(); + if paths.is_empty() { + DocSourceFileFlag::Builtin + } else { + DocSourceFileFlag::Paths(paths) + } + } else { + DocSourceFileFlag::Builtin + }; + + let private = result.get_bool("private"); + let lint = result.get_bool("lint"); + let json = result.get_bool("json"); + let filter = result.get_one("filter").map(|s| s.to_string()); + + let html = if result.get_bool("html") { + let name = result.get_one("name").map(|s| s.to_string()); + let category_docs_path = + result.get_one("category-docs").map(|s| s.to_string()); + let symbol_redirect_map_path = + result.get_one("symbol-redirect-map").map(|s| s.to_string()); + let default_symbol_map_path = + result.get_one("default-symbol-map").map(|s| s.to_string()); + let strip_trailing_html = result.get_bool("strip-trailing-html"); + let output = result + .get_one("output") + .map(|s| s.to_string()) + .unwrap_or_else(|| "./docs/".to_string()); + Some(DocHtmlFlag { + name, + category_docs_path, + symbol_redirect_map_path, + default_symbol_map_path, + strip_trailing_html, + output, + }) + } else { + None + }; + + flags.subcommand = DenoSubcommand::Doc(DocFlags { + source_files, + json, + lint, + html, + filter, + private, + }); + Ok(()) +} + +fn task_parse(result: &ParseResult, flags: &mut Flags) -> Result<(), CliError> { + config_args_parse(result, flags); + node_modules_arg_parse(result, flags); + lock_args_parse(result, flags); + env_file_arg_parse(result, flags); + + let mut recursive = result.get_bool("recursive"); + let members = result.get_bool("members"); + let filter = + if let Some(filter) = result.get_one("filter").map(|s| s.to_string()) { + recursive = false; + Some(filter) + } else if recursive || members { + Some("*".to_string()) + } else { + None + }; + + flags.tunnel = result.get_bool("tunnel"); + + let task_name = result.get_one("task_name").map(|s| s.to_string()); + let eval = result.get_bool("eval"); + + if eval && task_name.is_none() { + return Err(CliError::new( + CliErrorKind::MissingRequired, + "[TASK] must be specified when using --eval", + )); + } + + let concurrency = match result.get_one("jobs") { + Some(s) => Some(s.parse::().map_err(|_| { + CliError::new( + CliErrorKind::InvalidValue, + format!("invalid value '{s}' for '--jobs '"), + ) + })?), + None => None, + }; + + flags.subcommand = DenoSubcommand::Task(TaskFlags { + cwd: result.get_one("cwd").map(|s| s.to_string()), + task: task_name, + is_run: false, + recursive, + members, + filter, + eval, + no_prefix: result.get_bool("no-prefix"), + concurrency, + if_present: result.get_bool("if-present"), + }); + Ok(()) +} + +fn bench_parse( + result: &ParseResult, + flags: &mut Flags, +) -> Result<(), CliError> { + flags.type_check_mode = TypeCheckMode::Local; + runtime_args_parse(result, flags, true, false)?; + ext_arg_parse(result, flags); + + // bench always uses --no-prompt + flags.permissions.no_prompt = true; + + let json = result.get_bool("json"); + let ignore = result + .get_many("ignore") + .map(|v| v.iter().map(|s| s.to_string()).collect()) + .unwrap_or_default(); + let filter = result.get_one("filter").map(|s| s.to_string()); + let include = result + .get_many("files") + .map(|v| v.iter().map(|s| s.to_string()).collect()) + .unwrap_or_default(); + let no_run = result.get_bool("no-run"); + + flags.watch = watch_arg_parse_with_paths(result); + flags.subcommand = DenoSubcommand::Bench(BenchFlags { + files: FileFlags { include, ignore }, + filter, + json, + no_run, + permit_no_files: result.get_bool("permit-no-files"), + }); + Ok(()) +} + +fn compile_parse( + result: &ParseResult, + flags: &mut Flags, +) -> Result<(), CliError> { + flags.type_check_mode = TypeCheckMode::Local; + runtime_args_parse(result, flags, true, false)?; + + let source_file = result + .get_one("source_file") + .map(|s| s.to_string()) + .unwrap_or_default(); + let output = result.get_one("output").map(|s| s.to_string()); + let target = result.get_one("target").map(|s| s.to_string()); + let icon = result.get_one("icon").map(|s| s.to_string()); + let no_terminal = result.get_bool("no-terminal"); + let eszip = result.get_bool("eszip-internal-do-not-use"); + let self_extracting = result.get_bool("self-extracting"); + + let include = result + .get_many("include") + .map(|v| v.iter().map(|s| s.to_string()).collect()) + .unwrap_or_default(); + let exclude = result + .get_many("exclude") + .map(|v| v.iter().map(|s| s.to_string()).collect()) + .unwrap_or_default(); + + ext_arg_parse(result, flags); + flags.code_cache_enabled = !result.get_bool("no-code-cache"); + + // Trailing args are the compile args + let args = result.trailing.clone(); + + flags.watch = watch_arg_parse(result); + flags.subcommand = DenoSubcommand::Compile(CompileFlags { + source_file, + output, + args, + target, + no_terminal, + icon, + include, + exclude, + eszip, + self_extracting, + bundle: result.get_bool("bundle"), + app_name: result.get_one("app-name").map(|s| s.to_string()), + minify: result.get_bool("minify"), + exclude_unused_npm: result.get_bool("exclude-unused-npm"), + engine: result + .get_one("engine") + .map(|value| value.parse().expect("engine is validated by the parser")) + .unwrap_or_default(), + }); + Ok(()) +} + +fn coverage_parse( + result: &ParseResult, + flags: &mut Flags, +) -> Result<(), CliError> { + let threshold = match result.get_one("threshold") { + Some(s) => match s.parse::() { + Ok(v) if v <= 100 => Some(v), + _ => { + return Err(CliError::new( + CliErrorKind::InvalidValue, + format!("invalid value '{s}' for '--threshold': expected 0-100"), + )); + } + }, + None => None, + }; + let files = result + .get_many("files") + .map(|v| v.iter().map(|s| s.to_string()).collect()) + .unwrap_or_else(|| vec!["coverage".to_string()]); + let ignore = result + .get_many("ignore") + .map(|v| v.iter().map(|s| s.to_string()).collect()) + .unwrap_or_default(); + let include = result + .get_many("include") + .map(|v| v.iter().map(|s| s.to_string()).collect()) + .unwrap_or_else(|| vec!["^file:".to_string()]); + let exclude = result + .get_many("exclude") + .map(|v| v.iter().map(|s| s.to_string()).collect()) + .unwrap_or_else(|| vec![r"test\.(js|mjs|ts|jsx|tsx)$".to_string()]); + + let r#type = if result.get_bool("lcov") { + CoverageType::Lcov + } else if result.get_bool("html") { + CoverageType::Html + } else if result.get_bool("detailed") { + CoverageType::Detailed + } else { + CoverageType::Summary + }; + + let output = result.get_one("output").map(|s| s.to_string()); + + flags.subcommand = DenoSubcommand::Coverage(CoverageFlags { + files: FileFlags { + include: files, + ignore, + }, + output, + include, + exclude, + r#type, + threshold, + }); + Ok(()) +} + +fn repl_parse( + result: &ParseResult, + flags: &mut Flags, + is_default: bool, +) -> Result<(), CliError> { + compile_args_without_check_parse(result, flags); + cached_only_arg_parse(result, flags); + permission_args_parse(result, flags); + inspect_arg_parse(result, flags)?; + location_arg_parse(result, flags); + v8_flags_arg_parse(result, flags); + seed_arg_parse(result, flags); + enable_testing_features_arg_parse(result, flags); + env_file_arg_parse(result, flags); + trace_ops_parse(result, flags); + + let eval_files = result + .get_many("eval-file") + .map(|v| v.iter().map(|s| s.to_string()).collect()); + + let eval = result.get_one("eval").map(|s| s.to_string()); + let json = result.get_bool("json"); + + let repl_flags = ReplFlags { + eval_files, + eval, + is_default_command: is_default, + json, + }; + + // If user runs bare `deno`, allow all permissions. + if repl_flags.is_default_command { + flags.permissions.allow_all = true; + } + + flags.subcommand = DenoSubcommand::Repl(repl_flags); + Ok(()) +} + +fn install_parse( + result: &ParseResult, + flags: &mut Flags, +) -> Result<(), CliError> { + runtime_args_parse(result, flags, true, true)?; + + let global = result.get_bool("global"); + + if global { + let root = result.get_one("root").map(|s| s.to_string()); + let force = result.get_bool("force"); + let compile = result.get_bool("compile"); + let name = result.get_one("name").map(|s| s.to_string()); + let module_urls = result + .get_many("cmd") + .map(|v| v.iter().map(|s| s.to_string()).collect()) + .unwrap_or_default(); + let args = result.trailing.clone(); + + flags.subcommand = + DenoSubcommand::Install(InstallFlags::Global(InstallFlagsGlobal { + name, + module_urls, + args, + root, + force, + compile, + })); + return Ok(()); + } + + // Permission flags (other than --allow-import) are only valid for global installs. + { + let perm_flags = [ + "allow-all", + "allow-read", + "deny-read", + "allow-write", + "deny-write", + "allow-net", + "deny-net", + "allow-env", + "deny-env", + "allow-run", + "deny-run", + "allow-sys", + "deny-sys", + "allow-ffi", + "deny-ffi", + ]; + for flag in &perm_flags { + if result.contains(flag) { + return Err(CliError::new( + CliErrorKind::InvalidValue, + "Note: Permission flags can only be used in a global setting\n\ + Use `deno install -g --allow-net jsr:@std/fs` instead", + )); + } + } + } + + let lockfile_only = result.get_bool("lockfile-only"); + let production = result.get_bool("prod"); + let skip_types = result.get_bool("skip-types"); + let npm_target = NpmInstallTargetFlags { + os: result.get_one("os").map(|s| s.to_string()), + arch: result.get_one("arch").map(|s| s.to_string()), + }; + + if result.get_bool("entrypoint") { + // `--entrypoint` is a boolean flag; the entrypoints are the positional + // `cmd` args (mirrors clap). + let entrypoints: Vec = result + .get_many("cmd") + .map(|v| v.iter().map(|s| s.to_string()).collect()) + .unwrap_or_default(); + flags.subcommand = DenoSubcommand::Install(InstallFlags::Local( + InstallFlagsLocal::Entrypoints(InstallEntrypointsFlags { + entrypoints, + lockfile_only, + production, + skip_types, + }), + npm_target, + )); + } else if let Some(packages) = result.get_many("cmd") { + if !packages.is_empty() { + // Adding packages needs a config to write them to; `--no-config` + // disables that. Mirror clap's guard. + if matches!(flags.config_flag, ConfigFlag::Disabled) { + return Err(CliError::new( + CliErrorKind::InvalidValue, + "deno install can't be used to add packages if `--no-config` is passed.", + ) + .with_suggestion( + "to cache the packages without adding to a config, pass the `--entrypoint` flag", + )); + } + let dev = result.get_bool("dev"); + let default_registry = if result.get_bool("jsr") { + Some(DefaultRegistry::Jsr) + } else { + // Default to npm when neither --npm nor --jsr is provided. + Some(DefaultRegistry::Npm) + }; + flags.subcommand = DenoSubcommand::Install(InstallFlags::Local( + InstallFlagsLocal::Add(AddFlags { + packages: packages.iter().map(|s| s.to_string()).collect(), + dev, + optional: result.get_bool("save-optional"), + no_save: result.get_bool("no-save"), + default_registry, + lockfile_only, + save_exact: result.get_bool("save-exact"), + package_json: result.get_bool("package-json"), + unscoped: result.get_bool("unscoped"), + }), + npm_target, + )); + } else { + flags.subcommand = DenoSubcommand::Install(InstallFlags::Local( + InstallFlagsLocal::TopLevel(InstallTopLevelFlags { + lockfile_only, + production, + skip_types, + }), + npm_target, + )); + } + } else { + flags.subcommand = DenoSubcommand::Install(InstallFlags::Local( + InstallFlagsLocal::TopLevel(InstallTopLevelFlags { + lockfile_only, + production, + skip_types, + }), + npm_target, + )); + } + Ok(()) +} + +fn uninstall_parse(result: &ParseResult, flags: &mut Flags) { + lock_args_parse(result, flags); + + let global = result.get_bool("global"); + let packages = result + .get_many("packages") + .map(|v| v.iter().map(|s| s.to_string()).collect::>()) + .unwrap_or_default(); + + let kind = if global { + let root = result.get_one("root").map(|s| s.to_string()); + UninstallKind::Global(UninstallFlagsGlobal { packages, root }) + } else { + UninstallKind::Local(RemoveFlags { + packages, + lockfile_only: result.get_bool("lockfile-only"), + package_json: result.get_bool("package-json"), + }) + }; + + flags.subcommand = DenoSubcommand::Uninstall(UninstallFlags { kind }); +} + +fn completions_parse(result: &ParseResult, flags: &mut Flags) { + let shell = result + .get_one("shell") + .map(|s| s.to_string()) + .unwrap_or_else(|| "bash".to_string()); + // Dynamic completions (bash/fish/zsh) are generated CLI-side; only record + // the target shell here. Other shells fall back to static generation. + if result.get_bool("dynamic") + && matches!(shell.as_str(), "bash" | "fish" | "zsh") + { + flags.subcommand = + DenoSubcommand::Completions(CompletionsFlags::Dynamic { shell }); + return; + } + let buf = crate::completions::generate(&shell, &crate::defs::DENO_ROOT); + flags.subcommand = DenoSubcommand::Completions(CompletionsFlags::Static( + buf.into_boxed_slice(), + )); +} + +fn init_parse(result: &ParseResult, flags: &mut Flags) -> Result<(), CliError> { + let mut lib = result.get_bool("lib"); + let mut serve = result.get_bool("serve"); + let mut empty = result.get_bool("empty"); + let mut yes = result.get_bool("yes"); + + let use_npm = result.get_bool("npm"); + let use_jsr = result.get_bool("jsr"); + + let mut dir = None; + let mut package = None; + let mut package_args = vec![]; + + if let Some(args) = result.get_many("args") + && !args.is_empty() + { + let name = args[0].clone(); + let rest: Vec = args[1..].iter().map(|s| s.to_string()).collect(); + + if use_npm { + package = Some(format!( + "npm:{}", + name.strip_prefix("npm:").unwrap_or(&name) + )); + package_args = rest; + } else if use_jsr { + package = Some(format!( + "jsr:{}", + name.strip_prefix("jsr:").unwrap_or(&name) + )); + package_args = rest; + } else { + dir = Some(name); + // Per-positional trailing captures flags like --lib after + // the dir positional. Re-parse them as init flags + // (matching the original clap sub-parse behavior). + for extra in &rest { + match extra.as_str() { + "--lib" => lib = true, + "--serve" => serve = true, + "--empty" => empty = true, + "--yes" | "-y" => yes = true, + _ => {} + } + } + } + } else if use_npm { + return Err(CliError::new( + CliErrorKind::MissingRequired, + "Missing package name for --npm.", + )); + } else if use_jsr { + return Err(CliError::new( + CliErrorKind::MissingRequired, + "Missing package name for --jsr.", + )); + } + + flags.subcommand = DenoSubcommand::Init(InitFlags { + package, + package_args, + dir, + lib, + serve, + empty, + yes, + }); + Ok(()) +} + +fn create_parse( + result: &ParseResult, + flags: &mut Flags, +) -> Result<(), CliError> { + let package = result + .get_one("package") + .map(|s| s.to_string()) + .unwrap_or_default(); + let use_npm = result.get_bool("npm"); + let use_jsr = result.get_bool("jsr"); + // Extra package args are only accepted after `--` (clap's `last(true)`), + // so they arrive via `result.trailing`. + let package_args: Vec = result.trailing.clone(); + + let package = normalize_create_package_name(package, use_npm, use_jsr)?; + + flags.subcommand = DenoSubcommand::Init(InitFlags { + package: Some(package), + package_args, + dir: None, + lib: false, + serve: false, + empty: false, + yes: result.get_bool("yes"), + }); + Ok(()) +} + +fn normalize_create_package_name( + package: String, + use_npm: bool, + use_jsr: bool, +) -> Result { + if let Some(name) = package.strip_prefix("jsr:") { + if use_npm { + Err(CliError::new( + CliErrorKind::InvalidValue, + "Cannot use `--npm` with a `jsr:` specifier.", + )) + } else if name.is_empty() { + Err(CliError::new( + CliErrorKind::InvalidValue, + "Missing package name after 'jsr:' prefix.", + )) + } else { + Ok(package) + } + } else if let Some(name) = package.strip_prefix("npm:") { + if use_jsr { + Err(CliError::new( + CliErrorKind::InvalidValue, + "Cannot use `--jsr` with an `npm:` specifier.", + )) + } else if name.is_empty() { + Err(CliError::new( + CliErrorKind::InvalidValue, + "Missing package name after 'npm:' prefix.", + )) + } else { + Ok(package) + } + } else if use_npm { + Ok(format!("npm:{package}")) + } else if use_jsr { + Ok(format!("jsr:{package}")) + } else { + Err(CliError::new( + CliErrorKind::InvalidValue, + "Missing `jsr:` or `npm:` prefix. For example: `deno create npm:vite`.", + )) + } +} + +fn jupyter_parse(result: &ParseResult, flags: &mut Flags) { + let conn_file = result.get_one("conn").map(|s| s.to_string()); + let kernel = result.get_bool("kernel"); + let install = result.get_bool("install"); + let display = result.get_one("display").map(|s| s.to_string()); + let name = result.get_one("name").map(|s| s.to_string()); + let force = result.get_bool("force"); + + flags.subcommand = DenoSubcommand::Jupyter(JupyterFlags { + install, + kernel, + conn_file, + name, + display, + force, + }); +} + +fn publish_parse(result: &ParseResult, flags: &mut Flags) { + flags.type_check_mode = TypeCheckMode::Local; + no_check_arg_parse(result, flags); + check_arg_parse(result, flags); + config_args_parse(result, flags); + + flags.subcommand = DenoSubcommand::Publish(PublishFlags { + token: result.get_one("token").map(|s| s.to_string()), + dry_run: result.get_bool("dry-run"), + allow_slow_types: result.get_bool("allow-slow-types"), + allow_dirty: result.get_bool("allow-dirty"), + no_provenance: result.get_bool("no-provenance"), + set_version: result.get_one("set-version").map(|s| s.to_string()), + }); +} + +fn add_parse(result: &ParseResult, flags: &mut Flags) { + allow_and_deny_import_parse(result, flags); + min_dep_age_arg_parse(result, flags); + lock_args_parse(result, flags); + env_file_arg_parse(result, flags); + + let packages = result + .get_many("packages") + .map(|v| v.iter().map(|s| s.to_string()).collect()) + .unwrap_or_default(); + let dev = result.get_bool("dev"); + let default_registry = if result.get_bool("jsr") { + Some(DefaultRegistry::Jsr) + } else { + // Default to npm when neither --npm nor --jsr is provided, so + // `deno add express` works without requiring an `npm:` prefix. + Some(DefaultRegistry::Npm) + }; + + flags.subcommand = DenoSubcommand::Add(AddFlags { + packages, + dev, + optional: result.get_bool("save-optional"), + no_save: result.get_bool("no-save"), + default_registry, + lockfile_only: result.get_bool("lockfile-only"), + save_exact: result.get_bool("save-exact"), + package_json: result.get_bool("package-json"), + unscoped: result.get_bool("unscoped"), + }); +} + +fn pack_parse(result: &ParseResult, flags: &mut Flags) { + flags.type_check_mode = TypeCheckMode::Local; // local by default + config_args_parse(result, flags); + env_file_arg_parse(result, flags); + let include = result + .get_many("files") + .map(|v| v.iter().map(|s| s.to_string()).collect()) + .unwrap_or_default(); + let ignore = result + .get_many("ignore") + .map(|v| v.iter().map(|s| s.to_string()).collect()) + .unwrap_or_default(); + flags.subcommand = DenoSubcommand::Pack(PackFlags { + files: FileFlags { include, ignore }, + output: result.get_one("output").map(|s| s.to_string()), + dry_run: result.get_bool("dry-run"), + allow_slow_types: result.get_bool("allow-slow-types"), + allow_dirty: result.get_bool("allow-dirty"), + set_version: result.get_one("set-version").map(|s| s.to_string()), + no_source_maps: result.get_bool("no-source-maps"), + }); +} + +fn desktop_parse( + result: &ParseResult, + flags: &mut Flags, +) -> Result<(), CliError> { + flags.type_check_mode = TypeCheckMode::Local; + runtime_args_parse(result, flags, true, true)?; + ext_arg_parse(result, flags); + flags.code_cache_enabled = !result.get_bool("no-code-cache"); + + let mut script = result + .get_many("script_arg") + .map(|v| v.iter().map(|s| s.to_string()).collect::>()) + .unwrap_or_default() + .into_iter(); + // The entrypoint is optional: bare `deno desktop` defaults to "." so + // framework detection runs against the current directory. + let source_file = script.next().unwrap_or_else(|| ".".to_string()); + let args = script.collect(); + + // `--compress` (bare) defaults to xz; the `lzma` alias normalizes to xz. + let compress = if result.contains("compress") { + result + .get_one("compress") + .map(|c| if c == "lzma" { "xz" } else { c }.to_string()) + } else { + None + }; + let inspect_renderer = if result.contains("inspect-renderer") { + // Unlike the `--inspect*` flags, an unparseable renderer address is + // ignored rather than rejected (pre-existing behavior). + result + .get_one("inspect-renderer") + .and_then(|s| parse_socket_addr(s).ok()) + } else { + None + }; + let include = result + .get_many("include") + .map(|v| v.iter().map(|s| s.to_string()).collect()) + .unwrap_or_default(); + let exclude = result + .get_many("exclude") + .map(|v| v.iter().map(|s| s.to_string()).collect()) + .unwrap_or_default(); + + flags.subcommand = DenoSubcommand::Desktop(DesktopFlags { + source_file, + output: result.get_one("output").map(|s| s.to_string()), + args, + target: result.get_one("target").map(|s| s.to_string()), + icon: result + .get_one("icon") + .map(|s| IconConfig::Single(s.to_string())), + include, + exclude, + hmr: result.get_bool("hmr"), + backend: result.get_one("backend").map(|s| s.to_string()), + all_targets: result.get_bool("all-targets"), + identifier: None, + deep_links: Vec::new(), + codesign_identity: None, + inspect_renderer, + compress, + exclude_unused_npm: result.get_bool("exclude-unused-npm"), + engine: result + .get_one("engine") + .map(|value| value.parse().expect("engine is validated by the parser")) + .unwrap_or_default(), + }); + Ok(()) +} + +fn ci_parse(result: &ParseResult, flags: &mut Flags) { + env_file_arg_parse(result, flags); + flags.frozen_lockfile = Some(true); + flags.subcommand = DenoSubcommand::Ci(CiFlags { + production: result.get_bool("prod"), + skip_types: result.get_bool("skip-types"), + }); +} + +fn bump_version_parse( + result: &ParseResult, + flags: &mut Flags, +) -> Result<(), CliError> { + let increment = match result.get_one("increment") { + None => None, + Some(s) => Some(match s { + "major" => VersionIncrement::Major, + "minor" => VersionIncrement::Minor, + "patch" => VersionIncrement::Patch, + "premajor" => VersionIncrement::Premajor, + "preminor" => VersionIncrement::Preminor, + "prepatch" => VersionIncrement::Prepatch, + "prerelease" => VersionIncrement::Prerelease, + other => { + return Err(CliError::new( + CliErrorKind::InvalidValue, + format!("invalid value '{other}' for '[increment]'"), + )); + } + }), + }; + let workspace = if result.get_bool("workspace") { + Some(true) + } else if result.get_bool("no-workspace") { + Some(false) + } else { + None + }; + flags.subcommand = DenoSubcommand::BumpVersion(VersionFlags { + increment, + workspace, + start: result.get_one("start").map(|s| s.to_string()), + base: result.get_one("base").map(|s| s.to_string()), + import_map: result.get_one("import-map").map(|s| s.to_string()), + release_notes: result.get_one("release-notes").map(|s| s.to_string()), + dry_run: result.get_bool("dry-run"), + config: result.get_one("config").map(|s| s.to_string()), + }); + Ok(()) +} + +fn transpile_parse(result: &ParseResult, flags: &mut Flags) { + let files = result + .get_many("file") + .map(|v| v.iter().map(|s| s.to_string()).collect()) + .unwrap_or_default(); + let source_map = match result.get_one("source-map") { + Some("inline") => SourceMapMode::Inline, + Some("separate") => SourceMapMode::Separate, + _ => SourceMapMode::None, + }; + flags.subcommand = DenoSubcommand::Transpile(TranspileFlags { + files, + output: result.get_one("output").map(|s| s.to_string()), + output_dir: result.get_one("outdir").map(|s| s.to_string()), + declaration: result.get_bool("declaration"), + source_map, + }); +} + +fn why_parse(result: &ParseResult, flags: &mut Flags) { + lock_args_parse(result, flags); + env_file_arg_parse(result, flags); + let package = result.get_one("package").map(|s| s.to_string()); + flags.subcommand = DenoSubcommand::Why(WhyFlags { + package: package.unwrap_or_default(), + }); +} + +fn remove_parse(result: &ParseResult, flags: &mut Flags) { + lock_args_parse(result, flags); + min_dep_age_arg_parse(result, flags); + let packages: Vec = result + .get_many("packages") + .map(|v| v.iter().map(|s| s.to_string()).collect()) + .unwrap_or_default(); + + // `deno remove --global ...` is an alias for `deno uninstall + // --global ...`: it removes globally installed executables rather + // than dependencies from the configuration file, so route it through the + // same subcommand the uninstaller uses. + if result.get_bool("global") { + let root = result.get_one("root").map(|s| s.to_string()); + flags.subcommand = DenoSubcommand::Uninstall(UninstallFlags { + kind: UninstallKind::Global(UninstallFlagsGlobal { packages, root }), + }); + return; + } + + flags.subcommand = DenoSubcommand::Remove(RemoveFlags { + packages, + lockfile_only: result.get_bool("lockfile-only"), + package_json: result.get_bool("package-json"), + }); +} + +fn outdated_parse(result: &ParseResult, flags: &mut Flags, is_update: bool) { + let filters = result + .get_many("filters") + .map(|v| v.iter().map(|s| s.to_string()).collect()) + .unwrap_or_default(); + let recursive = result.get_bool("recursive"); + + let update_flag = result.get_bool("update"); + let kind = if is_update || update_flag { + let latest = result.get_bool("latest"); + let interactive = result.get_bool("interactive"); + OutdatedKind::Update { + latest, + interactive, + lockfile_only: result.get_bool("lockfile-only"), + } + } else { + let compatible = result.get_bool("compatible"); + OutdatedKind::PrintOutdated { compatible } + }; + + flags.subcommand = DenoSubcommand::Outdated(OutdatedFlags { + filters, + recursive, + kind, + }); + + lock_args_parse(result, flags); + min_dep_age_arg_parse(result, flags); +} + +fn clean_parse(result: &ParseResult, flags: &mut Flags) { + let mut except_paths = Vec::new(); + if result.get_bool("except") { + except_paths = result + .get_many("except-paths") + .map(|v| v.iter().map(|s| s.to_string()).collect()) + .unwrap_or_default(); + flags.cached_only = true; + node_modules_and_vendor_dir_arg_parse(result, flags); + } + let dry_run = result.get_bool("dry-run"); + + flags.subcommand = DenoSubcommand::Clean(CleanFlags { + except_paths, + dry_run, + }); +} + +fn list_parse(result: &ParseResult, flags: &mut Flags) { + let filters = result + .get_many("filters") + .map(|v| v.iter().map(|s| s.to_string()).collect()) + .unwrap_or_default(); + flags.subcommand = DenoSubcommand::List(ListFlags { + recursive: result.get_bool("recursive"), + depth: result + .get_one("depth") + .and_then(|s| s.parse::().ok()) + .unwrap_or(0), + prod: result.get_bool("prod"), + dev: result.get_bool("dev"), + filters, + }); +} + +fn link_parse(result: &ParseResult, flags: &mut Flags) { + lock_args_parse(result, flags); + flags.subcommand = DenoSubcommand::Link(LinkFlags { + paths: result + .get_many("paths") + .map(|v| v.iter().map(|s| s.to_string()).collect()) + .unwrap_or_default(), + lockfile_only: result.get_bool("lockfile-only"), + }); +} + +fn sync_types_parse(result: &ParseResult, flags: &mut Flags) { + allow_and_deny_import_parse(result, flags); + flags.subcommand = DenoSubcommand::SyncTypes(SyncTypesFlags { + roots: result + .get_many("roots") + .map(|v| v.iter().map(|s| s.to_string()).collect()) + .unwrap_or_default(), + }); +} + +fn unlink_parse(result: &ParseResult, flags: &mut Flags) { + lock_args_parse(result, flags); + flags.subcommand = DenoSubcommand::Unlink(UnlinkFlags { + names_or_paths: result + .get_many("names_or_paths") + .map(|v| v.iter().map(|s| s.to_string()).collect()) + .unwrap_or_default(), + lockfile_only: result.get_bool("lockfile-only"), + }); +} + +fn approve_scripts_parse(result: &ParseResult, flags: &mut Flags) { + flags.subcommand = DenoSubcommand::ApproveScripts(ApproveScriptsFlags { + packages: result + .get_many("packages") + .map(|v| v.iter().map(|s| s.to_string()).collect()) + .unwrap_or_default(), + lockfile_only: result.get_bool("lockfile-only"), + }); +} + +fn types_parse(flags: &mut Flags) { + flags.subcommand = DenoSubcommand::Types; +} + +fn lsp_parse(flags: &mut Flags) { + flags.subcommand = DenoSubcommand::Lsp; +} + +fn vendor_parse(flags: &mut Flags) { + flags.subcommand = DenoSubcommand::Vendor; +} + +fn deploy_parse(flags: &mut Flags, sandbox: bool) { + // deploy/sandbox are passthrough - all args go into argv. Note that argv is + // filled in by the shared trailing-arg handling in `flags_from_vec`, so this + // must not copy `result.trailing` itself or every arg would be duplicated. + flags.subcommand = DenoSubcommand::Deploy(DeployFlags { sandbox }); +} + +// ============================================================ +// NODE_OPTIONS support +// ============================================================ + +#[cfg(test)] +thread_local! { + static TEST_NODE_OPTIONS: std::cell::RefCell> = + const { std::cell::RefCell::new(None) }; +} + +#[cfg(test)] +pub fn set_test_node_options(value: Option<&str>) { + TEST_NODE_OPTIONS.with(|opt| { + *opt.borrow_mut() = value.map(|s| s.to_string()); + }); +} + +/// Parse NODE_OPTIONS environment variable into a list of arguments. +/// Handles quoted strings and backslash escapes, matching Node.js behavior. +pub fn parse_node_options_env_var( + node_options: &str, +) -> Result, String> { + let mut env_argv = Vec::new(); + let mut is_in_string = false; + let mut will_start_new_arg = true; + + let chars: Vec = node_options.chars().collect(); + let mut index = 0; + + while index < chars.len() { + let mut c = chars[index]; + + // Backslashes escape the following character + if c == '\\' && is_in_string { + if index + 1 == chars.len() { + return Err( + "invalid value for NODE_OPTIONS (invalid escape)".to_string(), + ); + } else { + index += 1; + c = chars[index]; + } + } else if c == ' ' && !is_in_string { + will_start_new_arg = true; + index += 1; + continue; + } else if c == '"' { + is_in_string = !is_in_string; + index += 1; + continue; + } + + if will_start_new_arg { + env_argv.push(c.to_string()); + will_start_new_arg = false; + } else if let Some(last) = env_argv.last_mut() { + last.push(c); + } + + index += 1; + } + + if is_in_string { + return Err( + "invalid value for NODE_OPTIONS (unterminated string)".to_string(), + ); + } + + Ok(env_argv) +} + +/// Reads some flags from NODE_OPTIONS: +/// https://nodejs.org/api/cli.html#node_optionsoptions +/// Currently supports: +/// - `--require` / `-r` +/// - `--inspect-publish-uid` +/// +/// Public because the CLI's bare-run fast path skips the full parse and has to +/// apply these itself; `fast_path_matches_full_parse` asserts the two agree. +#[allow(clippy::disallowed_methods, reason = "reads NODE_OPTIONS from env")] +pub fn apply_node_options(flags: &mut Flags) { + let node_options = match std::env::var("NODE_OPTIONS") { + Ok(val) if !val.is_empty() => val, + _ => { + #[cfg(test)] + { + match TEST_NODE_OPTIONS.with(|opt| opt.borrow().clone()) { + Some(val) if !val.is_empty() => val, + _ => return, + } + } + #[cfg(not(test))] + return; + } + }; + + let args = match parse_node_options_env_var(&node_options) { + Ok(args) => args, + Err(_) => return, + }; + + // Filter to only supported flags, using a scan to track whether the + // previous token was --require/-r (so we keep its value argument too). + let filtered: Vec<&str> = args + .iter() + .map(String::as_str) + .scan(false, |prev_was_require, word| { + if word == "--require" || word == "-r" { + *prev_was_require = true; + return Some((word, true)); + } + if word.starts_with("--inspect-publish-uid=") || *prev_was_require { + *prev_was_require = false; + return Some((word, true)); + } + *prev_was_require = false; + Some((word, false)) + }) + .filter_map(|(word, should_keep)| should_keep.then_some(word)) + .collect(); + + // Parse --require values + let mut node_require: Vec = Vec::new(); + let mut i = 0; + while i < filtered.len() { + let word = filtered[i]; + if (word == "--require" || word == "-r") && i + 1 < filtered.len() { + node_require.push(filtered[i + 1].to_string()); + i += 2; + } else if word.starts_with("--inspect-publish-uid=") { + // Handle below + i += 1; + } else { + i += 1; + } + } + + // Prepend NODE_OPTIONS --require values before CLI --require values + if !node_require.is_empty() { + node_require.append(&mut flags.require); + flags.require = node_require; + } + + // Parse --inspect-publish-uid (only if not already set from CLI) + if flags.inspect_publish_uid.is_none() { + for word in &filtered { + if let Some(value) = word.strip_prefix("--inspect-publish-uid=") { + let mut uid = InspectPublishUid { + console: false, + http: false, + }; + for part in value.split(',') { + match part.trim() { + "stderr" | "console" => uid.console = true, + "http" => uid.http = true, + _ => {} + } + } + flags.inspect_publish_uid = Some(uid); + break; + } + } + } + + // Parse --inspect / --inspect-brk / --inspect-wait from NODE_OPTIONS. These + // are mutually exclusive, so only apply them if no inspector flag was passed + // on the CLI. + if flags.inspect.is_none() + && flags.inspect_brk.is_none() + && flags.inspect_wait.is_none() + { + for word in &args { + if let Some(addr) = node_inspect_addr(word, "--inspect") { + flags.inspect = Some(addr); + break; + } else if let Some(addr) = node_inspect_addr(word, "--inspect-brk") { + flags.inspect_brk = Some(addr); + break; + } else if let Some(addr) = node_inspect_addr(word, "--inspect-wait") { + flags.inspect_wait = Some(addr); + break; + } + } + } +} + +/// Parse a NODE_OPTIONS inspector flag (`--inspect`, `--inspect-brk`, +/// `--inspect-wait`) into its listen address. A bare flag uses the default +/// `127.0.0.1:9229`; `--flag=host:port` parses the attached address. +fn node_inspect_addr(word: &str, flag: &str) -> Option { + if word == flag { + Some(SocketAddr::from(([127, 0, 0, 1], 9229))) + } else if let Some(rest) = word.strip_prefix(flag) + && let Some(value) = rest.strip_prefix('=') + { + // Unparseable values in NODE_OPTIONS are ignored rather than rejected + // (clap parsed NODE_OPTIONS with `ignore_errors`). + parse_socket_addr(value).ok() + } else { + None + } +} + +fn bundle_parse(result: &ParseResult, flags: &mut Flags) { + compile_args_without_check_parse(result, flags); + // `compile_args_without_check_parse` deliberately omits `--check`; apply it + // here so `deno bundle --check=all` type-checks (mirrors clap, #30159). + check_arg_parse(result, flags); + // `bundle` resolves a module graph but never runs it, so the only + // permissions it takes are the import ones. + allow_and_deny_import_parse(result, flags); + + let entrypoints = result + .get_many("file") + .map(|v| v.to_vec()) + .unwrap_or_default(); + let output_path = result.get_one("output").map(|s| s.to_string()); + let output_dir = result.get_one("outdir").map(|s| s.to_string()); + let format_str = result.get_one("format").unwrap_or("esm"); + let format = match format_str { + "cjs" => BundleFormat::Cjs, + "iife" => BundleFormat::Iife, + _ => BundleFormat::Esm, + }; + let packages_str = result.get_one("packages").unwrap_or("bundle"); + let packages = match packages_str { + "external" => PackageHandling::External, + _ => PackageHandling::Bundle, + }; + let platform_str = result.get_one("platform").unwrap_or("deno"); + let platform = match platform_str { + "browser" => BundlePlatform::Browser, + _ => BundlePlatform::Deno, + }; + // `--sourcemap` takes an optional value that must be attached with `=`, so a + // bare `--sourcemap` means "linked" and never swallows the next argument. + let sourcemap = if result.contains("sourcemap") { + Some(match result.get_one("sourcemap").unwrap_or("linked") { + "inline" => SourceMapType::Inline, + "external" => SourceMapType::External, + _ => SourceMapType::Linked, + }) + } else { + None + }; + let external = result + .get_many("external") + .map(|v| v.to_vec()) + .unwrap_or_default(); + + flags.subcommand = DenoSubcommand::Bundle(BundleFlags { + entrypoints, + output_path, + output_dir, + external, + format, + packages, + platform, + sourcemap, + watch: result.get_bool("watch"), + minify: result.get_bool("minify"), + keep_names: result.get_bool("keep-names"), + code_splitting: result.get_bool("code-splitting"), + inline_imports: !matches!(result.get_one("inline-imports"), Some("false")), + declaration: result.get_bool("declaration"), + }); +} + +fn audit_parse( + result: &ParseResult, + flags: &mut Flags, +) -> Result<(), CliError> { + lock_args_parse(result, flags); + + // The only accepted positional is the literal `fix`. + if let Some(action) = result.get_one("action") + && action != "fix" + { + return Err(CliError::new( + CliErrorKind::UnexpectedPositional, + format!("unexpected argument '{action}' found"), + )); + } + + let severity = result.get_one("level").unwrap_or("low").to_string(); + let ignore_unfixable = result.get_bool("ignore-unfixable"); + let ignore_registry_errors = result.get_bool("ignore-registry-errors"); + let socket = result.get_bool("socket"); + let ignore = result + .get_many("ignore") + .map(|v| v.to_vec()) + .unwrap_or_default(); + + flags.subcommand = DenoSubcommand::Audit(AuditFlags { + severity, + dev: true, + prod: true, + optional: true, + ignore_registry_errors, + ignore_unfixable, + ignore, + socket, + fix: result.get_bool("fix") || result.get_one("action") == Some("fix"), + }); + Ok(()) +} + +fn x_parse(result: &ParseResult, flags: &mut Flags) -> Result<(), CliError> { + let kind = if let Some(alias) = result.get_one("install-alias") { + if !result.contains("install-alias") { + // Not provided, check for script_arg + goto_script(result, flags)? + } else { + let name = match alias { + "dx" => DenoXShimName::Dx, + "denox" => DenoXShimName::Denox, + "dnx" => DenoXShimName::Dnx, + other => DenoXShimName::Other(other.to_string()), + }; + XFlagsKind::InstallAlias(name) + } + } else { + goto_script(result, flags)? + }; + + fn goto_script( + result: &ParseResult, + flags: &mut Flags, + ) -> Result { + Ok(if let Some(args) = result.get_many("script_arg") { + if !args.is_empty() { + let command = args[0].clone(); + let yes = result.get_bool("yes"); + runtime_args_parse(result, flags, true, true)?; + permission_args_parse(result, flags); + flags.argv.extend(args[1..].iter().cloned()); + XFlagsKind::Command(XCommandFlags { + yes, + command, + ignore_scripts: ignore_scripts_value(result)?, + package: result.get_one("package").map(|s| s.to_string()), + }) + } else { + XFlagsKind::Print + } + } else { + XFlagsKind::Print + }) + } + + if !flags.permissions.has_permission() && flags.permission_set.is_none() { + flags.permissions.allow_all = true; + } + flags.subcommand = DenoSubcommand::X(XFlags { kind }); + Ok(()) +} + +fn json_reference_parse(flags: &mut Flags) { + // Build a JSON representation of all commands from our static definitions. + // This replaces the clap-based json_reference_parse. + let root = &crate::defs::DENO_ROOT; + let json_str = serialize_command_def(root, true); + let json = deno_core::serde_json::from_str(&json_str) + .unwrap_or_else(|_| deno_core::serde_json::Value::Null); + flags.subcommand = DenoSubcommand::JSONReference(JSONReferenceFlags { json }); +} + +fn serialize_command_def(cmd: &crate::CommandDef, top_level: bool) -> String { + use std::fmt::Write; + let mut out = String::new(); + out.push('{'); + + // name + write!(out, "\"name\":{}", json_escape(cmd.name)).unwrap(); + + // about + write!( + out, + ",\"about\":{}", + json_escape_option(if cmd.about.is_empty() { + None + } else { + Some(cmd.about) + }) + ) + .unwrap(); + + // usage + let usage = format!("Usage: {}", cmd.name); + write!(out, ",\"usage\":{}", json_escape(&usage)).unwrap(); + + // args + let args: Vec<&crate::ArgDef> = if top_level { + cmd.all_args().filter(|a| a.global && !a.hidden).collect() + } else { + cmd.all_args().filter(|a| !a.global && !a.hidden).collect() + }; + + out.push_str(",\"args\":["); + for (i, arg) in args.iter().enumerate() { + if i > 0 { + out.push(','); + } + out.push('{'); + write!(out, "\"name\":{}", json_escape(arg.name)).unwrap(); + if let Some(short) = arg.short { + write!(out, ",\"short\":\"{}\"", short).unwrap(); + } else { + out.push_str(",\"short\":null"); + } + if let Some(long) = arg.long { + write!(out, ",\"long\":{}", json_escape(long)).unwrap(); + } else { + out.push_str(",\"long\":null"); + } + write!(out, ",\"required\":{}", arg.required).unwrap(); + write!( + out, + ",\"help\":{}", + json_escape_option(if arg.help.is_empty() { + None + } else { + Some(arg.help) + }) + ) + .unwrap(); + out.push_str(",\"help_heading\":null"); + + // usage string + let mut usage = String::new(); + if let Some(short) = arg.short { + write!(usage, "-{}", short).unwrap(); + if arg.long.is_some() { + usage.push_str(", "); + } + } + if let Some(long) = arg.long { + write!(usage, "--{}", long).unwrap(); + } + write!(out, ",\"usage\":{}", json_escape(&usage)).unwrap(); + + out.push('}'); + } + out.push(']'); + + // subcommands + out.push_str(",\"subcommands\":["); + let visible_subs: Vec<&crate::CommandDef> = cmd + .subcommands + .iter() + .filter(|s| !s.name.starts_with("json_reference") && s.name != "help") + .collect(); + for (i, sub) in visible_subs.iter().enumerate() { + if i > 0 { + out.push(','); + } + out.push_str(&serialize_command_def(sub, false)); + } + out.push(']'); + + out.push('}'); + out +} + +fn json_escape(s: &str) -> String { + let mut out = String::with_capacity(s.len() + 2); + out.push('"'); + for c in s.chars() { + match c { + '"' => out.push_str("\\\""), + '\\' => out.push_str("\\\\"), + '\n' => out.push_str("\\n"), + '\r' => out.push_str("\\r"), + '\t' => out.push_str("\\t"), + c if c < '\u{20}' => { + use std::fmt::Write; + write!(out, "\\u{:04x}", c as u32).unwrap(); + } + c => out.push(c), + } + } + out.push('"'); + out +} + +fn json_escape_option(s: Option<&str>) -> String { + match s { + Some(s) => json_escape(s), + None => "null".to_string(), + } +} + +fn help_subcommand_parse(result: &ParseResult, flags: &mut Flags) { + let root = &crate::defs::DENO_ROOT; + let sub_name = result.get_one("subcommand"); + let cmd = sub_name + .and_then(|name| root.find_subcommand(name)) + .unwrap_or(root); + let help_text = crate::help::render_help(cmd); + flags.subcommand = DenoSubcommand::Help(HelpFlags { help: help_text }); +} diff --git a/libs/cli_parser/src/flags.rs b/libs/cli_parser/src/flags.rs new file mode 100644 index 0000000..aa1354c --- /dev/null +++ b/libs/cli_parser/src/flags.rs @@ -0,0 +1,1071 @@ +// Copyright 2018-2026 the Deno authors. MIT license. +#![allow(dead_code, reason = "types are used by the deno CLI crate")] + +// Flag types using real Deno dependencies. These are the canonical type +// definitions shared by both the parser crate and `cli/args/flags.rs`. + +use std::net::SocketAddr; +use std::num::NonZeroU8; +use std::num::NonZeroU32; +use std::num::NonZeroUsize; +use std::path::PathBuf; + +pub use deno_bundle_runtime::BundleFormat; +use deno_bundle_runtime::BundleOptions; +pub use deno_bundle_runtime::BundlePlatform; +pub use deno_bundle_runtime::PackageHandling; +pub use deno_bundle_runtime::SourceMapType; +pub use deno_config::deno_json::NewestDependencyDate; +pub use deno_config::deno_json::NodeModulesDirMode; +pub use deno_config::deno_json::NodeModulesLinkerMode; +pub use deno_core::url::Url; +pub use deno_lib::args::CaData; +pub use deno_lib::args::UnstableConfig; +pub use deno_npm_installer::PackagesAllowedScripts; +pub use deno_runtime::deno_inspector_server::InspectPublishUid; +use deno_semver::package::PackageReq; +pub use log::Level; +use serde::Deserialize; +use serde::Serialize; + +#[derive(Clone, Debug, Default, Eq, PartialEq)] +pub enum ConfigFlag { + #[default] + Discover, + Path(String), + Disabled, +} + +#[derive(Clone, Debug, Default, Eq, PartialEq)] +pub struct FileFlags { + pub ignore: Vec, + pub include: Vec, +} + +#[derive(Clone, Debug, Copy, Eq, PartialEq)] +pub enum DefaultRegistry { + Npm, + Jsr, +} + +#[derive(Clone, Debug, Default, Eq, PartialEq)] +pub struct AddFlags { + pub packages: Vec, + pub dev: bool, + pub optional: bool, + pub no_save: bool, + pub default_registry: Option, + pub lockfile_only: bool, + pub save_exact: bool, + pub package_json: bool, + pub unscoped: bool, +} + +#[derive(Clone, Debug, Default, Eq, PartialEq)] +pub struct WhyFlags { + pub package: String, +} + +#[derive(Clone, Debug, Default, Eq, PartialEq)] +pub struct AuditFlags { + pub severity: String, + pub ignore_registry_errors: bool, + pub ignore_unfixable: bool, + pub dev: bool, + pub prod: bool, + pub optional: bool, + pub ignore: Vec, + pub socket: bool, + pub fix: bool, +} + +#[derive(Clone, Debug, Default, Eq, PartialEq)] +pub struct RemoveFlags { + pub packages: Vec, + pub lockfile_only: bool, + pub package_json: bool, +} + +#[derive(Clone, Debug, Default, Eq, PartialEq)] +pub struct LinkFlags { + pub paths: Vec, + pub lockfile_only: bool, +} + +#[derive(Clone, Debug, Default, Eq, PartialEq)] +pub struct UnlinkFlags { + pub names_or_paths: Vec, + pub lockfile_only: bool, +} + +#[derive(Clone, Debug, Eq, PartialEq, Default)] +pub struct VersionFlags { + pub increment: Option, + /// Bump every package in the workspace. Defaults to true when invoked at the + /// workspace root and the workspace has more than one member with a version. + pub workspace: Option, + /// When in workspace mode without an explicit increment, derive bumps from + /// commit messages between `start` and `base`. The default for both is to + /// fall back to git (latest tag and current branch respectively). + pub start: Option, + pub base: Option, + /// Path to the import map to rewrite jsr: version constraints in. Defaults + /// to the root deno.json (or whatever its `importMap` field points to). + pub import_map: Option, + /// Path to the release notes markdown file to prepend in conventional-commits + /// mode. Defaults to `Releases.md`. + pub release_notes: Option, + /// Don't write any files; just print what would happen. + pub dry_run: bool, + /// Explicit path to the manifest file to bump. May point to a `deno.json` + /// (or `.jsonc`) or a `package.json`. When set, single-file mode is forced + /// (workspace auto-detection is bypassed). Useful when both `deno.json` and + /// `package.json` exist in the same directory. + pub config: Option, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum VersionIncrement { + Major, + Minor, + Patch, + Premajor, + Preminor, + Prepatch, + Prerelease, +} + +#[derive(Clone, Debug, Default, Eq, PartialEq)] +pub struct BenchFlags { + pub files: FileFlags, + pub filter: Option, + pub json: bool, + pub no_run: bool, + pub permit_no_files: bool, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct CacheFlags { + pub files: Vec, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct CheckFlags { + pub files: Vec, + pub doc: bool, + pub doc_only: bool, + pub check_js: bool, +} + +#[derive(Clone, Debug, Default, Eq, PartialEq)] +pub struct SyncTypesFlags { + /// Files, directories, or remote modules to use as module graph roots. + /// These scope dependency discovery, not the generated TypeScript project. + /// An empty list discovers dependencies from the entire project. + pub roots: Vec, +} + +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +pub enum JavaScriptEngine { + #[default] + V8, + QuickJs, +} + +impl JavaScriptEngine { + pub const fn artifact_suffix(&self) -> &'static str { + match self { + Self::V8 => "", + Self::QuickJs => "-quickjs", + } + } +} + +impl std::str::FromStr for JavaScriptEngine { + type Err = String; + + fn from_str(value: &str) -> Result { + match value { + "v8" => Ok(Self::V8), + "quickjs" => Ok(Self::QuickJs), + _ => Err(format!("unsupported JavaScript engine: {value}")), + } + } +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct CompileFlags { + pub source_file: String, + pub output: Option, + pub args: Vec, + pub target: Option, + pub no_terminal: bool, + pub icon: Option, + pub include: Vec, + pub exclude: Vec, + pub eszip: bool, + pub self_extracting: bool, + /// Bundle the entrypoint with esbuild before embedding it, instead of + /// shipping the entire node_modules tree. Experimental. + pub bundle: bool, + /// Stable identity for the compiled app. Determines where origin-bound + /// storage (default `Deno.openKv()`, `localStorage`, `caches`) is persisted. + /// Defaults to the output file name when not set. + pub app_name: Option, + /// Minify the bundle. Only meaningful with `bundle: true`. + pub minify: bool, + /// Prune the embedded managed npm snapshot to only those packages reachable + /// from npm specifiers in the module graph. Opt-in because non-statically + /// analyzable dynamic imports may not appear in the graph; pass + /// `--include npm:` for any such packages. + pub exclude_unused_npm: bool, + /// JS engine the compiled binary should run on. Named `engine` because + /// `deno desktop --backend` already means the render backend. + pub engine: JavaScriptEngine, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct IconSetEntry { + pub path: String, + pub size: u32, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum IconConfig { + /// A single icon file (`.icns`, `.ico`, or `.png`). + Single(String), + /// Multiple PNGs at specific pixel sizes. + Set(Vec), +} + +#[derive(Clone, Debug, Default, Eq, PartialEq)] +pub struct DesktopFlags { + pub source_file: String, + pub output: Option, + pub args: Vec, + pub target: Option, + pub icon: Option, + pub include: Vec, + pub exclude: Vec, + pub hmr: bool, + pub backend: Option, + /// JS engine the desktop binary runs on. Distinct from `backend`, which + /// selects the render backend (webview/cef/raw). + pub engine: JavaScriptEngine, + pub all_targets: bool, + /// Reverse-DNS bundle / application identifier (e.g. `com.acme.foo`). + /// Used for the macOS `CFBundleIdentifier`, the Linux `.desktop` file + /// identifier, and (eventually) the Windows AppUserModelID. When unset + /// a synthetic `com.deno.desktop.` is generated. + pub identifier: Option, + /// Custom URL schemes (deep links) to register with the OS, e.g. `["acme"]` + /// for `acme://...` links. Sourced from `desktop.app.deepLinks` in + /// `deno.json`. Each entry is a bare scheme with no `://`. + pub deep_links: Vec, + /// macOS codesigning identity (e.g. `Developer ID Application: Acme, + /// Inc. (TEAMID)`, or `-` for ad-hoc). When unset the bundle is left + /// unsigned; the system will quarantine it on download. + pub codesign_identity: Option, + /// Optional override for the CEF renderer debugger port. When unset, a free + /// port is allocated. The user-visible inspector port (from `--inspect`) is + /// separate and is carried on `Flags::inspect`. + pub inspect_renderer: Option, + /// When set, emit a compressed distribution archive of the packaged app + /// next to it (`.tar.xz` or `.tar.zst`). The installed + /// `.app`/app dir is left untouched (and still code-signed); the archive is + /// just a small download artifact. Value is the compressor: `"xz"` (LZMA) + /// or `"zstd"`. + pub compress: Option, + /// Embed only the npm packages reachable from the module graph instead of + /// the full managed snapshot. Same opt-in semantics as + /// `deno compile --exclude-unused-npm`. + pub exclude_unused_npm: bool, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum CompletionsFlags { + /// Pre-rendered static completion script. + Static(Box<[u8]>), + /// Dynamic completions for the given shell. The actual generation is + /// performed on the CLI side (it needs the runtime's completion handler), + /// so this variant only records the target shell. + Dynamic { shell: String }, +} + +#[derive(Clone, Debug, Eq, PartialEq, Default)] +pub enum CoverageType { + #[default] + Summary, + Detailed, + Lcov, + Html, +} + +#[derive(Clone, Debug, Eq, PartialEq, Default)] +pub struct CoverageFlags { + pub files: FileFlags, + pub output: Option, + pub include: Vec, + pub exclude: Vec, + pub r#type: CoverageType, + /// Minimum coverage percentage (0-100) applied to line, branch, and function + /// coverage. Overrides per-metric thresholds from `deno.json`. + pub threshold: Option, +} + +#[derive(Clone, Debug, Eq, PartialEq, Default)] +pub struct DeployFlags { + pub sandbox: bool, +} + +#[derive(Clone, Debug, Eq, PartialEq, Default)] +pub enum DocSourceFileFlag { + #[default] + Builtin, + Paths(Vec), +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct DocHtmlFlag { + pub name: Option, + pub category_docs_path: Option, + pub symbol_redirect_map_path: Option, + pub default_symbol_map_path: Option, + pub strip_trailing_html: bool, + pub output: String, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct DocFlags { + pub private: bool, + pub json: bool, + pub lint: bool, + pub html: Option, + pub source_files: DocSourceFileFlag, + pub filter: Option, +} + +#[derive(Clone, Debug, Eq, PartialEq, Default)] +pub struct CpuProfFlags { + pub dir: Option, + pub name: Option, + pub interval: Option, + pub md: bool, + pub flamegraph: bool, +} + +#[derive(Clone, Default, Debug, Eq, PartialEq)] +pub struct EvalFlags { + pub print: bool, + pub code: String, +} + +#[derive(Clone, Default, Debug, Eq, PartialEq)] +pub struct FmtFlags { + pub check: bool, + pub fail_fast: bool, + pub files: FileFlags, + pub permit_no_files: bool, + pub use_tabs: Option, + pub line_width: Option, + pub indent_width: Option, + pub single_quote: Option, + pub prose_wrap: Option, + pub no_semicolons: Option, + pub no_editorconfig: bool, + pub unstable_component: bool, + pub unstable_sql: bool, +} + +impl FmtFlags { + pub fn is_stdin(&self) -> bool { + let args = &self.files.include; + args.len() == 1 && args[0] == "-" + } +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct InitFlags { + pub package: Option, + pub package_args: Vec, + pub dir: Option, + pub lib: bool, + pub serve: bool, + pub empty: bool, + pub yes: bool, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct InfoFlags { + pub json: bool, + pub file: Option, +} + +#[derive(Clone, Debug, PartialEq, Eq, Default)] +pub struct ListFlags { + pub recursive: bool, + pub depth: u16, + pub prod: bool, + pub dev: bool, + pub filters: Vec, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct InstallFlagsGlobal { + pub module_urls: Vec, + pub args: Vec, + pub name: Option, + pub root: Option, + pub force: bool, + pub compile: bool, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum InstallFlags { + Local(InstallFlagsLocal, NpmInstallTargetFlags), + Global(InstallFlagsGlobal), +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum InstallFlagsLocal { + Add(AddFlags), + TopLevel(InstallTopLevelFlags), + Entrypoints(InstallEntrypointsFlags), +} + +#[derive(Clone, Debug, Default, Eq, PartialEq)] +pub struct CiFlags { + pub production: bool, + pub skip_types: bool, +} + +/// Overrides for the target OS and architecture when installing npm packages. +#[derive(Clone, Debug, Default, Eq, PartialEq)] +pub struct NpmInstallTargetFlags { + pub os: Option, + pub arch: Option, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct InstallTopLevelFlags { + pub lockfile_only: bool, + pub production: bool, + pub skip_types: bool, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct InstallEntrypointsFlags { + pub entrypoints: Vec, + pub lockfile_only: bool, + pub production: bool, + pub skip_types: bool, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct JSONReferenceFlags { + pub json: deno_core::serde_json::Value, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct JupyterFlags { + pub install: bool, + pub name: Option, + pub display: Option, + pub kernel: bool, + pub conn_file: Option, + pub force: bool, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct UninstallFlagsGlobal { + pub packages: Vec, + pub root: Option, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum UninstallKind { + Local(RemoveFlags), + Global(UninstallFlagsGlobal), +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct UninstallFlags { + pub kind: UninstallKind, +} + +#[derive(Clone, Debug, Default, Eq, PartialEq)] +pub struct LintFlags { + pub files: FileFlags, + pub rules: bool, + pub fix: bool, + pub maybe_rules_tags: Option>, + pub maybe_rules_include: Option>, + pub maybe_rules_exclude: Option>, + pub permit_no_files: bool, + pub json: bool, + pub compact: bool, +} + +impl LintFlags { + pub fn is_stdin(&self) -> bool { + let args = &self.files.include; + args.len() == 1 && args[0] == "-" + } +} + +#[derive(Clone, Debug, Eq, PartialEq, Default)] +pub struct ReplFlags { + pub eval_files: Option>, + pub eval: Option, + pub is_default_command: bool, + pub json: bool, +} + +#[derive(Clone, Debug, Eq, PartialEq, Default)] +pub struct RunFlags { + pub script: String, + pub bare: bool, + pub coverage_dir: Option, + pub print_task_list: bool, +} + +impl RunFlags { + pub fn new_default(script: String) -> Self { + Self { + script, + bare: false, + coverage_dir: None, + print_task_list: false, + } + } + + pub fn is_stdin(&self) -> bool { + self.script == "-" + } +} + +#[derive(Clone, Debug, Eq, PartialEq, Default)] +pub enum DenoXShimName { + #[default] + Dx, + Denox, + Dnx, + Other(String), +} + +impl DenoXShimName { + pub fn name(&self) -> &str { + match self { + Self::Dx => "dx", + Self::Denox => "denox", + Self::Dnx => "dnx", + Self::Other(name) => name, + } + } +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum XFlagsKind { + InstallAlias(DenoXShimName), + Command(XCommandFlags), + Print, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct XCommandFlags { + pub yes: bool, + pub command: String, + pub ignore_scripts: PackagesAllowedScripts, + /// When set via `--package`/`-p`, specifies the package to install + /// while `command` is the binary name to execute from that package. + pub package: Option, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct XFlags { + pub kind: XFlagsKind, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ServeFlags { + pub script: String, + pub port: u16, + pub host: String, + pub parallel: bool, + pub open_site: bool, +} + +impl ServeFlags { + pub fn new_default(script: String, port: u16, host: &str) -> Self { + Self { + script, + port, + host: host.to_owned(), + parallel: false, + open_site: false, + } + } +} + +#[derive(Clone, Default, Debug, Eq, PartialEq)] +pub struct WatchFlagsWithPaths { + pub hmr: bool, + pub paths: Vec, + pub no_clear_screen: bool, + pub exclude: Vec, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct TaskFlags { + pub cwd: Option, + pub task: Option, + pub is_run: bool, + pub recursive: bool, + /// Run the task in all workspace members, but not in the workspace root. + pub members: bool, + pub filter: Option, + pub eval: bool, + pub no_prefix: bool, + /// Maximum number of workspace tasks to run concurrently. Overrides the + /// `DENO_JOBS` env var and the `available_parallelism()` default. Only + /// meaningful for multi-task (`-r`/`--filter`) runs. + pub concurrency: Option, + /// Exit with code 0 instead of an error when the named task is not found. + pub if_present: bool, +} + +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +pub enum TestReporterConfig { + #[default] + Pretty, + Dot, + Junit, + Tap, +} + +#[derive(Clone, Debug, Default, Eq, PartialEq)] +pub struct TestFlags { + pub doc: bool, + pub no_run: bool, + pub coverage_dir: Option, + pub coverage_raw_data_only: bool, + /// Minimum coverage percentage (0-100) required when `--coverage` is set. + /// Overrides per-metric thresholds from `deno.json`. + pub coverage_threshold: Option, + pub clean: bool, + pub fail_fast: Option, + pub files: FileFlags, + pub parallel: bool, + pub permit_no_files: bool, + pub filter: Option, + pub shuffle: Option, + pub retry: u32, + pub repeats: u32, + /// Run only a subset of test files, as `(index, count)` with a 1-based + /// index. Used to split a run across machines, e.g. `--shard=2/3`. + pub shard: Option<(usize, usize)>, + pub trace_leaks: bool, + pub sanitize_ops: bool, + pub sanitize_resources: bool, + pub reporter: TestReporterConfig, + pub junit_path: Option, + pub hide_stacktraces: bool, + /// Run only test modules affected by files changed in git. + /// `None` when `--changed` is absent, `Some(None)` for `--changed` with no + /// value (uncommitted changes), `Some(Some(ref))` for `--changed=`. + pub changed: Option>, + /// Run only test modules that depend on the given source files (`--related`). + pub related: Vec, + pub update_snapshots: bool, +} + +#[derive(Clone, Debug, Eq, PartialEq, Default)] +pub enum SourceMapMode { + #[default] + None, + Inline, + Separate, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct TranspileFlags { + pub files: Vec, + pub output: Option, + pub output_dir: Option, + pub declaration: bool, + pub source_map: SourceMapMode, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct UpgradeFlags { + pub dry_run: bool, + pub force: bool, + pub release_candidate: bool, + pub canary: bool, + pub no_delta: bool, + pub version: Option, + pub output: Option, + pub version_or_hash_or_channel: Option, + pub checksum: Option, + pub pr: Option, + pub branch: Option, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct PublishFlags { + pub token: Option, + pub dry_run: bool, + pub allow_slow_types: bool, + pub allow_dirty: bool, + pub no_provenance: bool, + pub set_version: Option, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct PackFlags { + pub files: FileFlags, + pub output: Option, + pub dry_run: bool, + pub allow_slow_types: bool, + pub allow_dirty: bool, + pub set_version: Option, + pub no_source_maps: bool, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct HelpFlags { + pub help: String, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct CleanFlags { + pub except_paths: Vec, + pub dry_run: bool, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct BundleFlags { + pub entrypoints: Vec, + pub output_path: Option, + pub output_dir: Option, + pub external: Vec, + pub format: BundleFormat, + pub minify: bool, + pub keep_names: bool, + pub code_splitting: bool, + pub inline_imports: bool, + pub packages: PackageHandling, + pub sourcemap: Option, + pub platform: BundlePlatform, + pub watch: bool, + pub declaration: bool, +} + +impl From for BundleFlags { + fn from(value: BundleOptions) -> Self { + Self { + entrypoints: value.entrypoints, + output_path: value.output_path, + output_dir: value.output_dir, + external: value.external, + format: value.format, + minify: value.minify, + keep_names: value.keep_names, + code_splitting: value.code_splitting, + platform: value.platform, + watch: false, + sourcemap: value.sourcemap, + inline_imports: value.inline_imports, + packages: value.packages, + declaration: false, + } + } +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum DenoSubcommand { + Add(AddFlags), + Audit(AuditFlags), + ApproveScripts(ApproveScriptsFlags), + Remove(RemoveFlags), + Bench(BenchFlags), + Bundle(BundleFlags), + Cache(CacheFlags), + Check(CheckFlags), + Ci(CiFlags), + Clean(CleanFlags), + Compile(CompileFlags), + Completions(CompletionsFlags), + Desktop(DesktopFlags), + Coverage(CoverageFlags), + Deploy(DeployFlags), + Doc(DocFlags), + Eval(EvalFlags), + Fmt(FmtFlags), + Init(InitFlags), + Info(InfoFlags), + List(ListFlags), + Install(InstallFlags), + JSONReference(JSONReferenceFlags), + Jupyter(JupyterFlags), + Uninstall(UninstallFlags), + Link(LinkFlags), + Unlink(UnlinkFlags), + Lsp, + SyncTypes(SyncTypesFlags), + Lint(LintFlags), + Repl(ReplFlags), + Run(RunFlags), + Serve(ServeFlags), + Task(TaskFlags), + Test(TestFlags), + Transpile(TranspileFlags), + Outdated(OutdatedFlags), + Types, + Upgrade(UpgradeFlags), + Vendor, + Why(WhyFlags), + BumpVersion(VersionFlags), + Publish(PublishFlags), + Pack(PackFlags), + Help(HelpFlags), + X(XFlags), +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum OutdatedKind { + Update { + latest: bool, + interactive: bool, + lockfile_only: bool, + }, + PrintOutdated { + compatible: bool, + }, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct OutdatedFlags { + pub filters: Vec, + pub recursive: bool, + pub kind: OutdatedKind, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ApproveScriptsFlags { + pub lockfile_only: bool, + pub packages: Vec, +} + +impl DenoSubcommand { + pub fn is_run(&self) -> bool { + matches!(self, Self::Run(_)) + } + + // Returns `true` if the subcommand depends on testing infrastructure. + pub fn needs_test(&self) -> bool { + matches!( + self, + Self::Test(_) + | Self::Jupyter(_) + | Self::Repl(_) + | Self::Bench(_) + | Self::Lint(_) + | Self::Lsp + ) + } +} + +impl Default for DenoSubcommand { + fn default() -> DenoSubcommand { + DenoSubcommand::Repl(ReplFlags { + eval_files: None, + eval: None, + is_default_command: true, + json: false, + }) + } +} + +#[derive(Debug, Clone, Copy, Eq, PartialEq, Default)] +pub enum TypeCheckMode { + /// Type-check all modules. + All, + /// Skip type-checking of all modules. The default value for "deno run" and + /// several other subcommands. + #[default] + None, + /// Only type-check local modules. The default value for "deno test" and + /// several other subcommands. + Local, +} + +impl TypeCheckMode { + /// Gets if type checking will occur under this mode. + pub fn is_true(&self) -> bool { + match self { + Self::None => false, + Self::Local | Self::All => true, + } + } +} + +#[derive(Clone, Debug, Eq, PartialEq, Default)] +pub struct InternalFlags { + /// Used when the language server is configured with an + /// explicit cache option. + pub cache_path: Option, + /// Override the path to use for the node_modules directory. + pub root_node_modules_dir_override: Option, + /// Only reads to the lockfile instead of writing to it. + pub lockfile_skip_write: bool, + /// Set when running the desktop subcommand to use desktop type libs. + pub is_desktop: bool, + /// Set by `deno compile --bundle` when the bundled output contains + /// references that need to resolve against npm packages at runtime + /// (CJS dependencies, native addons). When true, the standalone + /// binary writer embeds (a subset of) the npm tree. Pure-ESM bundles + /// leave this false and ship a tiny binary. + pub compile_bundle_embed_node_modules: bool, + /// Absolute paths the bundle path-rewriter resolved at build time — + /// the on-disk files the compiled binary will require() at runtime. + /// The standalone binary writer maps these back to npm packages so + /// it can embed only what's reachable, instead of the whole tree. + pub compile_bundle_referenced_paths: Vec, + /// Force-enable bundle-style resolution config regardless of the + /// current subcommand. Set by `compile --bundle` so the bundle phase + /// of compilation pulls deep CJS files (e.g. `jiti.cjs`) into the + /// module graph the same way `deno bundle` does. + pub force_bundle_mode: bool, +} + +#[derive(Clone, Debug, Eq, PartialEq, Default)] +pub struct Flags { + pub initial_cwd: Option, + /// Vector of CLI arguments - these are user script arguments, all Deno + /// specific flags are removed. + pub argv: Vec, + pub subcommand: DenoSubcommand, + + pub frozen_lockfile: Option, + pub ca_stores: Option>, + pub ca_data: Option, + pub cache_blocklist: Vec, + pub cached_only: bool, + pub type_check_mode: TypeCheckMode, + pub config_flag: ConfigFlag, + pub node_modules_dir: Option, + pub node_modules_linker: Option, + pub vendor: Option, + pub enable_testing_features: bool, + pub ext: Option, + /// Flags that aren't exposed in the CLI, but are used internally. + pub internal: InternalFlags, + pub ignore: Vec, + pub import_map_path: Option, + pub env_file: Option>, + pub inspect_brk: Option, + pub inspect_wait: Option, + pub inspect: Option, + pub inspect_publish_uid: Option, + pub location: Option, + pub lock: Option, + pub log_level: Option, + pub minimum_dependency_age: Option, + pub no_remote: bool, + pub no_lock: bool, + pub no_npm: bool, + pub reload: bool, + pub seed: Option, + pub trace_ops: Option>, + pub unstable_config: UnstableConfig, + pub unsafely_ignore_certificate_errors: Option>, + pub v8_flags: Vec, + pub code_cache_enabled: bool, + pub permissions: PermissionFlags, + pub allow_scripts: PackagesAllowedScripts, + pub deny_scripts: Vec, + pub permission_set: Option, + pub eszip: bool, + pub node_conditions: Vec, + pub preload: Vec, + pub require: Vec, + pub tunnel: bool, + pub cpu_prof: Option, + /// Watch configuration for subcommands that support `--watch`. `paths` is + /// only populated for subcommands whose `--watch` accepts values. + pub watch: Option, +} + +#[derive(Clone, Debug, Eq, PartialEq, Default, Serialize, Deserialize)] +pub struct PermissionFlags { + pub allow_all: bool, + pub allow_env: Option>, + pub deny_env: Option>, + pub ignore_env: Option>, + pub allow_ffi: Option>, + pub deny_ffi: Option>, + pub allow_net: Option>, + pub deny_net: Option>, + pub allow_read: Option>, + pub deny_read: Option>, + pub ignore_read: Option>, + pub allow_run: Option>, + pub deny_run: Option>, + pub allow_sys: Option>, + pub deny_sys: Option>, + pub allow_write: Option>, + pub deny_write: Option>, + pub no_prompt: bool, + pub allow_import: Option>, + pub deny_import: Option>, +} + +impl PermissionFlags { + pub fn has_permission(&self) -> bool { + self.allow_all + || self.allow_env.is_some() + || self.deny_env.is_some() + || self.ignore_env.is_some() + || self.allow_ffi.is_some() + || self.deny_ffi.is_some() + || self.allow_net.is_some() + || self.deny_net.is_some() + || self.allow_read.is_some() + || self.deny_read.is_some() + || self.ignore_read.is_some() + || self.allow_run.is_some() + || self.deny_run.is_some() + || self.allow_sys.is_some() + || self.deny_sys.is_some() + || self.allow_write.is_some() + || self.deny_write.is_some() + || self.allow_import.is_some() + || self.deny_import.is_some() + } +} +impl Flags { + pub fn has_permission(&self) -> bool { + self.permissions.has_permission() + } + + pub fn has_permission_in_argv(&self) -> bool { + self.argv.iter().any(|arg| { + arg == "--allow-all" + || arg.starts_with("--allow-env") + || arg.starts_with("--deny-env") + || arg.starts_with("--allow-ffi") + || arg.starts_with("--deny-ffi") + || arg.starts_with("--allow-net") + || arg.starts_with("--deny-net") + || arg.starts_with("--allow-read") + || arg.starts_with("--deny-read") + || arg.starts_with("--allow-run") + || arg.starts_with("--deny-run") + || arg.starts_with("--allow-sys") + || arg.starts_with("--deny-sys") + || arg.starts_with("--allow-write") + || arg.starts_with("--deny-write") + }) + } +}