fix(macos): 四项修复——typeless 胶囊 1/7 缩放贴底、红绿灯对齐、ASR 误报、Keychain 节流

- typeless 胶囊窗口 460×128 → 174×48(面积 1/7),内容 CSS zoom 0.378 同步缩放;
  底部间距按样式区分,typeless 贴紧工作区底边(Dock 可见时贴其上沿,自动隐藏时贴屏幕底)
- 红绿灯 trafficLightPosition y 16→26:实测左间距 21.5、上间距 12,下移 9.5px 后两边距相等
- 启动时把偏好 active ASR 同步进凭据库(原仅 Windows):get_credentials 按凭据库
  active 渠道取密钥,脱钩会误报「语音识别未配置」并弹设置提示
- Keychain 节流:credentials.v2 补写与旧版逐账户扫描每进程只做一次并缓存结果,
  失败重放同一错误;新增凭据来源一次性日志
This commit is contained in:
sim committed 2026-09-10 00:26:36 +08:00
1 parent 68f94b788b
commit 417ad38ce9
6 files changed
+111 -28

No files matched your search

+28 -11
View File
@@ -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<R: tauri::Runtime>(
) -> 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<R: tauri::Runtime>(
(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<R: tauri::Runtime>(
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<R: tauri::Runtime>(
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);
@@ -61,6 +61,14 @@ const KEYRING_CHUNK_MAX_UTF16_UNITS: usize = 1000;
static CREDENTIALS_LOCK: OnceLock<Mutex<()>> = 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<Result<Option<CredsRoot>, 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<Option<CredsRoot>> {
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<Option<CredsRoot>> {
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<CredsRoot> {
#[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<Option<CredsRoot>> {
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<Option<CredsRoot>>,
) -> CredsRoot {
+1 -1
View File
@@ -26,7 +26,7 @@
"shadow": true,
"hiddenTitle": true,
"titleBarStyle": "Overlay",
"trafficLightPosition": { "x": 16, "y": 16 },
"trafficLightPosition": { "x": 16, "y": 26 },
"visible": false,
"acceptFirstMouse": true
},
@@ -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 {
@@ -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`);
+16 -1
View File
@@ -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,