From 43d352e4b235c3ab0a755ecff65c4f6d83dbc7de Mon Sep 17 00:00:00 2001 From: StudentWeis Date: Wed, 13 May 2026 23:39:32 +0800 Subject: [PATCH 1/3] fix(autostart): use CurrentUser registry and normalise Scoop path on Windows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On Windows the auto-launch builder now explicitly targets HKCU instead of the default Dynamic mode (try HKLM, fall back to HKCU) which can silently fail on non-admin accounts or be blocked by security policies. Additionally, when running from a Scoop installation the resolved exe path (e.g. `…\apps\ropy\0.5.1\ropy.exe`) is normalised back to the stable `…\apps\ropy\current\ropy.exe` junction so that the registry entry survives package upgrades. Closes #132 --- src/config/autostart.rs | 49 +++++++++++++++++++++++++++++++++++------ 1 file changed, 42 insertions(+), 7 deletions(-) diff --git a/src/config/autostart.rs b/src/config/autostart.rs index fa04cac..40835e5 100644 --- a/src/config/autostart.rs +++ b/src/config/autostart.rs @@ -1,6 +1,8 @@ #![cfg_attr(test, allow(clippy::expect_used, clippy::unwrap_used))] use std::env; +#[cfg(target_os = "windows")] +use auto_launch::WindowsEnableMode; use auto_launch::{AutoLaunch, AutoLaunchBuilder}; use thiserror::Error; @@ -29,13 +31,15 @@ impl AutoStartManager { pub(crate) fn new(app_name: &str) -> Result { let app_path = Self::get_app_path()?; - let auto_launch = AutoLaunchBuilder::new() - .set_app_name(app_name) - .set_app_path(&app_path) - .build() - .map_err(|e| { - AutoStartError::Initialization(format!("Failed to build AutoLaunch: {e}")) - })?; + let mut builder = AutoLaunchBuilder::new(); + builder.set_app_name(app_name).set_app_path(&app_path); + + #[cfg(target_os = "windows")] + builder.set_windows_enable_mode(WindowsEnableMode::CurrentUser); + + let auto_launch = builder.build().map_err(|e| { + AutoStartError::Initialization(format!("Failed to build AutoLaunch: {e}")) + })?; Ok(Self { auto_launch }) } @@ -45,6 +49,11 @@ impl AutoStartManager { /// inside a `.app` bundle and the auto-launch entry must reference /// the bundle, not the inner Mach-O — otherwise launchd starts a /// detached binary without a working environment. + /// + /// On Windows, when installed via Scoop the resolved exe path may + /// point through a versioned directory (e.g. `apps\ropy\0.5.1\`) + /// instead of the stable `apps\ropy\current\` junction. We normalise + /// back to `current` so that the registry entry survives upgrades. fn get_app_path() -> Result { let exe_path = env::current_exe().map_err(|e| AutoStartError::ExecutablePath(e.to_string()))?; @@ -59,6 +68,32 @@ impl AutoStartManager { } } + #[cfg(target_os = "windows")] + { + let exe_str = exe_path.to_string_lossy(); + // Detect Scoop layout: ...\scoop\apps\\\ + // and replace the version segment with "current". + if let Some(apps_idx) = exe_str.to_lowercase().find("\\scoop\\apps\\") { + let after_apps = apps_idx + "\\scoop\\apps\\".len(); + // Find the app-name segment end (next backslash after apps\) + if let Some(name_end) = exe_str[after_apps..].find('\\') { + let version_start = after_apps + name_end + 1; + // Find the version segment end + if let Some(version_end) = exe_str[version_start..].find('\\') { + let version_segment = &exe_str[version_start..version_start + version_end]; + if version_segment != "current" { + let normalised = format!( + "{}current{}", + &exe_str[..version_start], + &exe_str[version_start + version_end..] + ); + return Ok(normalised); + } + } + } + } + } + exe_path.to_str().map(ToString::to_string).ok_or_else(|| { AutoStartError::ExecutablePath("Path contains invalid UTF-8".to_string()) }) From 65dff5516bc4e0df76f6b0e24c4cf7274177c4d6 Mon Sep 17 00:00:00 2001 From: StudentWeis Date: Thu, 14 May 2026 16:38:37 +0800 Subject: [PATCH 2/3] refactor(autostart): extract normalise_scoop_path helper with tests Move the inline Scoop versioned-path rewrite out of `get_app_path` into a standalone `normalise_scoop_path` helper, and cover it with pure string-level unit tests (versioned segment, `current` junction, case variations, non-Scoop paths, malformed inputs). Also drop the `to_lowercase()` allocation in favour of `eq_ignore_ascii_case` on raw bytes, since the marker is pure ASCII. Refs #132 --- src/config/autostart.rs | 121 +++++++++++++++++++++++++++++++--------- 1 file changed, 95 insertions(+), 26 deletions(-) diff --git a/src/config/autostart.rs b/src/config/autostart.rs index 40835e5..53b9418 100644 --- a/src/config/autostart.rs +++ b/src/config/autostart.rs @@ -48,12 +48,9 @@ impl AutoStartManager { /// Windows registry should point at. On macOS a release build runs /// inside a `.app` bundle and the auto-launch entry must reference /// the bundle, not the inner Mach-O — otherwise launchd starts a - /// detached binary without a working environment. - /// - /// On Windows, when installed via Scoop the resolved exe path may - /// point through a versioned directory (e.g. `apps\ropy\0.5.1\`) - /// instead of the stable `apps\ropy\current\` junction. We normalise - /// back to `current` so that the registry entry survives upgrades. + /// detached binary without a working environment. On Windows we + /// also rewrite Scoop's versioned install path back to the stable + /// `current` junction — see [`normalise_scoop_path`]. fn get_app_path() -> Result { let exe_path = env::current_exe().map_err(|e| AutoStartError::ExecutablePath(e.to_string()))?; @@ -71,26 +68,8 @@ impl AutoStartManager { #[cfg(target_os = "windows")] { let exe_str = exe_path.to_string_lossy(); - // Detect Scoop layout: ...\scoop\apps\\\ - // and replace the version segment with "current". - if let Some(apps_idx) = exe_str.to_lowercase().find("\\scoop\\apps\\") { - let after_apps = apps_idx + "\\scoop\\apps\\".len(); - // Find the app-name segment end (next backslash after apps\) - if let Some(name_end) = exe_str[after_apps..].find('\\') { - let version_start = after_apps + name_end + 1; - // Find the version segment end - if let Some(version_end) = exe_str[version_start..].find('\\') { - let version_segment = &exe_str[version_start..version_start + version_end]; - if version_segment != "current" { - let normalised = format!( - "{}current{}", - &exe_str[..version_start], - &exe_str[version_start + version_end..] - ); - return Ok(normalised); - } - } - } + if let Some(normalised) = normalise_scoop_path(&exe_str) { + return Ok(normalised); } } @@ -133,6 +112,42 @@ impl AutoStartManager { } } +/// Rewrite a Windows path that points through a Scoop versioned +/// directory (`...\scoop\apps\\\...`) so the version +/// segment becomes `current`. Returns `None` when `path` is not a +/// Scoop install path or already targets `current`. +/// +/// `current_exe()` resolves Scoop's `current` junction to the real +/// versioned directory; persisting that resolved path to the autostart +/// registry pins the entry to a specific version, which Scoop then +/// deletes on upgrade. Pointing at `current` lets the entry survive +/// upgrades. +#[cfg(any(target_os = "windows", test))] +fn normalise_scoop_path(path: &str) -> Option { + const MARKER: &[u8] = br"\scoop\apps\"; + + let bytes = path.as_bytes(); + let last_start = bytes.len().checked_sub(MARKER.len())?; + let apps_idx = + (0..=last_start).find(|&i| bytes[i..i + MARKER.len()].eq_ignore_ascii_case(MARKER))?; + + // Marker is pure ASCII, so byte indices are valid UTF-8 char + // boundaries; the same holds for the `\` positions found below. + let after_apps = apps_idx + MARKER.len(); + let name_end = path[after_apps..].find('\\')?; + let version_start = after_apps + name_end + 1; + let version_end = path[version_start..].find('\\')?; + let version_segment = &path[version_start..version_start + version_end]; + if version_segment.eq_ignore_ascii_case("current") { + return None; + } + Some(format!( + "{}current{}", + &path[..version_start], + &path[version_start + version_end..], + )) +} + #[cfg(test)] mod tests { use serial_test::serial; @@ -175,4 +190,58 @@ mod tests { assert!(!enabled); } } + + // Pure-string Scoop normalisation tests — no OS state, safe to run + // in parallel with each other on any platform. + + #[test] + fn normalise_scoop_replaces_versioned_segment() { + assert_eq!( + normalise_scoop_path(r"C:\Users\foo\scoop\apps\ropy\0.5.1\ropy.exe").as_deref(), + Some(r"C:\Users\foo\scoop\apps\ropy\current\ropy.exe"), + ); + } + + #[test] + fn normalise_scoop_returns_none_for_current_junction() { + assert_eq!( + normalise_scoop_path(r"C:\Users\foo\scoop\apps\ropy\current\ropy.exe"), + None, + ); + } + + #[test] + fn normalise_scoop_is_case_insensitive() { + // Marker matches case-insensitively… + assert_eq!( + normalise_scoop_path(r"C:\Users\foo\Scoop\Apps\ropy\0.5.1\ropy.exe").as_deref(), + Some(r"C:\Users\foo\Scoop\Apps\ropy\current\ropy.exe"), + ); + // …and so does the `current` check, so we don't pointlessly + // rewrite an already-stable path. + assert_eq!( + normalise_scoop_path(r"C:\Users\foo\scoop\apps\ropy\Current\ropy.exe"), + None, + ); + } + + #[test] + fn normalise_scoop_returns_none_for_non_scoop_path() { + assert_eq!( + normalise_scoop_path(r"C:\Program Files\ropy\ropy.exe"), + None, + ); + assert_eq!(normalise_scoop_path(""), None); + } + + #[test] + fn normalise_scoop_returns_none_when_version_segment_missing() { + // No `\` after the version, i.e. path ends at the version dir. + assert_eq!( + normalise_scoop_path(r"C:\Users\foo\scoop\apps\ropy\0.5.1"), + None, + ); + // No app-name segment at all. + assert_eq!(normalise_scoop_path(r"C:\scoop\apps\"), None); + } } From e039431a730d8cd5c253a66bf39fecc2c1519077 Mon Sep 17 00:00:00 2001 From: StudentWeis Date: Thu, 14 May 2026 16:38:53 +0800 Subject: [PATCH 3/3] chore: add claude config and docs links --- .claude | 1 + CLAUDE.md | 1 + 2 files changed, 2 insertions(+) create mode 120000 .claude create mode 120000 CLAUDE.md diff --git a/.claude b/.claude new file mode 120000 index 0000000..c0ca468 --- /dev/null +++ b/.claude @@ -0,0 +1 @@ +.agents \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md new file mode 120000 index 0000000..47dc3e3 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1 @@ +AGENTS.md \ No newline at end of file