diff --git a/openless-all/app/src-tauri/src/lib.rs b/openless-all/app/src-tauri/src/lib.rs index cbf955ba..1d37e639 100644 --- a/openless-all/app/src-tauri/src/lib.rs +++ b/openless-all/app/src-tauri/src/lib.rs @@ -545,11 +545,12 @@ fn run_desktop() { #[cfg(not(target_os = "windows"))] let coordinator = Arc::new(coordinator::Coordinator::new()); let core_backend = coordinator.backend(); - #[cfg(target_os = "windows")] - if let Err(error) = commands::sync_active_asr_provider_to_vault( - &core_backend.get_preferences().active_asr_provider, - ) { - log::warn!("[startup] sync active ASR provider from preferences failed: {error}"); + // 启动时把偏好里的 active ASR 同步进凭据库;get_credentials 按凭据库的 active 渠道取密钥。 + let startup_active_asr = core_backend.get_preferences().active_asr_provider; + if !startup_active_asr.is_empty() { + if let Err(error) = commands::sync_active_asr_provider_to_vault(&startup_active_asr) { + log::warn!("[startup] sync active ASR provider from preferences failed: {error}"); + } } let builder = tauri::Builder::default(); // macOS:胶囊要叠到别的 app 的全屏 Space 之上,必须是「非激活 NSPanel」(普通 @@ -3232,6 +3233,11 @@ pub(crate) fn position_capsule_bottom_center_with_style( ) -> tauri::Result<()> { let bounds = capsule_window_bounds_for_style(style); const EDGE_GAP: f64 = 12.0; + // typeless 贴紧工作区底边(工作区已由系统排除 Dock / taskbar)。 + let bottom_gap = match style { + types::CapsuleStyle::Typeless => 0.0, + _ => EDGE_GAP, + }; // Windows:跟随「正在输入的 App」所在显示器摆放,避免多显示器下胶囊 // 总是固定出现在主屏 / 胶囊自己那块屏。 @@ -3253,7 +3259,7 @@ pub(crate) fn position_capsule_bottom_center_with_style( (mon.left, mon.top, mon.right, mon.bottom) }; let x = work_l + ((work_r - work_l - phys_w) / 2).max(0); - let y = work_b - phys_h - (EDGE_GAP * scale).round() as i32; + let y = work_b - phys_h - (bottom_gap * scale).round() as i32; let (clamped_x, clamped_y) = clamp_to_monitor(x, y, phys_w, phys_h, work_l, work_t, work_r, work_b); log::debug!( @@ -3278,7 +3284,7 @@ pub(crate) fn position_capsule_bottom_center_with_style( mon.logical_work_area(), bounds.width, bounds.height, - EDGE_GAP, + bottom_gap, ); log::debug!( "[capsule] mac position: mon=({},{}) size=({}x{}) scale={:.2} -> logical=({:.1},{:.1})", @@ -3305,7 +3311,7 @@ pub(crate) fn position_capsule_bottom_center_with_style( let size = &monitor.work_area().size; let pos = &monitor.work_area().position; let frame = logical_monitor_frame(pos.x, pos.y, size.width, size.height, scale); - let (x, y) = bottom_center_position(frame, bounds.width, bounds.height, EDGE_GAP); + let (x, y) = bottom_center_position(frame, bounds.width, bounds.height, bottom_gap); window.set_position(LogicalPosition::new(x, y))?; Ok(()) } @@ -3326,11 +3332,16 @@ fn capsule_window_bounds(translation_active: bool) -> CapsuleWindowBounds { fn capsule_window_bounds_for_style(style: types::CapsuleStyle) -> CapsuleWindowBounds { CapsuleWindowBounds { - width: 460.0, + // typeless 窗口面积是旧尺寸(460×128)的 1/7;前端用 CSS zoom 同步缩放内容, + // 见 CapsuleStyles.css 与 src/lib/capsuleLayout.ts。 + width: match style { + types::CapsuleStyle::Typeless => 174.0, + types::CapsuleStyle::Siri | types::CapsuleStyle::Classic => 460.0, + }, height: match style { types::CapsuleStyle::Siri => 180.0, types::CapsuleStyle::Classic => 100.0, - types::CapsuleStyle::Typeless => 128.0, + types::CapsuleStyle::Typeless => 48.0, }, bottom_inset: 0.0, } @@ -3572,7 +3583,7 @@ mod tests { let (x, reserved_y) = bottom_center_position(work_area, bounds.width, bounds.height, 12.0); let (_, hidden_y) = bottom_center_position(monitor, bounds.width, bounds.height, 12.0); - assert_eq!(x, -950.0); + assert_eq!(x, work_area.x + (work_area.width - bounds.width) / 2.0); assert_eq!(reserved_y + bounds.height, 818.0); assert_eq!(hidden_y - reserved_y, 70.0); } @@ -3587,6 +3598,12 @@ mod tests { ); } + #[test] + fn typeless_capsule_window_is_one_seventh_of_the_old_area() { + let bounds = capsule_window_bounds_for_style(CapsuleStyle::Typeless); + assert_eq!((bounds.width, bounds.height), (174.0, 48.0)); + } + #[test] fn capsule_visual_height_keeps_the_qa_anchor_reference() { assert_eq!(capsule_visual_height(true), 140.0); diff --git a/openless-all/app/src-tauri/src/persistence/credentials.rs b/openless-all/app/src-tauri/src/persistence/credentials.rs index 8e3a278a..1d6afd05 100644 --- a/openless-all/app/src-tauri/src/persistence/credentials.rs +++ b/openless-all/app/src-tauri/src/persistence/credentials.rs @@ -61,6 +61,14 @@ const KEYRING_CHUNK_MAX_UTF16_UNITS: usize = 1000; static CREDENTIALS_LOCK: OnceLock> = OnceLock::new(); +// Keychain 访问节流:每进程只补写/扫描一次,避免重复授权弹窗。 +#[cfg(not(target_os = "android"))] +static SINGLE_ITEM_MIGRATION_ATTEMPTED: AtomicBool = AtomicBool::new(false); +#[cfg(not(target_os = "android"))] +static LEGACY_KEYRING_PROBE: OnceLock, String>> = OnceLock::new(); +#[cfg(any(not(target_os = "android"), test))] +static VAULT_SOURCE_LOGGED: AtomicBool = AtomicBool::new(false); + // A rejected Marketplace token must become unusable before best-effort durable // deletion starts. Keychain/credential-manager deletion can fail or prompt, so // this process-local tombstone is authoritative for every read until a newly @@ -1262,11 +1270,28 @@ fn delete_keyring_password(account: &str) { fn load_keyring_credentials() -> Result> { load_keyring_credentials_with( get_keyring_password, - set_keyring_password, + set_keyring_password_for_migration, cfg!(target_os = "macos"), ) } +/// credentials.v2 补写每进程只尝试一次。 +#[cfg(not(target_os = "android"))] +fn set_keyring_password_for_migration(account: &str, value: &str) -> Result<()> { + if SINGLE_ITEM_MIGRATION_ATTEMPTED.swap(true, Ordering::SeqCst) { + return Ok(()); + } + set_keyring_password(account, value) +} + +/// 首次成功定位凭据来源时记一条日志,用于诊断「未配置」误报。 +#[cfg(any(not(target_os = "android"), test))] +fn log_vault_source_once(source: &str) { + if !VAULT_SOURCE_LOGGED.swap(true, Ordering::SeqCst) { + log::info!("[vault] credential source: {source}"); + } +} + #[cfg(not(target_os = "android"))] fn set_keyring_password(account: &str, value: &str) -> Result<()> { keyring_entry_for(account)? @@ -1282,14 +1307,17 @@ fn load_keyring_credentials_with( ) -> Result> { if consolidate { if let Some(json) = read(KEYRING_SINGLE_CREDENTIALS_ACCOUNT)? { + log_vault_source_once("credentials.v2 (single item)"); return decode_single_credentials(&json).map(Some); } } let Some(json_or_manifest) = read(KEYRING_CREDENTIALS_ACCOUNT)? else { + log_vault_source_once("empty (no stored credentials)"); return Ok(None); }; let json = if let Some(manifest) = read_chunk_manifest(&json_or_manifest) { + log_vault_source_once("credentials.v1 chunks"); let mut json = String::new(); for index in 0..manifest.chunks { let account = chunk_account(manifest.generation.as_deref(), index); @@ -1379,22 +1407,38 @@ fn migrate_legacy_sources_for_update() -> Result { #[cfg(not(target_os = "android"))] { - let legacy_vault = load_legacy_keyring_credentials_for_update()?; - if legacy_vault_has_credentials(&legacy_vault) { - save_credentials(&legacy_vault)?; - let persisted = credentials_cache() - .lock() - .as_ref() - .cloned() - .unwrap_or(legacy_vault); - remove_legacy_keyring_credentials(); - return Ok(persisted); - } + // 旧版逐账户条目扫描每进程只跑一次并缓存;失败时重放同一错误,不重扫。 + let probe = LEGACY_KEYRING_PROBE.get_or_init(|| { + migrate_legacy_keyring_accounts().map_err(|error| format!("{error:#}")) + }); + return match probe { + Ok(Some(root)) => Ok(root.clone()), + Ok(None) => Ok(CredsRoot::default()), + Err(message) => Err(anyhow!(message.clone())), + }; } + #[cfg(target_os = "android")] Ok(CredsRoot::default()) } +/// 扫描旧版逐账户 Keychain 条目并迁移到当前存储。Some = 找到旧凭据。 +#[cfg(not(target_os = "android"))] +fn migrate_legacy_keyring_accounts() -> Result> { + let legacy_vault = load_legacy_keyring_credentials_for_update()?; + if !legacy_vault_has_credentials(&legacy_vault) { + return Ok(None); + } + save_credentials(&legacy_vault)?; + let persisted = credentials_cache() + .lock() + .as_ref() + .cloned() + .unwrap_or(legacy_vault); + remove_legacy_keyring_credentials(); + Ok(Some(persisted)) +} + fn load_credentials_into_cache_with( loader: impl FnOnce() -> Result>, ) -> CredsRoot { diff --git a/openless-all/app/src-tauri/tauri.conf.json b/openless-all/app/src-tauri/tauri.conf.json index d5e0d362..0005e47f 100644 --- a/openless-all/app/src-tauri/tauri.conf.json +++ b/openless-all/app/src-tauri/tauri.conf.json @@ -26,7 +26,7 @@ "shadow": true, "hiddenTitle": true, "titleBarStyle": "Overlay", - "trafficLightPosition": { "x": 16, "y": 16 }, + "trafficLightPosition": { "x": 16, "y": 26 }, "visible": false, "acceptFirstMouse": true }, diff --git a/openless-all/app/src/components/CapsuleStyles.css b/openless-all/app/src/components/CapsuleStyles.css index 8b4c97eb..63ab99c0 100644 --- a/openless-all/app/src/components/CapsuleStyles.css +++ b/openless-all/app/src/components/CapsuleStyles.css @@ -8,6 +8,9 @@ --ol-typeless-confirm-ink: #ffffff; position: relative; font-family: var(--ol-font-sans); + /* 整体缩到 1/7 面积(线性 1/√7≈0.378),与 174×48 的胶囊窗口配套; + 尺寸基准见 src-tauri/src/lib.rs 的 capsule_window_bounds_for_style。 */ + zoom: 0.378; } .ol-typeless-capsule { diff --git a/openless-all/app/src/lib/capsuleLayout.test.ts b/openless-all/app/src/lib/capsuleLayout.test.ts index 7000e67f..5742a811 100644 --- a/openless-all/app/src/lib/capsuleLayout.test.ts +++ b/openless-all/app/src/lib/capsuleLayout.test.ts @@ -86,9 +86,13 @@ for (const os of ['mac', 'win', 'linux'] as const) { const classic = getCapsuleHostMetrics(os, false, 'classic'); const typeless = getCapsuleHostMetrics(os, true, 'typeless'); assertEqual(classic.height, 100, `${os}: classic uses the compact native window`); - assertEqual(typeless.height, 128, `${os}: typeless reserves room for the translation badge`); - assertEqual(typeless.bottomInset, 16, `${os}: visible capsule sits close to the work-area edge`); - assertEqual(typeless.width, 460, `${os}: error text remains inside the shared window`); + assertEqual(typeless.height, 48, `${os}: typeless window is 1/7 of the old 460x128 area`); + assertEqual(typeless.width, 174, `${os}: typeless window keeps the 1/7 stage width`); + assertEqual( + typeless.bottomInset, + 0, + `${os}: typeless pill hugs the work-area bottom edge`, + ); } for (const style of ['siri', 'classic', 'typeless'] as const) { assertEqual(parseCapsuleStyle(style), style, `${style} is accepted from preferences and events`); diff --git a/openless-all/app/src/lib/capsuleLayout.ts b/openless-all/app/src/lib/capsuleLayout.ts index 23ab365b..fb0bcee1 100644 --- a/openless-all/app/src/lib/capsuleLayout.ts +++ b/openless-all/app/src/lib/capsuleLayout.ts @@ -31,6 +31,11 @@ const VOICE_ORB_STAGE_WIDTH = 460; const VOICE_ORB_STAGE_HEIGHT = 180; const VOICE_ORB_TEXT_WIDTH = 400; +// typeless 窗口面积是旧尺寸(460×128)的 1/7;内容由 CapsuleStyles.css 的 zoom 缩放, +// 与 src-tauri/src/lib.rs 的 capsule_window_bounds_for_style 保持一致。 +const TYPELESS_STAGE_WIDTH = 174; +const TYPELESS_STAGE_HEIGHT = 48; + export function parseCapsuleStyle(value: unknown): CapsuleStyle | undefined { return value === 'siri' || value === 'classic' || value === 'typeless' ? value : undefined; } @@ -50,8 +55,18 @@ export function getCapsuleHostMetrics( translationActive: boolean, style: CapsuleStyle = 'siri', ): CapsuleHostMetrics { - const stage = getCapsulePillMetrics(os); void translationActive; + if (style === 'typeless') { + return { + width: TYPELESS_STAGE_WIDTH, + height: TYPELESS_STAGE_HEIGHT, + horizontalInset: 0, + bottomInset: 0, + badgeGap: 8, + boxSizing: 'border-box', + }; + } + const stage = getCapsulePillMetrics(os); return { width: stage.width, height: style === 'siri' ? stage.height : style === 'classic' ? 100 : 128,