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:
1 parent
68f94b788b
commit
417ad38ce9
6 files changed
+111
-28
No files matched your search
@@ -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 {
|
||||
|
||||
@@ -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`);
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user