Files
denoland-deno/cli/tools/desktop.rs
T

8364 lines
288 KiB
Rust
Raw Blame History

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