diff --git a/apps/desktop-tauri/src-tauri/src/tray_bridge.rs b/apps/desktop-tauri/src-tauri/src/tray_bridge.rs index de9c78d1e0..db79fbc505 100644 --- a/apps/desktop-tauri/src-tauri/src/tray_bridge.rs +++ b/apps/desktop-tauri/src-tauri/src/tray_bridge.rs @@ -293,27 +293,36 @@ pub fn setup(app: &mut tauri::App) -> Result<(), Box> { .build(app)?; // Apply tray promotion on startup. The NotifyIconSettings entry is created - // by Windows only after the icon is first registered, so on the very first - // run the subkey may not exist yet — apply_promotion tolerates that - // (returns EntryNotFound, logs a debug message). A one-time retry task is - // spawned so that subsequent refreshes of the icon on first-run also work. - let promote = codexbar::settings::Settings::load().promote_tray_icon; - if promote { - crate::tray_visibility::apply_promotion(true); - let app_handle = app.handle().clone(); - tauri::async_runtime::spawn(async move { - tokio::time::sleep(std::time::Duration::from_secs(3)).await; - let still_promote = codexbar::settings::Settings::load().promote_tray_icon; - if still_promote { - crate::tray_visibility::apply_promotion(true); - } - drop(app_handle); - }); - } + // by Windows only after the icon is first registered, so on first run / + // post-upgrade the subkey may not exist yet — apply_promotion tolerates + // EntryNotFound. Retry a few times while explorer finishes registration. + schedule_tray_promotion_retries(app.handle().clone()); Ok(()) } +/// Re-apply Win11 tray promotion a few times after startup. +/// +/// Windows often creates the NotifyIconSettings subkey only after the first +/// successful NIM_ADD (and sometimes only after the icon is refreshed). A +/// single immediate write is not enough after upgrades. +fn schedule_tray_promotion_retries(app_handle: AppHandle) { + if !codexbar::settings::Settings::load().promote_tray_icon { + return; + } + crate::tray_visibility::apply_promotion(true); + tauri::async_runtime::spawn(async move { + for secs in [1_u64, 3, 8] { + tokio::time::sleep(std::time::Duration::from_secs(secs)).await; + if !codexbar::settings::Settings::load().promote_tray_icon { + break; + } + crate::tray_visibility::apply_promotion(true); + } + drop(app_handle); + }); +} + /// Route a native menu-item click to the corresponding shell action. fn handle_menu_event(app: &AppHandle, id: &str) { match resolve_menu_action(id) { diff --git a/apps/desktop-tauri/src-tauri/src/tray_visibility.rs b/apps/desktop-tauri/src-tauri/src/tray_visibility.rs index 535a204285..f00aa7b9bf 100644 --- a/apps/desktop-tauri/src-tauri/src/tray_visibility.rs +++ b/apps/desktop-tauri/src-tauri/src/tray_visibility.rs @@ -81,13 +81,41 @@ pub fn is_win11_build(build: u32) -> bool { build >= 22000 } -/// Case-insensitive path comparison after stripping trailing separators. +/// Normalize a Windows executable path for equality checks. +/// +/// Strips the `\\?\` / `\\?\UNC\` extended prefixes that `current_exe()` may +/// return, unifies separators, and lowercases. +pub fn normalize_exe_path(path: &str) -> String { + let mut s = path.replace('/', "\\"); + if let Some(rest) = s.strip_prefix(r"\\?\UNC\") { + s = format!(r"\\{rest}"); + } else if let Some(rest) = s.strip_prefix(r"\\?\") { + s = rest.to_string(); + } + s.trim_end_matches('\\').to_ascii_lowercase() +} + +/// Case-insensitive path comparison after stripping trailing separators and +/// Win32 extended-length prefixes. /// Returns true when `entry_path` names the same executable as `exe_path`. pub fn matches_current_exe(entry_path: &str, exe_path: &std::path::Path) -> bool { - let normalize = |s: &str| s.replace('/', "\\").trim_end_matches('\\').to_ascii_lowercase(); - let entry = normalize(entry_path); - let exe = normalize(&exe_path.to_string_lossy()); - entry == exe + normalize_exe_path(entry_path) == normalize_exe_path(&exe_path.to_string_lossy()) +} + +/// True when both paths end with the same file name (case-insensitive). +/// Used as a fallback when the full path differs after an install-dir move. +pub fn matches_exe_file_name(entry_path: &str, exe_path: &std::path::Path) -> bool { + let entry_name = normalize_exe_path(entry_path) + .rsplit('\\') + .next() + .unwrap_or_default() + .to_string(); + let exe_name = exe_path + .file_name() + .and_then(|n| n.to_str()) + .map(normalize_exe_path) + .unwrap_or_default(); + !entry_name.is_empty() && entry_name == exe_name } // ── Platform-specific implementation ───────────────────────────────────────── @@ -105,9 +133,7 @@ mod windows { fn current_build() -> u32 { use winreg::enums::*; let hklm = RegKey::predef(HKEY_LOCAL_MACHINE); - let Ok(key) = hklm.open_subkey( - r"SOFTWARE\Microsoft\Windows NT\CurrentVersion", - ) else { + let Ok(key) = hklm.open_subkey(r"SOFTWARE\Microsoft\Windows NT\CurrentVersion") else { return 0; }; let Ok(build_str): Result = key.get_value("CurrentBuild") else { @@ -128,6 +154,9 @@ mod windows { settings_key: &RegKey, exe_path: &std::path::Path, ) -> Result, std::io::Error> { + let mut by_name: Option = None; + let mut name_matches = 0u32; + for name in settings_key.enum_keys().flatten() { let Ok(subkey) = settings_key.open_subkey_with_flags(&name, KEY_READ | KEY_WRITE) else { @@ -139,6 +168,16 @@ mod windows { if matches_current_exe(&entry_path, exe_path) { return Ok(Some(subkey)); } + if matches_exe_file_name(&entry_path, exe_path) { + name_matches += 1; + by_name = Some(subkey); + } + } + + // Fallback: unique file-name match (handles install-dir moves / path + // rewrites where Windows still has a single CodexBar notify entry). + if name_matches == 1 { + return Ok(by_name); } Ok(None) } @@ -152,8 +191,8 @@ mod windows { let Ok(settings_key) = hkcu.open_subkey_with_flags(NOTIFY_ICON_SETTINGS, KEY_READ) else { return Ok(PromotionState::EntryNotFound); }; - let Some(subkey) = find_own_subkey(&settings_key, &exe_path) - .map_err(TrayVisibilityError::Registry)? + let Some(subkey) = + find_own_subkey(&settings_key, &exe_path).map_err(TrayVisibilityError::Registry)? else { return Ok(PromotionState::EntryNotFound); }; @@ -171,12 +210,13 @@ mod windows { } let exe_path = std::env::current_exe().map_err(TrayVisibilityError::ExePathUnresolvable)?; let hkcu = RegKey::predef(HKEY_CURRENT_USER); - let Ok(settings_key) = hkcu.open_subkey_with_flags(NOTIFY_ICON_SETTINGS, KEY_READ | KEY_WRITE) + let Ok(settings_key) = + hkcu.open_subkey_with_flags(NOTIFY_ICON_SETTINGS, KEY_READ | KEY_WRITE) else { return Ok(PromotionState::EntryNotFound); }; - let Some(subkey) = find_own_subkey(&settings_key, &exe_path) - .map_err(TrayVisibilityError::Registry)? + let Some(subkey) = + find_own_subkey(&settings_key, &exe_path).map_err(TrayVisibilityError::Registry)? else { return Ok(PromotionState::EntryNotFound); }; @@ -209,10 +249,10 @@ mod non_windows { } } -#[cfg(target_os = "windows")] -use windows as platform; #[cfg(not(target_os = "windows"))] use non_windows as platform; +#[cfg(target_os = "windows")] +use windows as platform; pub fn support_status() -> TrayVisibilitySupport { platform::support_status() @@ -312,6 +352,34 @@ mod tests { )); } + #[test] + fn matches_extended_length_prefix_from_current_exe() { + assert!(matches_current_exe( + r"C:\Users\mac\AppData\Local\Programs\CodexBar\codexbar.exe", + Path::new(r"\\?\C:\Users\mac\AppData\Local\Programs\CodexBar\codexbar.exe") + )); + } + + #[test] + fn matches_exe_file_name_across_install_dirs() { + assert!(matches_exe_file_name( + r"C:\Old\CodexBar\codexbar.exe", + Path::new(r"C:\Users\mac\AppData\Local\Programs\CodexBar\codexbar.exe") + )); + assert!(!matches_exe_file_name( + r"C:\Old\CodexBar\codexbar-cli.exe", + Path::new(r"C:\Users\mac\AppData\Local\Programs\CodexBar\codexbar.exe") + )); + } + + #[test] + fn normalize_strips_unc_extended_prefix() { + assert_eq!( + normalize_exe_path(r"\\?\UNC\server\share\codexbar.exe"), + r"\\server\share\codexbar.exe" + ); + } + #[test] fn should_write_demotion_only_when_previously_on() { assert!(should_write_demotion(true, false)); diff --git a/rust/installer/codexbar.iss b/rust/installer/codexbar.iss index ad23e75c79..ff94e0d6cf 100644 --- a/rust/installer/codexbar.iss +++ b/rust/installer/codexbar.iss @@ -60,7 +60,12 @@ Name: "{autoprograms}\CodexBar"; Filename: "{app}\codexbar.exe"; Parameters: "me Name: "{autodesktop}\CodexBar"; Filename: "{app}\codexbar.exe"; Parameters: "menubar"; WorkingDir: "{app}"; Tasks: desktopicon; IconFilename: "{app}\icon.ico" [Run] +; Interactive installs: optional checkbox on the finish page. Filename: "{app}\codexbar.exe"; Parameters: "menubar"; Description: "Launch CodexBar"; Flags: nowait postinstall skipifsilent; Check: CanLaunchCodexBar +; Silent upgrades (winget / in-app updater): always relaunch so the tray icon +; returns after CloseApplications kills the previous process. Single-instance +; handles a second launch from the updater helper if both fire. +Filename: "{app}\codexbar.exe"; Parameters: "menubar"; Flags: nowait postinstall skipifnotsilent; Check: CanLaunchCodexBar [Code] var diff --git a/rust/src/settings.rs b/rust/src/settings.rs index 0cc7a849fd..d45de347a5 100755 --- a/rust/src/settings.rs +++ b/rust/src/settings.rs @@ -255,7 +255,8 @@ pub struct Settings { /// Promote the tray icon out of the Windows hidden-icons overflow area. /// Only has effect on Windows 11 (build ≥ 22000); silently ignored elsewhere. - #[serde(default)] + /// Defaults on so upgrades keep the icon pinned to the taskbar notification area. + #[serde(default = "default_true")] pub promote_tray_icon: bool, } @@ -447,7 +448,7 @@ impl Default for Settings { float_bar_dark_text: false, float_bar_show_reset_inline: false, float_bar_show_cost: false, - promote_tray_icon: false, + promote_tray_icon: true, } } } @@ -475,11 +476,45 @@ impl Settings { #[cfg(target_os = "windows")] { settings.start_at_login = Self::sync_start_at_login_registry(); + settings.apply_promote_tray_default_migration(); } settings } + /// Marker written after the one-shot "pin tray by default" migration (issue #237). + fn promote_tray_default_marker_path() -> Option { + dirs::config_dir().map(|p| p.join("CodexBar").join(".tray-pin-default-v1")) + } + + /// Old builds defaulted `promote_tray_icon` to false and persisted that on any + /// settings save. Flip those installs to the new default once; later opt-outs + /// are preserved because the marker file remains. + fn should_migrate_promote_tray_default(promote_tray_icon: bool, already_migrated: bool) -> bool { + !already_migrated && !promote_tray_icon + } + + fn apply_promote_tray_default_migration(&mut self) { + let Some(marker) = Self::promote_tray_default_marker_path() else { + return; + }; + let already_migrated = marker.exists(); + if Self::should_migrate_promote_tray_default(self.promote_tray_icon, already_migrated) { + self.promote_tray_icon = true; + if let Err(error) = self.save() { + tracing::warn!("Failed to persist promote_tray_icon default migration: {error}"); + } + } + if !already_migrated + && let Some(parent) = marker.parent() + { + let _ = std::fs::create_dir_all(parent); + if let Err(error) = std::fs::write(&marker, b"1") { + tracing::warn!("Failed to write promote_tray_icon migration marker: {error}"); + } + } + } + /// Save settings to disk pub fn save(&self) -> anyhow::Result<()> { let path = Self::settings_path() diff --git a/rust/src/settings/raw.rs b/rust/src/settings/raw.rs index 57ab5c8170..43ca496f5e 100644 --- a/rust/src/settings/raw.rs +++ b/rust/src/settings/raw.rs @@ -153,7 +153,7 @@ pub(super) struct RawSettings { float_bar_show_reset_inline: bool, #[serde(default)] float_bar_show_cost: bool, - #[serde(default)] + #[serde(default = "default_true")] promote_tray_icon: bool, } diff --git a/rust/src/settings/tests.rs b/rust/src/settings/tests.rs index 6ae8c25079..d364b7a3fb 100644 --- a/rust/src/settings/tests.rs +++ b/rust/src/settings/tests.rs @@ -12,6 +12,27 @@ fn test_settings_default() { assert!(!settings.show_reset_when_exhausted); assert!(!settings.predictive_pace_warning_enabled); assert!(!settings.float_bar_show_cost); + assert!(settings.promote_tray_icon); +} + +#[test] +fn promote_tray_icon_defaults_on_when_missing_from_disk() { + let loaded: Settings = serde_json::from_str( + r#"{ + "enabled_providers": ["claude", "codex"], + "refresh_interval_secs": 300 + }"#, + ) + .expect("parse settings without promote_tray_icon"); + assert!(loaded.promote_tray_icon); +} + +#[test] +fn promote_tray_default_migration_flips_old_false_once() { + assert!(Settings::should_migrate_promote_tray_default(false, false)); + assert!(!Settings::should_migrate_promote_tray_default(true, false)); + assert!(!Settings::should_migrate_promote_tray_default(false, true)); + assert!(!Settings::should_migrate_promote_tray_default(true, true)); } #[test]