8364 lines
288 KiB
Rust
8364 lines
288 KiB
Rust
// 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);
|
||
}
|
||
}
|