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 05981dc..a2d3202 100644 --- a/crates/floatile-platform/Cargo.toml +++ b/crates/floatile-platform/Cargo.toml @@ -20,7 +20,8 @@ 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_Graphics_Gdi", "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 efe9277..b284e92 100644 --- a/crates/floatile-platform/src/lib.rs +++ b/crates/floatile-platform/src/lib.rs @@ -2,8 +2,12 @@ pub mod capability; pub mod hotkey; pub mod metrics; pub mod monitor; +pub mod single_instance; pub mod window; +#[cfg(windows)] +pub mod windows_host; + #[cfg(target_os = "linux")] mod x11; @@ -58,7 +62,13 @@ 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, + PlatformError, WindowOptions, apply_window_options, configure_widget_window_role, + remove_window_decorations, resize_window, set_always_on_top, set_click_through, + 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/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-platform/src/window.rs b/crates/floatile-platform/src/window.rs index d503f70..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` 钳制, @@ -217,6 +236,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 { @@ -227,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, @@ -312,6 +389,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 +492,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-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/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/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 f69f740..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}; @@ -32,13 +35,15 @@ 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, + 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_pointer_capture, set_window_position, start_window_drag, to_monitor_layout, }; #[cfg(windows)] use floatile_platform::{ - install_hotkey_message_hook, register_hotkey, remove_window_decorations, unregister_hotkey, + WindowsTrayEvent, WindowsTrayIcon, configure_widget_window_role, install_hotkey_message_hook, + register_hotkey, unregister_hotkey, }; use floatile_runtime::{WidgetConfig, WidgetManager}; use floatile_shell::{ @@ -54,15 +59,17 @@ slint::slint! { import { ClockPluginUI } from "generated/clock_plugin.slnt"; export component Clock inherits Window { - width: 260px; - height: 120px; + title: "Floatile Widget"; + 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; @@ -194,7 +201,7 @@ slint::slint! { } } - if edit-mode: touch := TouchArea { + if edit-mode: Rectangle { width: 24px; height: 24px; x: parent.width - 24px; @@ -209,33 +216,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<()>, @@ -246,6 +238,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); @@ -303,9 +298,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 } } } @@ -573,14 +609,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 }; @@ -850,6 +878,8 @@ 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"; +const WIDGET_WINDOW_TITLE: &str = "Floatile Widget"; fn main() -> Result<(), Box> { let process_started = Instant::now(); @@ -860,6 +890,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 { @@ -985,7 +1035,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()?; @@ -1148,12 +1202,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 事件 @@ -1204,6 +1298,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(())) => { @@ -1328,16 +1434,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); @@ -1368,6 +1464,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"); @@ -1398,53 +1496,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(); @@ -1466,6 +1517,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; @@ -1505,8 +1558,10 @@ 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); let candidate_index = Rc::new(RefCell::new(0usize)); let retries = Rc::new(RefCell::new(0u32)); register_timer.start( @@ -1519,7 +1574,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(); @@ -1527,21 +1582,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}"); } } } @@ -1586,6 +1641,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 { @@ -1596,12 +1666,63 @@ fn main() -> Result<(), Box> { register_timer }; + #[cfg(windows)] + 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), + 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 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"); + } + } + None => {} + } + }, + ); + timer + }; + 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 { @@ -1609,35 +1730,47 @@ fn main() -> Result<(), Box> { } #[cfg(windows)] - register_timer.stop(); + { + 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 { @@ -1669,3 +1802,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/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/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/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 new file mode 100644 index 0000000..cf5d575 --- /dev/null +++ b/docs/product/windows-alpha.md @@ -0,0 +1,29 @@ +# 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 | 宿主必须提供系统托盘入口;管理中心关闭后收起到托盘,托盘提供重新打开与显式退出。 | Windows 通知区显示 Floatile 图标;左键或菜单“打开 Floatile”显示插件与实例管理窗口;关闭该窗口后宿主与 Widget 继续运行;菜单“退出 Floatile”结束宿主。 | +| WA-HOST-03 | 管理中心与 Widget 必须使用不同窗口角色;Widget 不进入任务栏/Alt+Tab,管理中心按普通应用窗口呈现。 | Widget 保持无边框置顶但不出现在任务栏和 Alt+Tab;管理窗口显示 Windows 系统标题栏,可从任务栏和 Alt+Tab 切换。 | +| 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` 只消费获取结果。