diff --git a/apps/desktop-tauri/src-tauri/src/commands/bridge.rs b/apps/desktop-tauri/src-tauri/src/commands/bridge.rs index 44432314cb..0e0d00f072 100644 --- a/apps/desktop-tauri/src-tauri/src/commands/bridge.rs +++ b/apps/desktop-tauri/src-tauri/src/commands/bridge.rs @@ -975,6 +975,7 @@ pub struct SettingsSnapshot { http_proxy_username: String, http_proxy_password: String, ui_language: &'static str, + ui_language_follows_system: bool, theme: &'static str, window_scale_percent: u16, tray_scale_percent: u16, @@ -1140,6 +1141,7 @@ impl From for SettingsSnapshot { http_proxy_username: settings.http_proxy_username, http_proxy_password: settings.http_proxy_password, ui_language: language_label(settings.ui_language), + ui_language_follows_system: settings.ui_language_follows_system, theme: theme_label(settings.theme), window_scale_percent: settings.window_scale_percent, tray_scale_percent: settings.tray_scale_percent, diff --git a/apps/desktop-tauri/src-tauri/src/commands/locale_cmd.rs b/apps/desktop-tauri/src-tauri/src/commands/locale_cmd.rs index cd668fc83b..b0436cce44 100644 --- a/apps/desktop-tauri/src-tauri/src/commands/locale_cmd.rs +++ b/apps/desktop-tauri/src-tauri/src/commands/locale_cmd.rs @@ -75,9 +75,11 @@ pub fn set_ui_language(app: tauri::AppHandle, language: String) -> Result<(), St let lang = parse_locale_language(&language).ok_or_else(|| format!("unknown language: {language}"))?; let mut settings = Settings::load(); - if settings.ui_language == lang { + if settings.ui_language == lang && !settings.ui_language_follows_system { return Ok(()); } + // Picking a language explicitly stops following the system language. + settings.ui_language_follows_system = false; settings.ui_language = lang; settings.save().map_err(|e| e.to_string())?; let _ = app.emit(events::LOCALE_CHANGED, language_label(lang)); diff --git a/apps/desktop-tauri/src-tauri/src/commands/preferences_transfer.rs b/apps/desktop-tauri/src-tauri/src/commands/preferences_transfer.rs index 7ccccc79a9..9a9a27ee1b 100644 --- a/apps/desktop-tauri/src-tauri/src/commands/preferences_transfer.rs +++ b/apps/desktop-tauri/src-tauri/src/commands/preferences_transfer.rs @@ -62,6 +62,8 @@ pub async fn import_preferences( let applied = document .apply_to(&mut settings) .map_err(|error| error.to_string())?; + // An imported "System" language resolves to this PC's display language. + settings.resolve_system_language(); if settings.cost_usage_enabled && !previous_cost_tracking { // Turning "Track costs" on pins the bucket zone, as in Settings. settings.pin_cost_usage_bucket_time_zone(); diff --git a/apps/desktop-tauri/src-tauri/src/commands/settings.rs b/apps/desktop-tauri/src-tauri/src/commands/settings.rs index 3be594f168..8f2da78ba9 100644 --- a/apps/desktop-tauri/src-tauri/src/commands/settings.rs +++ b/apps/desktop-tauri/src-tauri/src/commands/settings.rs @@ -66,6 +66,7 @@ pub struct SettingsUpdate { pub http_proxy_username: Option, pub http_proxy_password: Option, pub ui_language: Option, + pub ui_language_follows_system: Option, pub theme: Option, pub window_scale_percent: Option, pub tray_scale_percent: Option, @@ -147,7 +148,22 @@ impl SettingsUpdate { } fn rebuilds_tray_menu(&self) -> bool { - self.float_bar_enabled.is_some() || self.ui_language.is_some() + self.float_bar_enabled.is_some() + || self.ui_language.is_some() + || self.ui_language_follows_system.is_some() + } + + /// Picking a language stops following the system; picking "System" + /// (`ui_language_follows_system: true`) resolves to `system`. + fn apply_language_with(&self, settings: &mut Settings, system: Language) { + if let Some(language) = self.ui_language.as_deref().and_then(parse_language) { + settings.ui_language = language; + settings.ui_language_follows_system = false; + } + if let Some(follow) = self.ui_language_follows_system { + settings.ui_language_follows_system = follow; + } + settings.resolve_system_language_with(system); } pub fn changes_tray_promotion(&self) -> bool { @@ -176,6 +192,7 @@ impl SettingsUpdate { || self.cost_reporting_period.is_some() || self.enabled_providers.is_some() || self.ui_language.is_some() + || self.ui_language_follows_system.is_some() } fn validate_shortcut_change( @@ -289,11 +306,13 @@ impl SettingsUpdate { if let Some(v) = self.global_shortcut.clone() { settings.global_shortcut = v; } - if let Some(v) = self.ui_language.as_deref().and_then(parse_language) - && settings.ui_language != v - { - settings.ui_language = v; - } + // Read the registry only when "System" is being picked. + let system = if self.ui_language_follows_system == Some(true) { + Language::system() + } else { + settings.ui_language + }; + self.apply_language_with(settings, system); if let Some(v) = self.theme.as_deref().and_then(parse_theme) { settings.theme = v; } @@ -1200,6 +1219,45 @@ mod tests { ); } + #[test] + fn choosing_system_language_follows_the_system() { + let mut settings = Settings { + ui_language: Language::Spanish, + ui_language_follows_system: false, + ..Settings::default() + }; + let patch = SettingsUpdate { + ui_language_follows_system: Some(true), + ..Default::default() + }; + assert!(patch.rebuilds_tray_menu()); + assert!(patch.refreshes_tray_presentation()); + patch.apply_language_with(&mut settings, Language::Korean); + assert!(settings.ui_language_follows_system); + assert_eq!(settings.ui_language, Language::Korean); + } + + #[test] + fn choosing_a_specific_language_stops_following_the_system() { + let mut settings = Settings { + ui_language: Language::Korean, + ui_language_follows_system: true, + ..Settings::default() + }; + SettingsUpdate { + ui_language: Some("japanese".to_string()), + ..Default::default() + } + .apply_language_with(&mut settings, Language::Korean); + assert!(!settings.ui_language_follows_system); + assert_eq!(settings.ui_language, Language::Japanese); + + // An unrelated patch leaves the choice alone. + SettingsUpdate::default().apply_language_with(&mut settings, Language::Korean); + assert!(!settings.ui_language_follows_system); + assert_eq!(settings.ui_language, Language::Japanese); + } + #[test] fn ui_language_change_refreshes_tray_presentation() { assert!( diff --git a/apps/desktop-tauri/src-tauri/src/commands/tests.rs b/apps/desktop-tauri/src-tauri/src/commands/tests.rs index f30a7758b4..587da2344e 100644 --- a/apps/desktop-tauri/src-tauri/src/commands/tests.rs +++ b/apps/desktop-tauri/src-tauri/src/commands/tests.rs @@ -1373,9 +1373,9 @@ fn usage_item_descriptors_keep_raw_ids_and_redact_titles() { let items = super::usage_item_descriptors(Some(&snapshot), &settings, ProviderId::Codex); assert_eq!(items[0].id, "metric:primary"); - assert_eq!(items[0].title, "Account Hidden"); + assert_eq!(items[0].title, "Account"); assert_eq!(items[1].id, "metric:extra-credits"); - assert_eq!(items[1].title, "Credits Hidden"); + assert_eq!(items[1].title, "Credits"); assert_eq!(items[2].id, "metric:extra-missing"); assert!(!items[2].available); } @@ -1434,7 +1434,7 @@ fn usage_item_descriptors_include_detail_sections() { .find(|item| item.id.starts_with("detailSection:") && !item.available) .expect("hidden detail placeholder"); assert!(hidden.id.contains("owner@example.com quota")); - assert_eq!(hidden.title, "Hidden quota"); + assert_eq!(hidden.title, "quota"); } #[test] diff --git a/apps/desktop-tauri/src-tauri/src/commands/usage_items.rs b/apps/desktop-tauri/src-tauri/src/commands/usage_items.rs index f7e05625ac..e60e8c16ea 100644 --- a/apps/desktop-tauri/src-tauri/src/commands/usage_items.rs +++ b/apps/desktop-tauri/src-tauri/src/commands/usage_items.rs @@ -29,7 +29,7 @@ pub(crate) fn usage_item_id(raw_id: &str) -> String { } fn redacted_usage_item_title(title: &str, settings: &Settings) -> String { - PersonalInfoRedactor::redact_emails_in_text(Some(title), settings.hide_personal_info) + PersonalInfoRedactor::hide_emails_in_text(Some(title), settings.hide_personal_info) .unwrap_or_default() } @@ -92,7 +92,9 @@ pub(crate) fn usage_item_descriptors( let mut detail_titles = std::collections::HashSet::new(); for detail in &snapshot.display_details { if detail_titles.insert(detail.title.clone()) { - let redacted = PersonalInfoRedactor::redact_emails_in_text( + // The ID keeps the "Hidden" marker it has always used, so + // sections hidden under Hide Personal Info stay hidden. + let id_title = PersonalInfoRedactor::redact_emails_in_text( Some(&detail.title), settings.hide_personal_info, ) @@ -100,12 +102,12 @@ pub(crate) fn usage_item_descriptors( let id = format!( "{}{}", codexbar::settings::USAGE_ITEM_DETAIL_SECTION_PREFIX, - redacted + id_title ); if seen.insert(id.clone()) { items.push(ProviderUsageItemSnapshot { id, - title: redacted, + title: redacted_usage_item_title(&detail.title, settings), available: true, }); } diff --git a/apps/desktop-tauri/src-tauri/src/tray_accounts.rs b/apps/desktop-tauri/src-tauri/src/tray_accounts.rs index eb9c897e38..4dd3da92ab 100644 --- a/apps/desktop-tauri/src-tauri/src/tray_accounts.rs +++ b/apps/desktop-tauri/src-tauri/src/tray_accounts.rs @@ -6,6 +6,7 @@ use codexbar::codex_accounts::{CodexAccount, ordinals_by_id}; use codexbar::locale::{self, LocaleKey}; use codexbar::settings::{Language, Settings}; +use std::collections::HashMap; use tauri::AppHandle; #[cfg(test)] use uuid::Uuid; @@ -261,20 +262,40 @@ fn codex_account_menu_label( ) } +/// Number accounts 1..n by their sorted stable id, not by list order, so an +/// account keeps its "Account N" when a switch reorders the list. Settings +/// uses the same rule (`buildAccountOrdinals`). +fn ordinals_by_sorted_id<'a>(ids: impl IntoIterator) -> HashMap<&'a str, usize> { + let mut ids: Vec<&str> = ids.into_iter().collect(); + ids.sort_unstable(); + ids.dedup(); + ids.into_iter() + .enumerate() + .map(|(index, id)| (id, index + 1)) + .collect() +} + +/// "Account N": Hide Personal Info shows no part of an email (Mac +/// `PersonalInfoRedactor.AccountOrdinal`). +fn hidden_account_label(lang: Language, ordinal: usize) -> String { + let word = locale::get_text(lang, LocaleKey::Account); + let word = word.trim(); + let word = if word.is_empty() { "Account" } else { word }; + format!("{word} {ordinal}") +} + fn claude_accounts_menu( accounts: &[codexbar::providers::claude::accounts::ClaudeAccount], lang: Language, hide_personal_info: bool, ) -> TrayMenuEntry { let text = |key| locale::get_text(lang, key); + let ordinals = ordinals_by_sorted_id(accounts.iter().map(|account| account.id.as_str())); let mut children: Vec<_> = accounts .iter() .map(|account| { let label = if hide_personal_info { - codexbar::core::PersonalInfoRedactor::partial_redact_email( - Some(&account.email), - true, - ) + hidden_account_label(lang, ordinals[account.id.as_str()]) } else { account.organization.as_ref().map_or_else( || account.email.clone(), @@ -322,14 +343,12 @@ fn grok_accounts_menu( hide_personal_info: bool, ) -> TrayMenuEntry { let text = |key| locale::get_text(lang, key); + let ordinals = ordinals_by_sorted_id(accounts.iter().map(|account| account.id.as_str())); let mut children: Vec<_> = accounts .iter() .map(|account| { let label = if hide_personal_info { - codexbar::core::PersonalInfoRedactor::partial_redact_email( - Some(&account.email), - true, - ) + hidden_account_label(lang, ordinals[account.id.as_str()]) } else { account.email.clone() }; @@ -605,4 +624,111 @@ mod tests { "add_grok_account" )); } + + fn claude_menu_account(id: &str) -> codexbar::providers::claude::accounts::ClaudeAccount { + codexbar::providers::claude::accounts::ClaudeAccount { + id: id.into(), + email: format!("{id}@example.com"), + organization: None, + plan: None, + is_active: false, + is_saved: true, + } + } + + #[test] + fn hidden_claude_and_grok_tray_labels_show_no_email() { + use codexbar::providers::claude::accounts::ClaudeAccount; + use codexbar::providers::grok::accounts::GrokAccount; + let claude = ClaudeAccount { + id: "a:org".into(), + email: "proof@example.com".into(), + organization: Some("proof@example.com's Organization".into()), + plan: None, + is_active: true, + is_saved: true, + }; + let claude_other = ClaudeAccount { + id: "b:org".into(), + email: "second@example.com".into(), + is_active: false, + ..claude.clone() + }; + let grok = GrokAccount { + id: "user-a".into(), + email: "proof@example.com".into(), + organization: None, + plan: None, + is_active: true, + is_saved: true, + }; + let claude_menu = claude_accounts_menu(&[claude, claude_other], Language::English, true); + let grok_menu = grok_accounts_menu(&[grok], Language::English, true); + let labels: Vec<&str> = claude_menu.children[..2] + .iter() + .chain(&grok_menu.children[..1]) + .map(|entry| entry.label.as_str()) + .collect(); + assert_eq!(labels, ["Account 1", "Account 2", "Account 1"]); + + // An unsaved live login is listed first; numbering still follows the + // sorted ids, so "b:org" stays "Account 2" whatever the list order. + let live = ClaudeAccount { + is_active: true, + is_saved: false, + ..claude_menu_account("0:org") + }; + let reordered = claude_accounts_menu( + &[ + live, + claude_menu_account("b:org"), + claude_menu_account("a:org"), + ], + Language::English, + true, + ); + let by_id: Vec<(&str, &str)> = reordered.children[..3] + .iter() + .map(|entry| (entry.id.as_deref().unwrap_or(""), entry.label.as_str())) + .collect(); + assert_eq!( + by_id, + [ + ("switch_claude_account:0:org", "Account 1"), + ("switch_claude_account:b:org", "Account 3"), + ("switch_claude_account:a:org", "Account 2"), + ] + ); + let grok_reordered = grok_accounts_menu( + &[ + GrokAccount { + id: "user-z".into(), + email: "z@example.com".into(), + organization: None, + plan: None, + is_active: true, + is_saved: false, + }, + GrokAccount { + id: "user-a".into(), + email: "a@example.com".into(), + organization: None, + plan: None, + is_active: false, + is_saved: true, + }, + ], + Language::English, + true, + ); + let grok_labels: Vec<&str> = grok_reordered.children[..2] + .iter() + .map(|entry| entry.label.as_str()) + .collect(); + assert_eq!(grok_labels, ["Account 2", "Account 1"]); + for entry in claude_menu.children.iter().chain(&grok_menu.children) { + assert!(!entry.label.contains('@'), "{}", entry.label); + assert!(!entry.label.contains("example.com"), "{}", entry.label); + } + } } diff --git a/apps/desktop-tauri/src-tauri/src/tray_bridge.rs b/apps/desktop-tauri/src-tauri/src/tray_bridge.rs index f8fe62a7d9..7aa865c46c 100644 --- a/apps/desktop-tauri/src-tauri/src/tray_bridge.rs +++ b/apps/desktop-tauri/src-tauri/src/tray_bridge.rs @@ -413,7 +413,7 @@ pub fn update_tray_icon_and_tooltip( let icon = Image::new_owned(rgba, w, h); let _ = tray.set_icon(Some(icon)); - let tooltip = build_tooltip(snapshots, settings.ui_language); + let tooltip = build_tooltip(snapshots, settings.ui_language, settings.hide_personal_info); let _ = tray.set_tooltip(Some(tooltip)); } @@ -421,6 +421,7 @@ pub fn update_tray_icon_and_tooltip( fn build_tooltip( snapshots: &[crate::commands::ProviderUsageSnapshot], lang: codexbar::settings::Language, + hide_personal_info: bool, ) -> String { use codexbar::locale::{LocaleKey, get_text}; @@ -432,7 +433,13 @@ fn build_tooltip( let mut lines = Vec::with_capacity(snapshots.len() + 1); for s in snapshots { let status = if let Some(ref err) = s.error { - let short = truncate_tooltip_text(err, 36); + // Provider errors can quote the signed-in email. + let err = codexbar::core::PersonalInfoRedactor::hide_emails_in_text( + Some(err), + hide_personal_info, + ) + .unwrap_or_default(); + let short = truncate_tooltip_text(&err, 36); format!("{}: {} ({})", s.display_name, error_label, short) } else { let label = crate::commands::compact_tray_status_label(headline_window(s), lang); @@ -854,7 +861,11 @@ mod tests { let mut codex = fake_snapshot("codex", "Codex", 8.0); codex.primary.reset_description = Some("4h 10m".to_string()); - let tooltip = build_tooltip(&[claude, codex], codexbar::settings::Language::English); + let tooltip = build_tooltip( + &[claude, codex], + codexbar::settings::Language::English, + false, + ); assert_eq!( tooltip, @@ -872,7 +883,7 @@ mod tests { codex.primary.reset_description = Some("No active 5h session".to_string()); codex.secondary.as_mut().unwrap().reset_description = Some("3d 17h".to_string()); - let tooltip = build_tooltip(&[codex], codexbar::settings::Language::English); + let tooltip = build_tooltip(&[codex], codexbar::settings::Language::English, false); assert_eq!(tooltip, "CodexBar\nCodex: 16% • Resets in 3d 17h"); } @@ -883,7 +894,7 @@ mod tests { claude.primary.reset_description = Some("resets in Jun 10 at 3:00PM with extra noisy suffix".to_string()); - let tooltip = build_tooltip(&[claude], codexbar::settings::Language::English); + let tooltip = build_tooltip(&[claude], codexbar::settings::Language::English, false); let line = tooltip.lines().nth(1).expect("provider tooltip line"); assert!(line.starts_with("Claude: 13% • Resets in Jun 10 at 3:00PM")); @@ -891,12 +902,30 @@ mod tests { assert!(line.chars().count() <= 53); } + #[test] + fn hidden_personal_info_removes_emails_from_tooltip_errors() { + let mut codex = fake_snapshot("codex", "Codex", 13.0); + codex.error = Some("proof@example.com: session expired".to_string()); + + let shown = build_tooltip( + &[codex.clone()], + codexbar::settings::Language::English, + false, + ); + assert!(shown.contains("proof@example.com"), "{shown}"); + + let hidden = build_tooltip(&[codex], codexbar::settings::Language::English, true); + assert!(hidden.contains("session expired"), "{hidden}"); + assert!(!hidden.contains('@'), "{hidden}"); + assert!(!hidden.contains("example.com"), "{hidden}"); + } + #[test] fn japanese_tooltip_localizes_error_status() { let mut claude = fake_snapshot("claude", "Claude", 13.0); claude.error = Some("network timeout".to_string()); - let tooltip = build_tooltip(&[claude], codexbar::settings::Language::Japanese); + let tooltip = build_tooltip(&[claude], codexbar::settings::Language::Japanese, false); assert!(tooltip.contains("エラー"), "{tooltip}"); assert!(!tooltip.contains(": error ("), "{tooltip}"); @@ -908,10 +937,16 @@ mod tests { claude.primary.resets_at = Some((chrono::Utc::now() + chrono::Duration::hours(2)).to_rfc3339()); - let english_tooltip = - build_tooltip(&[claude.clone()], codexbar::settings::Language::English); - let japanese_tooltip = - build_tooltip(&[claude.clone()], codexbar::settings::Language::Japanese); + let english_tooltip = build_tooltip( + &[claude.clone()], + codexbar::settings::Language::English, + false, + ); + let japanese_tooltip = build_tooltip( + &[claude.clone()], + codexbar::settings::Language::Japanese, + false, + ); assert!(english_tooltip.contains("Resets in"), "{english_tooltip}"); assert!( diff --git a/apps/desktop-tauri/src/App.test.tsx b/apps/desktop-tauri/src/App.test.tsx index fe76c8244d..6d299e4989 100644 --- a/apps/desktop-tauri/src/App.test.tsx +++ b/apps/desktop-tauri/src/App.test.tsx @@ -111,6 +111,7 @@ function settings(overrides: Partial = {}): SettingsSnapshot { switcherShortcuts: {}, codexCustomSessionsDirs: [], uiLanguage: "english", + uiLanguageFollowsSystem: false, // "dark" (not "auto") so useTheme's effect short-circuits before ever // touching window.matchMedia, which jsdom doesn't implement here. theme: "dark", diff --git a/apps/desktop-tauri/src/components/MenuCard.headerAccount.test.ts b/apps/desktop-tauri/src/components/MenuCard.headerAccount.test.ts index 9cc13a4120..9f7475a06a 100644 --- a/apps/desktop-tauri/src/components/MenuCard.headerAccount.test.ts +++ b/apps/desktop-tauri/src/components/MenuCard.headerAccount.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { headerAccount, maskEmail } from "./MenuCard"; +import { headerAccount } from "./MenuCard"; describe("headerAccount", () => { it("prefers the provider email over the token-account label", () => { @@ -17,10 +17,8 @@ describe("headerAccount", () => { ); }); - it("masks the email and hides the label under Hide Personal Info", () => { - expect(headerAccount({ accountEmail: "parity.user@example.com" }, true)).toBe( - "p••••••••••@example.com", - ); + it("shows no email and no label under Hide Personal Info", () => { + expect(headerAccount({ accountEmail: "parity.user@example.com" }, true)).toBeNull(); expect( headerAccount({ accountEmail: null, accountLabel: "parity-github" }, true), ).toBeNull(); @@ -33,8 +31,4 @@ describe("headerAccount", () => { expect(headerAccount({ accountEmail: null }, false)).toBeNull(); expect(headerAccount({ accountEmail: null, accountLabel: "" }, true)).toBeNull(); }); - - it("masks a non-email label completely", () => { - expect(maskEmail("parity-github")).toBe("••••@••••"); - }); }); diff --git a/apps/desktop-tauri/src/components/MenuCard.test.tsx b/apps/desktop-tauri/src/components/MenuCard.test.tsx index 1de8d7c0be..2ce4a3ad59 100644 --- a/apps/desktop-tauri/src/components/MenuCard.test.tsx +++ b/apps/desktop-tauri/src/components/MenuCard.test.tsx @@ -129,15 +129,17 @@ function renderCard( } describe("MenuCard", () => { - it("masks the header email under Hide Personal Info", async () => { - const snapshot = { ...provider(null, 20), accountEmail: "parity.user@example.com" }; + it("shows no email under Hide Personal Info, in the header or the error line", async () => { + const snapshot = { + ...provider(null, 20), + accountEmail: "parity.user@example.com", + error: "parity.user@example.com: session expired", + }; const { container, unmount } = renderCard(snapshot, { hideEmail: true }); - await waitFor(() => - expect(container.querySelector(".card-header__email")).toHaveTextContent( - "p••••••••••@example.com", - ), - ); - expect(container.textContent).not.toContain("parity.user@"); + await waitFor(() => expect(container.textContent).toContain("session expired")); + expect(container.querySelector(".card-header__email")).toBeNull(); + expect(container.innerHTML).not.toContain("@"); + expect(container.innerHTML).not.toContain("example.com"); unmount(); const shown = renderCard(snapshot); diff --git a/apps/desktop-tauri/src/components/MenuCard.tsx b/apps/desktop-tauri/src/components/MenuCard.tsx index 4ae38330bf..b6a8299c18 100644 --- a/apps/desktop-tauri/src/components/MenuCard.tsx +++ b/apps/desktop-tauri/src/components/MenuCard.tsx @@ -23,6 +23,7 @@ import InlineDashboard from "./card/InlineDashboard"; import ExtraUsageBlock from "./card/ExtraUsageBlock"; import WayfinderBlock from "./card/WayfinderBlock"; import { describeCard } from "./card/describeCard"; +import { hideEmailsInText } from "../lib/personalInfo"; export interface MenuCardDisplayOptions { hideEmail: boolean; @@ -53,25 +54,18 @@ interface MenuCardProps { onLayoutChange?: () => void; } -export function maskEmail(email: string): string { - const at = email.indexOf("@"); - if (at <= 1) return "••••@••••"; - return email[0] + "•".repeat(at - 1) + email.slice(at); -} - /** * Upstream `MenuCardView.email(from:)`: the provider-reported email, else the - * active token-account label. Hide Personal Info masks the email and drops the - * label, as upstream `PersonalInfoRedactor.redactAccountLabel` blanks it. + * active token-account label. Hide Personal Info shows neither, as upstream + * `PersonalInfoRedactor.redactEmail` / `redactAccountLabel` blank them + * (`PersonalInfoRedactor.swift:18`, `emailPlaceholder = ""`). */ export function headerAccount( provider: Pick, hideEmail: boolean, ): string | null { - const email = provider.accountEmail?.trim(); - if (email) return hideEmail ? maskEmail(email) : email; if (hideEmail) return null; - return provider.accountLabel?.trim() || null; + return provider.accountEmail?.trim() || provider.accountLabel?.trim() || null; } function cardMetrics( @@ -261,7 +255,7 @@ export default function MenuCard({ name={provider.displayName} email={email} plan={plan} - error={provider.error} + error={hideEmail ? hideEmailsInText(provider.error) : provider.error} updatedAt={provider.updatedAt} isRefreshing={isRefreshing} /> diff --git a/apps/desktop-tauri/src/components/claudeAccountDisplay.ts b/apps/desktop-tauri/src/components/claudeAccountDisplay.ts index a6ee39ae4c..8a593ea07d 100644 --- a/apps/desktop-tauri/src/components/claudeAccountDisplay.ts +++ b/apps/desktop-tauri/src/components/claudeAccountDisplay.ts @@ -8,10 +8,11 @@ export interface PrivateClaudeAccountLabel { /** * Assign opaque labels from stable account ids rather than the current array * order. The account list can be reordered when the active login changes, but - * the saved Claude account id remains source-owned and stable. + * the saved account id remains source-owned and stable. The tray numbers + * Claude and Grok accounts by the same rule (`ordinals_by_sorted_id`). */ -export function buildClaudeAccountOrdinals( - accounts: readonly ClaudeAccount[], +export function buildAccountOrdinals( + accounts: readonly { id: string }[], ): Record { const ids = [...new Set(accounts.map((account) => account.id))].sort((left, right) => left < right ? -1 : left > right ? 1 : 0, diff --git a/apps/desktop-tauri/src/floatbar/FloatBar.test.tsx b/apps/desktop-tauri/src/floatbar/FloatBar.test.tsx index 70034c4c43..2f85d3eb59 100644 --- a/apps/desktop-tauri/src/floatbar/FloatBar.test.tsx +++ b/apps/desktop-tauri/src/floatbar/FloatBar.test.tsx @@ -178,6 +178,7 @@ function settings(overrides: Partial = {}): SettingsSnapshot { switcherShortcuts: {}, codexCustomSessionsDirs: [], uiLanguage: "english", + uiLanguageFollowsSystem: false, theme: "dark", windowScalePercent: 125, trayScalePercent: 100, diff --git a/apps/desktop-tauri/src/lib/personalInfo.test.ts b/apps/desktop-tauri/src/lib/personalInfo.test.ts new file mode 100644 index 0000000000..ce206fc86f --- /dev/null +++ b/apps/desktop-tauri/src/lib/personalInfo.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from "vitest"; +import { containsEmail, hideEmailsInText } from "./personalInfo"; + +describe("hideEmailsInText", () => { + it.each([ + ["proof@example.com", ""], + ["Signed in as proof@example.com.", "Signed in as."], + ["Contact me at user@example.com or admin@test.org for help", "Contact me at or for help"], + ["Account: proof@example.com, Pro", "Account:, Pro"], + ["No email here", "No email here"], + ])("removes emails from %j", (input, expected) => { + const hidden = hideEmailsInText(input); + expect(hidden).toBe(expected); + expect(hidden).not.toContain("@"); + expect(hidden).not.toContain("example.com"); + }); + + it("passes empty values through", () => { + expect(hideEmailsInText(null)).toBeNull(); + expect(hideEmailsInText(undefined)).toBeUndefined(); + }); + + it("detects emails", () => { + expect(containsEmail("Proof@Example.COM's Organization")).toBe(true); + expect(containsEmail("parity-github")).toBe(false); + }); +}); diff --git a/apps/desktop-tauri/src/lib/personalInfo.ts b/apps/desktop-tauri/src/lib/personalInfo.ts new file mode 100644 index 0000000000..dd1c60302a --- /dev/null +++ b/apps/desktop-tauri/src/lib/personalInfo.ts @@ -0,0 +1,25 @@ +/** + * Hide Personal Info removes emails from what the user sees, with no + * placeholder (Mac `PersonalInfoRedactor.swift:18`, `emailPlaceholder = ""`). + */ +const EMAIL_PATTERN = /[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}/.source; + +export function containsEmail(value: string | null | undefined): boolean { + return !!value && new RegExp(EMAIL_PATTERN, "i").test(value); +} + +/** + * Mac `PersonalInfoRedactor.redactEmails(in:)` (`PersonalInfoRedactor.swift:41-55`): + * remove each email, then close the gap it leaves before punctuation and + * collapse repeated spaces. + */ +export function hideEmailsInText(value: string | null): string | null; +export function hideEmailsInText(value: string | null | undefined): string | null | undefined; +export function hideEmailsInText(value: string | null | undefined): string | null | undefined { + if (!value || !containsEmail(value)) return value; + return value + .replace(new RegExp(EMAIL_PATTERN, "gi"), "") + .replace(/\s+([:.,;])/g, "$1") + .replace(/\s{2,}/g, " ") + .trim(); +} diff --git a/apps/desktop-tauri/src/surfaces/TrayPanel.period.test.tsx b/apps/desktop-tauri/src/surfaces/TrayPanel.period.test.tsx index 591b3ecf36..585ee75a05 100644 --- a/apps/desktop-tauri/src/surfaces/TrayPanel.period.test.tsx +++ b/apps/desktop-tauri/src/surfaces/TrayPanel.period.test.tsx @@ -116,6 +116,7 @@ const settings = { overviewLayout: "detailed", hidePersonalInfo: false, uiLanguage: "english", + uiLanguageFollowsSystem: false, theme: "dark", windowScalePercent: 125, trayScalePercent: 100, diff --git a/apps/desktop-tauri/src/surfaces/TrayPanel.test.tsx b/apps/desktop-tauri/src/surfaces/TrayPanel.test.tsx index e0672b470f..18f943367f 100644 --- a/apps/desktop-tauri/src/surfaces/TrayPanel.test.tsx +++ b/apps/desktop-tauri/src/surfaces/TrayPanel.test.tsx @@ -176,6 +176,7 @@ function settings(overrides: Partial = {}): SettingsSnapshot { switcherShortcuts: {}, codexCustomSessionsDirs: [], uiLanguage: "english", + uiLanguageFollowsSystem: false, theme: "dark", windowScalePercent: 125, trayScalePercent: 100, diff --git a/apps/desktop-tauri/src/surfaces/settings/providers/sections/IdentitySection.test.tsx b/apps/desktop-tauri/src/surfaces/settings/providers/sections/IdentitySection.test.tsx index 6496db6d05..802680b05c 100644 --- a/apps/desktop-tauri/src/surfaces/settings/providers/sections/IdentitySection.test.tsx +++ b/apps/desktop-tauri/src/surfaces/settings/providers/sections/IdentitySection.test.tsx @@ -21,8 +21,8 @@ function detail(overrides: Partial = {}): IdentityDetails { const t = (key: LocaleKey) => key; describe("IdentitySection OpenAI project privacy", () => { - it("masks project ids in account and plan rows when privacy is enabled", () => { - render( + it("drops the project account row and masks the plan's project id when privacy is enabled", () => { + const { container } = render( { />, ); - expect(screen.getByText("Project: ••••")).toBeInTheDocument(); + expect(screen.queryByText("Account")).toBeNull(); expect(screen.getByText("Admin API: ••••")).toBeInTheDocument(); - expect(screen.queryByText("proj-private")).toBeNull(); + expect(container.innerHTML).not.toContain("proj-private"); }); it("keeps project ids visible when privacy is disabled", () => { @@ -44,8 +44,8 @@ describe("IdentitySection OpenAI project privacy", () => { }); describe("IdentitySection account privacy for every provider", () => { - it("masks the account email of non-OpenAI providers when privacy is enabled", () => { - render( + it("drops the account email of non-OpenAI providers when privacy is enabled", () => { + const { container } = render( { />, ); - expect(screen.getByText("p•••••@example.com")).toBeInTheDocument(); - expect(screen.queryByText("person@example.com")).toBeNull(); + expect(container.innerHTML).not.toContain("@"); + expect(container.innerHTML).not.toContain("example.com"); + expect(screen.queryByText("Account")).toBeNull(); expect(screen.getByText("Pro")).toBeInTheDocument(); }); - it("masks an email embedded in the organization label", () => { - render( + it("drops an organization label that embeds the email", () => { + const { container } = render( { />, ); - expect(screen.getByText("p•••••@example.com's Organization")).toBeInTheDocument(); + expect(container.innerHTML).not.toContain("@"); + expect(container.innerHTML).not.toContain("example.com"); + expect(screen.queryByText("Account")).toBeNull(); + }); + + it("drops an organization label without an email too", () => { + render( + , + ); + + expect(screen.queryByText("Account")).toBeNull(); + expect(screen.queryByText("Acme Research")).toBeNull(); + expect(screen.getByText("Max")).toBeInTheDocument(); }); it("shows the account email when privacy is disabled", () => { diff --git a/apps/desktop-tauri/src/surfaces/settings/providers/sections/IdentitySection.tsx b/apps/desktop-tauri/src/surfaces/settings/providers/sections/IdentitySection.tsx index f7390888cf..20a5518f77 100644 --- a/apps/desktop-tauri/src/surfaces/settings/providers/sections/IdentitySection.tsx +++ b/apps/desktop-tauri/src/surfaces/settings/providers/sections/IdentitySection.tsx @@ -3,7 +3,6 @@ import type { LocaleKey } from "../../../../i18n/keys"; import { ProviderIcon } from "../../../../components/providers/ProviderIcon"; import { Toggle } from "../../../../components/FormControls"; import { hideOpenAiApiProjectId } from "../../../../lib/openAiApiIdentity"; -import { maskEmail } from "../../../../components/MenuCard"; import { providerSourceHintShort } from "../providerDetailFormat"; interface Props { @@ -40,14 +39,9 @@ export function IdentitySection({ busy = false, }: Props) { const hideProjectId = provider.id === "openaiapi" && hidePersonalInfo; - // Same masking as the tray card (MenuCard), for every provider. - const account = provider.email - ? hidePersonalInfo - ? maskEmail(provider.email) - : provider.email - : hidePersonalInfo - ? maskEmailsInText(provider.organization) - : provider.organization; + // Hide Personal Info drops the account row for every provider, as the tray + // card (MenuCard `headerAccount`) and the Mac Account row do. + const account = hidePersonalInfo ? null : provider.email || provider.organization; const rows: { key: string; label: string; value: string | null }[] = [ { key: "source", @@ -121,12 +115,6 @@ export function IdentitySection({ ); } -/** Organization labels can embed the account email (e.g. "me@x.com's Organization"). */ -export function maskEmailsInText(value: string | null): string | null { - if (!value) return value; - return value.replace(/[^\s@'"]+@[^\s@'"]+/g, (email) => maskEmail(email)); -} - function displayIdentityValue( value: string | null, t: (key: LocaleKey) => string, diff --git a/apps/desktop-tauri/src/surfaces/settings/providers/sections/ProviderIssueNotice.test.tsx b/apps/desktop-tauri/src/surfaces/settings/providers/sections/ProviderIssueNotice.test.tsx index 6a60d08faa..e4be7515ec 100644 --- a/apps/desktop-tauri/src/surfaces/settings/providers/sections/ProviderIssueNotice.test.tsx +++ b/apps/desktop-tauri/src/surfaces/settings/providers/sections/ProviderIssueNotice.test.tsx @@ -55,6 +55,8 @@ describe("ProviderIssueNotice", () => { const status = screen.getByRole("status"); expect(status).toHaveTextContent("token expired for"); expect(status).not.toHaveTextContent("person@example.com"); + expect(status.textContent).not.toContain("@"); + expect(status.textContent).not.toContain("example.com"); }); it("falls back to the unknown state and offers no details without a diagnostic", () => { diff --git a/apps/desktop-tauri/src/surfaces/settings/providers/sections/ProviderIssueNotice.tsx b/apps/desktop-tauri/src/surfaces/settings/providers/sections/ProviderIssueNotice.tsx index d1080b889d..cc32a53566 100644 --- a/apps/desktop-tauri/src/surfaces/settings/providers/sections/ProviderIssueNotice.tsx +++ b/apps/desktop-tauri/src/surfaces/settings/providers/sections/ProviderIssueNotice.tsx @@ -2,7 +2,7 @@ import { useCallback, useEffect, useRef, useState } from "react"; import type { ProviderDetail } from "../../../../types/bridge"; import type { LocaleKey } from "../../../../i18n/keys"; import { describeProviderState } from "../../../../lib/providerState"; -import { maskEmailsInText } from "./IdentitySection"; +import { hideEmailsInText } from "../../../../lib/personalInfo"; interface Props { detail: Pick; @@ -22,7 +22,7 @@ export function ProviderIssueNotice({ detail, t, hidePersonalInfo = false }: Pro const state = describeProviderState(detail.errorState ?? "unknown"); const preview = t(state.labelKey); const raw = detail.lastError?.trim() || null; - const full = raw && hidePersonalInfo ? maskEmailsInText(raw) : raw; + const full = raw && hidePersonalInfo ? hideEmailsInText(raw) : raw; const title = t("ProviderLastFetchFailedTitle").replace("{}", detail.displayName); return ( diff --git a/apps/desktop-tauri/src/surfaces/settings/providers/sections/credentials/ClaudeAccountsSection.tsx b/apps/desktop-tauri/src/surfaces/settings/providers/sections/credentials/ClaudeAccountsSection.tsx index a1292b13ae..169b176774 100644 --- a/apps/desktop-tauri/src/surfaces/settings/providers/sections/credentials/ClaudeAccountsSection.tsx +++ b/apps/desktop-tauri/src/surfaces/settings/providers/sections/credentials/ClaudeAccountsSection.tsx @@ -16,7 +16,7 @@ import { import { ClaudeSwapAccountsSection } from "./ClaudeSwapAccountsSection"; import ClaudeAccountUsage from "../../../../../components/ClaudeAccountUsage"; import { - buildClaudeAccountOrdinals, + buildAccountOrdinals, buildPrivateClaudeAccountLabel, } from "../../../../../components/claudeAccountDisplay"; import { @@ -104,7 +104,7 @@ export function ClaudeAccountsSection({ } } }; - const accountOrdinals = buildClaudeAccountOrdinals(accounts); + const accountOrdinals = buildAccountOrdinals(accounts); return ( <>
diff --git a/apps/desktop-tauri/src/surfaces/settings/providers/sections/credentials/GrokAccountsSection.test.tsx b/apps/desktop-tauri/src/surfaces/settings/providers/sections/credentials/GrokAccountsSection.test.tsx index c4e836026a..2ab7fdd661 100644 --- a/apps/desktop-tauri/src/surfaces/settings/providers/sections/credentials/GrokAccountsSection.test.tsx +++ b/apps/desktop-tauri/src/surfaces/settings/providers/sections/credentials/GrokAccountsSection.test.tsx @@ -96,11 +96,24 @@ describe("GrokAccountsSection", () => { expect((screen.getByText("CodexAccountsAddButton") as HTMLButtonElement).disabled).toBe(false); }); - it("masks account emails under Hide Personal Info", async () => { + it("labels accounts Account N under Hide Personal Info, with no email", async () => { const { container } = render(); - await screen.findByText("o••@example.com"); - expect(screen.getByText("t••@example.com")).toBeInTheDocument(); - expect(container.innerHTML).not.toContain("one@example.com"); - expect(container.innerHTML).not.toContain("two@example.com"); + await screen.findByText("Account 1"); + expect(screen.getByText("Account 2")).toBeInTheDocument(); + expect(container.innerHTML).not.toContain("@"); + expect(container.innerHTML).not.toContain("example.com"); + }); + + it("numbers hidden accounts by sorted id, not list order", async () => { + // The unsaved live login is listed first, but "user-a" sorts first. + const live: GrokAccount = { ...current, id: "user-z", email: "z@example.com" }; + const saved: GrokAccount = { ...other, id: "user-a", email: "a@example.com" }; + mocks.grokAccountsList.mockResolvedValue([live, saved]); + const { container } = render(); + await screen.findByText("Account 2"); + const labels = [...container.querySelectorAll(".credential-card__info strong")].map( + (label) => label.textContent, + ); + expect(labels).toEqual(["Account 2", "Account 1"]); }); }); diff --git a/apps/desktop-tauri/src/surfaces/settings/providers/sections/credentials/GrokAccountsSection.tsx b/apps/desktop-tauri/src/surfaces/settings/providers/sections/credentials/GrokAccountsSection.tsx index ba26c3ea94..295c7387f6 100644 --- a/apps/desktop-tauri/src/surfaces/settings/providers/sections/credentials/GrokAccountsSection.tsx +++ b/apps/desktop-tauri/src/surfaces/settings/providers/sections/credentials/GrokAccountsSection.tsx @@ -10,7 +10,7 @@ import { } from "../../../../../lib/tauri"; import { useGrokAccounts } from "../../../../../hooks/useGrokAccounts"; import GrokAccountUsageDetails from "../../../../../components/GrokAccountUsageDetails"; -import { maskEmail } from "../../../../../components/MenuCard"; +import { buildAccountOrdinals } from "../../../../../components/claudeAccountDisplay"; export function GrokAccountsSection({ t, @@ -24,6 +24,7 @@ export function GrokAccountsSection({ const [loggingIn, setLoggingIn] = useState(false); const [message, setMessage] = useState(null); const { accounts, usage, busy, error, reportError, run, reload } = useGrokAccounts(); + const accountOrdinals = buildAccountOrdinals(accounts); const runOperation = (operation: () => Promise, success?: LocaleKey) => { setMessage(null); void run( @@ -54,7 +55,10 @@ export function GrokAccountsSection({
  • - {hidePersonalInfo ? maskEmail(account.email) : account.email} + + {/* Mac `PersonalInfoRedactor.AccountOrdinal`: no part of the email. */} + {hidePersonalInfo ? `${t("Account")} ${accountOrdinals[account.id]}` : account.email} + {usage[account.id]?.plan || account.plan} diff --git a/apps/desktop-tauri/src/surfaces/settings/tabs/AboutTab.test.tsx b/apps/desktop-tauri/src/surfaces/settings/tabs/AboutTab.test.tsx index 1766a9da58..3dbbb89d04 100644 --- a/apps/desktop-tauri/src/surfaces/settings/tabs/AboutTab.test.tsx +++ b/apps/desktop-tauri/src/surfaces/settings/tabs/AboutTab.test.tsx @@ -87,6 +87,7 @@ const settings: SettingsSnapshot = { codexCustomSessionsDirs: [], updateChannel: "stable", uiLanguage: "english", + uiLanguageFollowsSystem: false, theme: "dark", windowScalePercent: 125, trayScalePercent: 100, diff --git a/apps/desktop-tauri/src/surfaces/settings/tabs/AdvancedTab.test.tsx b/apps/desktop-tauri/src/surfaces/settings/tabs/AdvancedTab.test.tsx index f39085e4db..a01941f39c 100644 --- a/apps/desktop-tauri/src/surfaces/settings/tabs/AdvancedTab.test.tsx +++ b/apps/desktop-tauri/src/surfaces/settings/tabs/AdvancedTab.test.tsx @@ -64,6 +64,7 @@ const settings: SettingsSnapshot = { codexCustomSessionsDirs: [], updateChannel: "stable", uiLanguage: "english", + uiLanguageFollowsSystem: false, theme: "dark", windowScalePercent: 125, trayScalePercent: 100, diff --git a/apps/desktop-tauri/src/surfaces/settings/tabs/GeneralTab.test.tsx b/apps/desktop-tauri/src/surfaces/settings/tabs/GeneralTab.test.tsx index 784e3d6b61..3592733014 100644 --- a/apps/desktop-tauri/src/surfaces/settings/tabs/GeneralTab.test.tsx +++ b/apps/desktop-tauri/src/surfaces/settings/tabs/GeneralTab.test.tsx @@ -93,6 +93,7 @@ const settings: SettingsSnapshot = { codexCustomSessionsDirs: [], updateChannel: "stable", uiLanguage: "english", + uiLanguageFollowsSystem: false, theme: "dark", claudeAvoidKeychainPrompts: true, codexSparkUsageVisible: true, @@ -126,6 +127,32 @@ describe("GeneralTab running version", () => { }); describe("GeneralTab language picker", () => { + it("lists System first and shows it while following the system", () => { + render( + , + ); + + const select = screen.getByDisplayValue("System") as HTMLSelectElement; + expect(select.value).toBe("system"); + expect(select.querySelectorAll("option")[0].textContent).toBe("System"); + }); + + it("picking System follows the system; picking a language stops following", () => { + const set = vi.fn(); + render(); + + const select = screen.getByDisplayValue("English"); + fireEvent.change(select, { target: { value: "system" } }); + expect(set).toHaveBeenLastCalledWith({ uiLanguageFollowsSystem: true }); + + fireEvent.change(select, { target: { value: "japanese" } }); + expect(set).toHaveBeenLastCalledWith({ uiLanguage: "japanese" }); + }); + it("renders all supported language options", () => { render(); @@ -615,7 +642,7 @@ describe("GeneralTab notifications pane", () => { ).not.toBeChecked(); }); - it("locks credential and pace alerts and hides thresholds while usage warnings are off", () => { + it("locks credential and pace alerts but keeps the thresholds while usage warnings are off (#816)", () => { render( { expect(credential).toBeDisabled(); expect(credential).toBeChecked(); expect(screen.getByRole("checkbox", { name: "SessionQuotaNotifications" })).toBeEnabled(); - expect(screen.queryByText("WarningAtPercentUsed")).toBeNull(); + // The thresholds also colour the usage bars and the float bar, so they stay editable. + expect(screen.getByText("WarningAtPercentUsed")).toBeInTheDocument(); + expect(screen.getByText("CriticalAtPercentUsed")).toBeInTheDocument(); + expect(screen.getByDisplayValue("70")).toBeEnabled(); + expect(screen.getByDisplayValue("90")).toBeEnabled(); expect(screen.queryByText("ThresholdOverridesTitle")).toBeNull(); expect(screen.getByRole("checkbox", { name: "SoundEnabled" })).toBeInTheDocument(); }); diff --git a/apps/desktop-tauri/src/surfaces/settings/tabs/GeneralTab.tsx b/apps/desktop-tauri/src/surfaces/settings/tabs/GeneralTab.tsx index ebc3909161..2b2182906d 100644 --- a/apps/desktop-tauri/src/surfaces/settings/tabs/GeneralTab.tsx +++ b/apps/desktop-tauri/src/surfaces/settings/tabs/GeneralTab.tsx @@ -32,6 +32,9 @@ import type { TabProps } from "../settingsTabs"; import PreferencesTransferSection from "./PreferencesTransferSection"; import SwitcherShortcutsDialog from "../SwitcherShortcutsDialog"; +/** Select value for Language "System" (follow the Windows display language). */ +const SYSTEM_LANGUAGE = "system"; + const FALLBACK_LANGUAGE_OPTIONS: LanguageOption[] = [ { value: "english", display: "English" }, { value: "chinese", display: "中文" }, @@ -277,13 +280,24 @@ export default function GeneralTab({