From 7cd31e403f453e67a528fa49ed3fb9e26aef1d8f Mon Sep 17 00:00:00 2001 From: CN059 Date: Sat, 29 Aug 2026 09:26:03 +0800 Subject: [PATCH 1/5] feat(platform): enforce Windows host single instance Acquire a session-scoped Windows mutex before shell initialization so repeated launches exit without creating windows, opening storage, or starting plugins. Keep the native handle and unsafe calls isolated in floatile-platform and document the Windows Alpha host requirements. Refs: WA-HOST-01 Tests: cargo build -p floatile-shell --locked; cargo test -p floatile-platform --locked; cargo check --workspace --all-targets --locked; cargo clippy --workspace --all-targets --locked -- -D warnings; cargo test --workspace --all-targets --locked; Windows 11 x64 manual double-launch process count = 1 (user verified) Unverified: Linux and macOS runtime behavior --- crates/floatile-platform/Cargo.toml | 2 +- crates/floatile-platform/src/lib.rs | 4 + .../floatile-platform/src/single_instance.rs | 154 ++++++++++++++++++ crates/floatile-shell/src/main.rs | 28 +++- docs/README.md | 1 + docs/product/windows-alpha.md | 27 +++ 6 files changed, 212 insertions(+), 4 deletions(-) create mode 100644 crates/floatile-platform/src/single_instance.rs create mode 100644 docs/product/windows-alpha.md diff --git a/crates/floatile-platform/Cargo.toml b/crates/floatile-platform/Cargo.toml index 05981dc..592ee54 100644 --- a/crates/floatile-platform/Cargo.toml +++ b/crates/floatile-platform/Cargo.toml @@ -20,7 +20,7 @@ winit = { workspace = true } x11rb = { workspace = true } [target.'cfg(windows)'.dependencies] -windows-sys = { workspace = true, features = ["Win32_Foundation", "Win32_UI_WindowsAndMessaging", "Win32_UI_Input_KeyboardAndMouse"] } +windows-sys = { workspace = true, features = ["Win32_Foundation", "Win32_Security", "Win32_System_Threading", "Win32_UI_WindowsAndMessaging", "Win32_UI_Input_KeyboardAndMouse"] } [target.'cfg(target_os = "macos")'.dependencies] objc2 = { workspace = true } diff --git a/crates/floatile-platform/src/lib.rs b/crates/floatile-platform/src/lib.rs index efe9277..27c3409 100644 --- a/crates/floatile-platform/src/lib.rs +++ b/crates/floatile-platform/src/lib.rs @@ -2,6 +2,7 @@ pub mod capability; pub mod hotkey; pub mod metrics; pub mod monitor; +pub mod single_instance; pub mod window; #[cfg(target_os = "linux")] @@ -58,6 +59,9 @@ pub use hotkey::{ }; pub use metrics::{MetricsError, ProcessMetrics, process_metrics}; pub use monitor::{MonitorInfo, MonitorKeySource, enumerate_monitors, to_monitor_layout}; +pub use single_instance::{ + SingleInstanceError, SingleInstanceGuard, SingleInstanceState, acquire_single_instance, +}; pub use window::{ PlatformError, WindowOptions, apply_window_options, remove_window_decorations, resize_window, set_always_on_top, set_click_through, set_window_position, start_window_drag, diff --git a/crates/floatile-platform/src/single_instance.rs b/crates/floatile-platform/src/single_instance.rs new file mode 100644 index 0000000..f954328 --- /dev/null +++ b/crates/floatile-platform/src/single_instance.rs @@ -0,0 +1,154 @@ +//! 桌面宿主单实例守卫。 +//! +//! Windows 使用命名互斥体保证同一用户会话中只运行一个 Floatile 宿主。 +//! 其他平台暂未接入原生实现,调用方必须显式处理 [`SingleInstanceState::Unsupported`]。 + +/// 单实例获取结果。 +#[derive(Debug)] +pub enum SingleInstanceState { + /// 当前进程持有宿主实例守卫;守卫释放前其他进程不得成为主实例。 + Acquired(SingleInstanceGuard), + /// 当前用户会话中已有 Floatile 宿主实例。 + AlreadyRunning, + /// 当前平台尚未实现单实例约束。 + Unsupported, +} + +/// 持有平台单实例资源的 RAII 守卫。 +#[derive(Debug)] +pub struct SingleInstanceGuard { + #[cfg(windows)] + handle: windows_sys::Win32::Foundation::HANDLE, +} + +/// 单实例资源获取失败。 +#[derive(Debug, thiserror::Error)] +pub enum SingleInstanceError { + #[error("单实例名称不能为空")] + EmptyName, + #[error("单实例名称不能包含 NUL 字符")] + NameContainsNul, + #[error("创建 Windows 单实例互斥体失败: {code}")] + Platform { code: u32 }, +} + +/// 尝试取得当前桌面宿主的单实例所有权。 +/// +/// `name` 是宿主维护的稳定标识,不得包含 NUL。Windows 实现使用 `Local\\` 命名空间, +/// 因此约束只覆盖当前登录会话,不会阻止其他 Windows 用户运行自己的 Floatile。 +pub fn acquire_single_instance(name: &str) -> Result { + validate_name(name)?; + + #[cfg(windows)] + { + windows_impl::acquire(name) + } + + #[cfg(not(windows))] + { + Ok(SingleInstanceState::Unsupported) + } +} + +fn validate_name(name: &str) -> Result<(), SingleInstanceError> { + if name.is_empty() { + return Err(SingleInstanceError::EmptyName); + } + if name.contains('\0') { + return Err(SingleInstanceError::NameContainsNul); + } + Ok(()) +} + +#[cfg(windows)] +impl Drop for SingleInstanceGuard { + fn drop(&mut self) { + windows_impl::close(self.handle); + } +} + +#[cfg(windows)] +#[allow(unsafe_code)] +mod windows_impl { + use super::{SingleInstanceError, SingleInstanceGuard, SingleInstanceState}; + use windows_sys::Win32::Foundation::{ERROR_ALREADY_EXISTS, GetLastError}; + use windows_sys::Win32::System::Threading::CreateMutexW; + + pub(super) fn close(handle: windows_sys::Win32::Foundation::HANDLE) { + // SAFETY: handle 由成功的 CreateMutexW 返回,调用方保证只关闭一次。 + let _ = unsafe { windows_sys::Win32::Foundation::CloseHandle(handle) }; + } + + pub(super) fn acquire(name: &str) -> Result { + let wide_name: Vec = name.encode_utf16().chain(std::iter::once(0)).collect(); + // SAFETY: wide_name 是本函数持有的 NUL 结尾 UTF-16 缓冲区;不传安全描述符, + // CreateMutexW 只在调用期间读取名称并返回由守卫独占管理的 HANDLE。 + let handle = unsafe { CreateMutexW(std::ptr::null(), 0, wide_name.as_ptr()) }; + if handle == 0 { + // SAFETY: 读取紧邻失败 CreateMutexW 调用产生的线程本地错误码。 + let code = unsafe { GetLastError() }; + return Err(SingleInstanceError::Platform { code }); + } + + // SAFETY: 读取紧邻成功 CreateMutexW 调用产生的线程本地状态码; + // ERROR_ALREADY_EXISTS 表示本调用只取得了已有互斥体的额外句柄。 + let code = unsafe { GetLastError() }; + if code == ERROR_ALREADY_EXISTS { + close(handle); + return Ok(SingleInstanceState::AlreadyRunning); + } + + Ok(SingleInstanceState::Acquired(SingleInstanceGuard { + handle, + })) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn rejects_empty_and_embedded_nul_names() { + assert!(matches!( + acquire_single_instance(""), + Err(SingleInstanceError::EmptyName) + )); + assert!(matches!( + acquire_single_instance("Local\\Floatile\0Other"), + Err(SingleInstanceError::NameContainsNul) + )); + } + + #[cfg(not(windows))] + #[test] + fn unsupported_platform_is_explicit() { + assert!(matches!( + acquire_single_instance("Local\\Floatile.Test"), + Ok(SingleInstanceState::Unsupported) + )); + } + + #[cfg(windows)] + #[test] + fn second_windows_acquisition_is_rejected_until_guard_drops() { + use std::sync::atomic::{AtomicU64, Ordering}; + + static NEXT_ID: AtomicU64 = AtomicU64::new(1); + let id = NEXT_ID.fetch_add(1, Ordering::Relaxed); + let name = format!("Local\\Floatile.Test.{}.{id}", std::process::id()); + + { + let first = acquire_single_instance(&name); + assert!(matches!(first, Ok(SingleInstanceState::Acquired(_)))); + assert!(matches!( + acquire_single_instance(&name), + Ok(SingleInstanceState::AlreadyRunning) + )); + } + assert!(matches!( + acquire_single_instance(&name), + Ok(SingleInstanceState::Acquired(_)) + )); + } +} diff --git a/crates/floatile-shell/src/main.rs b/crates/floatile-shell/src/main.rs index f69f740..6b552fc 100644 --- a/crates/floatile-shell/src/main.rs +++ b/crates/floatile-shell/src/main.rs @@ -32,9 +32,10 @@ use floatile_platform::listen_hotkey; #[cfg(any(windows, target_os = "linux", target_os = "macos"))] use floatile_platform::{Hotkey, HotkeyModifiers}; use floatile_platform::{ - PlatformError, PlatformKind, WindowOptions, apply_window_options, data_dir, enumerate_monitors, - process_metrics, resize_window, set_always_on_top, set_click_through, set_window_position, - start_window_drag, to_monitor_layout, + PlatformError, PlatformKind, SingleInstanceState, WindowOptions, acquire_single_instance, + apply_window_options, data_dir, enumerate_monitors, process_metrics, resize_window, + set_always_on_top, set_click_through, set_window_position, start_window_drag, + to_monitor_layout, }; #[cfg(windows)] use floatile_platform::{ @@ -850,6 +851,7 @@ const HOTKEY_ID: u32 = 0x0001; const KEY_E: u32 = 0x0E; #[cfg(windows)] const KEY_F12: u32 = 0x7B; +const SINGLE_INSTANCE_NAME: &str = "Local\\Floatile.DesktopHost"; fn main() -> Result<(), Box> { let process_started = Instant::now(); @@ -860,6 +862,26 @@ fn main() -> Result<(), Box> { .add_directive(tracing::level_filters::LevelFilter::INFO.into()), ) .init(); + let _single_instance_guard = match acquire_single_instance(SINGLE_INSTANCE_NAME)? { + SingleInstanceState::Acquired(guard) => { + tracing::info!( + name = SINGLE_INSTANCE_NAME, + "single-instance ownership acquired" + ); + Some(guard) + } + SingleInstanceState::AlreadyRunning => { + tracing::info!( + name = SINGLE_INSTANCE_NAME, + "existing Floatile host detected" + ); + return Ok(()); + } + SingleInstanceState::Unsupported => { + tracing::info!("single-instance enforcement unavailable on this platform"); + None + } + }; let perf_sampler = if perf_enabled { Some(PerfSampler::start()?) } else { diff --git a/docs/README.md b/docs/README.md index f81180b..2a737f2 100644 --- a/docs/README.md +++ b/docs/README.md @@ -9,6 +9,7 @@ |---|---|---| | Git 分支、提交与 PR 协作 | `../CONTRIBUTING.md` | 分支命名、提交时机、message、审查或合并策略变化 | | 产品范围与需求 | `product/requirements.md` | 目标、非目标、验收映射变化 | +| Windows 内部 Alpha | `product/windows-alpha.md` | Windows-first 产品范围、宿主壳要求或验收顺序变化 | | 插件平台长期方向与演进 | `product/plugin-platform-roadmap.md` | 平台北极星、领域模型、里程碑、优先级或执行顺序变化 | | P0 验收 | `architecture/p0-acceptance.md` | 验收步骤、阈值或结果变化 | | 总体架构与线程模型 | `architecture/p0-design.md` | 模块、数据流、线程或安全边界变化 | diff --git a/docs/product/windows-alpha.md b/docs/product/windows-alpha.md new file mode 100644 index 0000000..1729c21 --- /dev/null +++ b/docs/product/windows-alpha.md @@ -0,0 +1,27 @@ +# Floatile Windows 内部 Alpha + +> 状态:Accepted +> +> 范围:Windows 11 x64 内部可用性验证,不授权公开分发 +> +> 基线:2026-08-29 + +本文定义 Windows-first 内部 Alpha 的增量产品要求。它不删除 +[`requirements.md`](requirements.md) 的 P0 跨平台要求,也不解除 NFR-LEGAL-01 发布门。 + +## 1. 宿主壳增量要求 + +| ID | 要求 | 验收 | +|---|---|---| +| WA-HOST-01 | Windows 桌面宿主必须在当前用户会话中保持单实例;第二次启动必须在创建窗口、打开数据库或启动插件前退出,不得影响首个实例。 | 同一 Windows 会话连续启动两次 `floatile-shell`,只保留首个宿主进程及其 Widget;首个实例继续响应。 | +| WA-HOST-02 | 宿主必须提供系统托盘入口;管理中心关闭后收起到托盘,托盘提供重新打开与显式退出。 | 后续切片。 | +| WA-HOST-03 | 管理中心与 Widget 必须使用不同窗口角色;Widget 不进入任务栏/Alt+Tab,管理中心按普通应用窗口呈现。 | 后续切片。 | +| WA-HOST-04 | 显式退出必须有界停止插件、保存布局、释放平台资源并关闭数据库;单个插件不得无限阻塞退出。 | 后续切片。 | + +## 2. 当前切片 + +WA-HOST-01 只建立单实例所有权和重复启动拒绝。第二次启动时唤醒已存在管理中心依赖托盘与窗口消息, +归入 WA-HOST-02,不在本切片伪造占位行为。 + +Windows 使用 `Local\\` 命名互斥体,范围限定为当前登录会话;其他平台本切片返回显式不支持并维持 +现有启动行为。平台资源与 `unsafe` 只存在于 `floatile-platform`,`floatile-shell` 只消费获取结果。 From fbbfe6192252c245588d6f15d341446adf3b5ac6 Mon Sep 17 00:00:00 2001 From: CN059 Date: Sat, 29 Aug 2026 10:29:40 +0800 Subject: [PATCH 2/5] feat(platform): add Windows tray host controls Provide a Windows notification-area icon with open and explicit-exit actions while keeping native tray ownership in floatile-platform. The shell polls bounded non-blocking events on the Slint thread so closing the management window only hides it and tray exit retains the existing cleanup path. Refs: WA-HOST-02 Tests: cargo fmt --all -- --check; cargo check --workspace --all-targets --locked; cargo clippy --workspace --all-targets --locked -- -D warnings; cargo test -p floatile-platform --locked (13 passed); cargo deny --locked check advisories bans sources; Windows 11 x64 manual tray open, hide/reopen, process retention, and explicit exit (user verified) Unverified: Linux/macOS behavior; left-click tray activation; cargo deny --locked check licenses remains release-blocked by Slint and cross-target option-ext MPL-2.0 policy --- Cargo.lock | 61 ++++++++++- Cargo.toml | 1 + crates/floatile-platform/Cargo.toml | 1 + crates/floatile-platform/src/lib.rs | 5 + crates/floatile-platform/src/windows_host.rs | 105 +++++++++++++++++++ crates/floatile-shell/src/main.rs | 69 +++++++++++- docs/architecture/technology-stack.md | 2 +- docs/product/windows-alpha.md | 6 +- 8 files changed, 242 insertions(+), 8 deletions(-) create mode 100644 crates/floatile-platform/src/windows_host.rs diff --git a/Cargo.lock b/Cargo.lock index 55f9909..e41960f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1324,6 +1324,27 @@ dependencies = [ "dirs-sys-next", ] +[[package]] +name = "dirs" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e" +dependencies = [ + "dirs-sys", +] + +[[package]] +name = "dirs-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" +dependencies = [ + "libc", + "option-ext", + "redox_users 0.5.2", + "windows-sys 0.61.2", +] + [[package]] name = "dirs-sys-next" version = "0.1.2" @@ -1331,7 +1352,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4ebda144c4fe02d1f7ea1a7d9641b6fc6b580adcfa024ae48797ecdeb6825b4d" dependencies = [ "libc", - "redox_users", + "redox_users 0.4.6", "winapi", ] @@ -1761,6 +1782,7 @@ dependencies = [ "objc2-foundation 0.3.2", "thiserror 2.0.20", "tracing", + "tray-icon", "windows-sys 0.52.0", "winit", "x11rb", @@ -4236,6 +4258,12 @@ dependencies = [ "portable-atomic", ] +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + [[package]] name = "orbclient" version = "0.3.55" @@ -4849,6 +4877,17 @@ dependencies = [ "thiserror 1.0.69", ] +[[package]] +name = "redox_users" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" +dependencies = [ + "getrandom 0.2.17", + "libredox", + "thiserror 2.0.20", +] + [[package]] name = "ref-cast" version = "1.0.27" @@ -6317,6 +6356,26 @@ dependencies = [ "tracing-log", ] +[[package]] +name = "tray-icon" +version = "0.24.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "045979e3f037cd18ad1cb2a419dfda133c5c29c9f3453370079f2255d46c257e" +dependencies = [ + "crossbeam-channel", + "dirs", + "muda", + "objc2 0.6.4", + "objc2-app-kit 0.3.2", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-foundation 0.3.2", + "once_cell", + "png", + "thiserror 2.0.20", + "windows-sys 0.61.2", +] + [[package]] name = "try-lock" version = "0.2.5" diff --git a/Cargo.toml b/Cargo.toml index 91dff15..907cd2f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -54,6 +54,7 @@ slint = { version = "1.17", features = ["backend-winit", "unstable-winit-030"] } winit = "0.30" x11rb = { version = "0.13", default-features = false, features = ["randr", "shape"] } windows-sys = "0.52" +tray-icon = { version = "0.24.2", default-features = false } objc2 = "0.6" objc2-app-kit = { version = "0.3", default-features = false, features = ["NSGraphics", "NSResponder", "NSScreen", "NSView", "NSWindow"] } objc2-foundation = { version = "0.3", default-features = false, features = ["NSArray", "NSDictionary", "NSEnumerator", "NSGeometry", "NSObject", "NSString"] } diff --git a/crates/floatile-platform/Cargo.toml b/crates/floatile-platform/Cargo.toml index 592ee54..00f5c3f 100644 --- a/crates/floatile-platform/Cargo.toml +++ b/crates/floatile-platform/Cargo.toml @@ -21,6 +21,7 @@ x11rb = { workspace = true } [target.'cfg(windows)'.dependencies] windows-sys = { workspace = true, features = ["Win32_Foundation", "Win32_Security", "Win32_System_Threading", "Win32_UI_WindowsAndMessaging", "Win32_UI_Input_KeyboardAndMouse"] } +tray-icon = { workspace = true } [target.'cfg(target_os = "macos")'.dependencies] objc2 = { workspace = true } diff --git a/crates/floatile-platform/src/lib.rs b/crates/floatile-platform/src/lib.rs index 27c3409..38e4922 100644 --- a/crates/floatile-platform/src/lib.rs +++ b/crates/floatile-platform/src/lib.rs @@ -5,6 +5,9 @@ pub mod monitor; pub mod single_instance; pub mod window; +#[cfg(windows)] +pub mod windows_host; + #[cfg(target_os = "linux")] mod x11; @@ -66,3 +69,5 @@ pub use window::{ PlatformError, WindowOptions, apply_window_options, remove_window_decorations, resize_window, set_always_on_top, set_click_through, set_window_position, start_window_drag, }; +#[cfg(windows)] +pub use windows_host::{WindowsTrayEvent, WindowsTrayIcon}; diff --git a/crates/floatile-platform/src/windows_host.rs b/crates/floatile-platform/src/windows_host.rs new file mode 100644 index 0000000..b15f21c --- /dev/null +++ b/crates/floatile-platform/src/windows_host.rs @@ -0,0 +1,105 @@ +//! Windows 系统托盘边界。 + +use crate::PlatformError; +use tray_icon::menu::{Menu, MenuEvent, MenuId, MenuItem}; +use tray_icon::{Icon, MouseButton, MouseButtonState, TrayIcon, TrayIconBuilder, TrayIconEvent}; + +const TRAY_OPEN_ID: &str = "floatile.tray.open"; +const TRAY_EXIT_ID: &str = "floatile.tray.exit"; + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum WindowsTrayEvent { + Open, + Exit, +} + +/// 托盘图标必须在 Win32 消息循环所在线程创建和轮询。 +pub struct WindowsTrayIcon { + _tray: TrayIcon, + open_id: MenuId, + exit_id: MenuId, +} + +impl WindowsTrayIcon { + pub fn install(tooltip: &str) -> Result { + let menu = Menu::new(); + let open = MenuItem::with_id(TRAY_OPEN_ID, "打开 Floatile", true, None); + let exit = MenuItem::with_id(TRAY_EXIT_ID, "退出 Floatile", true, None); + menu.append_items(&[&open, &exit]) + .map_err(|error| PlatformError::Platform(format!("tray menu failed: {error}")))?; + + let tray = TrayIconBuilder::new() + .with_tooltip(tooltip) + .with_icon(floatile_icon()?) + .with_menu(Box::new(menu)) + .with_menu_on_left_click(false) + .with_menu_on_right_click(true) + .build() + .map_err(|error| PlatformError::Platform(format!("tray icon failed: {error}")))?; + + Ok(Self { + _tray: tray, + open_id: open.id().clone(), + exit_id: exit.id().clone(), + }) + } + + /// 非阻塞排空库的全局托盘事件队列。 + pub fn poll_event(&self) -> Option { + while let Ok(event) = MenuEvent::receiver().try_recv() { + if event.id == self.open_id { + return Some(WindowsTrayEvent::Open); + } + if event.id == self.exit_id { + return Some(WindowsTrayEvent::Exit); + } + } + while let Ok(event) = TrayIconEvent::receiver().try_recv() { + if matches!( + event, + TrayIconEvent::Click { + button: MouseButton::Left, + button_state: MouseButtonState::Up, + .. + } + ) { + return Some(WindowsTrayEvent::Open); + } + } + None + } +} + +fn floatile_icon() -> Result { + const SIZE: u32 = 32; + let mut rgba = vec![0u8; (SIZE * SIZE * 4) as usize]; + for y in 0..SIZE { + for x in 0..SIZE { + let offset = ((y * SIZE + x) * 4) as usize; + let inside = (3..29).contains(&x) && (3..29).contains(&y); + let letter_f = (8..12).contains(&x) && (7..25).contains(&y) + || (8..23).contains(&x) && (7..11).contains(&y) + || (8..20).contains(&x) && (14..18).contains(&y); + let color = if letter_f { + [255, 255, 255, 255] + } else if inside { + [48, 112, 232, 255] + } else { + [0, 0, 0, 0] + }; + rgba[offset..offset + 4].copy_from_slice(&color); + } + } + Icon::from_rgba(rgba, SIZE, SIZE) + .map_err(|error| PlatformError::Platform(format!("tray icon pixels invalid: {error}"))) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn generated_tray_icon_is_valid() { + assert!(floatile_icon().is_ok()); + } +} diff --git a/crates/floatile-shell/src/main.rs b/crates/floatile-shell/src/main.rs index 6b552fc..0df3e97 100644 --- a/crates/floatile-shell/src/main.rs +++ b/crates/floatile-shell/src/main.rs @@ -39,7 +39,8 @@ use floatile_platform::{ }; #[cfg(windows)] use floatile_platform::{ - install_hotkey_message_hook, register_hotkey, remove_window_decorations, unregister_hotkey, + WindowsTrayEvent, WindowsTrayIcon, install_hotkey_message_hook, register_hotkey, + remove_window_decorations, unregister_hotkey, }; use floatile_runtime::{WidgetConfig, WidgetManager}; use floatile_shell::{ @@ -1390,6 +1391,8 @@ fn main() -> Result<(), Box> { let control_window = instance_control_surface .as_ref() .map(|surface| surface.weak()); + #[cfg(windows)] + let tray_control_window = control_window.clone(); app.on_settings_clicked(move || { let Some(window) = control_window.as_ref().and_then(slint::Weak::upgrade) else { tracing::warn!("instance control surface unavailable"); @@ -1488,6 +1491,8 @@ fn main() -> Result<(), Box> { // 一次性收敛:成功后不再重复执行,避免每次 tick 重改窗口样式 // (SetWindowLongPtrW + ShowWindow 隐藏/重现)造成持续频闪。 #[cfg(windows)] + let tray_icon = Rc::new(RefCell::new(None::)); + #[cfg(windows)] let register_timer = { // 200 ms × 25 = 5 s 的窗口就绪与热键注册预算;耗尽后按降级模型停止重试。 const MAX_RETRIES: u32 = 25; @@ -1529,6 +1534,8 @@ fn main() -> Result<(), Box> { let timer_for_callback = Rc::clone(®ister_timer); let decorations_done = Rc::new(RefCell::new(false)); let hotkey_done = Rc::new(RefCell::new(false)); + let tray_done = Rc::new(RefCell::new(false)); + let tray_icon_for_registration = Rc::clone(&tray_icon); let candidate_index = Rc::new(RefCell::new(0usize)); let retries = Rc::new(RefCell::new(0u32)); register_timer.start( @@ -1541,7 +1548,7 @@ fn main() -> Result<(), Box> { let attempts = *retries.borrow(); if attempts >= MAX_RETRIES { tracing::error!( - "window decorations/hotkey setup failed after {attempts} retries; \ + "window decorations/hotkey/tray setup failed after {attempts} retries; \ click-through stays disabled, Show mode exits by pressing Escape" ); timer_for_callback.stop(); @@ -1608,6 +1615,21 @@ fn main() -> Result<(), Box> { } } + if !*tray_done.borrow() { + let tray_result = WindowsTrayIcon::install("Floatile"); + match tray_result { + Ok(icon) => { + *tray_icon_for_registration.borrow_mut() = Some(icon); + *tray_done.borrow_mut() = true; + tracing::info!("system tray icon registered"); + } + Err(error) => { + pending = true; + tracing::debug!("system tray registration retry: {error}"); + } + } + } + if pending { *retries.borrow_mut() = attempts + 1; } else { @@ -1618,6 +1640,43 @@ fn main() -> Result<(), Box> { register_timer }; + #[cfg(windows)] + let tray_event_timer = { + let timer = Timer::default(); + let tray_icon = Rc::clone(&tray_icon); + timer.start( + slint::TimerMode::Repeated, + Duration::from_millis(50), + move || { + let event = tray_icon + .borrow() + .as_ref() + .and_then(WindowsTrayIcon::poll_event); + match event { + Some(WindowsTrayEvent::Open) => { + let Some(window) = + tray_control_window.as_ref().and_then(slint::Weak::upgrade) + else { + tracing::warn!("instance control surface unavailable"); + return; + }; + if let Err(error) = window.show() { + tracing::warn!(%error, "instance control surface show failed"); + } + } + Some(WindowsTrayEvent::Exit) => { + tracing::info!("explicit exit requested from system tray"); + if let Err(error) = slint::quit_event_loop() { + tracing::warn!(%error, "event loop exit request failed"); + } + } + None => {} + } + }, + ); + timer + }; + tracing::info!("floatile-shell running"); let run_result = app.run(); @@ -1631,7 +1690,11 @@ fn main() -> Result<(), Box> { } #[cfg(windows)] - register_timer.stop(); + { + register_timer.stop(); + tray_event_timer.stop(); + let _ = tray_icon.borrow_mut().take(); + } if let Some(runtime_clock) = runtime_clock { let _ = runtime_clock.stop.try_send(()); diff --git a/docs/architecture/technology-stack.md b/docs/architecture/technology-stack.md index ef05317..02949c1 100644 --- a/docs/architecture/technology-stack.md +++ b/docs/architecture/technology-stack.md @@ -10,7 +10,7 @@ | 语言 | Rust 2024,MSRV 1.97 | 原生跨平台、WASM guest 同语言;工具链固定到 `rust-toolchain.toml`。 | | 宿主 UI | Slint 1.17.x + winit 0.30.x | 原生 renderer 与窗口;ADR-0001 规定 Slint 只在宿主内使用,插件不提供 `.slint`。 | | 插件 UI | Floatile UI IR v1(`floatile-ui-schema` 已实现) | 版本化静态组件树 + State/Event schema;Rust/TypeScript SDK 同源生成;IR→Slint renderer 路径需 P0 spike。 | -| 平台 API | windows-sys 0.52.x + x11rb 0.13.x(`randr`、`shape`)+ objc2 0.6.x(`app-kit`/`foundation`)+ mach2 0.6.x | 平台句柄、Windows 窗口操作、X11 compositor/SHAPE/EWMH/RandR/热键探测,以及 macOS NSWindow/NSScreen/进程指标与 Carbon 全局热键;只允许 `floatile-platform` 直接依赖。 | +| 平台 API | windows-sys 0.52.x + tray-icon 0.24.x + x11rb 0.13.x(`randr`、`shape`)+ objc2 0.6.x(`app-kit`/`foundation`)+ mach2 0.6.x | 平台句柄、Windows 窗口与通知区操作、X11 compositor/SHAPE/EWMH/RandR/热键探测,以及 macOS NSWindow/NSScreen/进程指标与 Carbon 全局热键;只允许 `floatile-platform` 直接依赖。`tray-icon` 本阶段仅在 Windows target 启用并关闭默认 GTK/libxdo features。 | | 插件 ABI | WIT + WASM Component Model,guest `wasm32-wasip2` | 版本化接口、无原生句柄;`wit/` 为唯一源。 | | 插件 runtime | Wasmtime 47 + wasmtime-wasi p2(S5b 已引入,空 WASI 上下文实现零 ambient) | Component Model、异步调用、fuel 与资源限制;引入时固定兼容版本组。 | | TypeScript adapter | private SDK + ADR-0003 no-go runtime | TypeScript 作者契约、Rust Registry 同源 UI builder、WIT 1.2 adapter 与共享 conformance vectors 已实现;componentize-qjs 未发布修复候选已过三系统构建和 Linux 行为/安全/资源门,但上游发布、许可/NOTICE 与额外 `init` export 未决。暂不启用 CLI 模板或公共发布。 | diff --git a/docs/product/windows-alpha.md b/docs/product/windows-alpha.md index 1729c21..a8de8c3 100644 --- a/docs/product/windows-alpha.md +++ b/docs/product/windows-alpha.md @@ -14,14 +14,14 @@ | ID | 要求 | 验收 | |---|---|---| | WA-HOST-01 | Windows 桌面宿主必须在当前用户会话中保持单实例;第二次启动必须在创建窗口、打开数据库或启动插件前退出,不得影响首个实例。 | 同一 Windows 会话连续启动两次 `floatile-shell`,只保留首个宿主进程及其 Widget;首个实例继续响应。 | -| WA-HOST-02 | 宿主必须提供系统托盘入口;管理中心关闭后收起到托盘,托盘提供重新打开与显式退出。 | 后续切片。 | +| WA-HOST-02 | 宿主必须提供系统托盘入口;管理中心关闭后收起到托盘,托盘提供重新打开与显式退出。 | Windows 通知区显示 Floatile 图标;左键或菜单“打开 Floatile”显示插件与实例管理窗口;关闭该窗口后宿主与 Widget 继续运行;菜单“退出 Floatile”结束宿主。 | | WA-HOST-03 | 管理中心与 Widget 必须使用不同窗口角色;Widget 不进入任务栏/Alt+Tab,管理中心按普通应用窗口呈现。 | 后续切片。 | | WA-HOST-04 | 显式退出必须有界停止插件、保存布局、释放平台资源并关闭数据库;单个插件不得无限阻塞退出。 | 后续切片。 | ## 2. 当前切片 -WA-HOST-01 只建立单实例所有权和重复启动拒绝。第二次启动时唤醒已存在管理中心依赖托盘与窗口消息, -归入 WA-HOST-02,不在本切片伪造占位行为。 +WA-HOST-01 建立单实例所有权和重复启动拒绝。WA-HOST-02 建立 Windows 托盘、管理窗口重新打开和 +显式退出的最小闭环。第二次启动主动唤醒管理中心仍需要跨进程唤醒协议,不在当前闭环中伪造成功。 Windows 使用 `Local\\` 命名互斥体,范围限定为当前登录会话;其他平台本切片返回显式不支持并维持 现有启动行为。平台资源与 `unsafe` 只存在于 `floatile-platform`,`floatile-shell` 只消费获取结果。 From 30620677197b3818f499e91548e62af3c3be588f Mon Sep 17 00:00:00 2001 From: CN059 Date: Sat, 29 Aug 2026 10:45:16 +0800 Subject: [PATCH 3/5] feat(platform): separate Windows window roles Apply floating tool-window semantics only to Widgets so they stay out of the taskbar and Alt+Tab, while restoring the management center as a resizable native application window with system controls. Scope the shared winit attributes hook by the stable Widget title and keep Win32 style changes inside floatile-platform. Refs: WA-HOST-03 Tests: cargo fmt --all -- --check; cargo check --workspace --all-targets --locked; cargo clippy --workspace --all-targets --locked -- -D warnings; cargo test -p floatile-platform --locked (14 passed); cargo build -p floatile-shell --locked; Windows 11 x64 manual taskbar/Alt+Tab separation, native minimize/maximize/resize, close-to-tray, and reopen (user verified) Unverified: Linux and macOS runtime behavior --- crates/floatile-platform/src/lib.rs | 5 +- crates/floatile-platform/src/window.rs | 77 +++++++++++++++++++ crates/floatile-shell/src/instance_control.rs | 20 ++--- crates/floatile-shell/src/main.rs | 32 ++++---- docs/product/windows-alpha.md | 5 +- 5 files changed, 109 insertions(+), 30 deletions(-) diff --git a/crates/floatile-platform/src/lib.rs b/crates/floatile-platform/src/lib.rs index 38e4922..ee14fd9 100644 --- a/crates/floatile-platform/src/lib.rs +++ b/crates/floatile-platform/src/lib.rs @@ -66,8 +66,9 @@ pub use single_instance::{ SingleInstanceError, SingleInstanceGuard, SingleInstanceState, acquire_single_instance, }; pub use window::{ - PlatformError, WindowOptions, apply_window_options, remove_window_decorations, resize_window, - set_always_on_top, set_click_through, set_window_position, start_window_drag, + PlatformError, WindowOptions, apply_window_options, configure_widget_window_role, + remove_window_decorations, resize_window, set_always_on_top, set_click_through, + set_window_position, start_window_drag, }; #[cfg(windows)] pub use windows_host::{WindowsTrayEvent, WindowsTrayIcon}; diff --git a/crates/floatile-platform/src/window.rs b/crates/floatile-platform/src/window.rs index d503f70..4900510 100644 --- a/crates/floatile-platform/src/window.rs +++ b/crates/floatile-platform/src/window.rs @@ -217,6 +217,24 @@ pub fn remove_window_decorations(window: &winit::window::Window) -> Result<(), P } } +/// 将 Windows 上的 Widget 标记为无边框工具窗口。 +/// +/// 工具窗口不进入任务栏与 Alt+Tab;管理窗口不得调用此函数。 +pub fn configure_widget_window_role(window: &winit::window::Window) -> Result<(), PlatformError> { + #[cfg(windows)] + { + windows_impl::configure_widget_window_role(window) + } + + #[cfg(not(windows))] + { + let _ = window; + Err(PlatformError::Unsupported( + "Windows widget window role is unavailable on this platform", + )) + } +} + #[cfg(windows)] #[allow(unsafe_code)] mod windows_impl { @@ -312,6 +330,55 @@ mod windows_impl { let _ = unsafe { ShowWindow(hwnd, SW_SHOW) }; Ok(()) } + + pub(super) fn configure_widget_window_role( + window: &winit::window::Window, + ) -> Result<(), PlatformError> { + use windows_sys::Win32::UI::WindowsAndMessaging::{ + SWP_FRAMECHANGED, SWP_NOMOVE, SWP_NOSIZE, SWP_NOZORDER, SetWindowPos, + }; + use winit::platform::windows::WindowExtWindows; + + remove_window_decorations(window)?; + let handle = window + .window_handle() + .map_err(|e| PlatformError::Platform(format!("window handle unavailable: {e}")))?; + let RawWindowHandle::Win32(handle) = handle.as_raw() else { + return Err(PlatformError::Unsupported( + "window handle is not a Win32 HWND on Windows", + )); + }; + let hwnd: HWND = handle.hwnd.get(); + let ex_style = unsafe { GetWindowLongPtrW(hwnd, GWL_EXSTYLE) }; + let widget_style = widget_ex_style(ex_style); + unsafe { SetLastError(0) }; + let _ = unsafe { SetWindowLongPtrW(hwnd, GWL_EXSTYLE, widget_style) }; + let error = unsafe { GetLastError() }; + if error != 0 { + return Err(PlatformError::Platform(format!( + "SetWindowLongPtrW(widget GWL_EXSTYLE) failed: {error}" + ))); + } + window.set_skip_taskbar(true); + let _ = unsafe { + SetWindowPos( + hwnd, + 0, + 0, + 0, + 0, + 0, + SWP_NOMOVE | SWP_NOSIZE | SWP_NOZORDER | SWP_FRAMECHANGED, + ) + }; + Ok(()) + } + + pub(super) fn widget_ex_style(style: isize) -> isize { + use windows_sys::Win32::UI::WindowsAndMessaging::{WS_EX_APPWINDOW, WS_EX_TOOLWINDOW}; + + ((style as u32 | WS_EX_TOOLWINDOW) & !WS_EX_APPWINDOW) as isize + } } #[cfg(target_os = "macos")] @@ -366,6 +433,16 @@ mod tests { assert!(o.always_on_top); } + #[cfg(windows)] + #[test] + fn widget_role_uses_tool_window_without_app_window() { + use windows_sys::Win32::UI::WindowsAndMessaging::{WS_EX_APPWINDOW, WS_EX_TOOLWINDOW}; + + let style = windows_impl::widget_ex_style(WS_EX_APPWINDOW as isize); + assert_eq!(style as u32 & WS_EX_TOOLWINDOW, WS_EX_TOOLWINDOW); + assert_eq!(style as u32 & WS_EX_APPWINDOW, 0); + } + #[test] fn apply_sets_transparent_and_no_decorations() { let o = WindowOptions::default(); diff --git a/crates/floatile-shell/src/instance_control.rs b/crates/floatile-shell/src/instance_control.rs index e9fcf3f..d0c7799 100644 --- a/crates/floatile-shell/src/instance_control.rs +++ b/crates/floatile-shell/src/instance_control.rs @@ -57,10 +57,12 @@ slint::slint! { export component PluginControlWindow inherits Window { title: "Floatile 插件与实例"; - width: 900px; - height: 620px; + preferred-width: 900px; + preferred-height: 620px; + min-width: 700px; + min-height: 480px; background: #151922; - no-frame: true; + no-frame: false; in property <[InstallationListItem]> installations; in property <[InstanceListItem]> instances; @@ -87,15 +89,7 @@ slint::slint! { callback delete-instance; Rectangle { - x: 0px; y: 0px; width: parent.width; height: 46px; - background: #202735; - Text { x: 18px; y: 12px; text: "Floatile 插件与实例"; color: white; font-size: 17px; font-weight: 700; } - close := TouchArea { x: parent.width - 46px; width: 46px; height: 46px; clicked => { root.hide(); } } - Text { x: parent.width - 31px; y: 11px; text: "×"; color: #bdc8da; font-size: 20px; } - } - - Rectangle { - x: 0px; y: 46px; width: 310px; height: parent.height - 46px; + x: 0px; y: 0px; width: 310px; height: parent.height; background: #1a202b; SectionTitle { x: 16px; y: 14px; text: "已安装插件"; } ScrollView { @@ -128,7 +122,7 @@ slint::slint! { } Rectangle { - x: 310px; y: 46px; width: parent.width - 310px; height: parent.height - 46px; + x: 310px; y: 0px; width: parent.width - 310px; height: parent.height; background: #151922; Text { x: 22px; y: 18px; width: parent.width - 44px; text: root.selection-title; color: #f0f4fa; font-size: 18px; font-weight: 700; overflow: elide; } Text { x: 22px; y: 48px; width: parent.width - 44px; text: root.selection-subtitle; color: #93a2b9; font-size: 12px; overflow: elide; } diff --git a/crates/floatile-shell/src/main.rs b/crates/floatile-shell/src/main.rs index 0df3e97..04d9643 100644 --- a/crates/floatile-shell/src/main.rs +++ b/crates/floatile-shell/src/main.rs @@ -33,14 +33,14 @@ use floatile_platform::listen_hotkey; use floatile_platform::{Hotkey, HotkeyModifiers}; use floatile_platform::{ PlatformError, PlatformKind, SingleInstanceState, WindowOptions, acquire_single_instance, - apply_window_options, data_dir, enumerate_monitors, process_metrics, resize_window, - set_always_on_top, set_click_through, set_window_position, start_window_drag, - to_monitor_layout, + apply_window_options, configure_widget_window_role, data_dir, enumerate_monitors, + process_metrics, resize_window, set_always_on_top, set_click_through, set_window_position, + start_window_drag, to_monitor_layout, }; #[cfg(windows)] use floatile_platform::{ WindowsTrayEvent, WindowsTrayIcon, install_hotkey_message_hook, register_hotkey, - remove_window_decorations, unregister_hotkey, + unregister_hotkey, }; use floatile_runtime::{WidgetConfig, WidgetManager}; use floatile_shell::{ @@ -56,6 +56,7 @@ slint::slint! { import { ClockPluginUI } from "generated/clock_plugin.slnt"; export component Clock inherits Window { + title: "Floatile Widget"; width: 260px; height: 120px; background: transparent; @@ -853,6 +854,7 @@ const KEY_E: u32 = 0x0E; #[cfg(windows)] const KEY_F12: u32 = 0x7B; const SINGLE_INSTANCE_NAME: &str = "Local\\Floatile.DesktopHost"; +const WIDGET_WINDOW_TITLE: &str = "Floatile Widget"; fn main() -> Result<(), Box> { let process_started = Instant::now(); @@ -1008,7 +1010,11 @@ fn main() -> Result<(), Box> { slint::BackendSelector::new() .with_winit_window_attributes_hook(move |attrs| { - apply_window_options(&window_options, attrs) + if attrs.title == WIDGET_WINDOW_TITLE { + apply_window_options(&window_options, attrs) + } else { + attrs + } }) .with_winit_event_loop_builder(event_loop_builder) .select()?; @@ -1532,7 +1538,7 @@ fn main() -> Result<(), Box> { let controller_for_registration = Arc::clone(&controller); let register_timer = Rc::new(Timer::default()); let timer_for_callback = Rc::clone(®ister_timer); - let decorations_done = Rc::new(RefCell::new(false)); + let widget_role_done = Rc::new(RefCell::new(false)); let hotkey_done = Rc::new(RefCell::new(false)); let tray_done = Rc::new(RefCell::new(false)); let tray_icon_for_registration = Rc::clone(&tray_icon); @@ -1556,21 +1562,21 @@ fn main() -> Result<(), Box> { } let mut pending = false; - if !*decorations_done.borrow() { + if !*widget_role_done.borrow() { // winit 0.30 顶层窗口的 with_decorations(false) 不生效,需创建后强制移除; // 首次成功后不再重放,防止隐藏/重现窗口导致频闪。 - let deco_result = app + let role_result = app .window() - .with_winit_window(|w: &Window| remove_window_decorations(w)) + .with_winit_window(|w: &Window| configure_widget_window_role(w)) .unwrap_or(Err(PlatformError::WindowNotReady)); - match deco_result { + match role_result { Ok(()) => { - tracing::info!("window decorations removed"); - *decorations_done.borrow_mut() = true; + tracing::info!("Windows widget window role applied"); + *widget_role_done.borrow_mut() = true; } Err(error) => { pending = true; - tracing::debug!("remove_window_decorations retry: {error}"); + tracing::debug!("widget window role retry: {error}"); } } } diff --git a/docs/product/windows-alpha.md b/docs/product/windows-alpha.md index a8de8c3..87079fb 100644 --- a/docs/product/windows-alpha.md +++ b/docs/product/windows-alpha.md @@ -15,13 +15,14 @@ |---|---|---| | WA-HOST-01 | Windows 桌面宿主必须在当前用户会话中保持单实例;第二次启动必须在创建窗口、打开数据库或启动插件前退出,不得影响首个实例。 | 同一 Windows 会话连续启动两次 `floatile-shell`,只保留首个宿主进程及其 Widget;首个实例继续响应。 | | WA-HOST-02 | 宿主必须提供系统托盘入口;管理中心关闭后收起到托盘,托盘提供重新打开与显式退出。 | Windows 通知区显示 Floatile 图标;左键或菜单“打开 Floatile”显示插件与实例管理窗口;关闭该窗口后宿主与 Widget 继续运行;菜单“退出 Floatile”结束宿主。 | -| WA-HOST-03 | 管理中心与 Widget 必须使用不同窗口角色;Widget 不进入任务栏/Alt+Tab,管理中心按普通应用窗口呈现。 | 后续切片。 | +| WA-HOST-03 | 管理中心与 Widget 必须使用不同窗口角色;Widget 不进入任务栏/Alt+Tab,管理中心按普通应用窗口呈现。 | Widget 保持无边框置顶但不出现在任务栏和 Alt+Tab;管理窗口显示 Windows 系统标题栏,可从任务栏和 Alt+Tab 切换。 | | WA-HOST-04 | 显式退出必须有界停止插件、保存布局、释放平台资源并关闭数据库;单个插件不得无限阻塞退出。 | 后续切片。 | ## 2. 当前切片 WA-HOST-01 建立单实例所有权和重复启动拒绝。WA-HOST-02 建立 Windows 托盘、管理窗口重新打开和 -显式退出的最小闭环。第二次启动主动唤醒管理中心仍需要跨进程唤醒协议,不在当前闭环中伪造成功。 +显式退出的最小闭环。WA-HOST-03 将 Widget 与管理窗口的 Windows 样式、任务栏与 Alt+Tab 语义分开。 +第二次启动主动唤醒管理中心仍需要跨进程唤醒协议,不在当前闭环中伪造成功。 Windows 使用 `Local\\` 命名互斥体,范围限定为当前登录会话;其他平台本切片返回显式不支持并维持 现有启动行为。平台资源与 `unsafe` 只存在于 `floatile-platform`,`floatile-shell` 只消费获取结果。 From 96409e489c8c3c75f4ece537eb410266a250754f Mon Sep 17 00:00:00 2001 From: CN059 Date: Sat, 29 Aug 2026 11:25:39 +0800 Subject: [PATCH 4/5] feat(shell): complete bounded Windows host shutdown Save the final Widget layout before tray exit, release Windows host resources in an explicit order, and cap waits for background workers so one runtime cannot hold the process open indefinitely. Add Windows monitor enumeration and host-level pointer capture so resizable Widget geometry can be persisted and restored instead of being discarded when no monitor snapshot exists. Refs: WA-HOST-04, FR-LAYOUT-01, FR-DISPLAY-01 Tests: cargo fmt --all -- --check; cargo check --workspace --all-targets --locked; cargo clippy --workspace --all-targets --locked -- -D warnings; cargo test -p floatile-platform --locked -j1 (15 passed); cargo test -p floatile-shell --lib --locked -j1 (55 passed); cargo test -p floatile-shell --bin floatile-shell --locked -j1 (1 passed); cargo check -p floatile-sdk --target wasm32-wasip2 --locked; cargo deny --locked check advisories bans sources; Windows 11 x64 manual resize, tray exit, process release, single-instance restart, and layout restore (user verified). cargo test --workspace --all-targets --locked was attempted but hit the pre-existing concurrent floatile_clock_wasm.dll output collision; affected tests passed serially. Unverified: Windows physical multi-monitor, mixed-DPI, and hot-plug behavior; Linux and macOS runtime regression --- crates/floatile-platform/Cargo.toml | 2 +- crates/floatile-platform/src/lib.rs | 2 +- crates/floatile-platform/src/monitor.rs | 113 ++++++++- crates/floatile-platform/src/window.rs | 59 +++++ crates/floatile-shell/src/lib.rs | 29 ++- crates/floatile-shell/src/main.rs | 293 ++++++++++++++---------- docs/platform-matrix/platform-matrix.md | 1 + docs/product/windows-alpha.md | 5 +- 8 files changed, 375 insertions(+), 129 deletions(-) diff --git a/crates/floatile-platform/Cargo.toml b/crates/floatile-platform/Cargo.toml index 00f5c3f..a2d3202 100644 --- a/crates/floatile-platform/Cargo.toml +++ b/crates/floatile-platform/Cargo.toml @@ -20,7 +20,7 @@ winit = { workspace = true } x11rb = { workspace = true } [target.'cfg(windows)'.dependencies] -windows-sys = { workspace = true, features = ["Win32_Foundation", "Win32_Security", "Win32_System_Threading", "Win32_UI_WindowsAndMessaging", "Win32_UI_Input_KeyboardAndMouse"] } +windows-sys = { workspace = true, features = ["Win32_Foundation", "Win32_Graphics_Gdi", "Win32_Security", "Win32_System_Threading", "Win32_UI_WindowsAndMessaging", "Win32_UI_Input_KeyboardAndMouse"] } tray-icon = { workspace = true } [target.'cfg(target_os = "macos")'.dependencies] diff --git a/crates/floatile-platform/src/lib.rs b/crates/floatile-platform/src/lib.rs index ee14fd9..b284e92 100644 --- a/crates/floatile-platform/src/lib.rs +++ b/crates/floatile-platform/src/lib.rs @@ -68,7 +68,7 @@ pub use single_instance::{ pub use window::{ PlatformError, WindowOptions, apply_window_options, configure_widget_window_role, remove_window_decorations, resize_window, set_always_on_top, set_click_through, - set_window_position, start_window_drag, + set_pointer_capture, set_window_position, start_window_drag, }; #[cfg(windows)] pub use windows_host::{WindowsTrayEvent, WindowsTrayIcon}; diff --git a/crates/floatile-platform/src/monitor.rs b/crates/floatile-platform/src/monitor.rs index 09a7443..17e64d5 100644 --- a/crates/floatile-platform/src/monitor.rs +++ b/crates/floatile-platform/src/monitor.rs @@ -19,6 +19,8 @@ pub enum MonitorKeySource { ConnectorName, /// macOS 对 CGDirectDisplayID 分配的稳定 UUID(跨重启稳定)。 DisplayUuid, + /// Windows 显示设备名(例如 `\\.\DISPLAY1`,同一显示拓扑下跨重启稳定)。 + DisplayDeviceName, } /// 平台显示器描述。 @@ -42,8 +44,8 @@ pub struct MonitorInfo { /// 枚举当前显示协议的活动显示器。 /// -/// Linux X11 走 RandR;macOS 走 NSScreen + CoreGraphics(须在主线程调用)。 -/// 原生 Wayland、Windows 返回显式不支持,不回退到 XWayland 或伪造单显示器。 +/// Linux X11 走 RandR;Windows 走 EnumDisplayMonitors;macOS 走 NSScreen + +/// CoreGraphics(须在主线程调用)。原生 Wayland 返回显式不支持。 pub fn enumerate_monitors() -> Result, PlatformError> { let kind = probe().kind; match kind { @@ -54,8 +56,11 @@ pub fn enumerate_monitors() -> Result, PlatformError> { PlatformKind::Wayland => Err(PlatformError::Unsupported( "monitor enumeration is not implemented for native Wayland", )), + #[cfg(windows)] + PlatformKind::Windows => windows_impl::enumerate_monitors(), + #[cfg(not(windows))] PlatformKind::Windows => Err(PlatformError::Unsupported( - "monitor enumeration is not implemented for Windows", + "Windows monitor enumeration is unavailable on this platform", )), PlatformKind::Unknown => Err(PlatformError::Unsupported( "monitor enumeration requires a supported display protocol", @@ -71,6 +76,94 @@ pub fn enumerate_monitors() -> Result, PlatformError> { } } +#[cfg(windows)] +#[allow(unsafe_code)] +mod windows_impl { + use super::*; + use std::mem::size_of; + use windows_sys::Win32::Foundation::{BOOL, LPARAM, RECT}; + use windows_sys::Win32::Graphics::Gdi::{ + EnumDisplayMonitors, GetMonitorInfoW, HDC, HMONITOR, MONITORINFO, MONITORINFOEXW, + }; + use windows_sys::Win32::UI::WindowsAndMessaging::MONITORINFOF_PRIMARY; + + unsafe extern "system" fn collect_monitor( + monitor: HMONITOR, + _hdc: HDC, + _clip: *mut RECT, + data: LPARAM, + ) -> BOOL { + // SAFETY: `data` is the exclusive Vec pointer passed to EnumDisplayMonitors below and the + // callback is invoked synchronously before that Vec is used again. + let monitors = unsafe { &mut *(data as *mut Vec) }; + // SAFETY: zero is a valid initial bit pattern for MONITORINFOEXW; cbSize is set next. + let mut info: MONITORINFOEXW = unsafe { std::mem::zeroed() }; + info.monitorInfo.cbSize = size_of::() as u32; + // SAFETY: `monitor` is provided by EnumDisplayMonitors and `info` has the required size. + if unsafe { GetMonitorInfoW(monitor, (&raw mut info).cast::()) } == 0 { + return 1; + } + let end = info + .szDevice + .iter() + .position(|unit| *unit == 0) + .unwrap_or(info.szDevice.len()); + let name = String::from_utf16_lossy(&info.szDevice[..end]); + let rect = info.monitorInfo.rcMonitor; + let Ok(width) = u32::try_from(rect.right.saturating_sub(rect.left)) else { + return 1; + }; + let Ok(height) = u32::try_from(rect.bottom.saturating_sub(rect.top)) else { + return 1; + }; + if width == 0 || height == 0 || name.is_empty() { + return 1; + } + monitors.push(MonitorInfo { + key: MonitorKey(format!("windows-device-{name}")), + key_source: MonitorKeySource::DisplayDeviceName, + name, + position: PhysicalPosition { + x: rect.left, + y: rect.top, + }, + size: PhysicalSize { width, height }, + physical_size_mm: None, + primary: info.monitorInfo.dwFlags & MONITORINFOF_PRIMARY != 0, + }); + 1 + } + + pub(super) fn enumerate_monitors() -> Result, PlatformError> { + let mut monitors: Vec = Vec::new(); + // SAFETY: callback is synchronous; LPARAM points to `monitors` for the duration of this call. + let succeeded = unsafe { + EnumDisplayMonitors( + 0, + std::ptr::null(), + Some(collect_monitor), + (&raw mut monitors) as LPARAM, + ) + }; + if succeeded == 0 { + return Err(PlatformError::Platform( + "EnumDisplayMonitors failed".to_owned(), + )); + } + if monitors.is_empty() { + return Err(PlatformError::Platform( + "Windows reported no active monitors".to_owned(), + )); + } + if !monitors.iter().any(|monitor| monitor.primary) + && let Some(first) = monitors.first_mut() + { + first.primary = true; + } + Ok(monitors) + } +} + /// 将平台显示器快照归一为布局恢复使用的逻辑布局。 /// /// X11 RandR 报告物理像素;逻辑像素 = 物理像素 / scale factor。scale factor 由 @@ -285,6 +378,20 @@ mod macos_impl { mod tests { use super::*; + #[cfg(windows)] + #[test] + fn windows_enumerates_an_active_primary_monitor() { + let monitors = enumerate_monitors().unwrap(); + assert!(!monitors.is_empty()); + assert!(monitors.iter().any(|monitor| monitor.primary)); + assert!(monitors.iter().all(|monitor| { + monitor.size.width > 0 + && monitor.size.height > 0 + && !monitor.name.is_empty() + && monitor.key_source == MonitorKeySource::DisplayDeviceName + })); + } + #[test] fn to_monitor_layout_scales_physical_to_logical() { let info = MonitorInfo { diff --git a/crates/floatile-platform/src/window.rs b/crates/floatile-platform/src/window.rs index 4900510..ce18f23 100644 --- a/crates/floatile-platform/src/window.rs +++ b/crates/floatile-platform/src/window.rs @@ -87,6 +87,25 @@ pub fn start_window_drag(window: &winit::window::Window) -> Result<(), PlatformE .map_err(|e| PlatformError::Platform(format!("drag_window: {e}"))) } +/// 在无边框窗口的自定义拖动交互期间获取或释放鼠标捕获。 +pub fn set_pointer_capture( + window: &winit::window::Window, + captured: bool, +) -> Result<(), PlatformError> { + #[cfg(windows)] + { + windows_impl::set_pointer_capture(window, captured) + } + + #[cfg(not(windows))] + { + let _ = (window, captured); + Err(PlatformError::Unsupported( + "explicit pointer capture is unavailable on this platform", + )) + } +} + /// 调整窗口内容区尺寸(逻辑像素)。 /// /// 尺寸上限以 winit 的 max inner size 为准;调用方应先按 `SizeConstraints` 钳制, @@ -245,6 +264,46 @@ mod windows_impl { }; use winit::raw_window_handle::{HasWindowHandle, RawWindowHandle}; + pub(super) fn set_pointer_capture( + window: &winit::window::Window, + captured: bool, + ) -> Result<(), PlatformError> { + use windows_sys::Win32::UI::Input::KeyboardAndMouse::{ + GetCapture, ReleaseCapture, SetCapture, + }; + + let handle = window + .window_handle() + .map_err(|e| PlatformError::Platform(format!("window handle unavailable: {e}")))?; + let RawWindowHandle::Win32(handle) = handle.as_raw() else { + return Err(PlatformError::Unsupported( + "window handle is not a Win32 HWND on Windows", + )); + }; + let hwnd: HWND = handle.hwnd.get(); + if captured { + // SAFETY: `hwnd` comes from the live winit Window and Windows retains no Rust pointer. + let _ = unsafe { SetCapture(hwnd) }; + // SAFETY: `GetCapture` only reads the calling GUI thread's capture state. + if unsafe { GetCapture() } != hwnd { + return Err(PlatformError::Platform( + "SetCapture did not assign pointer capture".to_owned(), + )); + } + } else { + // SAFETY: releases capture owned by the current GUI thread. + let released = unsafe { ReleaseCapture() }; + if released == 0 { + // SAFETY: reads the calling thread's last-error value. + let error = unsafe { GetLastError() }; + return Err(PlatformError::Platform(format!( + "ReleaseCapture failed: {error}" + ))); + } + } + Ok(()) + } + pub(super) fn set_click_through( window: &winit::window::Window, enabled: bool, diff --git a/crates/floatile-shell/src/lib.rs b/crates/floatile-shell/src/lib.rs index 969635f..c5a74dc 100644 --- a/crates/floatile-shell/src/lib.rs +++ b/crates/floatile-shell/src/lib.rs @@ -171,16 +171,25 @@ pub fn is_window_drag_region( const CONTROL_RIGHT: f32 = 176.0; const CONTROL_TOP: f32 = 8.0; const CONTROL_BOTTOM: f32 = 32.0; - const RESIZE_HANDLE_SIZE: f32 = 24.0; - let in_control_strip = position.x >= CONTROL_LEFT && position.x < CONTROL_RIGHT && position.y >= CONTROL_TOP && position.y < CONTROL_BOTTOM; - let in_resize_handle = position.x >= size.width - RESIZE_HANDLE_SIZE - && position.y >= size.height - RESIZE_HANDLE_SIZE; + !in_control_strip && !is_window_resize_handle(position, size, mode) +} - !in_control_strip && !in_resize_handle +/// 判断逻辑像素坐标是否命中编辑模式右下角缩放手柄。 +pub fn is_window_resize_handle( + position: LogicalPosition, + size: LogicalSize, + mode: WidgetMode, +) -> bool { + const RESIZE_HANDLE_SIZE: f32 = 24.0; + mode == WidgetMode::Edit + && position.x >= size.width - RESIZE_HANDLE_SIZE + && position.x < size.width + && position.y >= size.height - RESIZE_HANDLE_SIZE + && position.y < size.height } /// 窗口当前几何快照(虚拟桌面逻辑矩形 + 物理尺寸 + DPI),供持久化构造使用。 @@ -587,6 +596,16 @@ mod tests { size, WidgetMode::Edit )); + assert!(is_window_resize_handle( + LogicalPosition { x: 248.0, y: 108.0 }, + size, + WidgetMode::Edit + )); + assert!(!is_window_resize_handle( + LogicalPosition { x: 248.0, y: 108.0 }, + size, + WidgetMode::Show + )); assert!(!is_window_drag_region( LogicalPosition { x: 200.0, y: 60.0 }, size, diff --git a/crates/floatile-shell/src/main.rs b/crates/floatile-shell/src/main.rs index 04d9643..5fcf0c1 100644 --- a/crates/floatile-shell/src/main.rs +++ b/crates/floatile-shell/src/main.rs @@ -34,8 +34,8 @@ use floatile_platform::{Hotkey, HotkeyModifiers}; use floatile_platform::{ PlatformError, PlatformKind, SingleInstanceState, WindowOptions, acquire_single_instance, apply_window_options, configure_widget_window_role, data_dir, enumerate_monitors, - process_metrics, resize_window, set_always_on_top, set_click_through, set_window_position, - start_window_drag, to_monitor_layout, + process_metrics, resize_window, set_always_on_top, set_click_through, set_pointer_capture, + set_window_position, start_window_drag, to_monitor_layout, }; #[cfg(windows)] use floatile_platform::{ @@ -57,15 +57,16 @@ slint::slint! { export component Clock inherits Window { title: "Floatile Widget"; - width: 260px; - height: 120px; + preferred-width: 260px; + preferred-height: 120px; + min-width: 120px; + min-height: 60px; + max-width: 2000px; + max-height: 1200px; background: transparent; no-frame: true; callback show-mode; - callback resize-down(pos-x: float, pos-y: float); - callback resize-move(pos-x: float, pos-y: float); - callback resize-up; callback settings-clicked; callback delete-clicked; @@ -197,7 +198,7 @@ slint::slint! { } } - if edit-mode: touch := TouchArea { + if edit-mode: Rectangle { width: 24px; height: 24px; x: parent.width - 24px; @@ -212,33 +213,18 @@ slint::slint! { border-color: #4a90e2; Text { - text: "⤢"; - font-size: 14px; + text: "//"; + font-size: 12px; color: white; + horizontal-stretch: 0; horizontal-alignment: center; vertical-alignment: center; } } - pointer-event(event) => { - if (event.kind == PointerEventKind.down) { - root.resize-down(touch.mouse-x / 1px, touch.mouse-y / 1px); - } else if (event.kind == PointerEventKind.move) { - root.resize-move(touch.mouse-x / 1px, touch.mouse-y / 1px); - } else if (event.kind == PointerEventKind.up) { - root.resize-up(); - } - } } } } - -/// 缩放手柄拖动状态(逻辑像素,由 Rust 侧跟踪起点)。 -struct ResizeState { - active: bool, - start_size: LogicalSize, - start_pos: (f32, f32), -} struct PerfSampler { stop: SyncSender<()>, worker: JoinHandle<()>, @@ -249,6 +235,9 @@ struct RuntimeSession { worker: JoinHandle<()>, } +const PERF_SHUTDOWN_BUDGET: Duration = Duration::from_millis(1_500); +const RUNTIME_SHUTDOWN_BUDGET: Duration = Duration::from_secs(3); + impl PerfSampler { fn start() -> std::io::Result { let (stop, receiver) = mpsc::sync_channel(1); @@ -306,9 +295,50 @@ impl PerfSampler { } fn stop(self) { - let _ = self.stop.try_send(()); - if self.worker.join().is_err() { - tracing::warn!(target: "floatile::perf", "process metrics worker panicked"); + stop_worker_bounded( + self.stop, + self.worker, + PERF_SHUTDOWN_BUDGET, + "process metrics", + ); + } +} + +fn stop_worker_bounded( + stop: SyncSender<()>, + worker: JoinHandle<()>, + budget: Duration, + worker_name: &'static str, +) -> bool { + let _ = stop.try_send(()); + let (finished_tx, finished_rx) = mpsc::sync_channel(1); + let reaper = std::thread::Builder::new() + .name(format!("floatile-{worker_name}-reaper")) + .spawn(move || { + let joined = worker.join().is_ok(); + let _ = finished_tx.try_send(joined); + }); + if let Err(error) = reaper { + tracing::warn!(%error, worker = worker_name, "shutdown reaper unavailable"); + return false; + } + match finished_rx.recv_timeout(budget) { + Ok(true) => true, + Ok(false) => { + tracing::warn!(worker = worker_name, "worker panicked during shutdown"); + false + } + Err(mpsc::RecvTimeoutError::Timeout) => { + tracing::warn!( + worker = worker_name, + ?budget, + "worker shutdown budget exhausted" + ); + false + } + Err(mpsc::RecvTimeoutError::Disconnected) => { + tracing::warn!(worker = worker_name, "shutdown reaper disconnected"); + false } } } @@ -576,14 +606,6 @@ fn now_hhmmss() -> String { format!("{h:02}:{m:02}:{s:02}") } -/// 将 Slint 组件读到的逻辑像素尺寸应用到窗口。 -fn apply_size(app: &Clock, size: LogicalSize) { - use slint::winit_030::winit::window::Window; - let _ = app - .window() - .with_winit_window(|w: &Window| resize_window(w, size)) - .unwrap_or(Err(PlatformError::WindowNotReady)); -} fn schedule_always_on_top(app: slint::Weak, delay: Duration) { Timer::single_shot(delay, move || { let Some(app) = app.upgrade() else { return }; @@ -1177,12 +1199,52 @@ fn main() -> Result<(), Box> { let controller_for_window_events = Arc::clone(&controller); let persisted_for_window_events = Arc::clone(&persisted); let mut cursor_position = None; + let mut window_resize = None::<(winit::dpi::PhysicalPosition, LogicalSize)>; + let size_constraints = SizeConstraints::default(); app.window() .on_winit_window_event(move |slint_window, event| { use slint::winit_030::winit::window::Window; if let winit::event::WindowEvent::CursorMoved { position, .. } = event { cursor_position = Some(*position); + if let Some((started_at, started_size)) = window_resize { + let scale_factor = f64::from(slint_window.scale_factor()); + let requested = LogicalSize { + width: started_size.width + + ((position.x - started_at.x) / scale_factor) as f32, + height: started_size.height + + ((position.y - started_at.y) / scale_factor) as f32, + }; + let clamped = size_constraints.clamp(requested); + let _ = slint_window + .with_winit_window(|window: &Window| resize_window(window, clamped)); + return EventResult::PreventDefault; + } + } + + if matches!( + event, + winit::event::WindowEvent::MouseInput { + state: winit::event::ElementState::Released, + button: winit::event::MouseButton::Left, + .. + } + ) && window_resize.take().is_some() + { + let _ = slint_window.with_winit_window(|window: &Window| { + if let Err(error) = set_pointer_capture(window, false) { + tracing::debug!(%error, "pointer capture release skipped"); + } + let state = persisted_for_window_events + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let mode = controller_for_window_events + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .mode; + save_layout(window, &state, mode); + }); + return EventResult::PreventDefault; } // 窗口移动/关闭后持久化布局。恢复流程主动移动窗口产生的 Moved 事件 @@ -1233,6 +1295,18 @@ fn main() -> Result<(), Box> { height: physical_size.height as f32 / scale_factor, }; + if floatile_shell::is_window_resize_handle(logical_position, logical_size, mode) + { + let _ = slint_window.with_winit_window(|window: &Window| { + if let Err(error) = set_pointer_capture(window, true) { + tracing::warn!(%error, "pointer capture unavailable for resize"); + } + }); + window_resize = Some((position, logical_size)); + tracing::debug!("window resize handle pressed"); + return EventResult::PreventDefault; + } + if floatile_shell::is_window_drag_region(logical_position, logical_size, mode) { match slint_window.with_winit_window(start_window_drag) { Some(Ok(())) => { @@ -1357,16 +1431,6 @@ fn main() -> Result<(), Box> { })?; } - let constraints = SizeConstraints::default(); - let resize_state = Rc::new(RefCell::new(ResizeState { - active: false, - start_size: LogicalSize { - width: 260.0, - height: 120.0, - }, - start_pos: (0.0, 0.0), - })); - // 展示模式按钮:切换模式并持久化。 let weak = app.as_weak(); let ctrl = Arc::clone(&controller); @@ -1429,53 +1493,6 @@ fn main() -> Result<(), Box> { } }); - // 缩放手柄:down 记录起点,move 按指针位移精确缩放,clamp 到约束。 - let weak = app.as_weak(); - let st = Rc::clone(&resize_state); - app.on_resize_down(move |x, y| { - let Some(app) = weak.upgrade() else { return }; - let mut s = st.borrow_mut(); - s.active = true; - s.start_size = current_size(&app); - s.start_pos = (x, y); - }); - let weak = app.as_weak(); - let st = Rc::clone(&resize_state); - app.on_resize_move(move |x, y| { - let Some(app) = weak.upgrade() else { return }; - let s = st.borrow_mut(); - if !s.active { - return; - } - let delta = ((x - s.start_pos.0).max(0.0), (y - s.start_pos.1).max(0.0)); - let requested = LogicalSize { - width: s.start_size.width + delta.0, - height: s.start_size.height + delta.1, - }; - let clamped = constraints.clamp(requested); - apply_size(&app, clamped); - }); - let st = Rc::clone(&resize_state); - let weak = app.as_weak(); - let ctrl = Arc::clone(&controller); - let persisted_for_resize = Arc::clone(&persisted); - app.on_resize_up(move || { - st.borrow_mut().active = false; - let Some(app) = weak.upgrade() else { return }; - let _ = app - .window() - .with_winit_window(|window: &winit::window::Window| { - let state = persisted_for_resize - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner); - let mode = ctrl - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner) - .mode; - save_layout(window, &state, mode); - }); - }); - // 回退路径:只有 runtime clock 未启动时才使用内建时钟文本。 let fallback_builtin_timer = runtime_clock.is_none(); let weak = app.as_weak(); @@ -1650,6 +1667,9 @@ fn main() -> Result<(), Box> { let tray_event_timer = { let timer = Timer::default(); let tray_icon = Rc::clone(&tray_icon); + let exit_app = app.as_weak(); + let exit_persisted = Arc::clone(&persisted); + let exit_controller = Arc::clone(&controller); timer.start( slint::TimerMode::Repeated, Duration::from_millis(50), @@ -1672,6 +1692,20 @@ fn main() -> Result<(), Box> { } Some(WindowsTrayEvent::Exit) => { tracing::info!("explicit exit requested from system tray"); + if let Some(app) = exit_app.upgrade() { + let _ = + app.window() + .with_winit_window(|window: &winit::window::Window| { + let state = exit_persisted + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let mode = exit_controller + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .mode; + save_layout(window, &state, mode); + }); + } if let Err(error) = slint::quit_event_loop() { tracing::warn!(%error, "event loop exit request failed"); } @@ -1685,10 +1719,7 @@ fn main() -> Result<(), Box> { tracing::info!("floatile-shell running"); let run_result = app.run(); - - if let Some(perf_sampler) = perf_sampler { - perf_sampler.stop(); - } + tracing::info!("host shutdown started"); #[cfg(any(target_os = "linux", target_os = "macos"))] if let Some(listener) = hotkey_listener { @@ -1699,36 +1730,44 @@ fn main() -> Result<(), Box> { { register_timer.stop(); tray_event_timer.stop(); + use slint::winit_030::winit::window::Window; + if let Some(Err(error)) = app + .window() + .with_winit_window(|w: &Window| unregister_hotkey(w, HOTKEY_ID)) + { + tracing::debug!(%error, "global hotkey release skipped"); + } let _ = tray_icon.borrow_mut().take(); + hotkey_app.borrow_mut().take(); + tracing::info!("Windows tray and hotkey resources released"); } + drop(instance_control_surface); + drop(runtime_instance_supervisor); + tracing::info!("persistent plugin instance sessions received stop requests"); + if let Some(runtime_clock) = runtime_clock { - let _ = runtime_clock.stop.try_send(()); - if runtime_clock.worker.join().is_err() { - tracing::warn!("runtime clock worker panicked"); - } + stop_worker_bounded( + runtime_clock.stop, + runtime_clock.worker, + RUNTIME_SHUTDOWN_BUDGET, + "runtime-clock", + ); } - // Windows 退出时注销热键。 - #[cfg(windows)] - let _ = { - use slint::winit_030::winit::window::Window; - app.window() - .with_winit_window(|w: &Window| unregister_hotkey(w, HOTKEY_ID)) - }; + if let Some(perf_sampler) = perf_sampler { + perf_sampler.stop(); + } + + drop(audit_listener); + drop(app); + drop(persisted); + tracing::info!("host shutdown resources released"); run_result?; Ok(()) } -fn current_size(app: &Clock) -> LogicalSize { - let size = app.window().size(); - LogicalSize { - width: size.width as f32, - height: size.height as f32, - } -} - /// 恢复热键候选的人类可读标签,与 `register_timer` 中候选数组下标一一对应。 #[cfg(windows)] fn hotkey_label(index: usize) -> &'static str { @@ -1760,3 +1799,23 @@ fn apply_mode_effect(app: &Clock, effect: floatile_shell::ModeEffect) { Err(error) => tracing::warn!(%error, mode = ?mode, "set_click_through failed"), } } + +#[cfg(test)] +mod shutdown_tests { + use super::*; + + #[test] + fn bounded_worker_stop_returns_when_worker_ignores_signal() { + let (stop, _stop_rx) = mpsc::sync_channel(1); + let worker = std::thread::spawn(|| std::thread::sleep(Duration::from_millis(200))); + let started = Instant::now(); + + assert!(!stop_worker_bounded( + stop, + worker, + Duration::from_millis(10), + "test-worker", + )); + assert!(started.elapsed() < Duration::from_millis(100)); + } +} diff --git a/docs/platform-matrix/platform-matrix.md b/docs/platform-matrix/platform-matrix.md index 46328a9..86aef46 100644 --- a/docs/platform-matrix/platform-matrix.md +++ b/docs/platform-matrix/platform-matrix.md @@ -85,3 +85,4 @@ | 2026-08-17 | macOS 15.7.5、Apple M4(arm64)、Quartz 合成桌面、单屏 1920×1080;release SHA-256 `e9dbc133199fb7dac41efad9a1a31e4ef03da3db001efc1fa3eccea6aa6e4fc4`,`agent/macos-platform`(base `669612f`) | 🟡 机制已接入:probe `compositing=true`,winit `with_transparent(true)` → NSWindow 非不透明;Quartz 实测整窗 `alpha=1.0`(逐像素 Alpha 由 Slint 合成),视觉逐像素透明未在本环境复核 | ✅ Quartz `CGWindowListCopyWindowInfo` 实测 `layer=3`(`kCGFloatingWindowLevel`),日志 `always-on-top applied`,窗口盖过普通窗口层 | 🟡 机制已接入:probe `click_through=true`;Edit/Show 切换调用 `NSWindow.setIgnoresMouseEvents`;未交互实测穿透点击 | ✅ 无边框(窗口标题为空)、编辑控件与时钟渲染;布局保存 + 跨重启恢复 `layout restored lost_monitor=false`,显示器键 `macos-uuid-A8B23E1A-…`(`CGDisplayCreateUUIDFromDisplayID`);拖拽/缩放机制已接入,未交互实测 | 仅验证单活动输出:NSScreen 枚举 `1920×1080@0,0`、primary、`677×381 mm`;未验证真实多屏 | 未测 | 探测日志:`kind=MacOS compositing=true click_through=true always_on_top=true`;Carbon `RegisterEventHotKey` 注册成功(`global hotkey registered (Ctrl+Shift+E)`,无需辅助功能授权);首帧 `115.8 ms`、稳态 RSS `67.5 MiB`、空闲 CPU `0.0%`、按需渲染 `2.55 FPS`;winit 0.30 顶层窗口 `with_decorations(false)` 在 macOS 生效(无需平台层移除装饰);进程存活无崩溃。截图:`/tmp/floatile-mac.png`。穿透/拖拽/缩放的交互实测待人工在真实桌面复核 | | 2026-08-25 | Arch Linux 7.1.8、Xvfb 21.1.24(1280×720×24,无 WM/合成器)、Slint 默认后端、dev 测试构建,`agent/instance-control-surface`(`5e5e708`) | 未测(本项仅验证 PP-M1 动态实例生命周期) | 未测 | 未测 | ✅ 自动测试实例化并显示插件控制窗,创建同一 Installation 的两个真实 Wasmtime/Slint 窗口并观测二者进入 running;第二实例停止、安装内容临时缺失后单独 failed,第一实例持续 running;恢复安装并手动 retry 后双实例重新 running | 单 Xvfb 输出,未测 | 未测 | 命令:`RUSTC_WRAPPER= xvfb-run -a -s '-screen 0 1280x720x24' cargo test -p floatile-shell --test persistent_instance_lifecycle --locked -- --nocapture`;1 test passed,复验耗时 1.49 s。测试期间 SQLite/文件操作在协调 worker,Slint timer 仅消费 observed 快照和发送有界命令;无视觉截图,不替代真实桌面交互与跨平台证据 | | 2026-08-26 | Arch Linux 7.1.8、Xvfb 21.1.24(1280×720×24,无 WM/合成器)、Slint 默认后端、dev 测试构建,`agent/pp-m4-rust-author-loop` | 未测(本项仅验证 PP-M4 作者预览/运行) | 未测 | 未测 | ✅ `floatile preview` 从项目构建、校验、临时安装后派生 shell preview-host,使用正式 renderer/Slint/Wasmtime/Broker;clock guest `start()` 后 observed lifecycle 进入 running。generation 替换测试先启动一代预览,再派生第二代、终止第一代,第二代独立进入 running。`run` 两次复用相同 digest 的安装,分别创建持久实例、推进 generation 并进入 running。仓库外干净目录从独立 SDK 包快照串行通过 `new/check/test/dev --once/preview/build/install/run/inspect` | 单 Xvfb 输出,未测 | 未测 | 命令:`RUSTC_WRAPPER= FLOATTILE_PREVIEW_HOST=$PWD/target/debug/floatile-preview-host xvfb-run -a -s '-screen 0 1280x720x24' cargo test -p floatile-cli --test sdk_package --locked clean_directory_completes_the_rust_author_loop -- --ignored --nocapture`;1 test passed,25.77 s。无截图,不替代真实桌面交互、Windows、macOS 或 Wayland 验证 | +| 2026-08-29 | Windows 11 x64、DWM 合成桌面、单活动显示器、dev 构建,`agent/windows-alpha-shell`(commit 待定) | 未复测 | 未复测 | 未复测 | ✅ 无边框 Widget 使用右下角宿主手柄放大/缩小,内容随窗口尺寸变化;托盘显式退出后进程归零,立即重启时位置、尺寸与模式从 SQLite 恢复;托盘、管理窗口和单实例继续正常 | ✅ `EnumDisplayMonitors` 自动实测识别活动主显示器;仅单屏,未验证跨屏 | 未测 | 自动测试 `windows_enumerates_an_active_primary_monitor` 通过;人工测试覆盖缩放最小/最大方向、托盘退出、数据库释放、连续启动单实例与布局恢复。Widget 内“删除”按产品语义移除布局记录,不应恢复;未验证真实多屏、混合 DPI 与热插拔。 | diff --git a/docs/product/windows-alpha.md b/docs/product/windows-alpha.md index 87079fb..cf5d575 100644 --- a/docs/product/windows-alpha.md +++ b/docs/product/windows-alpha.md @@ -16,13 +16,14 @@ | WA-HOST-01 | Windows 桌面宿主必须在当前用户会话中保持单实例;第二次启动必须在创建窗口、打开数据库或启动插件前退出,不得影响首个实例。 | 同一 Windows 会话连续启动两次 `floatile-shell`,只保留首个宿主进程及其 Widget;首个实例继续响应。 | | WA-HOST-02 | 宿主必须提供系统托盘入口;管理中心关闭后收起到托盘,托盘提供重新打开与显式退出。 | Windows 通知区显示 Floatile 图标;左键或菜单“打开 Floatile”显示插件与实例管理窗口;关闭该窗口后宿主与 Widget 继续运行;菜单“退出 Floatile”结束宿主。 | | WA-HOST-03 | 管理中心与 Widget 必须使用不同窗口角色;Widget 不进入任务栏/Alt+Tab,管理中心按普通应用窗口呈现。 | Widget 保持无边框置顶但不出现在任务栏和 Alt+Tab;管理窗口显示 Windows 系统标题栏,可从任务栏和 Alt+Tab 切换。 | -| WA-HOST-04 | 显式退出必须有界停止插件、保存布局、释放平台资源并关闭数据库;单个插件不得无限阻塞退出。 | 后续切片。 | +| WA-HOST-04 | 显式退出必须有界停止插件、保存布局、释放平台资源并关闭数据库;单个插件不得无限阻塞退出。 | 托盘“退出 Floatile”先保存当前布局;宿主停止计时器、托盘、热键和插件监督器,参考插件 worker 最多等待 3 秒,超时后继续退出;再次启动后布局可恢复且数据库可重新打开。 | ## 2. 当前切片 WA-HOST-01 建立单实例所有权和重复启动拒绝。WA-HOST-02 建立 Windows 托盘、管理窗口重新打开和 显式退出的最小闭环。WA-HOST-03 将 Widget 与管理窗口的 Windows 样式、任务栏与 Alt+Tab 语义分开。 -第二次启动主动唤醒管理中心仍需要跨进程唤醒协议,不在当前闭环中伪造成功。 +WA-HOST-04 为托盘显式退出增加最终布局保存和有界 worker 清理。第二次启动主动唤醒管理中心仍需要 +跨进程唤醒协议,不在当前闭环中伪造成功。 Windows 使用 `Local\\` 命名互斥体,范围限定为当前登录会话;其他平台本切片返回显式不支持并维持 现有启动行为。平台资源与 `unsafe` 只存在于 `floatile-platform`,`floatile-shell` 只消费获取结果。 From 64eb0eaa9e2e669ee6980ea568aa72fbd903b2fe Mon Sep 17 00:00:00 2001 From: CN059 Date: Sat, 29 Aug 2026 11:48:41 +0800 Subject: [PATCH 5/5] fix(shell): gate Windows-only host imports Keep Windows-specific UI state and window-role imports behind cfg(windows) so Linux and macOS Clippy builds do not reject them as unused. Refs: WA-HOST-03, WA-HOST-04 Tests: cargo fmt --all -- --check; cargo check --workspace --all-targets --locked; cargo clippy --workspace --all-targets --locked -- -D warnings; cargo test -p floatile-shell --bin floatile-shell --locked (1 passed) Unverified: Linux and macOS runtime behavior; cargo test -p floatile-shell --lib --locked blocked by pre-existing Windows floatile_clock_wasm.dll output collision/lock --- crates/floatile-shell/src/main.rs | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/crates/floatile-shell/src/main.rs b/crates/floatile-shell/src/main.rs index 5fcf0c1..6724501 100644 --- a/crates/floatile-shell/src/main.rs +++ b/crates/floatile-shell/src/main.rs @@ -9,9 +9,12 @@ #![cfg_attr(windows, windows_subsystem = "windows")] -use std::cell::{Cell, RefCell}; +use std::cell::Cell; +#[cfg(windows)] +use std::cell::RefCell; use std::collections::BTreeMap; use std::path::PathBuf; +#[cfg(windows)] use std::rc::Rc; use std::sync::mpsc::{self, SyncSender}; use std::sync::{Arc, Mutex}; @@ -33,14 +36,14 @@ use floatile_platform::listen_hotkey; use floatile_platform::{Hotkey, HotkeyModifiers}; use floatile_platform::{ PlatformError, PlatformKind, SingleInstanceState, WindowOptions, acquire_single_instance, - apply_window_options, configure_widget_window_role, data_dir, enumerate_monitors, - process_metrics, resize_window, set_always_on_top, set_click_through, set_pointer_capture, - set_window_position, start_window_drag, to_monitor_layout, + apply_window_options, data_dir, enumerate_monitors, process_metrics, resize_window, + set_always_on_top, set_click_through, set_pointer_capture, set_window_position, + start_window_drag, to_monitor_layout, }; #[cfg(windows)] use floatile_platform::{ - WindowsTrayEvent, WindowsTrayIcon, install_hotkey_message_hook, register_hotkey, - unregister_hotkey, + WindowsTrayEvent, WindowsTrayIcon, configure_widget_window_role, install_hotkey_message_hook, + register_hotkey, unregister_hotkey, }; use floatile_runtime::{WidgetConfig, WidgetManager}; use floatile_shell::{