Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 0 additions & 11 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion crates/sshwarden-ui/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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",
Expand Down
8 changes: 1 addition & 7 deletions crates/sshwarden-ui/src/bind_hosts/slint_dialog.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions crates/sshwarden-ui/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
8 changes: 1 addition & 7 deletions crates/sshwarden-ui/src/notify/slint_dialog.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
8 changes: 1 addition & 7 deletions crates/sshwarden-ui/src/unlock/slint_dialog.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
145 changes: 145 additions & 0 deletions crates/sshwarden-ui/src/window_placement.rs
Original file line number Diff line number Diff line change
@@ -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<ScreenBounds> {
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::<MONITORINFO>() 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)
}
16 changes: 9 additions & 7 deletions llmdoc/architecture/slint-authorization-dialog.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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.
Expand All @@ -49,5 +51,5 @@
- **移除条件编译:** 不再有 `notify/windows.rs`(Toast+TaskDialog)和 `notify/fallback.rs`(non-Windows 自动批准),所有平台使用统一的 Slint 授权对话框。
- **复用 UIRequest 通道:** 授权对话框复用与 PIN 对话框相同的 `mpsc::channel<UIRequest>` + bridge 线程 + `slint::invoke_from_event_loop` 架构,无需额外的 `spawn_blocking`。
- **Rc<RefCell<Option<Sender>>>:** AuthDialog 多个回调(approve/deny/close)共享 oneshot sender 的标准模式,确保只发送一次结果。注意 PinDialog 已改为 `Arc<Mutex>` 以支持跨线程 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。
2 changes: 1 addition & 1 deletion llmdoc/architecture/sshwarden-windows-hello-unlock.md
Original file line number Diff line number Diff line change
Expand Up @@ -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()` 前置激活
11 changes: 9 additions & 2 deletions llmdoc/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -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) |
Expand Down Expand Up @@ -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`
Expand Down Expand Up @@ -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 新增"内存敏感数据自动擦零"完整条目
Expand Down
Loading
Loading