diff --git a/Cargo.lock b/Cargo.lock index 1a469c1..5ed8112 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4141,8 +4141,10 @@ dependencies = [ "base64 0.22.1", "chrono", "dirs 6.0.0", + "embed-resource", "gpui-kit", "hmac", + "raw-window-handle", "rfd", "rumqttc-v4-next", "rumqttc-v5-next", @@ -4157,6 +4159,7 @@ dependencies = [ "tokio", "ureq 2.12.1", "uuid", + "windows 0.58.0", ] [[package]] diff --git a/gpui-app/Cargo.toml b/gpui-app/Cargo.toml index 1082a81..a3b7ad8 100644 --- a/gpui-app/Cargo.toml +++ b/gpui-app/Cargo.toml @@ -52,3 +52,18 @@ sentry = { version = "0.49", default-features = false, features = [ "reqwest", "rustls", ] } + +# 窗口置顶(raw HWND + SetWindowPos)与进程资源采样 +raw-window-handle = "0.6" +[target.'cfg(target_os = "windows")'.dependencies] +windows = { version = "0.58", features = [ + "Win32_Foundation", + "Win32_System_Threading", + "Win32_System_ProcessStatus", + "Win32_System_Diagnostics_ToolHelp", + "Win32_UI_WindowsAndMessaging", +] } + +# 嵌入窗口图标(gpui 从 exe 资源 ID 1 加载,缺失则任务栏显示默认图标) +[target.'cfg(target_os = "windows")'.build-dependencies] +embed-resource = "3" diff --git a/gpui-app/build.rs b/gpui-app/build.rs new file mode 100644 index 0000000..08c33ee --- /dev/null +++ b/gpui-app/build.rs @@ -0,0 +1,15 @@ +//! 构建脚本:向 exe 嵌入窗口图标资源。 +//! +//! gpui-pre-windows 注册窗口类时从 exe 资源 ID 1 加载图标 +//! (`LoadImageW(module, MAKEINTRESOURCE(1), IMAGE_ICON, ...)`), +//! 缺失时任务栏与资源管理器显示系统默认图标。 + +fn main() { + if std::env::var("CARGO_CFG_TARGET_OS").as_deref() == Ok("windows") { + println!("cargo:rerun-if-changed=resources/app.rc"); + println!("cargo:rerun-if-changed=resources/icon.ico"); + embed_resource::compile("resources/app.rc", embed_resource::NONE) + .manifest_required() + .expect("嵌入窗口图标资源失败"); + } +} diff --git a/gpui-app/resources/app.rc b/gpui-app/resources/app.rc new file mode 100644 index 0000000..a1ec328 --- /dev/null +++ b/gpui-app/resources/app.rc @@ -0,0 +1 @@ +1 ICON "icon.ico" diff --git a/gpui-app/resources/icon.ico b/gpui-app/resources/icon.ico new file mode 100644 index 0000000..8da6d24 Binary files /dev/null and b/gpui-app/resources/icon.ico differ diff --git a/gpui-app/src/lib.rs b/gpui-app/src/lib.rs index 8230e48..72d8a45 100644 --- a/gpui-app/src/lib.rs +++ b/gpui-app/src/lib.rs @@ -4,5 +4,6 @@ pub mod aliyun; pub mod model; pub mod mqtt; pub mod store; +pub mod sysmon; pub mod ui; pub mod update; diff --git a/gpui-app/src/main.rs b/gpui-app/src/main.rs index 3e6339c..ad9a243 100644 --- a/gpui-app/src/main.rs +++ b/gpui-app/src/main.rs @@ -19,6 +19,8 @@ fn init_sentry() -> sentry::ClientInitGuard { } fn main() { + // 资源监控的运行时长基准 + mqttx_desktop::sysmon::init(); // OTA 收尾:清理遗留文件;若暂存区有待安装的新版本且用户已同意 // (「下次启动安装」),在进入 UI 前完成自替换并重启。 update::cleanup_old(); diff --git a/gpui-app/src/sysmon.rs b/gpui-app/src/sysmon.rs new file mode 100644 index 0000000..1124b32 --- /dev/null +++ b/gpui-app/src/sysmon.rs @@ -0,0 +1,247 @@ +//! 进程自身资源采样与窗口置顶辅助。 +//! +//! 仅 Windows 实现数据采集(`windows` crate);其他平台采样返回 +//! 全零数据、置顶返回 false,调用方按缺失能力展示。 + +use std::time::{Duration, Instant}; + +/// 进程资源的一次采样。 +#[derive(Debug, Clone, Default)] +pub struct Sample { + /// 工作集(物理内存占用) + pub working_set: u64, + /// 峰值工作集(本次会话以来最高) + pub peak_working_set: u64, + /// 提交内存(私有字节) + pub private_bytes: u64, + /// 自上次采样以来的 CPU 占用百分比(按可用核数归一,0~100×核数内) + pub cpu_percent: f32, + /// 线程数 + pub threads: u32, + /// 句柄数(采样失败为 0) + pub handles: u32, +} + +/// 周期采样器:内部记录上次 CPU 时间,用于差分计算占用率。 +pub struct Sampler { + pid: u32, + last_cpu: Option<(u64, u64, Instant)>, +} + +impl Sampler { + pub fn new() -> Self { + Self { + pid: std::process::id(), + last_cpu: None, + } + } + + /// 采样一次;CPU 占用首次采样为 0(无差分基准)。 + pub fn sample(&mut self) -> Option { + if !cfg!(target_os = "windows") { + return Some(Sample::default()); + } + self.sample_windows() + } + + #[cfg(target_os = "windows")] + fn sample_windows(&mut self) -> Option { + use windows::Win32::Foundation::CloseHandle; + use windows::Win32::System::Diagnostics::ToolHelp::{ + CreateToolhelp32Snapshot, Thread32First, Thread32Next, TH32CS_SNAPTHREAD, + THREADENTRY32, + }; + use windows::Win32::System::ProcessStatus::{ + GetProcessMemoryInfo, PROCESS_MEMORY_COUNTERS, PROCESS_MEMORY_COUNTERS_EX, + }; + use windows::Win32::System::Threading::{ + GetProcessHandleCount, GetProcessTimes, OpenProcess, + PROCESS_QUERY_LIMITED_INFORMATION, + }; + use windows::Win32::Foundation::FILETIME; + + let process = unsafe { + OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, false, self.pid) + .ok()? + }; + + // ── 内存 ── + // windows 0.58 的 EX 结构是扁平的(cb 在首位),按 EX 大小传 cb, + // GetProcessMemoryInfo 即填充 PrivateUsage 扩展字段 + let mut mem: PROCESS_MEMORY_COUNTERS_EX = PROCESS_MEMORY_COUNTERS_EX { + cb: std::mem::size_of::() as u32, + PageFaultCount: 0, + PeakWorkingSetSize: 0, + WorkingSetSize: 0, + QuotaPeakPagedPoolUsage: 0, + QuotaPagedPoolUsage: 0, + QuotaPeakNonPagedPoolUsage: 0, + QuotaNonPagedPoolUsage: 0, + PagefileUsage: 0, + PeakPagefileUsage: 0, + PrivateUsage: 0, + }; + let ok = unsafe { + GetProcessMemoryInfo( + process, + &mut mem as *mut PROCESS_MEMORY_COUNTERS_EX as *mut PROCESS_MEMORY_COUNTERS, + mem.cb, + ) + .is_ok() + }; + if !ok { + let _ = unsafe { CloseHandle(process) }; + return None; + } + + // ── CPU 时间差分 ── + let mut creation = FILETIME::default(); + let mut exit = FILETIME::default(); + let mut kernel = FILETIME::default(); + let mut user = FILETIME::default(); + let mut cpu_percent = 0f32; + let now = Instant::now(); + let have_times = unsafe { + GetProcessTimes( + process, + &mut creation, + &mut exit, + &mut kernel, + &mut user, + ) + .is_ok() + }; + if have_times { + let k = ((kernel.dwHighDateTime as u64) << 32) | kernel.dwLowDateTime as u64; + let u = ((user.dwHighDateTime as u64) << 32) | user.dwLowDateTime as u64; + let cores = std::thread::available_parallelism().map(|n| n.get()).unwrap_or(1) as f32; + if let Some((pk, pu, pt)) = self.last_cpu { + let dt = now.duration_since(pt).as_secs_f32(); + if dt > 0.05 { + let cpu_secs = ((k - pk) + (u - pu)) as f32 / 1e7; + cpu_percent = cpu_secs / dt / cores * 100.; + } + } + self.last_cpu = Some((k, u, now)); + } + + // ── 线程数 / 句柄数 ── + let mut threads = 0u32; + unsafe { + if let Ok(snapshot) = CreateToolhelp32Snapshot(TH32CS_SNAPTHREAD, 0) { + let mut entry = THREADENTRY32 { + dwSize: std::mem::size_of::() as u32, + ..std::mem::zeroed() + }; + if Thread32First(snapshot, &mut entry).is_ok() { + loop { + if entry.th32OwnerProcessID == self.pid { + threads += 1; + } + if Thread32Next(snapshot, &mut entry).is_err() { + break; + } + } + } + let _ = CloseHandle(snapshot); + } + } + let mut handles = 0u32; + unsafe { + let _ = GetProcessHandleCount(process, &mut handles); + } + + let _ = unsafe { CloseHandle(process) }; + Some(Sample { + working_set: mem.WorkingSetSize as u64, + peak_working_set: mem.PeakWorkingSetSize as u64, + private_bytes: mem.PrivateUsage as u64, + cpu_percent, + threads, + handles, + }) } +} + +impl Default for Sampler { + fn default() -> Self { + Self::new() + } +} + +static START: std::sync::OnceLock = std::sync::OnceLock::new(); + +/// 进程启动时调用一次(main / 库初始化),用于计算运行时长。 +pub fn init() { + let _ = START.set(Instant::now()); +} + +/// 进程运行时长;未调用 [`init`] 时返回 0。 +pub fn uptime() -> Duration { + START.get().map(|t| t.elapsed()).unwrap_or_default() +} + +/// 窗口置顶 / 取消置顶。返回是否成功(非 Windows 或句柄不可得为 false)。 +pub fn set_topmost(window: &gpui_kit::Window, topmost: bool) -> bool { + if !cfg!(target_os = "windows") { + return false; + } + set_topmost_windows(window, topmost) +} + +#[cfg(target_os = "windows")] +fn set_topmost_windows(window: &gpui_kit::Window, topmost: bool) -> bool { + use raw_window_handle::RawWindowHandle; + use windows::Win32::Foundation::HWND; + use windows::Win32::UI::WindowsAndMessaging::{ + SetWindowPos, HWND_NOTOPMOST, HWND_TOPMOST, SET_WINDOW_POS_FLAGS, + }; + + // gpui 的 Window 自带同名方法,HasWindowHandle 必须全路径调用 + let handle = match raw_window_handle::HasWindowHandle::window_handle(window) { + Ok(h) => h, + Err(_) => return false, + }; + // WindowHandle::raw 是私有字段;HasWindowHandle 的实现返回自身拷贝, + // trait 方法在此类型上拿不到 RawWindowHandle,用 deref 后的 as_raw 途径 + let raw = handle.as_raw(); + #[allow(clippy::infallible_destructuring_match)] + let RawWindowHandle::Win32(w) = raw else { + return false; + }; + let hwnd = HWND(w.hwnd.get() as *mut core::ffi::c_void); + let insert = if topmost { HWND_TOPMOST } else { HWND_NOTOPMOST }; + let flags = SET_WINDOW_POS_FLAGS(SWP_NOMOVE | SWP_NOSIZE | SWP_NOACTIVATE); + unsafe { SetWindowPos(hwnd, insert, 0, 0, 0, 0, flags).is_ok() } +} + +#[cfg(not(target_os = "windows"))] +fn set_topmost_windows(_window: &gpui_kit::Window, _topmost: bool) -> bool { + false +} + +const SWP_NOSIZE: u32 = 0x0001; +const SWP_NOMOVE: u32 = 0x0002; +const SWP_NOACTIVATE: u32 = 0x0010; + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn sampler_returns_a_sample() { + init(); + let mut s = Sampler::new(); + let first = s.sample().expect("采样应成功"); + std::thread::sleep(Duration::from_millis(30)); + let second = s.sample().expect("第二次采样应成功"); + assert!(second.working_set > 0, "工作集应大于 0"); + let _ = (first, second); + } + + #[test] + fn uptime_increases() { + init(); + std::thread::sleep(Duration::from_millis(10)); + assert!(uptime().as_millis() >= 10); + } +} diff --git a/gpui-app/src/ui/app.rs b/gpui-app/src/ui/app.rs index 38e640c..7358663 100644 --- a/gpui-app/src/ui/app.rs +++ b/gpui-app/src/ui/app.rs @@ -103,6 +103,8 @@ pub struct MqttXApp { log_file: Option<(String, std::io::BufWriter)>, /// 系统外观变化订阅:设置为「跟随系统」时重应用主题,随实体存活 _appearance_obs: gpui_kit::Subscription, + /// 窗口置顶状态(标题栏钉子按钮切换) + pinned: bool, } impl MqttXApp { @@ -312,6 +314,7 @@ impl MqttXApp { file_dialog_open: false, log_file: None, _appearance_obs: appearance_obs, + pinned: false, } } @@ -1070,6 +1073,7 @@ impl MqttXApp { let w3 = app_weak.clone(); let w4 = app_weak.clone(); let w5 = app_weak.clone(); + let w6 = app_weak.clone(); menu.item( PopupMenuItem::new("阿里云设备") .on_click(move |_, window, cx| {