Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 26 additions & 17 deletions apps/desktop-tauri/src-tauri/src/tray_bridge.rs
Original file line number Diff line number Diff line change
Expand Up @@ -293,27 +293,36 @@ pub fn setup(app: &mut tauri::App) -> Result<(), Box<dyn std::error::Error>> {
.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) {
Expand Down
98 changes: 83 additions & 15 deletions apps/desktop-tauri/src-tauri/src/tray_visibility.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 ─────────────────────────────────────────
Expand All @@ -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<String, _> = key.get_value("CurrentBuild") else {
Expand All @@ -128,6 +154,9 @@ mod windows {
settings_key: &RegKey,
exe_path: &std::path::Path,
) -> Result<Option<RegKey>, std::io::Error> {
let mut by_name: Option<RegKey> = 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 {
Expand All @@ -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)
}
Expand All @@ -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);
};
Expand All @@ -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);
};
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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));
Expand Down
5 changes: 5 additions & 0 deletions rust/installer/codexbar.iss
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
39 changes: 37 additions & 2 deletions rust/src/settings.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}

Expand Down Expand Up @@ -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,
}
}
}
Expand Down Expand Up @@ -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<PathBuf> {
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()
Expand Down
2 changes: 1 addition & 1 deletion rust/src/settings/raw.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}

Expand Down
21 changes: 21 additions & 0 deletions rust/src/settings/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
Loading