diff --git a/Cargo.lock b/Cargo.lock index 7c9ea91..7d4c418 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4992,16 +4992,6 @@ dependencies = [ "vtable", ] -[[package]] -name = "slint-center-win" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "00c7ed3dcfbafe6a53051c3a1e4ed72a85d6ac4a7f9ed2d63232fe4b59620e5a" -dependencies = [ - "i-slint-backend-winit", - "slint", -] - [[package]] name = "slint-macros" version = "1.15.1" @@ -5324,7 +5314,6 @@ dependencies = [ "security-framework", "sha2", "slint", - "slint-center-win", "sshwarden-api", "tokio", "tracing", diff --git a/crates/sshwarden-ui/Cargo.toml b/crates/sshwarden-ui/Cargo.toml index b08676b..8b9927a 100644 --- a/crates/sshwarden-ui/Cargo.toml +++ b/crates/sshwarden-ui/Cargo.toml @@ -11,7 +11,6 @@ tracing = { workspace = true } tokio = { workspace = true, features = ["sync", "time"] } sshwarden-api = { path = "../sshwarden-api" } slint = { version = "1.15", features = ["unstable-winit-030"] } -slint-center-win = "0.3" [target.'cfg(target_os = "macos")'.dependencies] security-framework = "2.11" @@ -22,6 +21,7 @@ scopeguard = "1" windows = { version = "0.61", features = [ "Foundation", "Win32_Foundation", + "Win32_Graphics_Gdi", "Win32_UI_WindowsAndMessaging", "Win32_UI_HiDpi", "Win32_System_Com", diff --git a/crates/sshwarden-ui/src/bind_hosts/slint_dialog.rs b/crates/sshwarden-ui/src/bind_hosts/slint_dialog.rs index a1fa54c..6ffa062 100644 --- a/crates/sshwarden-ui/src/bind_hosts/slint_dialog.rs +++ b/crates/sshwarden-ui/src/bind_hosts/slint_dialog.rs @@ -234,13 +234,7 @@ pub struct BindHostsDialogRequest { } fn center_and_focus_dialog(dialog: &BindHostsDialog) { - let window = dialog.window(); - slint_center_win::center_window(window); - use slint::winit_030::WinitWindowAccessor; - let _ = window.with_winit_window(|winit_window: &slint::winit_030::winit::window::Window| { - winit_window.focus_window(); - None::<()> - }); + crate::window_placement::center_on_active_monitor_and_focus(dialog.window()); } /// Cheap UI-side host pattern check. Final validation happens in the main loop diff --git a/crates/sshwarden-ui/src/lib.rs b/crates/sshwarden-ui/src/lib.rs index 1e85321..5f53dd4 100644 --- a/crates/sshwarden-ui/src/lib.rs +++ b/crates/sshwarden-ui/src/lib.rs @@ -2,6 +2,8 @@ pub mod bind_hosts; pub mod notify; pub mod unlock; +mod window_placement; + use std::collections::BTreeMap; /// Information about an SSH sign request, used to display to the user. diff --git a/crates/sshwarden-ui/src/notify/slint_dialog.rs b/crates/sshwarden-ui/src/notify/slint_dialog.rs index c480fcc..c4ec164 100644 --- a/crates/sshwarden-ui/src/notify/slint_dialog.rs +++ b/crates/sshwarden-ui/src/notify/slint_dialog.rs @@ -139,13 +139,7 @@ pub struct AuthDialogRequest { } fn center_and_focus_dialog(dialog: &AuthDialog) { - let window = dialog.window(); - slint_center_win::center_window(window); - use slint::winit_030::WinitWindowAccessor; - let _ = window.with_winit_window(|winit_window: &slint::winit_030::winit::window::Window| { - winit_window.focus_window(); - None::<()> - }); + crate::window_placement::center_on_active_monitor_and_focus(dialog.window()); } pub fn show_auth_dialog(request: AuthDialogRequest) { diff --git a/crates/sshwarden-ui/src/unlock/slint_dialog.rs b/crates/sshwarden-ui/src/unlock/slint_dialog.rs index 9443102..ebfc927 100644 --- a/crates/sshwarden-ui/src/unlock/slint_dialog.rs +++ b/crates/sshwarden-ui/src/unlock/slint_dialog.rs @@ -102,13 +102,7 @@ slint::slint! { } fn center_and_focus_dialog(dialog: &PinDialog) { - let window = dialog.window(); - slint_center_win::center_window(window); - use slint::winit_030::WinitWindowAccessor; - let _ = window.with_winit_window(|winit_window: &slint::winit_030::winit::window::Window| { - winit_window.focus_window(); - None::<()> - }); + crate::window_placement::center_on_active_monitor_and_focus(dialog.window()); } fn unlock_context_text(context: &crate::UnlockRequestContext) -> String { diff --git a/crates/sshwarden-ui/src/window_placement.rs b/crates/sshwarden-ui/src/window_placement.rs new file mode 100644 index 0000000..5063c7d --- /dev/null +++ b/crates/sshwarden-ui/src/window_placement.rs @@ -0,0 +1,145 @@ +//! Helpers for placing transient Slint dialogs where the user is looking. + +use slint::winit_030::winit::{ + dpi::PhysicalPosition, monitor::MonitorHandle, window::Window as WinitWindow, +}; +use slint::winit_030::WinitWindowAccessor; + +#[derive(Clone, Copy, Debug)] +struct ScreenBounds { + x: i32, + y: i32, + width: i32, + height: i32, +} + +impl ScreenBounds { + fn from_winit_monitor(monitor: &MonitorHandle) -> Self { + let position = monitor.position(); + let size = monitor.size(); + + Self { + x: position.x, + y: position.y, + width: i32::try_from(size.width).unwrap_or(i32::MAX), + height: i32::try_from(size.height).unwrap_or(i32::MAX), + } + } + + fn is_valid(self) -> bool { + self.width > 0 && self.height > 0 + } +} + +/// Center a transient Slint window on the most relevant monitor, then focus it. +/// +/// On Windows, background prompts do not have a parent window, so the best hint +/// is the display that currently contains the mouse cursor. Other platforms use +/// winit's current/primary monitor information as a portable fallback. +pub(crate) fn center_on_active_monitor_and_focus(window: &slint::Window) { + if !window.has_winit_window() { + tracing::debug!("Skipping dialog placement because no winit window is available"); + return; + } + + let _ = window.with_winit_window(|winit_window: &WinitWindow| { + if !center_on_platform_active_monitor(winit_window) + && !center_on_winit_monitor(winit_window) + { + tracing::warn!("Unable to determine monitor for dialog placement"); + } + + winit_window.focus_window(); + None::<()> + }); +} + +#[cfg(windows)] +fn center_on_platform_active_monitor(window: &WinitWindow) -> bool { + let Some(bounds) = cursor_monitor_work_area() else { + return false; + }; + + center_window_in_bounds(window, bounds) +} + +#[cfg(not(windows))] +fn center_on_platform_active_monitor(_window: &WinitWindow) -> bool { + false +} + +fn center_on_winit_monitor(window: &WinitWindow) -> bool { + let monitor = window + .current_monitor() + .or_else(|| window.primary_monitor()) + .or_else(|| window.available_monitors().next()); + + match monitor { + Some(monitor) => { + center_window_in_bounds(window, ScreenBounds::from_winit_monitor(&monitor)) + } + None => false, + } +} + +fn center_window_in_bounds(window: &WinitWindow, bounds: ScreenBounds) -> bool { + if !bounds.is_valid() { + return false; + } + + let window_size = window.outer_size(); + let window_width = i32::try_from(window_size.width).unwrap_or(i32::MAX); + let window_height = i32::try_from(window_size.height).unwrap_or(i32::MAX); + + if window_width <= 0 || window_height <= 0 { + return false; + } + + let x = bounds + .x + .saturating_add((bounds.width - window_width).max(0) / 2); + let y = bounds + .y + .saturating_add((bounds.height - window_height).max(0) / 2); + + window.set_outer_position(PhysicalPosition::new(x, y)); + true +} + +#[cfg(windows)] +fn cursor_monitor_work_area() -> Option { + use windows::Win32::Foundation::POINT; + use windows::Win32::Graphics::Gdi::{ + GetMonitorInfoW, MonitorFromPoint, MONITORINFO, MONITOR_DEFAULTTONEAREST, + }; + use windows::Win32::UI::WindowsAndMessaging::GetCursorPos; + + let mut cursor = POINT { x: 0, y: 0 }; + if unsafe { GetCursorPos(&mut cursor) }.is_err() { + return None; + } + + let monitor = unsafe { MonitorFromPoint(cursor, MONITOR_DEFAULTTONEAREST) }; + if monitor.is_invalid() { + return None; + } + + let mut monitor_info = MONITORINFO { + cbSize: std::mem::size_of::() as u32, + ..Default::default() + }; + + if !unsafe { GetMonitorInfoW(monitor, &mut monitor_info) }.as_bool() { + return None; + } + + let work_area = monitor_info.rcWork; + let bounds = ScreenBounds { + x: work_area.left, + y: work_area.top, + width: work_area.right.saturating_sub(work_area.left), + height: work_area.bottom.saturating_sub(work_area.top), + }; + + bounds.is_valid().then_some(bounds) +} diff --git a/llmdoc/architecture/slint-authorization-dialog.md b/llmdoc/architecture/slint-authorization-dialog.md index 3899e4c..d31a078 100644 --- a/llmdoc/architecture/slint-authorization-dialog.md +++ b/llmdoc/architecture/slint-authorization-dialog.md @@ -9,7 +9,8 @@ - `crates/sshwarden-ui/src/lib.rs` (`SignRequestInfo`, `AuthorizationResult`, `UIRequest`): 签名请求信息结构体、授权结果枚举(Approved/Denied/Timeout)、统一 UI 请求枚举(`UIRequest::AuthDialog` 变体). - `crates/sshwarden-ui/src/notify/mod.rs`: 导出 `show_auth_dialog`、`request_authorization`、`AuthDialogRequest`. -- `crates/sshwarden-ui/src/notify/slint_dialog.rs` (`AuthDialogRequest`, `show_auth_dialog`, `request_authorization`, `center_and_focus_dialog`): Slint 授权对话框核心实现。`slint::slint!{}` 内联宏定义 `AuthDialog` 窗口组件,`show_auth_dialog()` 在 Slint 主线程创建对话框,`request_authorization()` 从 tokio 线程异步请求,`center_and_focus_dialog()` 跨平台居中+聚焦(slint_center_win + winit focus_window). +- `crates/sshwarden-ui/src/notify/slint_dialog.rs` (`AuthDialogRequest`, `show_auth_dialog`, `request_authorization`, `center_and_focus_dialog`): Slint 授权对话框核心实现。`slint::slint!{}` 内联宏定义 `AuthDialog` 窗口组件,`show_auth_dialog()` 在 Slint 主线程创建对话框,`request_authorization()` 从 tokio 线程异步请求,`center_and_focus_dialog()` 调用统一窗口定位 helper 完成多屏居中+聚焦。 +- `crates/sshwarden-ui/src/window_placement.rs` (`center_on_active_monitor_and_focus`): Slint transient dialog 定位 helper。Windows 上优先取鼠标所在显示器的工作区中心;其他平台或 Windows API 失败时,回退到 winit current/primary/available monitor,并调用 `focus_window()` 前置窗口。 - `src/main.rs` (`run_slint_event_loop`): bridge 线程 match `UIRequest::AuthDialog` 分支,构造 `AuthDialogRequest` 后通过 `slint::invoke_from_event_loop` 调度 `show_auth_dialog()`. ## 3. Execution Flow (LLM Retrieval Map) @@ -30,13 +31,14 @@ ### 3.3 窗口居中与聚焦 -- **`center_and_focus_dialog()`:** 跨平台函数(无 `#[cfg]` 条件编译),先调用 `slint_center_win::center_window()` 居中窗口,再通过 `slint::winit_030::WinitWindowAccessor::with_winit_window()` 获取底层 winit 窗口并调用 `focus_window()` 确保窗口前置激活. 参见 `crates/sshwarden-ui/src/notify/slint_dialog.rs:134-142`. -- **延迟调度:** `dialog.show()` 成功后,通过 `Timer::single_shot(30ms)` 延迟执行居中+聚焦(确保窗口尺寸就绪后再居中). 参见 `crates/sshwarden-ui/src/notify/slint_dialog.rs:204-210`. -- **依赖:** Slint 需启用 `unstable-winit-030` feature 以暴露 `WinitWindowAccessor` API. 参见 `crates/sshwarden-ui/Cargo.toml:13`. +- **`center_and_focus_dialog()`:** 授权对话框显示后调用 `window_placement::center_on_active_monitor_and_focus()`。Windows 上用 `GetCursorPos` + `MonitorFromPoint` + `GetMonitorInfoW` 选择鼠标所在显示器的 `rcWork` 工作区并居中,避免多屏用户在副屏操作时弹窗落到主屏;失败或非 Windows 平台时,使用 winit 的 current/primary/available monitor fallback。 +- **聚焦:** helper 通过 `slint::winit_030::WinitWindowAccessor::with_winit_window()` 获取底层 winit 窗口并调用 `focus_window()` 确保窗口前置激活。 +- **延迟调度:** `dialog.show()` 成功后,通过 `Timer::single_shot(30ms)` 延迟执行定位+聚焦(确保窗口尺寸就绪后再居中)。 +- **依赖:** Slint 需启用 `unstable-winit-030` feature 以暴露 `WinitWindowAccessor` API;Windows 多屏工作区定位还需要 `windows` crate 的 `Win32_Graphics_Gdi` feature。 ### 3.4 AuthDialog UI 组件 -- **窗口属性:** 380x195px, always-on-top, 系统 Palette 配色(跟随暗色/亮色主题),`default-font-family: "Segoe UI"`. +- **窗口属性:** 380x230px, always-on-top, 系统 Palette 配色(跟随暗色/亮色主题),`default-font-family: "Segoe UI"`. - **显示内容:** 进程名(22px 粗体)、"is requesting to use an SSH key"(13px)、Key 名(13px)、Operation(13px,Git Signing/SSH Authentication/自定义 namespace). - **代理转发警告:** `is-forwarding` 为 true 时显示橙色警告条. - **布局:** 内边距 16px,间距 8px,按钮高度显式 30px. @@ -49,5 +51,5 @@ - **移除条件编译:** 不再有 `notify/windows.rs`(Toast+TaskDialog)和 `notify/fallback.rs`(non-Windows 自动批准),所有平台使用统一的 Slint 授权对话框。 - **复用 UIRequest 通道:** 授权对话框复用与 PIN 对话框相同的 `mpsc::channel` + bridge 线程 + `slint::invoke_from_event_loop` 架构,无需额外的 `spawn_blocking`。 - **Rc>>:** AuthDialog 多个回调(approve/deny/close)共享 oneshot sender 的标准模式,确保只发送一次结果。注意 PinDialog 已改为 `Arc` 以支持跨线程 validator(参见 `/llmdoc/architecture/sshwarden-windows-hello-unlock.md`)。 -- **跨平台窗口居中+聚焦:** `center_and_focus_dialog()` 移除了原先的 `#[cfg(windows)]`/`#[cfg(not(windows))]` 条件编译分支,改为统一的跨平台实现。通过 Slint `unstable-winit-030` feature 暴露 `WinitWindowAccessor` API,获取底层 winit 窗口调用 `focus_window()` 确保前置激活。配合 `slint_center_win::center_window()` 实现居中。 -- **UI 美化:** AuthDialog 调整为紧凑布局(380x195px、16px 内边距、8px 间距),设置 `default-font-family: "Segoe UI"` 解决 Windows 字体问题,进程名 22px 加粗突出显示,正文统一 13px,按钮高度显式 30px。 +- **多屏窗口定位+聚焦:** transient dialog 没有父窗口上下文,因此统一通过 `window_placement::center_on_active_monitor_and_focus()` 定位。Windows 优先使用鼠标所在显示器的工作区,适配多屏和任务栏占位;非 Windows 或平台 API 失败时回退到 winit monitor 信息。定位完成后通过 `focus_window()` 前置激活。 +- **UI 美化:** AuthDialog 调整为紧凑布局(380x230px、16px 内边距、8px 间距),设置 `default-font-family: "Segoe UI"` 解决 Windows 字体问题,进程名 22px 加粗突出显示,正文统一 13px,按钮高度显式 30px。 diff --git a/llmdoc/architecture/sshwarden-windows-hello-unlock.md b/llmdoc/architecture/sshwarden-windows-hello-unlock.md index 977d430..4985ec7 100644 --- a/llmdoc/architecture/sshwarden-windows-hello-unlock.md +++ b/llmdoc/architecture/sshwarden-windows-hello-unlock.md @@ -53,4 +53,4 @@ - **签名路径优先:** 自动解锁优先签名路径(无需交互),失败后降级到 PIN 对话框(需用户输入)。 - **Focus helper:** 签名路径使用后台线程持续调用 focus helper,确保安全提示窗口前置。 - **spawn_blocking:** WinRT 同步 API 不能在 tokio 异步运行时中直接调用。 -- **跨平台窗口居中+聚焦:** `center_and_focus_dialog()` 移除了 `#[cfg(windows)]` 条件编译,通过 Slint `unstable-winit-030` feature 暴露的 `WinitWindowAccessor` 获取底层 winit 窗口并调用 `focus_window()`,在所有 winit 支持的平台工作。 +- **多屏窗口定位+聚焦:** PIN/授权/绑定主机等 Slint transient dialog 复用 `window_placement::center_on_active_monitor_and_focus()`。Windows 上优先居中到鼠标所在显示器的工作区,适配多屏用户;其他平台或 Windows API 失败时回退到 winit current/primary/available monitor。定位后通过 `focus_window()` 前置激活。 diff --git a/llmdoc/index.md b/llmdoc/index.md index af7be45..3220b2f 100644 --- a/llmdoc/index.md +++ b/llmdoc/index.md @@ -48,6 +48,7 @@ | "make_pin_validator 和 decrypted_cache 如何避免重复 KDF?" | `architecture/sshwarden-windows-hello-unlock.md` (3.2 节) + `architecture/sshwarden-pin-encryption.md` (3.2 节) | | "Slint PIN 对话框如何跨线程调度?" | `architecture/sshwarden-windows-hello-unlock.md` (3.2 节) + `architecture/sshwarden-main-loop.md` (3.1 节) | | "Slint 授权对话框如何工作?" | `architecture/slint-authorization-dialog.md` | +| "授权弹窗多屏幕如何定位?" | `architecture/slint-authorization-dialog.md` (3.3 节) | | "UIRequest 枚举如何统一 UI 请求?" | `architecture/sshwarden-main-loop.md` (2 节 UIRequest) + `architecture/slint-authorization-dialog.md` (3.2 节) | | "PIN 加解密如何实现?" | `architecture/sshwarden-pin-encryption.md` | | "vault.enc 持久化如何工作?" | `architecture/sshwarden-pin-encryption.md` (3.1 步骤 6-7) | @@ -82,7 +83,7 @@ #### 3. `architecture/slint-authorization-dialog.md` **身份**: Slint 跨平台授权对话框 -**内容**: SSH 签名请求授权 UI(替代原 Windows Toast 通知 + TaskDialog/MessageBox)、AuthDialog 窗口组件(进程名/密钥名/操作类型/代理转发警告/Approve+Deny)、UIRequest::AuthDialog 跨线程调度(request_authorization -> bridge -> show_auth_dialog)、3 处调用点(Hello 签名路径后/PIN 解锁后/正常签名) +**内容**: SSH 签名请求授权 UI(替代原 Windows Toast 通知 + TaskDialog/MessageBox)、AuthDialog 窗口组件(进程名/密钥名/操作类型/代理转发警告/Approve+Deny)、UIRequest::AuthDialog 跨线程调度(request_authorization -> bridge -> show_auth_dialog)、多屏窗口定位+聚焦、3 处调用点(Hello 签名路径后/PIN 解锁后/正常签名) **适用角色**: UI 开发、授权流程开发 #### 4. `architecture/sshwarden-windows-hello-unlock.md` @@ -168,7 +169,13 @@ ## 文档更新日志 -**最后更新**: 2026-03-15 +**最后更新**: 2026-06-05 + +### 授权弹窗多屏适配 +- UPDATE `architecture/slint-authorization-dialog.md` - 授权弹窗定位改为 `window_placement::center_on_active_monitor_and_focus()`:Windows 优先使用鼠标所在显示器的工作区居中,非 Windows 或平台 API 失败时回退到 winit monitor,并继续调用 `focus_window()` 前置。 +- UPDATE `architecture/sshwarden-windows-hello-unlock.md` - 记录 PIN/授权/绑定主机等 Slint transient dialog 复用统一多屏定位 helper。 +- UPDATE `overview/project-overview.md` - UI 技术栈和关键设计决策更新为多屏窗口定位+聚焦,不再依赖 `slint_center_win`。 +- UPDATE `llmdoc/index.md` - 查询表新增授权弹窗多屏定位入口,文档描述和更新日志同步。 ### 安全重构: 内存敏感数据自动擦零(对标 Bitwarden Desktop 安全模型) - UPDATE `overview/project-overview.md` - Crypto 层新增 zeroize,sshwarden-api crate 新增 zeroize 擦零描述,Key Design Decisions 新增"内存敏感数据自动擦零"完整条目 diff --git a/llmdoc/overview/project-overview.md b/llmdoc/overview/project-overview.md index 0ab03ac..e7e366d 100644 --- a/llmdoc/overview/project-overview.md +++ b/llmdoc/overview/project-overview.md @@ -17,7 +17,7 @@ SSHWarden 是一个 Rust CLI 程序,以守护进程模式运行于 Windows Nam | SSH Agent | `sshwarden-agent` + `bitwarden-russh` | SSH Agent 协议、密钥存储、Named Pipe/Unix Socket | | Bitwarden API | `sshwarden-api` + reqwest + tokio-tungstenite + rmpv | Bitwarden 登录、sync、密钥解密、SignalR 实时通知(WebSocket + MessagePack)、token 刷新 | | Crypto | `sshwarden-api::crypto` + `zeroize` | AES-256-CBC+HMAC、Argon2id PIN 派生、敏感数据自动擦零(Zeroizing/ZeroizeOnDrop) | -| UI / Unlock | `sshwarden-ui` + Slint + winit (via WinitWindowAccessor) | Windows Hello UV、Hello 签名路径、PIN 输入对话框(Slint 跨平台暗色窗口)、SSH 签名授权对话框(Slint 跨平台)、窗口居中+聚焦(slint_center_win + winit focus_window)、Credential Manager | +| UI / Unlock | `sshwarden-ui` + Slint + winit (via WinitWindowAccessor) | Windows Hello UV、Hello 签名路径、PIN 输入对话框(Slint 跨平台暗色窗口)、SSH 签名授权对话框(Slint 跨平台)、多屏窗口定位+聚焦(Windows 光标显示器工作区 + winit fallback)、Credential Manager | | Config & Vault | `sshwarden-config` + TOML + JSON | 配置文件管理、Local Key Cache、session 文件(设备独立会话恢复);当前以平台标准存储为默认,portable/exe-relative 为显式 opt-in | | IPC Control | `sshwarden-agent::control` | Named Pipe JSON 协议控制通道 | @@ -51,7 +51,7 @@ SSHWarden 是一个 Rust CLI 程序,以守护进程模式运行于 Windows Nam - **PIN 便捷解锁**: Argon2id 派生密钥加密内存中的密钥缓存,同时持久化到 vault.enc 文件。 - **vault.enc 持久化**: 守护进程重启后无需重新输入主密码,通过 PIN/Hello/Password 解锁即可恢复密钥。 - **双线程架构**: 同步 `fn main()` 主线程运行 Slint 事件循环(PIN 对话框 + 授权对话框),tokio 运行时在独立线程。通过 `mpsc::channel` + bridge 线程 + `slint::invoke_from_event_loop` 跨线程调度 UI 对话框。`UIRequest` 枚举统一了 `PinDialog` 和 `AuthDialog` 两种跨线程 UI 请求。 -- **跨平台窗口居中+聚焦**: Slint 启用 `unstable-winit-030` feature,通过 `WinitWindowAccessor` 获取底层 winit 窗口,调用 `focus_window()` 确保对话框前置激活。居中使用 `slint_center_win` crate。两者均为跨平台实现,无 `#[cfg]` 条件编译。 +- **多屏窗口定位+聚焦**: Slint 启用 `unstable-winit-030` feature,通过 `WinitWindowAccessor` 获取底层 winit 窗口。`window_placement::center_on_active_monitor_and_focus()` 在 Windows 上优先居中到鼠标所在显示器的工作区,适配多屏和任务栏占位;其他平台或 Windows API 失败时回退到 winit current/primary/available monitor。定位后调用 `focus_window()` 确保对话框前置激活。 - **Windows Hello 签名路径 + Slint PIN 对话框降级**: KeyCredentialManager 签名路径作为主要自动解锁方式(持久化加密密钥,跨重启可用)。Hello 不可用或失败时,弹出 Slint 跨平台 PIN 对话框(暗色主题、always-on-top)作为降级方案。PIN 对话框采用 validator 注入模式:验证逻辑通过闭包注入对话框内部,在后台线程执行 Argon2id 验证;错误 PIN 时对话框保持打开(抖动+红色提示),成功时缓存解密结果并关闭。UV(UserConsentVerifier)路径已从自动解锁流程中移除。 - **自动锁定**: 配置化的 `lock_timeout`(默认 3600 秒),60 秒检查间隔。 - **启动文件夹自启动**: `daemon --install` 在用户启动文件夹创建快捷方式(而非 Task Scheduler),确保守护进程在交互式桌面会话中运行,支持 Slint 授权/解锁对话框和 Windows Hello 等 UI 交互。