From 9e83b0371b9b8f11bb9f0f38a24965bfeb5bf4f5 Mon Sep 17 00:00:00 2001 From: Matheus de Jesus Date: Tue, 27 Oct 2026 19:05:20 -0300 Subject: [PATCH] fix(windows): app launch crash, atlas update over a running app, CLI from the installer Signed-off-by: Matheus de Jesus --- CHANGELOG.md | 6 + .../atlas-cli/src/commands/desktop_windows.rs | 193 +++++++++++++++--- crates/atlas-desktop/src/dock.rs | 6 +- crates/atlas-desktop/src/shots/surface.rs | 6 +- crates/atlas-desktop/src/webview_win.rs | 54 +++++ crates/atlas-desktop/tauri.windows.conf.json | 3 +- packaging/windows/installer-hooks.nsh | 14 ++ 7 files changed, 247 insertions(+), 35 deletions(-) create mode 100644 packaging/windows/installer-hooks.nsh diff --git a/CHANGELOG.md b/CHANGELOG.md index b043b0e4..4c3e7025 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,12 @@ All notable changes to Atlas (CLI, TUI, MCP server and Atlas Desktop). Versions ## [Unreleased] +### Fixed + +- **Windows: Atlas Desktop 1.5.0-alpha.24 closed at launch** (`WebView2 error 0x8007139F` in `~/.atlas/desktop/app.log`). The new agent dock and capture windows built their webview with other browser options than the main window, and WebView2 refuses two sets in one app. Every window now passes the same set, and a test fails the build when a new window forgets it. +- **Windows: `atlas update` could not update Atlas Desktop** ("cannot move …\Atlas aside (is Atlas still running?)", so the CLI rolled back too). The update moved the whole install folder, which Windows refuses while any file in it is open, and one always is: the app, its terminal host or the `atlas.exe` running the update. It now moves the files aside one by one (Windows allows renaming a running program), and reopens Atlas Desktop when the installer had to close it; terminals and stacks keep running. +- **Windows: installing only Atlas Desktop now also gives you the `atlas` command.** The installer sets up the CLI it carries (`%USERPROFILE%\.atlas\bin` on your PATH, as the PowerShell install does) when you have none yet, so `atlas update` and `atlas .` work from a new terminal. + ## [1.5.0-alpha.24] — 2026-10-27 Atlas is in **alpha**: expect bugs and breaking changes. Please [report what breaks](https://github.com/codeatlasdev/atlas/issues/new). diff --git a/crates/atlas-cli/src/commands/desktop_windows.rs b/crates/atlas-cli/src/commands/desktop_windows.rs index 6bc3ab1a..9acf291b 100644 --- a/crates/atlas-cli/src/commands/desktop_windows.rs +++ b/crates/atlas-cli/src/commands/desktop_windows.rs @@ -57,9 +57,9 @@ fn reg_value(out: &str, name: &str) -> Option { } /// Run the installer `source` silently with its install dir set to `target`'s folder, wait for -/// it, and check the app is there at `version`. The installer closes a running Atlas first and -/// replaces the files in place; the previous install folder is kept aside until the new one is -/// verified and put back when the installer fails or leaves another version behind. +/// it, and check the app is there at `version`. The installer closes a running Atlas Desktop and +/// writes the files in place; the previous files are kept aside until the new ones are verified +/// and put back when the installer fails or leaves another version behind. pub(super) fn install(source: &Path, target: &Path, version: Option<&str>) -> Result<()> { if !is_pe(source) { bail!( @@ -67,13 +67,36 @@ pub(super) fn install(source: &Path, target: &Path, version: Option<&str>) -> Re source.display() ); } + // The silent installer terminates a running Atlas Desktop: bring it back afterwards (the terminal + // host and the stacks keep running on their own, so it reattaches where it was). + let was_running = app_running(target); install_with( target, version, &version_file(), |dir| run_installer(source, dir), registered_version, - ) + )?; + if was_running && !app_running(target) { + let _ = open(target, &[]); + } + Ok(()) +} + +/// An Atlas Desktop runs from `target` (not a dev build elsewhere with the same image name). +fn app_running(target: &Path) -> bool { + let Some(name) = target + .file_name() + .map(|n| n.to_string_lossy().to_lowercase()) + else { + return false; + }; + let want = target.to_string_lossy().to_lowercase().replace('/', "\\"); + atlas_core::sys::process_rows().iter().any(|r| { + r.exe.to_lowercase() == name + && atlas_core::sys::command_line(r.pid) + .is_some_and(|c| c.to_lowercase().replace('/', "\\").contains(&want)) + }) } /// NSIS: `/S` silent; `/D=` must come last and unquoted, even with spaces. @@ -90,16 +113,59 @@ fn run_installer(source: &Path, dir: &Path) -> Result<()> { Ok(()) } -/// The previous install folder, beside the new one. -fn backup_dir(dir: &Path) -> PathBuf { - let mut name = dir.file_name().unwrap_or_default().to_os_string(); - name.push(".prev"); - dir.with_file_name(name) +/// Suffix of a file moved aside by the install run of process `pid`: `atlas.exe` → +/// `atlas.exe.atlas-old-1234`. +fn aside_suffix(pid: u32) -> String { + format!(".atlas-old-{pid}") +} + +/// `(original name, run pid)` when `name` is a file some install run moved aside. +fn parse_aside(name: &str) -> Option<(&str, u32)> { + let (orig, pid) = name.rsplit_once(".atlas-old-")?; + Some((orig, pid.parse().ok()?)) +} + +/// Put back what an earlier run (killed mid-install) moved aside, and delete leftovers of earlier +/// successful runs. Windows cannot delete an image that is still running (the old hostd keeps +/// `atlas.exe` open until it exits), so an undeletable leftover simply waits for the next run. +fn recover(dir: &Path, own: u32) { + let Ok(entries) = std::fs::read_dir(dir) else { + return; + }; + for e in entries.flatten() { + let name = e.file_name().to_string_lossy().into_owned(); + let Some((orig, pid)) = parse_aside(&name) else { + continue; + }; + if pid == own { + continue; + } + let orig = dir.join(orig); + if orig.exists() { + let _ = remove(&e.path()); + } else { + let _ = std::fs::rename(e.path(), orig); + } + } +} + +fn remove(p: &Path) -> std::io::Result<()> { + if p.is_dir() { + std::fs::remove_dir_all(p) + } else { + std::fs::remove_file(p) + } } /// The install steps around the installer, with the installer (`run`: given the install folder) -/// and the registry read (`registered`) injected. On any failure the previous folder and version -/// record are restored; on success the backup is deleted. +/// and the registry read (`registered`) injected. On any failure the previous files and version +/// record are restored; on success the moved-aside files are deleted (or left for the next run +/// when still in use). +/// +/// Every entry of the install folder is moved aside by renaming it in place, never the folder +/// itself: Windows refuses to move a folder while any file in it is open, and one always is +/// during `atlas update` (the bundled `atlas.exe` running the update, the terminal host +/// `atlas.exe hostd`), but it does let a running image be renamed. /// /// The check: `target` is a file and, when the installer registered a version, it equals /// `version`. An installer that registers nothing (a `--from` build) is judged by the file only. @@ -111,21 +177,29 @@ fn install_with( registered: impl FnOnce() -> Option, ) -> Result<()> { let dir = target.parent().context("bad install path")?; - let backup = backup_dir(dir); - // A previous run killed after moving the install aside: put it back first. - if !dir.exists() && backup.exists() { - let _ = std::fs::rename(&backup, dir); - } - let _ = std::fs::remove_dir_all(&backup); - let had_old = dir.exists(); - if had_old { - std::fs::rename(dir, &backup).with_context(|| { - format!( - "cannot move {} aside (is Atlas still running?)", - dir.display() - ) - })?; + let pid = std::process::id(); + recover(dir, pid); + let suffix = aside_suffix(pid); + let mut aside: Vec<(PathBuf, PathBuf)> = Vec::new(); + if let Ok(entries) = std::fs::read_dir(dir) { + for e in entries.flatten() { + let name = e.file_name().to_string_lossy().into_owned(); + if parse_aside(&name).is_some() { + continue; + } + let from = e.path(); + let to = dir.join(format!("{name}{suffix}")); + if let Err(err) = std::fs::rename(&from, &to) { + for (f, t) in aside.iter().rev() { + let _ = std::fs::rename(t, f); + } + return Err(err) + .with_context(|| format!("cannot move {} aside to update it", from.display())); + } + aside.push((from, to)); + } } + let had_old = !aside.is_empty(); let old_version = std::fs::read(vf).ok(); let result = (|| -> Result<()> { @@ -144,13 +218,26 @@ fn install_with( match result { Ok(()) => { - let _ = std::fs::remove_dir_all(&backup); + for (_, t) in &aside { + let _ = remove(t); + } Ok(()) } Err(e) => { - let _ = std::fs::remove_dir_all(dir); - if had_old { - let _ = std::fs::rename(&backup, dir); + // Drop what the installer wrote, then put every original back under its name. + if let Ok(entries) = std::fs::read_dir(dir) { + for e in entries.flatten() { + let name = e.file_name().to_string_lossy().into_owned(); + if parse_aside(&name).is_none() { + let _ = remove(&e.path()); + } + } + } + for (f, t) in &aside { + let _ = std::fs::rename(t, f); + } + if !had_old { + let _ = std::fs::remove_dir(dir); } match old_version { Some(v) => { @@ -359,16 +446,20 @@ mod install_tests { assert!(!vf.exists()); } - /// Killed after the install was moved aside, before the installer ran: the next run puts the - /// old install back, then replaces it. + /// Killed after the files were moved aside, before the installer ran: the next run puts the + /// old files back, then replaces them. #[test] fn an_install_killed_after_the_move_is_recovered_by_the_next_one() { let (tmp, target, vf) = installed(); - std::fs::rename(tmp.path().join("Atlas"), tmp.path().join("Atlas.prev")).unwrap(); + let dir = tmp.path().join("Atlas"); + for f in ["atlas-desktop.exe", "data.bin"] { + std::fs::rename(dir.join(f), dir.join(format!("{f}{}", aside_suffix(1)))).unwrap(); + } let e = install_with(&target, Some("1.1.0"), &vf, |_| bail!("boom"), || None).unwrap_err(); assert!(format!("{e:#}").contains("boom")); assert_eq!(std::fs::read_to_string(&target).unwrap(), "old"); + assert_eq!(names(&dir), ["atlas-desktop.exe", "data.bin"]); install_with(&target, Some("1.1.0"), &vf, installs("new"), || { Some("1.1.0".into()) @@ -377,4 +468,42 @@ mod install_tests { assert_eq!(std::fs::read_to_string(&target).unwrap(), "new"); assert_eq!(names(tmp.path()), ["Atlas", "app-version"]); } + + /// The bug `atlas update` hit on Windows: a file in the install folder is open (the bundled + /// `atlas.exe` running the update, `atlas.exe hostd`), so the folder could not be moved. The + /// open file is now renamed in place and the update goes through; the leftover it leaves while + /// still open is cleaned by the next run. + #[cfg(windows)] + #[test] + fn an_open_file_in_the_install_folder_does_not_block_the_update() { + use std::os::windows::fs::OpenOptionsExt; + let (tmp, target, vf) = installed(); + let dir = tmp.path().join("Atlas"); + let held = dir.join("atlas.exe"); + std::fs::write(&held, "running").unwrap(); + // How Windows opens a running image: others may read and rename it (FILE_SHARE_DELETE), not write. + let handle = std::fs::OpenOptions::new() + .read(true) + .share_mode(0x1 | 0x4) + .open(&held) + .unwrap(); + assert!( + std::fs::rename(&dir, tmp.path().join("Atlas.moved")).is_err(), + "the folder must be unmovable while a file in it is open, else this test proves nothing" + ); + + install_with(&target, Some("1.1.0"), &vf, installs("new"), || { + Some("1.1.0".into()) + }) + .unwrap(); + assert_eq!(std::fs::read_to_string(&target).unwrap(), "new"); + assert_eq!(std::fs::read_to_string(&vf).unwrap(), "1.1.0"); + + drop(handle); + install_with(&target, Some("1.1.0"), &vf, installs("new"), || { + Some("1.1.0".into()) + }) + .unwrap(); + assert_eq!(names(&dir), ["atlas-desktop.exe"]); + } } diff --git a/crates/atlas-desktop/src/dock.rs b/crates/atlas-desktop/src/dock.rs index 9cca462c..3c6d9d0f 100644 --- a/crates/atlas-desktop/src/dock.rs +++ b/crates/atlas-desktop/src/dock.rs @@ -223,7 +223,11 @@ fn pty_of(snapshot: &Value, tab: &str) -> Option { fn build(app: &AppHandle) -> tauri::Result { let (theme, script) = crate::boot::window_boot(crate::boot::load_cache().as_ref()); - let win = WebviewWindowBuilder::new(app, LABEL, WebviewUrl::App("dock".into())) + let builder = WebviewWindowBuilder::new(app, LABEL, WebviewUrl::App("dock".into())); + // Windows: every webview of the app passes the same browser args (webview_win::ARGS). + #[cfg(windows)] + let builder = builder.additional_browser_args(crate::webview_win::ARGS); + let win = builder .theme(theme) .initialization_script(script) .title("Atlas Dock") diff --git a/crates/atlas-desktop/src/shots/surface.rs b/crates/atlas-desktop/src/shots/surface.rs index c4de431f..c9b2d7c6 100644 --- a/crates/atlas-desktop/src/shots/surface.rs +++ b/crates/atlas-desktop/src/shots/surface.rs @@ -207,7 +207,11 @@ pub fn build( format!("{}?{query}", surface.route()) }; let (w, h) = surface.size(); - let win = WebviewWindowBuilder::new(app, &label, WebviewUrl::App(url.into())) + let builder = WebviewWindowBuilder::new(app, &label, WebviewUrl::App(url.into())); + // Windows: every webview of the app passes the same browser args (webview_win::ARGS). + #[cfg(windows)] + let builder = builder.additional_browser_args(crate::webview_win::ARGS); + let win = builder .theme(theme) .initialization_script(script) .title("Atlas Capture") diff --git a/crates/atlas-desktop/src/webview_win.rs b/crates/atlas-desktop/src/webview_win.rs index b6271b15..83e04415 100644 --- a/crates/atlas-desktop/src/webview_win.rs +++ b/crates/atlas-desktop/src/webview_win.rs @@ -70,3 +70,57 @@ pub fn sync(app: &AppHandle) { } }); } + +#[cfg(test)] +mod tests { + /// Incident (1.5.0-alpha.24): the Windows app died at launch with `0x8007139F` because a window added + /// later (the agent dock) built its webview without [`super::ARGS`]. WebView2 refuses a second set of + /// options in one process, so one forgotten builder takes the whole app down, and only on Windows. + /// Every place that builds a webview must pass the shared args right after creating the builder. + #[test] + fn every_webview_builder_passes_the_shared_browser_args() { + let src = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src"); + let mut missing = Vec::new(); + let mut stack = vec![src.clone()]; + while let Some(dir) = stack.pop() { + for e in std::fs::read_dir(&dir).unwrap().flatten() { + let p = e.path(); + if p.is_dir() { + stack.push(p); + continue; + } + if p.extension().is_none_or(|x| x != "rs") { + continue; + } + let text = std::fs::read_to_string(&p).unwrap(); + // Production code only: a `#[cfg(test)]` module builds mock webviews. + let code = text.split("#[cfg(test)]").next().unwrap_or_default(); + let lines: Vec<&str> = code.lines().collect(); + for (i, l) in lines.iter().enumerate() { + let builds = [ + "WebviewWindowBuilder::new(", + "WebviewWindowBuilder::from_config(", + "WebviewBuilder::new(", + ] + .iter() + .any(|b| l.contains(b)); + if !builds { + continue; + } + let next = lines[i..lines.len().min(i + 15)].join("\n"); + if !next.contains("webview_win::ARGS") { + missing.push(format!( + "{}:{}", + p.strip_prefix(&src).unwrap().display(), + i + 1 + )); + } + } + } + } + assert!( + missing.is_empty(), + "webview built without webview_win::ARGS: {missing:?}" + ); + } +} diff --git a/crates/atlas-desktop/tauri.windows.conf.json b/crates/atlas-desktop/tauri.windows.conf.json index beccb964..ea52f1f5 100644 --- a/crates/atlas-desktop/tauri.windows.conf.json +++ b/crates/atlas-desktop/tauri.windows.conf.json @@ -32,7 +32,8 @@ "headerImage": "../../packaging/windows/installer-header.bmp", "sidebarImage": "../../packaging/windows/installer-sidebar.bmp", "startMenuFolder": "Atlas", - "compression": "lzma" + "compression": "lzma", + "installerHooks": "../../packaging/windows/installer-hooks.nsh" } } } diff --git a/packaging/windows/installer-hooks.nsh b/packaging/windows/installer-hooks.nsh new file mode 100644 index 00000000..4a7af1b9 --- /dev/null +++ b/packaging/windows/installer-hooks.nsh @@ -0,0 +1,14 @@ +; NSIS hooks of Atlas Desktop's Windows installer (tauri.windows.conf.json `installerHooks`). + +; The installer carries `atlas.exe` next to the app. Someone who installs only the app (no +; install.ps1) had no `atlas` command in a terminal and nothing for `atlas update` to update. +; First install: let that `atlas.exe` install itself the way install.ps1 does (`atlas install`: +; %USERPROFILE%\.atlas\bin, user PATH, bundled skills). Never over an existing CLI: it may be a +; newer one or a linked dev build, and `atlas update` keeps it in step with the app. +!macro NSIS_HOOK_POSTINSTALL + IfFileExists "$PROFILE\.atlas\bin\atlas.exe" atlas_cli_done 0 + IfFileExists "$INSTDIR\atlas.exe" 0 atlas_cli_done + nsExec::Exec '"$INSTDIR\atlas.exe" install' + Pop $0 + atlas_cli_done: +!macroend