fix: update deps, fix windows

This commit is contained in:
zhom committed 2026-10-11 15:45:02 +04:00
1 parent c1183321f5
commit d97c33c66b
10 files changed
+691 -887

No files matched your search

Generated
+393 -538
View File
File diff suppressed because it is too large. Load diff
+8 -7
View File
@@ -7,6 +7,7 @@ license = "MIT"
authors = ["zhom <contact@donutbrowser.com>"]
version = "0.2.2"
edition = "2021"
rust-version = "1.93.1"
[dependencies]
clap = { version = "4.6", features = ["derive"] }
@@ -22,22 +23,22 @@ reqwest = { version = "0.13", default-features = false, features = [
"http2",
] }
serde = { version = "1.0.229", features = ["derive"] }
serde_json = "1.0.151"
uuid = { version = "1.24", features = ["v4"] }
zip = { version = "8", default-features = false, features = ["deflate", "zstd"] }
serde_json = "1.0.152"
uuid = { version = "1.28", features = ["v4"] }
zip = { version = "9", default-features = false, features = ["deflate", "zstd"] }
directories = "6"
anyhow = "1.0.104"
walkdir = "2.5"
futures-util = "0.3.33"
futures-util = "0.3.34"
tempfile = "3.27"
indicatif = "0.18.6"
log = "0.4.33"
log = "0.4.34"
env_logger = "0.11.11"
indicatif-log-bridge = "0.2.3"
console = "0.16.4"
console = "0.16.6"
tar = "0.4.46"
# sevenz-rust is unmaintained; sevenz-rust2 is the maintained fork.
sevenz-rust2 = "0.21"
sevenz-rust2 = "0.24"
lzma-rs = "0.3"
[dev-dependencies]
+1 -1
View File
@@ -1,6 +1,6 @@
# Banderole
Create cross-platform single-executables for Node.js projects. Windows is not supported.
Create cross-platform single-executables for Node.js projects on Linux, macOS and Windows (x64 and arm64).
Banderole bundles your Node.js app, all dependencies, and a portable Node binary into a single native executable. On first launch, it unpacks to a cache directory for fast subsequent executions.
+89 -39
View File
@@ -42,8 +42,6 @@ const NODE_COMPRESSION_LEVEL: i64 = 12;
/// automatically-generated name is used.
/// * `custom_name` – optional custom name for the executable.
/// * `no_compression` – disable compression for faster bundling (useful for testing).
///
/// The implementation uses a simpler, more reliable approach based on Playwright's bundling strategy.
pub async fn bundle_project(
project_path: PathBuf,
output_path: Option<PathBuf>,
@@ -165,14 +163,14 @@ pub async fn bundle_project(
// high level there turns bundling a large project into minutes of compression. The
// Node binary is a fixed ~105 MB that dominates the output size, so it is worth
// compressing hard; a couple of seconds buys ~25% off the dominant term.
let opts: zip::write::FileOptions<'static, ()> = if no_compression {
let opts: zip::write::SimpleFileOptions = if no_compression {
zip::write::FileOptions::default().compression_method(zip::CompressionMethod::Stored)
} else {
zip::write::FileOptions::default()
.compression_method(zip::CompressionMethod::Zstd)
.compression_level(Some(APP_COMPRESSION_LEVEL))
};
let node_opts: zip::write::FileOptions<'static, ()> = if no_compression {
let node_opts: zip::write::SimpleFileOptions = if no_compression {
opts
} else {
opts.compression_level(Some(NODE_COMPRESSION_LEVEL))
@@ -272,7 +270,7 @@ fn count_files_in_dir(dir: &Path, exclude_node_modules: bool, follow_links: bool
fn add_node_runtime_to_zip<W>(
zip: &mut ZipWriter<W>,
node_root: &Path,
opts: zip::write::FileOptions<'static, ()>,
opts: zip::write::SimpleFileOptions,
progress: Option<&ProgressBar>,
) -> Result<()>
where
@@ -356,7 +354,7 @@ fn bundle_dependencies<W>(
project_path: &Path,
source_dir: &Path,
_package_value: &Value,
opts: zip::write::FileOptions<'static, ()>,
opts: zip::write::SimpleFileOptions,
progress: Option<&ProgressBar>,
) -> Result<()>
where
@@ -411,7 +409,7 @@ struct DependenciesResult {
fn find_and_bundle_dependencies<W>(
zip: &mut ZipWriter<W>,
project_path: &Path,
opts: zip::write::FileOptions<'static, ()>,
opts: zip::write::SimpleFileOptions,
progress: Option<&ProgressBar>,
) -> Result<DependenciesResult>
where
@@ -429,7 +427,7 @@ where
entries.flatten().any(|entry| {
if entry.file_type().ok().is_some_and(|ft| ft.is_symlink()) {
if let Ok(target) = fs::read_link(entry.path()) {
let target_str = target.to_string_lossy();
let target_str = target.to_string_lossy().replace('\\', "/");
target_str.contains("/.pnpm/") && target_str.starts_with("../")
} else {
false
@@ -590,7 +588,7 @@ fn detect_package_manager(node_modules_path: &Path, project_path: &Path) -> Pack
for entry in entries.flatten() {
if entry.file_type().ok().is_some_and(|ft| ft.is_symlink()) {
if let Ok(target) = fs::read_link(entry.path()) {
let target_str = target.to_string_lossy();
let target_str = target.to_string_lossy().replace('\\', "/");
if target_str.contains("/.pnpm/") {
return PackageManager::Pnpm;
}
@@ -619,7 +617,7 @@ fn detect_package_manager(node_modules_path: &Path, project_path: &Path) -> Pack
fn bundle_pnpm_dependencies<W>(
zip: &mut ZipWriter<W>,
project_path: &Path,
opts: zip::write::FileOptions<'static, ()>,
opts: zip::write::SimpleFileOptions,
progress: Option<&ProgressBar>,
) -> Result<()>
where
@@ -711,7 +709,7 @@ where
let file_path = node_modules_path.join(file_name);
if file_path.exists() {
let dest_path = Path::new("app/node_modules").join(file_name);
zip.start_file(dest_path.to_string_lossy().as_ref(), opts)?;
zip.start_file(zip_entry_name(&dest_path), opts)?;
let data = fs::read(&file_path)?;
zip.write_all(&data)?;
}
@@ -882,7 +880,7 @@ fn copy_pnpm_package_comprehensive<W>(
node_modules_path: &Path,
pnpm_dir: &Path,
package_name: &str,
opts: zip::write::FileOptions<'static, ()>,
opts: zip::write::SimpleFileOptions,
progress: Option<&ProgressBar>,
) -> Result<()>
where
@@ -951,7 +949,7 @@ fn bundle_node_modules_comprehensive<W>(
zip: &mut ZipWriter<W>,
node_modules_path: &Path,
project_path: &Path,
opts: zip::write::FileOptions<'static, ()>,
opts: zip::write::SimpleFileOptions,
progress: Option<&ProgressBar>,
) -> Result<()>
where
@@ -1058,7 +1056,7 @@ where
let file_path = node_modules_path.join(file_name);
if file_path.exists() {
let dest_path = Path::new("app/node_modules").join(file_name);
zip.start_file(dest_path.to_string_lossy().as_ref(), opts)?;
zip.start_file(zip_entry_name(&dest_path), opts)?;
let data = fs::read(&file_path)?;
zip.write_all(&data)?;
if let Some(pb) = progress {
@@ -1076,7 +1074,7 @@ fn bundle_workspace_dependencies<W>(
node_modules_path: &Path,
_parent_path: &Path,
project_path: &Path,
opts: zip::write::FileOptions<'static, ()>,
opts: zip::write::SimpleFileOptions,
progress: Option<&ProgressBar>,
) -> Result<()>
where
@@ -1148,7 +1146,7 @@ where
let file_path = node_modules_path.join(file_name);
if file_path.exists() {
let dest_path = Path::new("app/node_modules").join(file_name);
zip.start_file(dest_path.to_string_lossy().as_ref(), opts)?;
zip.start_file(zip_entry_name(&dest_path), opts)?;
let data = fs::read(&file_path)?;
zip.write_all(&data)?;
if let Some(pb) = progress {
@@ -1165,7 +1163,7 @@ fn bundle_pnpm_workspace_dependencies<W>(
zip: &mut ZipWriter<W>,
parent_path: &Path,
project_path: &Path,
opts: zip::write::FileOptions<'static, ()>,
opts: zip::write::SimpleFileOptions,
progress: Option<&ProgressBar>,
) -> Result<()>
where
@@ -1245,7 +1243,7 @@ where
let file_path = parent_path.join("node_modules").join(file_name);
if file_path.exists() {
let dest_path = Path::new("app/node_modules").join(file_name);
zip.start_file(dest_path.to_string_lossy().as_ref(), opts)?;
zip.start_file(zip_entry_name(&dest_path), opts)?;
let data = fs::read(&file_path)?;
zip.write_all(&data)?;
if let Some(pb) = progress {
@@ -1480,12 +1478,72 @@ fn resolve_output_path(
// Utility helpers
// ────────────────────────────────────────────────────────────────────────────
/// Add directory to zip without following symlinks but preserving them
/// Archive entry name for a relative path. Zip names always use `/`, whatever the host
/// separator is, so a bundle made on Windows extracts the same way as one made elsewhere.
fn zip_entry_name(path: &Path) -> String {
path.components()
.map(|c| c.as_os_str().to_string_lossy())
.collect::<Vec<_>>()
.join("/")
}
/// Add what a symlink points at under the link's own archive path.
///
/// The launcher extracts plain files only. If the link itself went into the archive, the
/// extracted bundle would have a small text file with the target path where a module or a
/// package directory should be. Links on Windows are often junctions, which this handles too.
fn add_symlink_target_to_zip<W>(
zip: &mut ZipWriter<W>,
link: &Path,
zip_path: &Path,
opts: zip::write::SimpleFileOptions,
) -> Result<()>
where
W: Write + Read + std::io::Seek,
{
let Ok(meta) = fs::metadata(link) else {
debug!("Skipping broken symlink {}", link.display());
return Ok(());
};
let add_file = |zip: &mut ZipWriter<W>, src: &Path, dest: &Path| -> Result<()> {
#[cfg(unix)]
let opts = {
use std::os::unix::fs::PermissionsExt;
opts.unix_permissions(fs::metadata(src)?.permissions().mode())
};
zip.start_file(zip_entry_name(dest), opts)?;
let data = fs::read(src)
.with_context(|| format!("Failed to read {} while zipping", src.display()))?;
zip.write_all(&data)?;
Ok(())
};
if meta.is_file() {
return add_file(zip, link, zip_path);
}
// A link loop makes walkdir return an error for that entry; skip it, as a broken link.
for entry in walkdir::WalkDir::new(link)
.follow_links(true)
.into_iter()
.flatten()
{
if entry.file_type().is_file() {
let rel = entry.path().strip_prefix(link).unwrap_or(entry.path());
add_file(zip, entry.path(), &zip_path.join(rel))?;
}
}
Ok(())
}
/// Add directory to zip. Symlinks are not followed during the walk; their targets are stored
/// under the link's path instead.
fn add_dir_to_zip_no_follow<W>(
zip: &mut ZipWriter<W>,
src_dir: &Path,
dest_dir: &Path,
opts: zip::write::FileOptions<'static, ()>,
opts: zip::write::SimpleFileOptions,
progress: Option<&ProgressBar>,
) -> Result<()>
where
@@ -1498,7 +1556,7 @@ where
let zip_path = dest_dir.join(rel_path);
if entry.file_type().is_dir() {
zip.add_directory(zip_path.to_string_lossy().as_ref(), opts)?;
zip.add_directory(zip_entry_name(&zip_path), opts)?;
continue;
}
@@ -1521,14 +1579,10 @@ where
}
};
zip.start_file(zip_path.to_string_lossy().as_ref(), file_opts)?;
if entry.file_type().is_symlink() {
if let Ok(target) = fs::read_link(path) {
let target_str = target.to_string_lossy();
zip.write_all(target_str.as_bytes())?;
}
add_symlink_target_to_zip(zip, path, &zip_path, opts)?;
} else {
zip.start_file(zip_entry_name(&zip_path), file_opts)?;
let data = fs::read(path).context("Failed to read file while zipping")?;
zip.write_all(&data)?;
}
@@ -1544,7 +1598,7 @@ fn add_dir_to_zip_no_follow_skip_parents<W>(
zip: &mut ZipWriter<W>,
src_dir: &Path,
dest_dir: &Path,
opts: zip::write::FileOptions<'static, ()>,
opts: zip::write::SimpleFileOptions,
progress: Option<&ProgressBar>,
) -> Result<()>
where
@@ -1558,7 +1612,7 @@ where
if entry.file_type().is_dir() {
if !rel_path.as_os_str().is_empty() {
zip.add_directory(zip_path.to_string_lossy().as_ref(), opts)?;
zip.add_directory(zip_entry_name(&zip_path), opts)?;
}
continue;
}
@@ -1582,14 +1636,10 @@ where
}
};
zip.start_file(zip_path.to_string_lossy().as_ref(), file_opts)?;
if entry.file_type().is_symlink() {
if let Ok(target) = fs::read_link(path) {
let target_str = target.to_string_lossy();
zip.write_all(target_str.as_bytes())?;
}
add_symlink_target_to_zip(zip, path, &zip_path, opts)?;
} else {
zip.start_file(zip_entry_name(&zip_path), file_opts)?;
let data = fs::read(path).context("Failed to read file while zipping")?;
zip.write_all(&data)?;
}
@@ -1605,7 +1655,7 @@ fn add_dir_to_zip_excluding_node_modules<W>(
zip: &mut ZipWriter<W>,
src_dir: &Path,
dest_dir: &Path,
opts: zip::write::FileOptions<'static, ()>,
opts: zip::write::SimpleFileOptions,
progress: Option<&ProgressBar>,
) -> Result<()>
where
@@ -1622,7 +1672,7 @@ where
}
if entry.file_type().is_dir() {
zip.add_directory(zip_path.to_string_lossy().as_ref(), opts)?;
zip.add_directory(zip_entry_name(&zip_path), opts)?;
continue;
}
@@ -1645,7 +1695,7 @@ where
}
};
zip.start_file(zip_path.to_string_lossy().as_ref(), file_opts)?;
zip.start_file(zip_entry_name(&zip_path), file_opts)?;
let data = fs::read(path).context("Failed to read file while zipping")?;
zip.write_all(&data)?;
if let Some(pb) = progress {
@@ -1660,7 +1710,7 @@ fn copy_workspace_package<W>(
zip: &mut ZipWriter<W>,
node_modules_path: &Path,
package_name: &str,
opts: zip::write::FileOptions<'static, ()>,
opts: zip::write::SimpleFileOptions,
progress: Option<&ProgressBar>,
) -> Result<()>
where
+12 -1
View File
@@ -8,11 +8,22 @@ anyhow = "1.0"
directories = "6"
# Only the codecs the bundler can actually emit. The default feature set drags in bzip2,
# liblzma, aes, zopfli, deflate64 and ppmd-rust, none of which are ever used.
zip = { version = "8", default-features = false, features = ["deflate", "zstd"] }
zip = { version = "9", default-features = false, features = ["deflate", "zstd"] }
serde_json = "1.0"
# fs2 is unmaintained (last release 2018); fs4 is the maintained fork.
fs4 = { version = "1.1", default-features = false, features = ["sync"] }
# Already a dependency of fs4 on Windows, so the launcher's job object and console handler
# add no extra crate to compile.
[target.'cfg(windows)'.dependencies]
windows-sys = { version = "0.61", features = [
"Win32_Foundation",
"Win32_Security",
"Win32_System_Console",
"Win32_System_JobObjects",
"Win32_System_Threading",
] }
[build-dependencies]
# No build dependencies needed - data is embedded at compile time
+80 -11
View File
@@ -70,7 +70,13 @@ fn main() -> Result<()> {
if let Ok(main_script) = fs::read_to_string(&ready_path) {
let main_script = main_script.trim().to_string();
if !main_script.is_empty() {
// Without exec() the launcher waits for Node, so it must release the lock first.
// Otherwise every other launch would block until this app exits.
#[cfg(windows)]
FileExt::unlock(&lock_file).context("Failed to release extraction lock")?;
run_app(&app_dir, &main_script, &args[1..])?;
#[cfg(windows)]
FileExt::lock(&lock_file).context("Failed to acquire extraction lock")?;
}
}
@@ -141,8 +147,9 @@ fn extract_application(app_dir: &Path) -> Result<()> {
let entry = archive
.by_index_raw(index)
.context("Failed to read zip entry")?;
let is_dir = entry.is_dir() || entry.name().ends_with('/');
let Some(path) = safe_join(app_dir, entry.name()) else {
let name = entry.name().context("Failed to read zip entry name")?;
let is_dir = entry.is_dir() || name.ends_with('/');
let Some(path) = safe_join(app_dir, &name) else {
continue;
};
let size = entry.size();
@@ -184,7 +191,7 @@ fn extract_application(app_dir: &Path) -> Result<()> {
// static split of the work list hands it to one worker that is still writing long after
// the rest have finished. Longest-job-first plus a shared cursor keeps every core busy to
// the end.
files.sort_unstable_by(|a, b| b.size.cmp(&a.size));
files.sort_unstable_by_key(|f| std::cmp::Reverse(f.size));
let threads = std::thread::available_parallelism()
.map(|n| n.get())
@@ -328,7 +335,9 @@ fn run_app(app_dir: &Path, main_script: &str, args: &[std::ffi::OsString]) -> Re
command.env("NODE_COMPILE_CACHE", app_dir.join(".v8cache"));
}
// The early return is needed on Unix only because the Windows block follows it.
#[cfg(unix)]
#[allow(clippy::needless_return)]
{
use std::os::unix::process::CommandExt;
// Replaces this process image with Node: no fork, no resident parent holding the
@@ -348,16 +357,22 @@ fn run_app(app_dir: &Path, main_script: &str, args: &[std::ffi::OsString]) -> Re
#[cfg(windows)]
{
use std::process::Stdio;
// Windows has no exec(), so the launcher stays as Node's parent. It must not die
// before Node on Ctrl+C (the shell would get its prompt back while Node still
// writes to the console), and Node must not outlive a launcher that is killed.
windows_supervisor::ignore_console_interrupts();
let mut last_err: Option<std::io::Error> = None;
for attempt in 1..=8u32 {
match command
.stdin(Stdio::inherit())
.stdout(Stdio::inherit())
.stderr(Stdio::inherit())
.status()
{
Ok(status) => std::process::exit(status.code().unwrap_or(1)),
match command.spawn() {
Ok(mut child) => {
windows_supervisor::kill_on_launcher_exit(&child);
let code = match child.wait() {
Ok(status) => status.code().unwrap_or(1),
Err(_) => 1,
};
std::process::exit(code);
}
Err(e) => {
last_err = Some(e);
std::thread::sleep(std::time::Duration::from_millis(50 * attempt as u64));
@@ -377,6 +392,60 @@ fn run_app(app_dir: &Path, main_script: &str, args: &[std::ffi::OsString]) -> Re
}
}
#[cfg(windows)]
mod windows_supervisor {
use std::os::windows::io::AsRawHandle;
use windows_sys::core::BOOL;
use windows_sys::Win32::System::Console::{
SetConsoleCtrlHandler, CTRL_BREAK_EVENT, CTRL_C_EVENT,
};
use windows_sys::Win32::System::JobObjects::{
AssignProcessToJobObject, CreateJobObjectW, JobObjectExtendedLimitInformation,
SetInformationJobObject, JOBOBJECT_EXTENDED_LIMIT_INFORMATION,
JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE, JOB_OBJECT_LIMIT_SILENT_BREAKAWAY_OK,
};
unsafe extern "system" fn on_console_event(event: u32) -> BOOL {
// Ctrl+C and Ctrl+Break go to every process on the console, so Node gets them too
// and decides how to exit. The launcher only waits and forwards the exit code.
(event == CTRL_C_EVENT || event == CTRL_BREAK_EVENT) as BOOL
}
pub fn ignore_console_interrupts() {
// A handler routine, unlike `SetConsoleCtrlHandler(NULL, TRUE)`, is not inherited,
// so Node keeps the default Ctrl+C behaviour.
unsafe {
SetConsoleCtrlHandler(Some(on_console_event), 1);
}
}
/// Put Node in a job that is closed, and so terminated, when the launcher exits for any
/// reason. Processes that Node starts are not added to the job, so detached children
/// still outlive the app. Failures are ignored: the app then runs as before.
pub fn kill_on_launcher_exit(child: &std::process::Child) {
unsafe {
let job = CreateJobObjectW(std::ptr::null(), std::ptr::null());
if job.is_null() {
return;
}
let mut info = JOBOBJECT_EXTENDED_LIMIT_INFORMATION::default();
info.BasicLimitInformation.LimitFlags =
JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE | JOB_OBJECT_LIMIT_SILENT_BREAKAWAY_OK;
let configured = SetInformationJobObject(
job,
JobObjectExtendedLimitInformation,
&info as *const _ as *const _,
std::mem::size_of::<JOBOBJECT_EXTENDED_LIMIT_INFORMATION>() as u32,
);
if configured != 0 {
AssignProcessToJobObject(job, child.as_raw_handle() as _);
}
// The job handle stays open on purpose. The system closes it when the launcher
// exits, and that terminates Node.
}
}
}
fn find_main_script(app_path: &Path) -> Result<String> {
let package_json_path = app_path.join("package.json");
+37 -146
View File
@@ -7,6 +7,20 @@ use std::process::Command;
use std::time::Duration;
use tempfile::TempDir;
/// Build a command for a Node package manager (`npm`, `pnpm`, ...).
///
/// On Windows these are `.cmd` shims, which `Command::new` does not find by bare name, so
/// they go through `cmd /C`.
pub fn package_manager_command(program: &str) -> Command {
if cfg!(windows) {
let mut cmd = Command::new("cmd");
cmd.args(["/C", program]);
cmd
} else {
Command::new(program)
}
}
/// Represents different project types for testing
#[derive(Debug, Clone)]
pub enum ProjectType {
@@ -146,7 +160,7 @@ impl TestProjectManager {
/// Install dependencies using npm
pub fn install_dependencies(&self) -> Result<()> {
let npm_install = Command::new("npm")
let npm_install = package_manager_command("npm")
.args(["install"])
.current_dir(&self.project_path)
.output()?;
@@ -164,7 +178,7 @@ impl TestProjectManager {
/// Install dependencies using pnpm
pub fn install_pnpm_dependencies(&self) -> Result<()> {
// First try pnpm
let pnpm_install = Command::new("pnpm")
let pnpm_install = package_manager_command("pnpm")
.args(["install"])
.current_dir(self.workspace_root.as_ref().unwrap_or(&self.project_path))
.output();
@@ -188,7 +202,7 @@ impl TestProjectManager {
/// Install dependencies in workspace root
pub fn install_workspace_dependencies(&self) -> Result<()> {
if let Some(workspace_root) = &self.workspace_root {
let npm_install = Command::new("npm")
let npm_install = package_manager_command("npm")
.args(["install"])
.current_dir(workspace_root)
.output()?;
@@ -494,27 +508,8 @@ pub struct BundlerTestHelper;
impl BundlerTestHelper {
/// Get the path to the banderole binary
pub fn get_bundler_path() -> Result<PathBuf> {
let target_dir = std::env::current_dir()?.join("target");
let bundler_path = if cfg!(windows) {
target_dir.join("debug/banderole.exe")
} else {
target_dir.join("debug/banderole")
};
if !bundler_path.exists() {
// Build the bundler if it doesn't exist
println!("Building banderole...");
let output = Command::new("cargo").args(["build"]).output()?;
if !output.status.success() {
anyhow::bail!(
"Failed to build banderole: {}",
String::from_utf8_lossy(&output.stderr)
);
}
}
Ok(bundler_path)
// Cargo builds the binary before it runs integration tests and reports its path.
Ok(PathBuf::from(env!("CARGO_BIN_EXE_banderole")))
}
/// Bundle a project and return the path to the created executable
@@ -697,123 +692,26 @@ impl BundlerTestHelper {
fs::set_permissions(executable_path, perms)?;
}
// Build command to run the executable.
#[cfg(windows)]
let (exec_to_run, work_dir, _run_guard) = {
// Copy to a unique name in the same directory as the original to avoid policy issues with %TEMP%
let parent = executable_path.parent().ok_or_else(|| {
anyhow::anyhow!("Executable has no parent: {}", executable_path.display())
})?;
let run_dir = TempDir::new_in(parent).unwrap_or_else(|_| TempDir::new().unwrap());
let mut base = executable_path
.file_name()
.map(|s| s.to_os_string())
.unwrap_or_else(|| "app.exe".into());
if Path::new(&base).extension().is_none() {
base.push(".exe");
}
let candidate = run_dir.path().join(&base);
std::fs::copy(executable_path, &candidate).with_context(|| {
format!(
"Failed to copy executable to run dir: {} -> {}",
executable_path.display(),
candidate.display()
)
})?;
std::thread::sleep(std::time::Duration::from_millis(50));
if !candidate.exists() {
anyhow::bail!(
"Run executable not found after copy: {}",
candidate.display()
);
}
(candidate, parent.to_path_buf(), run_dir)
};
let work_dir = executable_path.parent().ok_or_else(|| {
anyhow::anyhow!("Executable has no parent: {}", executable_path.display())
})?;
#[cfg(not(windows))]
let (exec_to_run, work_dir) = (
executable_path.to_path_buf(),
executable_path.parent().unwrap().to_path_buf(),
println!(
"Executing: {} with args: {:?}",
executable_path.display(),
args
);
println!("Executing: {} with args: {:?}", exec_to_run.display(), args);
let mut cmd = Command::new(executable_path);
cmd.args(args).current_dir(work_dir);
for (key, value) in env_vars {
cmd.env(key, value);
}
// Build a verbatim Windows path to avoid MAX_PATH and normalization issues
#[cfg(windows)]
let exec_for_spawn = {
use std::ffi::OsString;
let abs = exec_to_run
.canonicalize()
.unwrap_or_else(|_| exec_to_run.clone());
let mut s: OsString = OsString::from(r"\\?\");
s.push(&abs);
s
};
#[cfg(not(windows))]
let exec_for_spawn = exec_to_run.as_os_str().to_os_string();
// First try direct spawn
let direct = {
let mut cmd = Command::new(&exec_for_spawn);
cmd.args(args);
for (key, value) in env_vars {
cmd.env(key, value);
}
cmd.current_dir(&work_dir).output()
};
// If NotFound on Windows, retry using the copied executable directly with verbatim prefix; else cmd /C
#[cfg(windows)]
let output = match direct {
Ok(o) => Ok(o),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
use std::ffi::OsString;
let abs = exec_to_run
.canonicalize()
.unwrap_or_else(|_| exec_to_run.clone());
let mut verbatim: OsString = OsString::from(r"\\?\");
verbatim.push(&abs);
let mut cmd = Command::new(&verbatim);
cmd.args(args).current_dir(&work_dir);
for (key, value) in env_vars {
cmd.env(key, value);
}
match cmd.output() {
Ok(o2) => Ok(o2),
Err(e2) if e2.kind() == std::io::ErrorKind::NotFound => {
// Fallback to cmd /C with quoting
fn quote(s: &str) -> String {
let mut out = String::from("\"");
out.push_str(&s.replace('"', "\\\""));
out.push('"');
out
}
let exe_str = exec_to_run.display().to_string();
let mut cmdline = quote(&exe_str);
for a in args {
cmdline.push(' ');
cmdline.push_str(&quote(a));
}
let mut c2 = Command::new("cmd");
c2.args(["/C", &cmdline]).current_dir(&work_dir);
for (key, value) in env_vars {
c2.env(key, value);
}
c2.output()
}
Err(e2) => Err(e2),
}
}
Err(e) => Err(e),
};
#[cfg(not(windows))]
let output = direct;
let output = output.with_context(|| {
let output = cmd.output().with_context(|| {
format!(
"Failed to execute command: {}\nArgs: {:?}\nEnv vars: {:?}\nWorking directory: {:?}",
exec_to_run.display(),
executable_path.display(),
args,
env_vars,
work_dir
@@ -864,17 +762,10 @@ pub struct TestCacheManager;
impl TestCacheManager {
/// The directory the launcher extracts bundles into, matching `directories::BaseDirs`.
pub fn application_cache_dir() -> PathBuf {
if cfg!(windows) {
std::env::var_os("LOCALAPPDATA")
.map(|d| PathBuf::from(d).join("banderole"))
.unwrap_or_else(|| PathBuf::from("banderole-cache"))
} else if let Some(xdg_cache) = std::env::var_os("XDG_CACHE_HOME") {
PathBuf::from(xdg_cache).join("banderole")
} else if let Some(home) = std::env::var_os("HOME") {
PathBuf::from(home).join(".cache").join("banderole")
} else {
PathBuf::from("/tmp").join("banderole-cache")
}
directories::BaseDirs::new()
.expect("Failed to determine base directories")
.cache_dir()
.join("banderole")
}
/// Clear application cache for testing
+18 -122
View File
@@ -77,28 +77,12 @@ process.exit(0);"#;
);
}
// Build banderole if not already built
println!("Building banderole...");
let target_dir = std::env::current_dir()?.join("target");
let banderole_path = target_dir.join("debug/banderole");
if !banderole_path.exists() {
let output = Command::new("cargo")
.args(["build"])
.output()
.expect("Failed to build banderole");
assert!(
output.status.success(),
"Failed to build banderole: {}",
String::from_utf8_lossy(&output.stderr)
);
}
let banderole_path = env!("CARGO_BIN_EXE_banderole");
// Bundle the test app)
println!("Bundling test app...");
let mut bundle_cmd = Command::new(&banderole_path);
let mut bundle_cmd = Command::new(banderole_path);
bundle_cmd
.args([
"bundle",
@@ -233,26 +217,11 @@ process.exit(0);"#;
std::fs::write(test_app_path.join("index.js"), index_js).unwrap();
std::fs::write(test_app_path.join(".nvmrc"), nvmrc).unwrap();
// Build banderole
println!("Building banderole for Node version test...");
let output = Command::new("cargo")
.args(["build", "--release"])
.output()
.expect("Failed to build banderole");
assert!(
output.status.success(),
"Failed to build banderole: {}",
String::from_utf8_lossy(&output.stderr)
);
let banderole_path = std::env::current_dir()
.unwrap()
.join("target/release/banderole");
let banderole_path = env!("CARGO_BIN_EXE_banderole");
// Bundle the test app (keep compression for this test to verify it works)
println!("Bundling test app with .nvmrc...");
let mut bundle_cmd = Command::new(&banderole_path);
let mut bundle_cmd = Command::new(banderole_path);
bundle_cmd
.args(["bundle", test_app_path.to_str().unwrap()])
.current_dir(temp_dir.path());
@@ -368,27 +337,12 @@ async fn test_output_path_collision_handling() -> Result<(), Box<dyn std::error:
// Create a directory with the same name as the app to cause collision
std::fs::create_dir_all(temp_dir.path().join("collision-test-app"))?;
// Build banderole
let target_dir = std::env::current_dir()?.join("target");
let banderole_path = target_dir.join("debug/banderole");
if !banderole_path.exists() {
let output = Command::new("cargo")
.args(["build"])
.output()
.expect("Failed to build banderole");
assert!(
output.status.success(),
"Failed to build banderole: {}",
String::from_utf8_lossy(&output.stderr)
);
}
let banderole_path = env!("CARGO_BIN_EXE_banderole");
// Bundle the test app)
println!("Testing output path collision handling...");
let mut bundle_cmd = Command::new(&banderole_path);
let mut bundle_cmd = Command::new(banderole_path);
bundle_cmd
.args([
"bundle",
@@ -506,27 +460,12 @@ try {
std::fs::write(test_app_path.join("dist/index.js"), dist_index_js)?;
std::fs::write(test_app_path.join("dist/marker.js"), dist_marker)?;
// Build banderole
let target_dir = std::env::current_dir()?.join("target");
let banderole_path = target_dir.join("debug/banderole");
if !banderole_path.exists() {
let output = Command::new("cargo")
.args(["build"])
.output()
.expect("Failed to build banderole");
assert!(
output.status.success(),
"Failed to build banderole: {}",
String::from_utf8_lossy(&output.stderr)
);
}
let banderole_path = env!("CARGO_BIN_EXE_banderole");
// Bundle the TypeScript project)
println!("Testing TypeScript project bundling...");
let mut bundle_cmd = Command::new(&banderole_path);
let mut bundle_cmd = Command::new(banderole_path);
bundle_cmd
.args([
"bundle",
@@ -654,27 +593,12 @@ try {
std::fs::write(test_app_path.join("build/index.js"), build_index_js)?;
std::fs::write(test_app_path.join("build/marker.js"), build_marker)?;
// Build banderole
let target_dir = std::env::current_dir()?.join("target");
let banderole_path = target_dir.join("debug/banderole");
if !banderole_path.exists() {
let output = Command::new("cargo")
.args(["build"])
.output()
.expect("Failed to build banderole");
assert!(
output.status.success(),
"Failed to build banderole: {}",
String::from_utf8_lossy(&output.stderr)
);
}
let banderole_path = env!("CARGO_BIN_EXE_banderole");
// Bundle the project)
println!("Testing TypeScript project with custom outDir...");
let mut bundle_cmd = Command::new(&banderole_path);
let mut bundle_cmd = Command::new(banderole_path);
bundle_cmd
.args([
"bundle",
@@ -801,27 +725,12 @@ try {
std::fs::write(test_app_path.join("lib/index.js"), lib_index_js)?;
std::fs::write(test_app_path.join("lib/marker.js"), lib_marker)?;
// Build banderole
let target_dir = std::env::current_dir()?.join("target");
let banderole_path = target_dir.join("debug/banderole");
if !banderole_path.exists() {
let output = Command::new("cargo")
.args(["build"])
.output()
.expect("Failed to build banderole");
assert!(
output.status.success(),
"Failed to build banderole: {}",
String::from_utf8_lossy(&output.stderr)
);
}
let banderole_path = env!("CARGO_BIN_EXE_banderole");
// Bundle the project)
println!("Testing TypeScript project with extends...");
let mut bundle_cmd = Command::new(&banderole_path);
let mut bundle_cmd = Command::new(banderole_path);
bundle_cmd
.args([
"bundle",
@@ -1035,27 +944,12 @@ packages:
}
}
// Build banderole
let target_dir = std::env::current_dir()?.join("target");
let banderole_path = target_dir.join("debug/banderole");
if !banderole_path.exists() {
let output = Command::new("cargo")
.args(["build"])
.output()
.expect("Failed to build banderole");
assert!(
output.status.success(),
"Failed to build banderole: {}",
String::from_utf8_lossy(&output.stderr)
);
}
let banderole_path = env!("CARGO_BIN_EXE_banderole");
// Bundle the pnpm project)
println!("Testing pnpm dependency bundling...");
let mut bundle_cmd = Command::new(&banderole_path);
let mut bundle_cmd = Command::new(banderole_path);
bundle_cmd
.args([
"bundle",
@@ -1235,8 +1129,10 @@ async fn test_bundle_simple_project() {
String::from_utf8_lossy(&bundle_output.stderr)
);
// Test that the bundle exists
let bundle_path = temp_dir.path().join("test-bundle");
// Test that the bundle exists (the bundler adds `.exe` on Windows)
let bundle_path = temp_dir
.path()
.join(format!("test-bundle{}", std::env::consts::EXE_SUFFIX));
assert!(bundle_path.exists(), "Bundle file not created");
}
+52 -2
View File
@@ -8,7 +8,6 @@ use common::{BundlerTestHelper, TestCacheManager};
use serial_test::serial;
use std::fs;
use std::path::{Path, PathBuf};
use std::process::Command;
use tempfile::TempDir;
/// Writes a project whose entrypoint can exit with a chosen code or idle forever.
@@ -103,7 +102,7 @@ async fn test_launcher_execs_into_node() -> Result<(), Box<dyn std::error::Error
#[serial]
async fn test_no_launcher_process_remains_resident() -> Result<(), Box<dyn std::error::Error>> {
use std::io::Read;
use std::process::Stdio;
use std::process::{Command, Stdio};
let temp_dir = TempDir::new()?;
let app = write_probe_project(temp_dir.path())?;
@@ -216,3 +215,54 @@ async fn test_payload_contains_only_node_executable() -> Result<(), Box<dyn std:
Ok(())
}
/// Windows has no exec(), so the launcher stays as Node's parent. When the launcher is
/// killed, Node must stop too, or it keeps the extracted files in use and runs unattended.
#[cfg(windows)]
#[tokio::test(flavor = "multi_thread")]
#[serial]
async fn test_node_stops_when_launcher_is_killed() -> Result<(), Box<dyn std::error::Error>> {
use std::io::Read;
use std::process::{Command, Stdio};
use std::sync::mpsc;
use std::time::Duration;
let temp_dir = TempDir::new()?;
let app = write_probe_project(temp_dir.path())?;
let out_dir = temp_dir.path().join("out");
fs::create_dir_all(&out_dir)?;
TestCacheManager::clear_application_cache()?;
let exe = BundlerTestHelper::bundle_project(&app, &out_dir, Some("probe-app"))?;
BundlerTestHelper::run_executable(&exe, &[], &[])?;
let mut child = Command::new(&exe)
.arg("idle")
.stdout(Stdio::piped())
.stderr(Stdio::null())
.spawn()?;
let mut stdout = child.stdout.take().expect("piped stdout");
let mut buf = [0u8; 32];
let n = stdout.read(&mut buf)?;
assert!(
String::from_utf8_lossy(&buf[..n]).contains("ready"),
"idle app never became ready"
);
// Node holds the write end of the pipe too, so the read only ends when Node has exited.
child.kill()?;
child.wait()?;
let (tx, rx) = mpsc::channel();
std::thread::spawn(move || {
let mut rest = Vec::new();
let _ = stdout.read_to_end(&mut rest);
let _ = tx.send(());
});
assert!(
rx.recv_timeout(Duration::from_secs(15)).is_ok(),
"Node kept running after the launcher was killed"
);
Ok(())
}
+1 -20
View File
@@ -103,26 +103,7 @@ fn prepare_node_modules_for_spec(app_dir: &Path, local_pkg_dir: &Path, mode: &st
}
fn bundle_app(app_dir: &Path, out_dir: &Path, name: &str) -> PathBuf {
// Build banderole if needed
let bundler = {
let target_dir = std::env::current_dir().unwrap().join("target");
let path = if cfg!(windows) {
target_dir.join("debug/banderole.exe")
} else {
target_dir.join("debug/banderole")
};
if !path.exists() {
let out = Command::new("cargo").args(["build"]).output().unwrap();
assert!(
out.status.success(),
"Failed to build banderole: {}",
String::from_utf8_lossy(&out.stderr)
);
}
path
};
let output = Command::new(&bundler)
let output = Command::new(env!("CARGO_BIN_EXE_banderole"))
.args([
"bundle",
app_dir.to_str().unwrap(),