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;
const LAUFEY_VERSION: &str = env!("LAUFEY_VERSION");
const LAUFEY_NATIVE_TARGET: &str = env!("TARGET");
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
}
}
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> {
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,
});
}
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,
});
}
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()) {
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,
});
}
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,
});
}
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_command.is_some();
let entrypoint_code = if use_framework_hmr {
NOOP_ENTRYPOINT.to_string()
} else {
detection.entrypoint_code.clone()
};
log::info!("Detected {} framework", detection.name);
if !use_framework_hmr {
super::framework::run_build_command(detection, cwd)?;
}
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;
}
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());
}
}
}
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());
}
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();
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();
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,
};
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?;
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 {
let bundle_path = package_desktop_app(
&output_path,
&desktop_flags,
cli_options,
laufey_resolver,
)
.await?;
if let Some(format) = desktop_flags.compress.as_deref() {
make_self_extracting(&bundle_path, format, &desktop_flags)?;
}
let final_path = if let Some(dmg) = dmg_output.as_deref() {
let dmg_abs = cli_options.initial_cwd().join(dmg);
create_macos_dmg(&bundle_path, &dmg_abs)?;
dmg_abs
} else if let Some(appimage) = appimage_output.as_deref() {
let appimage_abs = cli_options.initial_cwd().join(appimage);
create_linux_appimage(
&bundle_path,
&appimage_abs,
desktop_flags.target.as_deref(),
)?;
appimage_abs
} else if let Some(deb) = deb_output.as_deref() {
let deb_abs = cli_options.initial_cwd().join(deb);
create_linux_deb(
&bundle_path,
&deb_abs,
&desktop_flags,
desktop_flags.target.as_deref(),
)?;
deb_abs
} else if let Some(rpm) = rpm_output.as_deref() {
let rpm_abs = cli_options.initial_cwd().join(rpm);
create_linux_rpm(
&bundle_path,
&rpm_abs,
&desktop_flags,
desktop_flags.target.as_deref(),
)?;
rpm_abs
} else if let Some(msi) = msi_output.as_deref() {
let msi_abs = cli_options.initial_cwd().join(msi);
create_windows_msi(
&bundle_path,
&msi_abs,
&desktop_flags,
desktop_flags.target.as_deref(),
)?;
msi_abs
} else {
bundle_path
};
let initial_cwd =
deno_path_util::url_from_directory_path(cli_options.initial_cwd())?;
log::info!(
"{} {}",
colors::green("Bundle"),
if let Ok(bundle_url) = deno_path_util::url_from_file_path(&final_path) {
crate::util::path::relative_specifier_path_for_display(
&initial_cwd,
&bundle_url,
)
} else {
final_path.display().to_string()
}
);
}
Ok(())
}
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),
}
}
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(())
}
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(())
}
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(())
}
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=") {
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=") {
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(())
}
fn register_deep_links_windows(
bundle_path: &Path,
schemes: &[String],
) -> Result<(), AnyError> {
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(())
}
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" => {
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))
}
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",
}
}
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)?;
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()?;
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)?;
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)?;
}
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(())
}
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(())
}
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])
}
}
#[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))
}
fn parse_dev_server_url(line: &str) -> Option<String> {
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())
}
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);
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"
)
})??;
tokio::spawn(async move {
while let Ok(Some(line)) = lines.next_line().await {
log::info!("{line}");
}
});
Ok((url, child))
}
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());
#[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()
});
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);
}
if desktop_flags.hmr {
cmd.env("DENO_DESKTOP_HMR", &source_abs);
}
let _dev_server_child = if desktop_flags.hmr
&& 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
};
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(),
)
.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
};
#[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")?
};
drop(mux_handle);
if !status.success() {
bail!("LAUFEY backend exited with status: {}", status);
}
Ok(())
}
const APP_DIR_MARKER: &str = ".deno-desktop-app";
const NOOP_ENTRYPOINT: &str =
"// @ts-nocheck\nawait new Promise<void>(() => {});\n";
fn reserve_app_dir(app_dir: &Path) -> Result<(), AnyError> {
let meta = match std::fs::symlink_metadata(app_dir) {
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()
);
}
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(())
}
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
}
}
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)?;
crate::tools::compile::copy_dir_all(&laufey_dir, &app_dir)?;
std::fs::write(app_dir.join(APP_DIR_MARKER), b"")?;
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);
}
let dylib_filename = parts.file_name;
let dest_dylib = app_dir.join(dylib_filename);
std::fs::copy(dylib_path, &dest_dylib)?;
validate_launcher_name(&app_name, "app name")?;
let launcher_path = app_dir.join(format!("{}.exe", app_name));
let staged_backend = app_dir.join(&laufey_binary_name);
if staged_backend != launcher_path {
std::fs::rename(&staged_backend, &launcher_path)?;
}
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)?;
}
}
}
register_deep_links(&app_dir, desktop_flags)?;
let _ = std::fs::remove_file(dylib_path);
Ok(app_dir)
}
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)?;
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)?;
crate::tools::compile::copy_dir_all(&laufey_dir, &app_dir)?;
std::fs::write(app_dir.join(APP_DIR_MARKER), b"")?;
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);
}
validate_launcher_name(&app_name, "app name")?;
let dest_dylib = app_dir.join(format!("{}.so", app_name));
std::fs::copy(dylib_path, &dest_dylib)?;
let launcher_path = app_dir.join(&app_name);
let staged_backend = app_dir.join(&laufey_binary_name);
if staged_backend != launcher_path {
std::fs::rename(&staged_backend, &launcher_path)?;
}
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(
&launcher_path,
std::fs::Permissions::from_mode(0o755),
)?;
}
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) => {
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());
}
}
}
}
}
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,
)?;
}
register_deep_links(&app_dir, desktop_flags)?;
let _ = std::fs::remove_file(dylib_path);
Ok(app_dir)
}
const LAUFEY_DEV_DIR_ENV: &str = "LAUFEY_DEV_DIR";
struct LaufeyBackendResolver {
http_client_provider: Arc<HttpClientProvider>,
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)
}
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()?;
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);
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 {
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)?;
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}"))?;
std::fs::write(
staging.path().join(".downloaded"),
format!("v{LAUFEY_VERSION}\n"),
)?;
if marker.exists() {
return Ok(dir);
}
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) {
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(),
));
}
#[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)
}
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
)
})?;
#[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()
)
})
}
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()
)
})
}
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"
};
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}"
)
}
fn parse_sha256sum(contents: &str, file: &str) -> Option<String> {
for line in contents.lines() {
let mut parts = line.split_whitespace();
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);
archive.set_preserve_permissions(false);
for entry in archive.entries()? {
let mut entry = entry?;
let entry_path = entry.path()?.into_owned();
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()
);
}
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);
if let Ok(meta) = std::fs::symlink_metadata(&dest_path)
&& meta.file_type().is_file()
{
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))?;
for i in 0..archive.len() {
let mut entry = archive.by_index(i)?;
let Some(rel_path) = entry.enclosed_name() else {
bail!("refusing zip entry with unsafe path: {}", entry.name());
};
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()
);
}
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;
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(())
}
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)
}
fn laufey_target_for(desktop_flags: &DesktopFlags) -> &str {
desktop_flags
.target
.as_deref()
.unwrap_or(LAUFEY_NATIVE_TARGET)
}
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)
}
fn locate_dev_backend_binary(laufey: &Path, backend: &str) -> Option<PathBuf> {
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())
}
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())
}
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())
}
fn validate_bundle_identifier(id: &str) -> Result<(), AnyError> {
if id.is_empty() {
bail!("bundle identifier is empty");
}
if id.len() > 155 {
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(())
}
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();
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(())
}
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()
)
})?;
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),
);
plist::to_file_xml(plist_path, &dict)
.with_context(|| format!("failed to write {}", plist_path.display()))?;
Ok(())
}
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,
);
let bundle_id = read_bundle_identifier(app_bundle)?;
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") {
codesign_one(&path, identity, None, None)?;
}
}
}
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;
}
if !is_macho_file(&path) {
continue;
}
codesign_one(&path, identity, None, Some(&bundle_id))?;
}
}
let browser_entitlements = locate_browser_entitlements(app_bundle);
codesign_one(app_bundle, identity, browser_entitlements.as_deref(), None)?;
Ok(())
}
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)
}
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),
0xcafebabe | 0xbebafeca | 0xcafebabf | 0xbfbafeca
| 0xfeedface | 0xcefaedfe | 0xfeedfacf | 0xcffaedfe
)
}
fn locate_helper_entitlements(helper_app: &Path) -> Option<PathBuf> {
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())
}
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())
}
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(())
}
#[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)
}
#[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");
let _parked = park_bundle_cache_dir(&macos_dir);
for entry in std::fs::read_dir(&macos_dir)?.flatten() {
let path = entry.path();
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(())
}
#[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);
}
}
}
#[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;
}
let app_parent = macos_dir.parent()?.parent()?.parent()?;
let parked_at = app_parent.join(".laufey-harmonize-parked");
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,
}
}
#[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;
};
let stderr = String::from_utf8_lossy(&output.stderr);
stderr
.lines()
.filter_map(|l| l.strip_prefix("Identifier="))
.any(|id| id.trim() == expected)
}
#[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
}
}
#[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())
}
fn validate_launcher_name(name: &str, kind: &str) -> Result<(), AnyError> {
if name.is_empty() {
bail!("invalid {kind}: name is empty");
}
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(())
}
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,
})
}
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()
}
}
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();
validate_launcher_name(&app_name, "app name")?;
let app_bundle = parts.parent.join(format!("{}.app", app_name));
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()
);
}
reserve_app_dir(&app_bundle)?;
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)?;
std::fs::write(resources_dir.join(APP_DIR_MARKER), b"")?;
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_cef_bloat(&contents_dir);
let dest_dylib =
macos_dir.join(macos_runtime_dylib_name(backend, &laufey_exe_stem));
std::fs::copy(dylib_path, &dest_dylib)?;
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}")
}
};
let info_plist = render_macos_info_plist(
&app_name,
&bundle_id,
&laufey_executable_name,
desktop_flags.icon.is_some(),
);
std::fs::write(contents_dir.join("Info.plist"), info_plist)?;
rewrite_cef_helper_bundle_ids(&contents_dir, &bundle_id)?;
register_deep_links(&app_bundle, desktop_flags)?;
let codesign_identity = desktop_flags.codesign_identity.as_deref().or(
if cfg!(target_os = "macos") {
Some("-")
} else {
None
},
);
if let Some(identity) = codesign_identity {
codesign_macos_bundle(&app_bundle, identity)?;
}
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)?;
}
}
}
let _ = std::fs::remove_file(dylib_path);
Ok(app_bundle)
}
fn render_macos_info_plist(
app_name: &str,
bundle_id: &str,
executable_name: &str,
has_icon: bool,
) -> String {
format!(
r#"<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>en</string>
<key>CFBundleExecutable</key>
<string>{executable_name}</string>
<key>CFBundleIconFile</key>
<string>{icon_file}</string>
<key>CFBundleIdentifier</key>
<string>{bundle_id}</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>{app_name}</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>1.0</string>
<key>CFBundleVersion</key>
<string>1.0.0</string>
<key>LSMinimumSystemVersion</key>
<string>10.15</string>
<key>NSHighResolutionCapable</key>
<true/>
<key>NSPrincipalClass</key>
<string>LaufeyApplication</string>
<key>NSSupportsAutomaticGraphicsSwitching</key>
<true/>
<key>NSAppTransportSecurity</key>
<dict>
<key>NSAllowsLocalNetworking</key>
<true/>
</dict>
<key>NSMicrophoneUsageDescription</key>
<string>{app_name} requires access to the microphone</string>
<key>NSCameraUsageDescription</key>
<string>{app_name} requires access to the camera</string>
<key>NSAudioCaptureUsageDescription</key>
<string>{app_name} requires access to audio capture</string>
<key>NSBluetoothAlwaysUsageDescription</key>
<string>{app_name} requires access to Bluetooth</string>
<key>NSBluetoothPeripheralUsageDescription</key>
<string>{app_name} requires access to Bluetooth</string>
</dict>
</plist>
"#,
app_name = app_name,
bundle_id = bundle_id,
executable_name = executable_name,
icon_file = if has_icon { "AppIcon" } else { "" },
)
}
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());
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())
})?;
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")?;
if !status.success() {
bail!("hdiutil failed to create DMG at {}", dmg_path.display());
}
Ok(())
}
const APPIMAGE_RUNTIME_X86_64: &[u8] =
include_bytes!("appimage_runtime/runtime-x86_64");
const APPIMAGE_RUNTIME_AARCH64: &[u8] =
include_bytes!("appimage_runtime/runtime-aarch64");
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,
];
fn appimage_runtime_for_target(
target: Option<&str>,
) -> Result<&'static [u8], AnyError> {
let arch = target
.and_then(|t| t.split('-').next())
.unwrap_or(std::env::consts::ARCH);
match arch {
"x86_64" => Ok(APPIMAGE_RUNTIME_X86_64),
"aarch64" => Ok(APPIMAGE_RUNTIME_AARCH64),
other => bail!(
"No bundled AppImage runtime for arch '{other}'; supported: x86_64, aarch64"
),
}
}
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,
}
}
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(())
}
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);
push_dir_contents_to_squashfs(&mut writer, app_dir)?;
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),
)?;
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),
)?;
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),
)?;
let mut squashfs = Cursor::new(Vec::<u8>::new());
writer
.write(&mut squashfs)
.context("Failed to write SquashFS image")?;
drop(writer);
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(())
}
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"),
];
const LINUX_PACKAGE_VERSION: &str = "1.0.0";
struct LinuxPackageMeta {
package: String,
app_name: String,
version: String,
maintainer: String,
summary: String,
identifier: String,
}
fn debian_package_name(app_name: &str) -> String {
let mut out = String::with_capacity(app_name.len());
for c in app_name.to_lowercase().chars() {
if c.is_ascii_alphanumeric() || matches!(c, '+' | '.' | '-') {
out.push(c);
} else {
out.push('-');
}
}
let trimmed = out.trim_start_matches(|c: char| !c.is_ascii_alphanumeric());
let trimmed = trimmed.trim_end_matches('-');
if trimmed.len() < 2 {
"app".to_string()
} else {
trimmed.to_string()
}
}
fn linux_package_meta(
app_dir: &Path,
desktop_flags: &DesktopFlags,
) -> LinuxPackageMeta {
let app_name = app_dir
.file_name()
.map(|s| s.to_string_lossy().into_owned())
.unwrap_or_else(|| "App".to_string());
let package = debian_package_name(&app_name);
let identifier = desktop_flags
.identifier
.clone()
.unwrap_or_else(|| format!("com.deno.desktop.{package}"));
LinuxPackageMeta {
summary: format!("{app_name} desktop application"),
maintainer: format!("{app_name} <noreply@deno.com>"),
version: LINUX_PACKAGE_VERSION.to_string(),
package,
app_name,
identifier,
}
}
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"
),
}
}
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"
),
}
}
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,
)
}
fn create_linux_deb(
app_dir: &Path,
deb_path: &Path,
desktop_flags: &DesktopFlags,
target: Option<&str>,
) -> Result<(), AnyError> {
let meta = linux_package_meta(app_dir, desktop_flags);
let arch = debian_arch_for_target(target)?;
let data_tar_gz = build_deb_data_tar(app_dir, &meta)?;
let installed_size_kib = data_tar_gz.installed_size_kib;
let depends = CEF_RUNTIME_DEPS
.iter()
.map(|(_, pkg)| *pkg)
.collect::<Vec<_>>()
.join(", ");
let control = format!(
"Package: {package}\n\
Version: {version}\n\
Architecture: {arch}\n\
Maintainer: {maintainer}\n\
Installed-Size: {size}\n\
Depends: {depends}\n\
Section: utils\n\
Priority: optional\n\
Description: {summary}\n",
package = meta.package,
version = meta.version,
maintainer = meta.maintainer,
size = installed_size_kib,
summary = meta.summary,
);
let control_tar_gz = build_deb_control_tar(&control)?;
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(())
}
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);
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");
let mode = b"100644";
header[40..40 + mode.len()].copy_from_slice(mode);
let size = data.len().to_string();
header[48..48 + size.len()].copy_from_slice(size.as_bytes());
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>,
installed_size_kib: u64,
}
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(())
};
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)?;
}
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[..])?;
}
}
{
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(),
)?;
}
{
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[..],
)?;
}
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),
})
}
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)
}
fn create_linux_rpm(
app_dir: &Path,
rpm_path: &Path,
desktop_flags: &DesktopFlags,
target: Option<&str>,
) -> Result<(), AnyError> {
let meta = linux_package_meta(app_dir, desktop_flags);
let arch = rpm_arch_for_target(target)?;
let config = rpm::BuildConfig::default()
.compression(rpm::CompressionType::Zstd)
.source_date(0u32);
let mut builder = rpm::PackageBuilder::new(
&meta.package,
&meta.version,
"MIT",
arch,
&meta.summary,
);
builder.using_config(config);
builder.description(&meta.summary);
builder.vendor(&meta.maintainer);
builder
.with_dir(app_dir, format!("/usr/lib/{}", meta.package), |o| o)
.context("failed to add app directory to rpm")?;
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")?;
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")?;
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")?;
}
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(())
}
const MSI_GUID_NAMESPACE: uuid::Uuid =
uuid::Uuid::from_u128(0x6f1d3c8a_4b2e_4f5a_9c7d_8e0f1a2b3c4d);
fn msi_guid(uuid: uuid::Uuid) -> String {
format!("{{{}}}", uuid.as_hyphenated().to_string().to_uppercase())
}
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()))
}
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"
),
}
}
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()
}
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}")
}
}
}
struct MsiFile {
key: String,
component: String,
file_name: String,
size: u64,
abs_path: PathBuf,
}
fn create_windows_msi(
app_dir: &Path,
msi_path: &Path,
desktop_flags: &DesktopFlags,
target: Option<&str>,
) -> Result<(), AnyError> {
use msi::CodePage;
use msi::Column;
use msi::Insert;
use msi::Package;
use msi::PackageType;
use msi::Value;
let app_name = app_dir
.file_name()
.map(|s| s.to_string_lossy().into_owned())
.unwrap_or_else(|| "App".to_string());
let version = "1.0.0";
let identifier = desktop_flags
.identifier
.clone()
.unwrap_or_else(|| format!("com.deno.desktop.{}", app_name.to_lowercase()));
let manufacturer = "Deno";
let arch = msi_arch_for_target(target)?;
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()));
}
}
}
rel_files.sort();
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}"));
}
let pf_folder = "ProgramFiles64Folder";
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
)),
]);
}
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");
}
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()));
if shortcut_target.is_some() {
directory_rows.push(vec![
Value::Str("ProgramMenuFolder".to_string()),
Value::Str("TARGETDIR".to_string()),
Value::Str(".".to_string()),
]);
}
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();
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, Value::Null, Value::Int(FILE_VITAL),
Value::Int(1 + i as i32), ]
})
.collect();
let cab_bytes = build_msi_cabinet(&files)?;
let mut cursor = std::io::Cursor::new(Vec::<u8>::new());
let mut package = Package::create(PackageType::Installer, &mut cursor)?;
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");
summary.set_uuid(uuid::Uuid::new_v5(
&MSI_GUID_NAMESPACE,
format!("{identifier}\0package:{version}").as_bytes(),
));
summary.set_word_count(2);
summary.set_page_count(200);
summary.set_creation_time(
std::time::UNIX_EPOCH + std::time::Duration::from_secs(1_577_836_800),
);
}
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(),
],
)?;
}
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()),
Value::Str(format!("[#{launcher_key}]")),
Value::Null, Value::Null, Value::Null, Value::Null, Value::Null, Value::Null, Value::Str("INSTALLDIR".to_string()), ]))?;
}
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),
],
vec![
Value::Str("ALLUSERS".to_string()),
Value::Str("1".to_string()),
],
]))?;
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() {
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(),
),
)?;
{
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())
})?;
msi_sort_tables_by_string_id(msi_path).with_context(|| {
format!("Failed to finalize .msi at {}", msi_path.display())
})?;
Ok(())
}
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
}
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();
}
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
}
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()
};
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
};
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()]; {
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)
};
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)?;
}
}
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; 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;
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);
}
for raw in &names {
let demangled = msi_demangle_stream_name(raw);
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(())
}
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")?;
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())
}
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");
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());
}
}
}
let _ = std::fs::remove_file(cef_libraries.join("libvk_swiftshader.dylib"));
let _ = std::fs::remove_file(cef_libraries.join("vk_swiftshader_icd.json"));
}
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");
}
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(())
}
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
);
&[]
}
}
}
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;
let header_size = 6 + (count as u32) * 16;
let mut buf = Vec::new();
buf.write_all(&0u16.to_le_bytes())?; buf.write_all(&1u16.to_le_bytes())?; buf.write_all(&count.to_le_bytes())?;
let mut data_offset = header_size;
for (size, data) in &images {
let dim = if *size >= 256 { 0u8 } else { *size as u8 };
buf.push(dim); buf.push(dim); buf.push(0); buf.push(0); buf.write_all(&1u16.to_le_bytes())?; buf.write_all(&32u16.to_le_bytes())?; buf.write_all(&(data.len() as u32).to_le_bytes())?; buf.write_all(&data_offset.to_le_bytes())?; data_offset += data.len() as u32;
}
for (_, data) in &images {
buf.write_all(data)?;
}
std::fs::write(ico_path, &buf)?;
Ok(())
}
#[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;
unsafe extern "C" {
fn responsibility_spawnattrs_setdisclaim(
attrs: *mut libc::posix_spawnattr_t,
disclaim: libc::c_int,
) -> libc::c_int;
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 {
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) {
if !self.exited {
unsafe {
libc::kill(self.pid, libc::SIGKILL);
}
}
}
}
type SpawnArgs = (CString, Vec<CString>, Vec<CString>, Option<CString>);
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());
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> {
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::*;
#[test]
fn macos_info_plist_includes_principal_class_and_tcc_usage_strings() {
let plist = render_macos_info_plist(
"Deno Demo",
"com.deno.demo",
"laufey_webview",
true,
);
assert!(plist.contains("<string>LaufeyApplication</string>"));
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() {
assert_eq!(
macos_runtime_dylib_name("webview", "laufey_webview"),
"libruntime.dylib"
);
assert_eq!(
macos_runtime_dylib_name("cef", "laufey_cef"),
"laufey_cef.dylib"
);
}
#[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() {
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"));
assert!(!url.contains(' '));
}
#[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() {
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")
);
}
#[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_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
);
}
#[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());
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)"
);
}
#[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() {
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}"
);
}
#[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() {
let p = std::path::PathBuf::from("/tmp/libdenort.so");
let parts = dylib_parts(&p).expect("parts");
assert_eq!(parts.file_name, "libdenort.so");
assert_eq!(parts.app_name, "libdenort");
}
#[test]
fn dylib_parts_rejects_degenerate_inputs() {
assert!(dylib_parts(std::path::Path::new("/")).is_err());
let empty = std::path::Path::new("");
let _ = dylib_parts(empty); }
#[test]
fn appimage_runtime_target_arch_lookup() {
assert!(
appimage_runtime_for_target(Some("x86_64-unknown-linux-gnu")).is_ok()
);
assert!(
appimage_runtime_for_target(Some("aarch64-unknown-linux-gnu")).is_ok()
);
}
#[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}"
);
}
#[test]
fn icns_ostypes_known_sizes() {
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());
}
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() {
fn ustar_block(name: &str, body: &[u8]) -> Vec<u8> {
let mut hdr = [0u8; 512];
let nb = name.as_bytes();
hdr[..nb.len()].copy_from_slice(nb);
hdr[100..108].copy_from_slice(b"000644 \0");
hdr[108..116].copy_from_slice(b"000000 \0");
hdr[116..124].copy_from_slice(b"000000 \0");
let sz = format!("{:011o} ", body.len());
hdr[124..136].copy_from_slice(sz.as_bytes());
hdr[136..148].copy_from_slice(b"00000000000 ");
hdr[148..156].copy_from_slice(b" ");
hdr[156] = b'0';
hdr[257..263].copy_from_slice(b"ustar\0");
hdr[263..265].copy_from_slice(b"00");
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);
let pad = (512 - body.len() % 512) % 512;
out.extend(std::iter::repeat_n(0u8, pad));
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}"
);
assert!(!tmp.path().parent().unwrap().join("escape.txt").exists());
}
#[test]
fn extract_tar_rejects_symlink_escape() {
let tmp = tempfile::tempdir().unwrap();
let gz = make_tar_gz_symlink("foo", "../../etc/passwd");
let _ = extract_laufey_archive("evil.tar.gz", &gz, tmp.path());
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"
);
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();
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;
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();
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"));
}
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() {
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() {
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");
}
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| {
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}"
);
assert!(!std::path::Path::new("/etc/escape.txt").exists());
}
#[test]
fn extract_zip_rejects_parent_traversal() {
use zip::write::SimpleFileOptions;
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| {
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}"
);
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"
);
}
#[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() {
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();
assert_eq!(
read_plist_string(&tmp.path().join("missing.plist"), "K"),
None
);
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);
}
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",
);
let other = fw.join("Other.app/Contents");
std::fs::create_dir_all(&other).unwrap();
write_helper_plist(&other.join("Info.plist"), "com.example.other");
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"
);
assert_eq!(
read_bundle_id(&fw.join("Other.app/Contents/Info.plist")),
"com.example.other"
);
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() {
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");
}
#[test]
fn locate_dev_webview_returns_first_existing_candidate() {
let tmp = tempfile::tempdir().unwrap();
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();
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() {
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()));
}
fn fake_png(label: &[u8]) -> Vec<u8> {
let mut v = Vec::with_capacity(8 + label.len());
v.extend_from_slice(b"\x89PNG\r\n\x1a\n"); 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();
assert_eq!(read_u16(&buf, 0), 0);
assert_eq!(read_u16(&buf, 2), 1);
assert_eq!(read_u16(&buf, 4), 3);
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());
assert_eq!(off0, 54);
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");
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();
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"));
assert!(!out.exists());
}
#[test]
fn ico_data_offsets_are_monotonically_increasing() {
let cwd = tempfile::tempdir().unwrap();
let a = fake_png(b"aaaaaaaaaa"); 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");
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");
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"));
}
#[test]
fn debian_package_name_sanitizes() {
assert_eq!(debian_package_name("MyApp"), "myapp");
assert_eq!(debian_package_name("My App!"), "my-app");
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");
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());
}
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();
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,
all_targets: false,
identifier: None,
deep_links: Vec::new(),
codesign_identity: None,
inspect_renderer: None,
compress: None,
exclude_unused_npm: false,
}
}
fn gunzip(data: &[u8]) -> Vec<u8> {
let mut dec = flate2::read::GzDecoder::new(data);
let mut out = Vec::new();
dec.read_to_end(&mut out).unwrap();
out
}
#[test]
fn appimage_uses_runtime_supported_zstd_squashfs() {
let tmp = tempfile::tempdir().unwrap();
let app_dir = fake_linux_app_dir(tmp.path(), "MyApp");
let appimage_path = tmp.path().join("MyApp.AppImage");
let target = Some("x86_64-unknown-linux-gnu");
create_linux_appimage(&app_dir, &appimage_path, target).unwrap();
let runtime_offset =
appimage_runtime_for_target(target).unwrap().len() as u64;
let appimage =
std::io::BufReader::new(std::fs::File::open(&appimage_path).unwrap());
let filesystem = backhand::FilesystemReader::from_reader_with_offset(
appimage,
runtime_offset,
)
.unwrap();
assert_eq!(
filesystem.compressor,
backhand::compression::Compressor::Zstd
);
}
#[test]
fn deb_has_ar_members_and_control_fields() {
let tmp = tempfile::tempdir().unwrap();
let app_dir = fake_linux_app_dir(tmp.path(), "MyApp");
let deb = tmp.path().join("MyApp.deb");
let flags = empty_desktop_flags();
create_linux_deb(&app_dir, &deb, &flags, Some("x86_64-unknown-linux-gnu"))
.unwrap();
let bytes = std::fs::read(&deb).unwrap();
assert!(bytes.starts_with(b"!<arch>\n"), "missing ar global header");
let mut members: Vec<(String, Vec<u8>)> = Vec::new();
let mut pos = 8; 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()));
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");
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: "));
let data_tar = gunzip(&members[2].1);
let mut archive = tar::Archive::new(&data_tar[..]);
let mut paths: Vec<String> = Vec::new();
let mut bin_link_target: Option<String> = None;
for entry in archive.entries().unwrap() {
let entry = entry.unwrap();
let p = entry.path().unwrap().to_string_lossy().into_owned();
if p == "usr/bin/myapp" {
bin_link_target = entry
.link_name()
.unwrap()
.map(|l| l.to_string_lossy().into_owned());
}
paths.push(p);
}
assert!(paths.iter().any(|p| p == "usr/lib/myapp/MyApp"));
assert!(paths.iter().any(|p| p == "usr/lib/myapp/MyApp.so"));
assert!(
paths
.iter()
.any(|p| p == "usr/share/applications/myapp.desktop")
);
assert!(
paths
.iter()
.any(|p| p == "usr/share/icons/hicolor/512x512/apps/myapp.png")
);
assert_eq!(
bin_link_target.as_deref(),
Some("../lib/myapp/MyApp"),
"/usr/bin symlink must point at the staged launcher"
);
}
#[test]
fn rpm_parses_with_expected_metadata() {
let tmp = tempfile::tempdir().unwrap();
let app_dir = fake_linux_app_dir(tmp.path(), "MyApp");
let rpm_path = tmp.path().join("MyApp.rpm");
let flags = empty_desktop_flags();
create_linux_rpm(
&app_dir,
&rpm_path,
&flags,
Some("aarch64-unknown-linux-gnu"),
)
.unwrap();
let bytes = std::fs::read(&rpm_path).unwrap();
let pkg = rpm::Package::parse(&mut &bytes[..]).unwrap();
assert_eq!(pkg.metadata.get_name().unwrap(), "myapp");
assert_eq!(pkg.metadata.get_version().unwrap(), "1.0.0");
assert_eq!(pkg.metadata.get_arch().unwrap(), "aarch64");
let requires: Vec<String> = pkg
.metadata
.get_requires()
.unwrap()
.into_iter()
.map(|d| d.name)
.collect();
assert!(
requires.iter().any(|r| r == "libgtk-3.so.0()(64bit)"),
"rpm Requires must carry CEF sonames with the 64-bit ELF class suffix, got: {requires:?}"
);
let files: Vec<String> = pkg
.metadata
.get_file_paths()
.unwrap()
.into_iter()
.map(|p| p.to_string_lossy().into_owned())
.collect();
assert!(files.iter().any(|f| f == "/usr/lib/myapp/MyApp"));
assert!(files.iter().any(|f| f == "/usr/bin/myapp"));
assert!(
files
.iter()
.any(|f| f == "/usr/share/applications/myapp.desktop")
);
}
#[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");
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");
assert_eq!(msi_short_name(2, "LICENSE", false), "F2");
}
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();
std::fs::write(app_dir.join(format!("{app_name}.exe")), b"MZfake-exe")
.unwrap();
std::fs::write(app_dir.join(format!("{app_name}.dll")), b"MZfake-dll")
.unwrap();
let locales = app_dir.join("locales");
std::fs::create_dir_all(&locales).unwrap();
std::fs::write(locales.join("en-US.pak"), b"pakdata").unwrap();
app_dir
}
#[test]
fn msi_parses_with_expected_tables_and_payload() {
use std::io::Read as _;
let tmp = tempfile::tempdir().unwrap();
let app_dir = fake_windows_app_dir(tmp.path(), "MyApp");
let msi_path = tmp.path().join("MyApp.msi");
let flags = empty_desktop_flags();
create_windows_msi(
&app_dir,
&msi_path,
&flags,
Some("x86_64-pc-windows-msvc"),
)
.unwrap();
let mut package = msi::open(&msi_path).unwrap();
assert_eq!(package.summary_info().arch(), Some("x64"));
assert_eq!(package.database_codepage(), msi::CodePage::Windows1252);
assert_eq!(
package.summary_info().codepage(),
msi::CodePage::Windows1252
);
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()));
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"));
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");
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:?}");
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");
let key_for_dll = files
.iter()
.find(|(_, name, _)| name.ends_with("MyApp.dll"))
.map(|(key, _, _)| key.clone())
.unwrap();
let mut cab_bytes = Vec::new();
package
.read_stream("appcab")
.unwrap()
.read_to_end(&mut cab_bytes)
.unwrap();
let mut cabinet =
cab::Cabinet::new(std::io::Cursor::new(cab_bytes)).unwrap();
let mut content = Vec::new();
cabinet
.read_file(&key_for_dll)
.unwrap()
.read_to_end(&mut content)
.unwrap();
assert_eq!(content, b"MZfake-dll");
}
#[test]
fn msi_tables_sorted_by_string_id() {
use std::io::Read as _;
let tmp = tempfile::tempdir().unwrap();
let app_dir = fake_windows_app_dir(tmp.path(), "SortApp");
let msi_path = tmp.path().join("SortApp.msi");
create_windows_msi(
&app_dir,
&msi_path,
&empty_desktop_flags(),
Some("x86_64-pc-windows-msvc"),
)
.unwrap();
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:?}"
);
let dir_keys = read_ids(&mut comp, "\0Directory");
let dir_rows = dir_keys.len() / 3; 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();
let app_dir = fake_windows_app_dir(tmp.path(), "MyApp");
let msi_path = tmp.path().join("MyApp.msi");
create_windows_msi(
&app_dir,
&msi_path,
&empty_desktop_flags(),
Some("x86_64-pc-windows-msvc"),
)
.unwrap();
let mut package = msi::open(&msi_path).unwrap();
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"));
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("[#"));
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"));
}
#[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() {
for reserved in &["http", "https", "file", "ftp", "ws", "wss"] {
assert!(
validate_url_scheme(reserved).is_err(),
"{reserved:?} must be rejected as reserved"
);
}
}
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();
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"]);
}
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();
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();
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();
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}"
);
}
assert!(
script.contains("MyApp.exe"),
"handler should invoke the launcher; got:\n{script}"
);
}
#[test]
fn register_deep_links_lowercases_and_dispatches_by_target() {
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() {
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() {
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() {
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"));
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());
}
#[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);
assert_eq!(flags.backend.as_deref(), None);
}
}