From bf2a3e0c6c4c54a8698f4dc951835bca97ecd872 Mon Sep 17 00:00:00 2001 From: Ng Guoyou Date: Thu, 18 Jun 2026 23:56:04 +0800 Subject: [PATCH 01/36] style(backend): format the crate with rustfmt MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Run `cargo fmt` across the backend (semantics-preserving — wrapping, trailing commas, indentation only). These modules have no behavior change; the formatting of the feature-bearing modules (main/bridge/command/display/ddc) lands with the feature commit, and the `cargo fmt --check` CI gate is added there once the whole tree is clean. Co-Authored-By: Claude Opus 4.8 (1M context) --- client/package-lock.json | 1 + widgetsack/src/agenda.rs | 50 ++- widgetsack/src/art.rs | 22 +- widgetsack/src/audio.rs | 161 +++++++--- widgetsack/src/autostart.rs | 6 +- widgetsack/src/clickthrough.rs | 16 +- widgetsack/src/control.rs | 75 ++++- widgetsack/src/event.rs | 120 +++---- widgetsack/src/ha.rs | 58 +++- widgetsack/src/listener.rs | 395 +++++++++++------------ widgetsack/src/llm.rs | 114 +++++-- widgetsack/src/log.rs | 7 +- widgetsack/src/media.rs | 8 +- widgetsack/src/mqtt.rs | 22 +- widgetsack/src/netconn.rs | 80 ++++- widgetsack/src/ping.rs | 19 +- widgetsack/src/recyclebin.rs | 2 +- widgetsack/src/rss.rs | 37 ++- widgetsack/src/sensors.rs | 228 +++++++++++--- widgetsack/src/state.rs | 557 +++++++++++++++++---------------- widgetsack/src/stocks.rs | 47 ++- widgetsack/src/timings.rs | 2 +- widgetsack/src/weather.rs | 46 ++- widgetsack/src/wifi.rs | 43 ++- widgetsack/src/windowmgr.rs | 211 +++++++++---- 25 files changed, 1516 insertions(+), 811 deletions(-) diff --git a/client/package-lock.json b/client/package-lock.json index 763c13b..d25c90f 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -7,6 +7,7 @@ "": { "name": "client", "version": "0.0.1", + "license": "MIT OR Apache-2.0", "dependencies": { "@codemirror/autocomplete": "^6.20.3", "@codemirror/commands": "^6.10.3", diff --git a/widgetsack/src/agenda.rs b/widgetsack/src/agenda.rs index 2eb3d5f..541be7c 100644 --- a/widgetsack/src/agenda.rs +++ b/widgetsack/src/agenda.rs @@ -211,7 +211,9 @@ fn agenda_config_path(app: &AppHandle) -> Result pub fn load_agenda_config(app: &AppHandle) -> Result, String> { let path = agenda_config_path(app)?; match std::fs::read_to_string(&path) { - Ok(txt) => serde_json::from_str(&txt).map(Some).map_err(|e| e.to_string()), + Ok(txt) => serde_json::from_str(&txt) + .map(Some) + .map_err(|e| e.to_string()), Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(None), Err(err) => Err(err.to_string()), } @@ -407,14 +409,23 @@ mod tests { #[test] fn ics_to_iso_handles_the_three_forms() { assert_eq!(ics_to_iso("20261231"), Some(("2026-12-31".into(), true))); - assert_eq!(ics_to_iso("20261231T180000Z"), Some(("2026-12-31T18:00:00Z".into(), false))); - assert_eq!(ics_to_iso("20261231T093000"), Some(("2026-12-31T09:30:00".into(), false))); + assert_eq!( + ics_to_iso("20261231T180000Z"), + Some(("2026-12-31T18:00:00Z".into(), false)) + ); + assert_eq!( + ics_to_iso("20261231T093000"), + Some(("2026-12-31T09:30:00".into(), false)) + ); assert_eq!(ics_to_iso("garbage"), None); } #[test] fn unescape_ics_decodes_text_escapes() { - assert_eq!(unescape_ics("Lunch with Bob\\, then gym"), "Lunch with Bob, then gym"); + assert_eq!( + unescape_ics("Lunch with Bob\\, then gym"), + "Lunch with Bob, then gym" + ); assert_eq!(unescape_ics("line1\\nline2"), "line1 line2"); } @@ -436,12 +447,15 @@ mod tests { END:VCALENDAR"; let ev = parse_ics_events(ics, 10); assert_eq!(ev.len(), 2); // the third (no SUMMARY) is skipped - assert_eq!(ev[0], AgendaEvent { - summary: "Standup".into(), - start: "2026-12-31T09:30:00".into(), - all_day: false, - location: "Room 2".into(), - }); + assert_eq!( + ev[0], + AgendaEvent { + summary: "Standup".into(), + start: "2026-12-31T09:30:00".into(), + all_day: false, + location: "Room 2".into(), + } + ); assert_eq!(ev[1].summary, "All-day off"); assert!(ev[1].all_day); assert_eq!(ev[1].start, "2027-01-01"); @@ -479,13 +493,23 @@ mod tests { #[test] fn normalize_url_rewrites_webcal() { - assert_eq!(normalize_url("webcal://ex.com/cal.ics"), "https://ex.com/cal.ics"); - assert_eq!(normalize_url("https://ex.com/cal.ics"), "https://ex.com/cal.ics"); + assert_eq!( + normalize_url("webcal://ex.com/cal.ics"), + "https://ex.com/cal.ics" + ); + assert_eq!( + normalize_url("https://ex.com/cal.ics"), + "https://ex.com/cal.ics" + ); } #[test] fn has_feed_accepts_http_and_webcal() { - let mk = |u: &str| AgendaConfig { url: u.into(), title: String::new(), poll_interval_secs: 1800 }; + let mk = |u: &str| AgendaConfig { + url: u.into(), + title: String::new(), + poll_interval_secs: 1800, + }; assert!(has_feed(&mk("https://ex.com/c.ics"))); assert!(has_feed(&mk("webcal://ex.com/c.ics"))); assert!(!has_feed(&mk("ftp://x"))); diff --git a/widgetsack/src/art.rs b/widgetsack/src/art.rs index 8e44547..c56a265 100644 --- a/widgetsack/src/art.rs +++ b/widgetsack/src/art.rs @@ -26,7 +26,7 @@ use std::collections::{HashMap, VecDeque}; use std::hash::{Hash, Hasher}; use std::sync::{Arc, Mutex}; -use tauri::http::{header, Request, Response, StatusCode}; +use tauri::http::{Request, Response, StatusCode, header}; use tauri::{AppHandle, Manager, Runtime, UriSchemeContext}; use crate::listener::{ImageWrapper, SessionUpdateEventWrapper}; @@ -164,7 +164,10 @@ pub fn build_response(art: Option<&ImageWrapper>) -> Response match art { Some(img) => Response::builder() .status(StatusCode::OK) - .header(header::CONTENT_TYPE, content_type_for(&img.content_type, &img.data)) + .header( + header::CONTENT_TYPE, + content_type_for(&img.content_type, &img.data), + ) .header(header::CACHE_CONTROL, "no-store") .header(header::ACCESS_CONTROL_ALLOW_ORIGIN, "*") .body(Cow::Owned(img.data.clone())) @@ -234,7 +237,10 @@ mod tests { assert_eq!(content_type_for("IMAGE/JPG", &[]), "image/jpeg"); // Unknown declared type → sniff the magic bytes. assert_eq!(content_type_for("", &[0x89, 0x50, 0x4E, 0x47]), "image/png"); - assert_eq!(content_type_for("application/octet-stream", &[0xFF, 0xD8, 0xFF]), "image/jpeg"); + assert_eq!( + content_type_for("application/octet-stream", &[0xFF, 0xD8, 0xFF]), + "image/jpeg" + ); // Unrecognisable → jpeg default (never an invalid header value). assert_eq!(content_type_for("", &[0, 0, 0]), "image/jpeg"); } @@ -309,12 +315,18 @@ mod tests { let state = ArtState::default(); note_record(&state, &rec); - assert!(state.0.lock().unwrap().get(hash).is_some(), "media cover is registered"); + assert!( + state.0.lock().unwrap().get(hash).is_some(), + "media cover is registered" + ); // A model/timeline update carries no cover → nothing new registered. rec.last_media_update = Some(SessionUpdateEventWrapper::Model(model)); let state2 = ArtState::default(); note_record(&state2, &rec); - assert!(state2.0.lock().unwrap().is_empty(), "model update registers no cover"); + assert!( + state2.0.lock().unwrap().is_empty(), + "model update registers no cover" + ); } } diff --git a/widgetsack/src/audio.rs b/widgetsack/src/audio.rs index 54567c8..253d03e 100644 --- a/widgetsack/src/audio.rs +++ b/widgetsack/src/audio.rs @@ -27,8 +27,8 @@ use std::time::{SystemTime, UNIX_EPOCH}; use realfft::num_complex::Complex; use realfft::{RealFftPlanner, RealToComplex}; use serde::Serialize; -use tauri::ipc::Channel; use tauri::Runtime; +use tauri::ipc::Channel; use crate::log; @@ -160,14 +160,22 @@ fn band_edges(band_count: usize, fmin: f32, fmax: f32, linear: bool) -> Vec .collect(); } let ratio = (fmax / fmin).powf(1.0 / band_count as f32); - (0..=band_count).map(|i| fmin * ratio.powi(i as i32)).collect() + (0..=band_count) + .map(|i| fmin * ratio.powi(i as i32)) + .collect() } /// Group linear FFT-bin magnitudes into `band_count` display bands over [`FMIN_HZ`, `FMAX_HZ`] /// (log- or `linear`-spaced), each the dB-normalised (0..1) PEAK magnitude within its frequency span. /// Peak (not average) keeps narrow tones visible. The frequency range and dB floor are the fixed /// app constants, so they aren't parameters. -fn to_bands(mags: &[f32], sample_rate: u32, fft_size: usize, band_count: usize, linear: bool) -> Vec { +fn to_bands( + mags: &[f32], + sample_rate: u32, + fft_size: usize, + band_count: usize, + linear: bool, +) -> Vec { if band_count == 0 || mags.is_empty() || fft_size == 0 { return vec![0.0; band_count]; } @@ -249,7 +257,11 @@ impl SpectrumProcessor { *slot = window.get(i).copied().unwrap_or(0.0) * self.window[i]; } // realfft uses `input` as scratch and writes N/2+1 complex bins into `spectrum`. - if self.fft.process(&mut self.input, &mut self.spectrum).is_ok() { + if self + .fft + .process(&mut self.input, &mut self.spectrum) + .is_ok() + { let mags = magnitudes(&self.spectrum); let bands = to_bands(&mags, self.sample_rate, self.fft_size, band_count, linear); if self.smoothed.len() != band_count { @@ -338,7 +350,7 @@ pub fn stop_spectrum( #[cfg(target_os = "windows")] #[tauri::command] pub fn list_audio_outputs() -> Result, String> { - use wasapi::{initialize_mta, Direction}; + use wasapi::{Direction, initialize_mta}; // The command may run on a COM-uninitialised pool thread; init MTA (a no-op / harmless // RPC_E_CHANGED_MODE if COM is already up on this thread — enumeration works either way). let _ = initialize_mta().ok(); @@ -374,7 +386,7 @@ pub fn list_audio_outputs() -> Result, String> { #[cfg(target_os = "windows")] #[tauri::command] pub fn default_audio_output() -> Option { - use wasapi::{initialize_mta, Direction}; + use wasapi::{Direction, initialize_mta}; let _ = initialize_mta().ok(); let enumerator = wasapi::DeviceEnumerator::new().ok()?; enumerator @@ -397,19 +409,71 @@ pub fn default_audio_output() -> Option { #[cfg(target_os = "windows")] #[windows::core::interface("f8679f50-850a-41cf-9c72-430f290290c8")] unsafe trait IPolicyConfig: windows::core::IUnknown { - unsafe fn get_mix_format(&self, id: windows::core::PCWSTR, fmt: *mut *mut ::core::ffi::c_void) -> windows::core::HRESULT; - unsafe fn get_device_format(&self, id: windows::core::PCWSTR, default: i32, fmt: *mut *mut ::core::ffi::c_void) -> windows::core::HRESULT; + unsafe fn get_mix_format( + &self, + id: windows::core::PCWSTR, + fmt: *mut *mut ::core::ffi::c_void, + ) -> windows::core::HRESULT; + unsafe fn get_device_format( + &self, + id: windows::core::PCWSTR, + default: i32, + fmt: *mut *mut ::core::ffi::c_void, + ) -> windows::core::HRESULT; unsafe fn reset_device_format(&self, id: windows::core::PCWSTR) -> windows::core::HRESULT; - unsafe fn set_device_format(&self, id: windows::core::PCWSTR, endpoint_fmt: *mut ::core::ffi::c_void, mix_fmt: *mut ::core::ffi::c_void) -> windows::core::HRESULT; - unsafe fn get_processing_period(&self, id: windows::core::PCWSTR, default: i32, default_period: *mut i64, min_period: *mut i64) -> windows::core::HRESULT; - unsafe fn set_processing_period(&self, id: windows::core::PCWSTR, period: *mut i64) -> windows::core::HRESULT; - unsafe fn get_share_mode(&self, id: windows::core::PCWSTR, mode: *mut ::core::ffi::c_void) -> windows::core::HRESULT; - unsafe fn set_share_mode(&self, id: windows::core::PCWSTR, mode: *mut ::core::ffi::c_void) -> windows::core::HRESULT; - unsafe fn get_property_value(&self, id: windows::core::PCWSTR, key: i32, value: *const ::core::ffi::c_void, out: *mut ::core::ffi::c_void) -> windows::core::HRESULT; - unsafe fn set_property_value(&self, id: windows::core::PCWSTR, key: i32, value: *const ::core::ffi::c_void, out: *mut ::core::ffi::c_void) -> windows::core::HRESULT; + unsafe fn set_device_format( + &self, + id: windows::core::PCWSTR, + endpoint_fmt: *mut ::core::ffi::c_void, + mix_fmt: *mut ::core::ffi::c_void, + ) -> windows::core::HRESULT; + unsafe fn get_processing_period( + &self, + id: windows::core::PCWSTR, + default: i32, + default_period: *mut i64, + min_period: *mut i64, + ) -> windows::core::HRESULT; + unsafe fn set_processing_period( + &self, + id: windows::core::PCWSTR, + period: *mut i64, + ) -> windows::core::HRESULT; + unsafe fn get_share_mode( + &self, + id: windows::core::PCWSTR, + mode: *mut ::core::ffi::c_void, + ) -> windows::core::HRESULT; + unsafe fn set_share_mode( + &self, + id: windows::core::PCWSTR, + mode: *mut ::core::ffi::c_void, + ) -> windows::core::HRESULT; + unsafe fn get_property_value( + &self, + id: windows::core::PCWSTR, + key: i32, + value: *const ::core::ffi::c_void, + out: *mut ::core::ffi::c_void, + ) -> windows::core::HRESULT; + unsafe fn set_property_value( + &self, + id: windows::core::PCWSTR, + key: i32, + value: *const ::core::ffi::c_void, + out: *mut ::core::ffi::c_void, + ) -> windows::core::HRESULT; /// SetDefaultEndpoint(wszDeviceId, eRole) — the one we call. role: 0 eConsole, 1 eMultimedia, 2 eCommunications. - unsafe fn set_default_endpoint(&self, device_id: windows::core::PCWSTR, role: u32) -> windows::core::HRESULT; - unsafe fn set_endpoint_visibility(&self, id: windows::core::PCWSTR, visible: i32) -> windows::core::HRESULT; + unsafe fn set_default_endpoint( + &self, + device_id: windows::core::PCWSTR, + role: u32, + ) -> windows::core::HRESULT; + unsafe fn set_endpoint_visibility( + &self, + id: windows::core::PCWSTR, + visible: i32, + ) -> windows::core::HRESULT; } /// Make `id` the default render endpoint for ALL roles (console + multimedia + communications), like @@ -419,10 +483,10 @@ unsafe trait IPolicyConfig: windows::core::IUnknown { #[tauri::command] pub fn set_default_audio_output(id: String) -> Result<(), String> { use std::iter::once; - use windows::core::PCWSTR; use windows::Win32::System::Com::{ - CoCreateInstance, CoInitializeEx, CLSCTX_ALL, COINIT_MULTITHREADED, + CLSCTX_ALL, COINIT_MULTITHREADED, CoCreateInstance, CoInitializeEx, }; + use windows::core::PCWSTR; // CPolicyConfigClient. const CLSID_POLICY_CONFIG: windows::core::GUID = @@ -471,15 +535,20 @@ pub struct AudioVolume { /// Acquire the default render endpoint's `IAudioEndpointVolume`. COM is initialised MTA on the calling /// (pool) thread; `RPC_E_CHANGED_MODE` if already up in another mode is harmless. #[cfg(target_os = "windows")] -fn endpoint_volume() -> windows::core::Result -{ - use windows::Win32::Media::Audio::{eConsole, eRender, IMMDeviceEnumerator, MMDeviceEnumerator}; - use windows::Win32::System::Com::{CoCreateInstance, CoInitializeEx, CLSCTX_ALL, COINIT_MULTITHREADED}; +fn endpoint_volume() +-> windows::core::Result { + use windows::Win32::Media::Audio::{ + IMMDeviceEnumerator, MMDeviceEnumerator, eConsole, eRender, + }; + use windows::Win32::System::Com::{ + CLSCTX_ALL, COINIT_MULTITHREADED, CoCreateInstance, CoInitializeEx, + }; // SAFETY: standard COM create/activate; every interface is released on drop. unsafe { let _ = CoInitializeEx(None, COINIT_MULTITHREADED); - let enumerator: IMMDeviceEnumerator = CoCreateInstance(&MMDeviceEnumerator, None, CLSCTX_ALL)?; + let enumerator: IMMDeviceEnumerator = + CoCreateInstance(&MMDeviceEnumerator, None, CLSCTX_ALL)?; let device = enumerator.GetDefaultAudioEndpoint(eRender, eConsole)?; device.Activate(CLSCTX_ALL, None) } @@ -529,7 +598,8 @@ pub fn set_audio_mute(muted: bool) -> Result<(), String> { // SAFETY: writes the mute flag; eventcontext is null. unsafe { let vol = endpoint_volume().map_err(|e| e.to_string())?; - vol.SetMute(muted, std::ptr::null()).map_err(|e| e.to_string()) + vol.SetMute(muted, std::ptr::null()) + .map_err(|e| e.to_string()) } } @@ -560,9 +630,7 @@ mod capture { use super::*; use std::collections::VecDeque; use std::time::{Duration, Instant}; - use wasapi::{ - initialize_mta, Direction, SampleType, StreamMode, WaveFormat, - }; + use wasapi::{Direction, SampleType, StreamMode, WaveFormat, initialize_mta}; /// How long to wait before re-initialising after a recoverable device error / switch. const RETRY_BACKOFF: Duration = Duration::from_millis(500); @@ -620,7 +688,11 @@ mod capture { } } if failures >= MAX_CONSECUTIVE_FAILURES { - log::error("audio", "spectrum: giving up after repeated capture failures").emit(); + log::error( + "audio", + "spectrum: giving up after repeated capture failures", + ) + .emit(); state.lock().running = false; return; } @@ -664,7 +736,14 @@ mod capture { .unwrap_or_else(|_| "unknown".to_string()); let mut audio_client = device.get_iaudioclient()?; - let format = WaveFormat::new(32, 32, &SampleType::Float, SAMPLE_RATE as usize, CHANNELS as usize, None); + let format = WaveFormat::new( + 32, + 32, + &SampleType::Float, + SAMPLE_RATE as usize, + CHANNELS as usize, + None, + ); let (_default_period, min_period) = audio_client.get_device_period()?; // Polling (not event-driven): loopback is incompatible with EVENTCALLBACK in shared mode. let mode = StreamMode::PollingShared { @@ -866,7 +945,10 @@ mod tests { mags[bin] = FFT_SIZE as f32 / 4.0; // ≈ full scale let bands = to_bands(&mags, SAMPLE_RATE, FFT_SIZE, 32, false); assert!(bands.iter().all(|&b| (0.0..=1.0).contains(&b))); - assert!(bands.iter().cloned().fold(0.0_f32, f32::max) > 0.9, "1 kHz band should be near full"); + assert!( + bands.iter().cloned().fold(0.0_f32, f32::max) > 0.9, + "1 kHz band should be near full" + ); } #[test] @@ -899,7 +981,12 @@ mod tests { assert!(frame.bands.iter().all(|&b| (0.0..=1.0).contains(&b))); // Which band owns 1 kHz? - let edges = band_edges(band_count, FMIN_HZ, FMAX_HZ.min(SAMPLE_RATE as f32 / 2.0), false); + let edges = band_edges( + band_count, + FMIN_HZ, + FMAX_HZ.min(SAMPLE_RATE as f32 / 2.0), + false, + ); let tone_band = (0..band_count) .find(|&b| freq >= edges[b] && freq < edges[b + 1]) .expect("1 kHz within range"); @@ -907,10 +994,12 @@ mod tests { assert!(peak > 0.5, "tone band should be strong, got {peak}"); // A far-away band (well below the tone, e.g. ~50 Hz) should be near silent. - let low_band = (0..band_count) - .find(|&b| edges[b + 1] < 100.0) - .unwrap_or(0); - assert!(frame.bands[low_band] < 0.2, "low band should be quiet, got {}", frame.bands[low_band]); + let low_band = (0..band_count).find(|&b| edges[b + 1] < 100.0).unwrap_or(0); + assert!( + frame.bands[low_band] < 0.2, + "low band should be quiet, got {}", + frame.bands[low_band] + ); } #[test] diff --git a/widgetsack/src/autostart.rs b/widgetsack/src/autostart.rs index 7d3b366..6fa0fab 100644 --- a/widgetsack/src/autostart.rs +++ b/widgetsack/src/autostart.rs @@ -81,7 +81,11 @@ pub fn reconcile(app: &tauri::AppHandle) { let Some(want) = reconcile_action(read_pref(), current) else { return; }; - let result = if want { manager.enable() } else { manager.disable() }; + let result = if want { + manager.enable() + } else { + manager.disable() + }; if let Err(err) = result { crate::log::error("startup", "failed to reconcile autostart from preference") .field("enable", want) diff --git a/widgetsack/src/clickthrough.rs b/widgetsack/src/clickthrough.rs index 6d9f7a8..19c2884 100644 --- a/widgetsack/src/clickthrough.rs +++ b/widgetsack/src/clickthrough.rs @@ -100,7 +100,7 @@ pub fn current_work_area(window: tauri::WebviewWindow) -> Result Result { use windows::Win32::Foundation::POINT; use windows::Win32::Graphics::Gdi::{ - GetMonitorInfoW, MonitorFromPoint, MONITORINFO, MONITOR_DEFAULTTONEAREST, + GetMonitorInfoW, MONITOR_DEFAULTTONEAREST, MONITORINFO, MonitorFromPoint, }; let monitor = window @@ -141,7 +141,10 @@ fn work_area_for(_window: &tauri::WebviewWindow) -> Result { /// surviving Show Desktop (the Wallpaper-Engine trick). When disabled, re-attach it to the /// desktop root so it's a normal top-level overlay again. Windows-only; a no-op error elsewhere. #[tauri::command] -pub fn set_overlay_wallpaper(window: tauri::WebviewWindow, enabled: bool) -> Result { +pub fn set_overlay_wallpaper( + window: tauri::WebviewWindow, + enabled: bool, +) -> Result { set_wallpaper_parent(&window, enabled) } @@ -149,7 +152,7 @@ pub fn set_overlay_wallpaper(window: tauri::WebviewWindow, enabled: bool) -> Res fn set_wallpaper_parent(window: &tauri::WebviewWindow, enabled: bool) -> Result { use windows::Win32::Foundation::{HWND, LPARAM, WPARAM}; use windows::Win32::UI::WindowsAndMessaging::{ - EnumWindows, FindWindowW, SendMessageTimeoutW, SetParent, SMTO_NORMAL, + EnumWindows, FindWindowW, SMTO_NORMAL, SendMessageTimeoutW, SetParent, }; use windows::core::w; @@ -185,7 +188,10 @@ fn set_wallpaper_parent(window: &tauri::WebviewWindow, enabled: bool) -> Result< let mut worker = HWND::default(); for attempt in 0..10 { unsafe { - let _ = EnumWindows(Some(enum_find_workerw), LPARAM(&mut worker as *mut HWND as isize)); + let _ = EnumWindows( + Some(enum_find_workerw), + LPARAM(&mut worker as *mut HWND as isize), + ); } if !worker.is_invalid() { break; @@ -210,7 +216,7 @@ unsafe extern "system" fn enum_find_workerw( ) -> windows::core::BOOL { use windows::Win32::Foundation::{HWND, TRUE}; use windows::Win32::UI::WindowsAndMessaging::FindWindowExW; - use windows::core::{w, BOOL}; + use windows::core::{BOOL, w}; let defview = unsafe { FindWindowExW(Some(top), None, w!("SHELLDLL_DefView"), None) }.unwrap_or_default(); diff --git a/widgetsack/src/control.rs b/widgetsack/src/control.rs index 803e717..f8621dc 100644 --- a/widgetsack/src/control.rs +++ b/widgetsack/src/control.rs @@ -18,15 +18,15 @@ use std::collections::HashMap; use std::sync::Arc; use std::time::Duration; -use serde_json::{json, Value}; +use serde_json::{Value, json}; use tauri::async_runtime::{JoinHandle, Mutex}; use tauri::{AppHandle, Manager, Runtime}; use tokio::io::{AsyncReadExt, AsyncWriteExt}; use tokio::net::{TcpListener, TcpStream}; use tokio::sync::Semaphore; -use crate::log; use crate::AppState; +use crate::log; const MAX_HEAD: usize = 16 * 1024; const MAX_BODY: usize = 256 * 1024; @@ -244,7 +244,12 @@ async fn handle_conn(app: AppHandle, token: String, mut stream: T } // Everything else needs the bearer token. if !bearer_ok(&head.headers, &token) { - write_resp(&mut stream, "401 Unauthorized", &json!({ "error": "unauthorized" })).await; + write_resp( + &mut stream, + "401 Unauthorized", + &json!({ "error": "unauthorized" }), + ) + .await; return; } @@ -261,16 +266,31 @@ async fn handle_conn(app: AppHandle, token: String, mut stream: T .iter() .filter_map(|r| serde_json::to_value(r).ok()) .collect(); - write_resp(&mut stream, "200 OK", &json!(now_playing_from_records(&values))).await; + write_resp( + &mut stream, + "200 OK", + &json!(now_playing_from_records(&values)), + ) + .await; } ("POST", "/media") => { if !is_json(&head.headers) { - write_resp(&mut stream, "415 Unsupported Media Type", &json!({ "error": "send application/json" })).await; + write_resp( + &mut stream, + "415 Unsupported Media Type", + &json!({ "error": "send application/json" }), + ) + .await; return; } let v: Value = serde_json::from_slice(&body).unwrap_or(Value::Null); let Some(action) = v["action"].as_str().map(String::from) else { - write_resp(&mut stream, "400 Bad Request", &json!({ "error": "missing action" })).await; + write_resp( + &mut stream, + "400 Bad Request", + &json!({ "error": "missing action" }), + ) + .await; return; }; let source = v["source"].as_str().map(String::from); @@ -282,23 +302,42 @@ async fn handle_conn(app: AppHandle, token: String, mut stream: T } ("POST", "/ha") => { if !is_json(&head.headers) { - write_resp(&mut stream, "415 Unsupported Media Type", &json!({ "error": "send application/json" })).await; + write_resp( + &mut stream, + "415 Unsupported Media Type", + &json!({ "error": "send application/json" }), + ) + .await; return; } let v: Value = serde_json::from_slice(&body).unwrap_or(Value::Null); let domain = v["domain"].as_str().unwrap_or("").to_string(); let service = v["service"].as_str().unwrap_or("").to_string(); if domain.is_empty() || service.is_empty() { - write_resp(&mut stream, "400 Bad Request", &json!({ "error": "missing domain/service" })).await; + write_resp( + &mut stream, + "400 Bad Request", + &json!({ "error": "missing domain/service" }), + ) + .await; return; } let data = v.get("data").cloned().unwrap_or_else(|| json!({})); match crate::ha::ha_call_service(app.clone(), domain, service, data).await { - Ok(res) => write_resp(&mut stream, "200 OK", &json!({ "ok": true, "result": res })).await, + Ok(res) => { + write_resp(&mut stream, "200 OK", &json!({ "ok": true, "result": res })).await + } Err(e) => write_resp(&mut stream, "502 Bad Gateway", &json!({ "error": e })).await, } } - _ => write_resp(&mut stream, "404 Not Found", &json!({ "error": "not found" })).await, + _ => { + write_resp( + &mut stream, + "404 Not Found", + &json!({ "error": "not found" }), + ) + .await + } } } @@ -308,7 +347,9 @@ async fn run_control_server(app: AppHandle, listener: TcpListener let token = gen_token(); let url = format!("http://127.0.0.1:{port}"); write_control_file(&app, &url, &token); - log::info("control", "agent control listening").field("url", &url).emit(); + log::info("control", "agent control listening") + .field("url", &url) + .emit(); let sem = Arc::new(Semaphore::new(MAX_CONNS)); loop { @@ -327,7 +368,9 @@ async fn run_control_server(app: AppHandle, listener: TcpListener }); } Err(e) => { - log::warn("control", "accept failed").field("error", e.to_string()).emit(); + log::warn("control", "accept failed") + .field("error", e.to_string()) + .emit(); } } } @@ -355,14 +398,18 @@ pub async fn start_if_enabled(app: AppHandle, state: &ControlStat let listener = match TcpListener::bind("127.0.0.1:0").await { Ok(l) => l, Err(e) => { - log::error("control", "bind failed").field("error", e.to_string()).emit(); + log::error("control", "bind failed") + .field("error", e.to_string()) + .emit(); return; } }; let port = match listener.local_addr() { Ok(a) => a.port(), Err(e) => { - log::error("control", "local_addr failed").field("error", e.to_string()).emit(); + log::error("control", "local_addr failed") + .field("error", e.to_string()) + .emit(); return; } }; diff --git a/widgetsack/src/event.rs b/widgetsack/src/event.rs index 27dbfab..dfb1884 100644 --- a/widgetsack/src/event.rs +++ b/widgetsack/src/event.rs @@ -1,60 +1,60 @@ -#![allow(clippy::large_enum_variant)] - -use serde::Serialize; -use tauri::Emitter; - -use crate::{ - listener::{ManagerEventWrapper, SessionUpdateEventWrapper}, - log, - state::SessionRecord, -}; - -pub fn emit_to_bridge( - emitter: &impl Emitter, - delta: (&str, Option), -) { - match delta { - (event_type, Some(record)) => { - let _ = emitter.emit(event_type, record); - } - (event_type, None) => { - // FIXME: Might not be true in all cases - log::debug("bridge", "skipped emit: no session record") - .field("event_type", event_type) - .emit(); - } - }; -} - -#[derive(Clone, Debug, Serialize)] -pub enum NpSessionEvent { - /// session ID, event - /// ManagerEvent actually already contains session_id but we still keep session ID to be consistent - Create(usize, ManagerEventWrapper), - Update(usize, SessionUpdateEventWrapper), - Delete(usize, ManagerEventWrapper), - Unsupported(Option, String), -} - -impl From for NpSessionEvent { - fn from(event: ManagerEventWrapper) -> Self { - match &event { - ManagerEventWrapper::SessionCreated { - session_id, - source: _, - } => NpSessionEvent::Create(*session_id, event), - ManagerEventWrapper::SessionRemoved { session_id } => { - NpSessionEvent::Delete(*session_id, event) - } - ManagerEventWrapper::CurrentSessionChanged { session_id } => { - NpSessionEvent::Unsupported(*session_id, "CurrentSessionChanged".to_owned()) - } - } - } -} - -impl NpSessionEvent { - pub fn from_session_update_event(event: SessionUpdateEventWrapper, session_id: usize) -> Self { - NpSessionEvent::Update(session_id, event) - } -} +#![allow(clippy::large_enum_variant)] + +use serde::Serialize; +use tauri::Emitter; + +use crate::{ + listener::{ManagerEventWrapper, SessionUpdateEventWrapper}, + log, + state::SessionRecord, +}; + +pub fn emit_to_bridge( + emitter: &impl Emitter, + delta: (&str, Option), +) { + match delta { + (event_type, Some(record)) => { + let _ = emitter.emit(event_type, record); + } + (event_type, None) => { + // FIXME: Might not be true in all cases + log::debug("bridge", "skipped emit: no session record") + .field("event_type", event_type) + .emit(); + } + }; +} + +#[derive(Clone, Debug, Serialize)] +pub enum NpSessionEvent { + /// session ID, event + /// ManagerEvent actually already contains session_id but we still keep session ID to be consistent + Create(usize, ManagerEventWrapper), + Update(usize, SessionUpdateEventWrapper), + Delete(usize, ManagerEventWrapper), + Unsupported(Option, String), +} + +impl From for NpSessionEvent { + fn from(event: ManagerEventWrapper) -> Self { + match &event { + ManagerEventWrapper::SessionCreated { + session_id, + source: _, + } => NpSessionEvent::Create(*session_id, event), + ManagerEventWrapper::SessionRemoved { session_id } => { + NpSessionEvent::Delete(*session_id, event) + } + ManagerEventWrapper::CurrentSessionChanged { session_id } => { + NpSessionEvent::Unsupported(*session_id, "CurrentSessionChanged".to_owned()) + } + } + } +} + +impl NpSessionEvent { + pub fn from_session_update_event(event: SessionUpdateEventWrapper, session_id: usize) -> Self { + NpSessionEvent::Update(session_id, event) + } +} diff --git a/widgetsack/src/ha.rs b/widgetsack/src/ha.rs index 62e7491..267f4ec 100644 --- a/widgetsack/src/ha.rs +++ b/widgetsack/src/ha.rs @@ -20,11 +20,11 @@ use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; use futures_util::{SinkExt, Stream, StreamExt}; use serde::{Deserialize, Serialize}; -use serde_json::{json, Value}; +use serde_json::{Value, json}; use tauri::async_runtime::{JoinHandle, Mutex}; use tauri::{AppHandle, Emitter, Manager, Runtime, State}; use tokio_tungstenite::tungstenite::{Error as WsError, Message}; -use tokio_tungstenite::{connect_async, connect_async_tls_with_config, Connector}; +use tokio_tungstenite::{Connector, connect_async, connect_async_tls_with_config}; use crate::log; use crate::sensors::{SensorSample, SensorValue, TELEMETRY_EVENT}; @@ -142,7 +142,9 @@ fn ha_config_path(app: &AppHandle) -> Result { pub fn load_ha_config(app: &AppHandle) -> Result, String> { let path = ha_config_path(app)?; match std::fs::read_to_string(&path) { - Ok(txt) => serde_json::from_str(&txt).map(Some).map_err(|e| e.to_string()), + Ok(txt) => serde_json::from_str(&txt) + .map(Some) + .map_err(|e| e.to_string()), Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(None), Err(err) => Err(err.to_string()), } @@ -424,7 +426,10 @@ async fn ws_request_many( None => return Err("stream ended during registry fetch".into()), } } - Ok(results.into_iter().map(|r| r.unwrap_or(Value::Null)).collect()) + Ok(results + .into_iter() + .map(|r| r.unwrap_or(Value::Null)) + .collect()) } /// One connection lifecycle: connect → auth → seed snapshot → subscribe → stream events. @@ -598,7 +603,9 @@ pub async fn ha_test_connection( .await .map_err(|e| e.to_string())?; let auth = json!({ "type": "auth", "access_token": token }).to_string(); - ws.send(Message::Text(auth)).await.map_err(|e| e.to_string())?; + ws.send(Message::Text(auth)) + .await + .map_err(|e| e.to_string())?; while let Some(msg) = ws.next().await { if let Message::Text(txt) = msg.map_err(|e| e.to_string())? { let v: Value = serde_json::from_str(&txt).map_err(|e| e.to_string())?; @@ -667,7 +674,10 @@ pub async fn list_ha_entities(app: AppHandle) -> Result(app: AppHandle) -> Result, - tx: mpsc::Sender, -) -> Result<(), Box> { - while let Some(evt) = manager_rx.recv().await { - match evt { - ManagerEvent::SessionCreated { - session_id, - mut rx, - source, - } => { - // `rx` is killing our .into() when destructured so manually create the struct here - let evt_wrapper: ManagerEventWrapper = ManagerEventWrapper::SessionCreated { - session_id, - source: source.clone(), - }; - - log::info("gsmtc", "session created") - .field("session_id", session_id) - .field("source", &source) - .emit(); - - let _ = tx.send(evt_wrapper.into()).await; - - let tx_child = tx.clone(); - tokio::spawn(async move { - while let Some(evt_update) = rx.recv().await { - let evt_update_wrapper: SessionUpdateEventWrapper = evt_update.into(); - let _ = tx_child - .send(NpSessionEvent::from_session_update_event( - evt_update_wrapper, - session_id, - )) - .await; - } - }); - } - ManagerEvent::SessionRemoved { session_id } => { - let evt_wrapper: ManagerEventWrapper = - ManagerEventWrapper::SessionRemoved { session_id }; - - let _ = tx.send(evt_wrapper.into()).await; - log::info("gsmtc", "session removed") - .field("session_id", session_id) - .emit(); - } - ManagerEvent::CurrentSessionChanged { - session_id: Some(id), - } => { - // TODO: reset frontend - log::debug("gsmtc", "current session changed") - .field("session_id", id) - .emit(); - } - ManagerEvent::CurrentSessionChanged { session_id: None } => { - // TODO: clear frontend - log::debug("gsmtc", "no current session").emit(); - } - } - } - - Ok(()) -} - -#[derive(Clone)] -pub struct ImageWrapper { - pub content_type: String, - pub data: Vec, - /// Content hash of `data`, computed once at construction (`art::art_hash`). Drives the cover's - /// bridge URL (`art::art_url`) and its registry key, so identical covers (same album across - /// tracks) map to the same URL — a browser cache hit and a stable crossfade `artKey`. - pub hash: u64, -} - -impl ImageWrapper { - pub fn new(content_type: String, data: Vec) -> Self { - let hash = crate::art::art_hash(&data); - ImageWrapper { - content_type, - data, - hash, - } - } -} - -impl From for ImageWrapper { - fn from(value: Image) -> Self { - ImageWrapper::new(value.content_type, value.data) - } -} - -// The cover bytes DO NOT cross the JSON bridge — that's the whole point of `art.rs`. Instead of -// serde_json rendering `data: Vec` as a multi-MB array of decimal numbers, an `ImageWrapper` -// serializes to a compact descriptor the frontend (`stores.ts` `ThumbnailInfo`) reads: `url` points -// the `` at the custom `art` scheme handler, `bytes` is the retained byte count surfaced in the -// studio Diagnostics panel. Keep this in lockstep with `ThumbnailInfo` (AGENTS.md §5). -impl Serialize for ImageWrapper { - fn serialize(&self, serializer: S) -> Result { - let mut state = serializer.serialize_struct("ImageWrapper", 3)?; - state.serialize_field("content_type", &self.content_type)?; - state.serialize_field("url", &crate::art::art_url(self.hash))?; - state.serialize_field("bytes", &self.data.len())?; - state.end() - } -} - -impl fmt::Debug for ImageWrapper { - fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { - write!( - f, - "ImageWrapper {{ content_type: {}, data: u8[{}] }}", - self.content_type, - self.data.len() - ) - } -} - -// Serde's remote doesn't seem to work on enum fields? Image in Media wants to be Serialize, but that won't work. -// The cover art is held behind an `Arc` so carrying it forward across model/timeline updates -// (state.rs `updater`) and emitting it are pointer copies, not memcpy of the (hundreds-of-KB) bytes. -// `Arc` serializes identically to `ImageWrapper` (serde `rc` feature) — the bridge JSON -// is unchanged, so the TS mirror in stores.ts needs no change. -#[derive(Clone, Debug, Serialize)] -pub enum SessionUpdateEventWrapper { - Model(SessionModel), - Media(SessionModel, Option>), -} - -impl From for SessionUpdateEventWrapper { - fn from(value: gsmtc::SessionUpdateEvent) -> Self { - match value { - SessionUpdateEvent::Model(model) => SessionUpdateEventWrapper::Model(model), - SessionUpdateEvent::Media(model, image) => { - SessionUpdateEventWrapper::Media(model, image.map(|i| Arc::new(i.into()))) - } - } - } -} - -#[derive(Clone, Debug, Serialize)] -pub enum ManagerEventWrapper { - SessionCreated { session_id: usize, source: String }, - SessionRemoved { session_id: usize }, - CurrentSessionChanged { session_id: Option }, -} - -impl From for ManagerEventWrapper { - fn from(value: ManagerEvent) -> Self { - match value { - gsmtc::ManagerEvent::SessionCreated { - session_id, - rx: _, - source, - } => Self::SessionCreated { session_id, source }, - gsmtc::ManagerEvent::SessionRemoved { session_id } => { - Self::SessionRemoved { session_id } - } - gsmtc::ManagerEvent::CurrentSessionChanged { session_id } => { - Self::CurrentSessionChanged { session_id } - } - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn image_wrapper_serializes_as_url_and_bytes_not_raw_data() { - let data = vec![0x89u8, 0x50, 0x4E, 0x47, 1, 2, 3]; - let wrapper = ImageWrapper::new("image/png".to_string(), data.clone()); - let value = serde_json::to_value(&wrapper).expect("serialize"); - - assert_eq!(value["content_type"], "image/png"); - assert_eq!(value["bytes"], data.len() as u64); - // The whole point: the encoded bytes must NOT cross the JSON bridge. - assert!(value.get("data").is_none(), "raw cover bytes must not be serialized"); - // The url is the content-hash scheme URL the webview's fetches. - assert_eq!( - value["url"], - crate::art::art_url(crate::art::art_hash(&data)) - ); - } -} +use std::fmt; +use std::sync::Arc; + +use gsmtc::{Image, ManagerEvent, SessionModel, SessionUpdateEvent}; +use serde::ser::SerializeStruct; +use serde::{Serialize, Serializer}; +use tokio::sync::mpsc; + +use crate::event::NpSessionEvent; +use crate::log; + +pub async fn session_listener_windows_gsmtc( + mut manager_rx: mpsc::UnboundedReceiver, + tx: mpsc::Sender, +) -> Result<(), Box> { + while let Some(evt) = manager_rx.recv().await { + match evt { + ManagerEvent::SessionCreated { + session_id, + mut rx, + source, + } => { + // `rx` is killing our .into() when destructured so manually create the struct here + let evt_wrapper: ManagerEventWrapper = ManagerEventWrapper::SessionCreated { + session_id, + source: source.clone(), + }; + + log::info("gsmtc", "session created") + .field("session_id", session_id) + .field("source", &source) + .emit(); + + let _ = tx.send(evt_wrapper.into()).await; + + let tx_child = tx.clone(); + tokio::spawn(async move { + while let Some(evt_update) = rx.recv().await { + let evt_update_wrapper: SessionUpdateEventWrapper = evt_update.into(); + let _ = tx_child + .send(NpSessionEvent::from_session_update_event( + evt_update_wrapper, + session_id, + )) + .await; + } + }); + } + ManagerEvent::SessionRemoved { session_id } => { + let evt_wrapper: ManagerEventWrapper = + ManagerEventWrapper::SessionRemoved { session_id }; + + let _ = tx.send(evt_wrapper.into()).await; + log::info("gsmtc", "session removed") + .field("session_id", session_id) + .emit(); + } + ManagerEvent::CurrentSessionChanged { + session_id: Some(id), + } => { + // TODO: reset frontend + log::debug("gsmtc", "current session changed") + .field("session_id", id) + .emit(); + } + ManagerEvent::CurrentSessionChanged { session_id: None } => { + // TODO: clear frontend + log::debug("gsmtc", "no current session").emit(); + } + } + } + + Ok(()) +} + +#[derive(Clone)] +pub struct ImageWrapper { + pub content_type: String, + pub data: Vec, + /// Content hash of `data`, computed once at construction (`art::art_hash`). Drives the cover's + /// bridge URL (`art::art_url`) and its registry key, so identical covers (same album across + /// tracks) map to the same URL — a browser cache hit and a stable crossfade `artKey`. + pub hash: u64, +} + +impl ImageWrapper { + pub fn new(content_type: String, data: Vec) -> Self { + let hash = crate::art::art_hash(&data); + ImageWrapper { + content_type, + data, + hash, + } + } +} + +impl From for ImageWrapper { + fn from(value: Image) -> Self { + ImageWrapper::new(value.content_type, value.data) + } +} + +// The cover bytes DO NOT cross the JSON bridge — that's the whole point of `art.rs`. Instead of +// serde_json rendering `data: Vec` as a multi-MB array of decimal numbers, an `ImageWrapper` +// serializes to a compact descriptor the frontend (`stores.ts` `ThumbnailInfo`) reads: `url` points +// the `` at the custom `art` scheme handler, `bytes` is the retained byte count surfaced in the +// studio Diagnostics panel. Keep this in lockstep with `ThumbnailInfo` (AGENTS.md §5). +impl Serialize for ImageWrapper { + fn serialize(&self, serializer: S) -> Result { + let mut state = serializer.serialize_struct("ImageWrapper", 3)?; + state.serialize_field("content_type", &self.content_type)?; + state.serialize_field("url", &crate::art::art_url(self.hash))?; + state.serialize_field("bytes", &self.data.len())?; + state.end() + } +} + +impl fmt::Debug for ImageWrapper { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + write!( + f, + "ImageWrapper {{ content_type: {}, data: u8[{}] }}", + self.content_type, + self.data.len() + ) + } +} + +// Serde's remote doesn't seem to work on enum fields? Image in Media wants to be Serialize, but that won't work. +// The cover art is held behind an `Arc` so carrying it forward across model/timeline updates +// (state.rs `updater`) and emitting it are pointer copies, not memcpy of the (hundreds-of-KB) bytes. +// `Arc` serializes identically to `ImageWrapper` (serde `rc` feature) — the bridge JSON +// is unchanged, so the TS mirror in stores.ts needs no change. +#[derive(Clone, Debug, Serialize)] +pub enum SessionUpdateEventWrapper { + Model(SessionModel), + Media(SessionModel, Option>), +} + +impl From for SessionUpdateEventWrapper { + fn from(value: gsmtc::SessionUpdateEvent) -> Self { + match value { + SessionUpdateEvent::Model(model) => SessionUpdateEventWrapper::Model(model), + SessionUpdateEvent::Media(model, image) => { + SessionUpdateEventWrapper::Media(model, image.map(|i| Arc::new(i.into()))) + } + } + } +} + +#[derive(Clone, Debug, Serialize)] +pub enum ManagerEventWrapper { + SessionCreated { session_id: usize, source: String }, + SessionRemoved { session_id: usize }, + CurrentSessionChanged { session_id: Option }, +} + +impl From for ManagerEventWrapper { + fn from(value: ManagerEvent) -> Self { + match value { + gsmtc::ManagerEvent::SessionCreated { + session_id, + rx: _, + source, + } => Self::SessionCreated { session_id, source }, + gsmtc::ManagerEvent::SessionRemoved { session_id } => { + Self::SessionRemoved { session_id } + } + gsmtc::ManagerEvent::CurrentSessionChanged { session_id } => { + Self::CurrentSessionChanged { session_id } + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn image_wrapper_serializes_as_url_and_bytes_not_raw_data() { + let data = vec![0x89u8, 0x50, 0x4E, 0x47, 1, 2, 3]; + let wrapper = ImageWrapper::new("image/png".to_string(), data.clone()); + let value = serde_json::to_value(&wrapper).expect("serialize"); + + assert_eq!(value["content_type"], "image/png"); + assert_eq!(value["bytes"], data.len() as u64); + // The whole point: the encoded bytes must NOT cross the JSON bridge. + assert!( + value.get("data").is_none(), + "raw cover bytes must not be serialized" + ); + // The url is the content-hash scheme URL the webview's fetches. + assert_eq!( + value["url"], + crate::art::art_url(crate::art::art_hash(&data)) + ); + } +} diff --git a/widgetsack/src/llm.rs b/widgetsack/src/llm.rs index c4151fe..5504112 100644 --- a/widgetsack/src/llm.rs +++ b/widgetsack/src/llm.rs @@ -24,7 +24,7 @@ use std::time::Duration; use futures_util::StreamExt; use serde::{Deserialize, Serialize}; -use serde_json::{json, Value}; +use serde_json::{Value, json}; use tauri::async_runtime::{JoinHandle, Mutex}; use tauri::{AppHandle, Emitter, Manager, Runtime, State}; @@ -374,7 +374,10 @@ fn mime_ext(mime: &str) -> &'static str { /// Pull the transcript text out of a transcription response (`{ "text": "..." }`). fn parse_transcription(v: &Value) -> Option { - v["text"].as_str().map(str::to_string).filter(|s| !s.is_empty()) + v["text"] + .as_str() + .map(str::to_string) + .filter(|s| !s.is_empty()) } /// The OpenAI-style text-to-speech endpoint. Shares `supports_transcription`'s provider gate (the same @@ -536,11 +539,7 @@ fn parse_chat_text(provider: &str, v: &Value) -> Option { .unwrap_or("") .to_string(), }; - if text.is_empty() { - None - } else { - Some(text) - } + if text.is_empty() { None } else { Some(text) } } /// Pull a human-readable error message out of an error response body (best-effort). @@ -651,7 +650,11 @@ fn llm_http_client(insecure: bool) -> Result { } /// Attach the provider's auth headers to a request. The key never leaves this process. -fn apply_auth(rb: reqwest::RequestBuilder, provider: &str, api_key: &str) -> reqwest::RequestBuilder { +fn apply_auth( + rb: reqwest::RequestBuilder, + provider: &str, + api_key: &str, +) -> reqwest::RequestBuilder { match provider { "anthropic" => rb .header("x-api-key", api_key) @@ -911,7 +914,12 @@ pub async fn llm_list_models( } None => { let cfg = load_llm_config(&app)?.ok_or("AI provider not configured")?; - (cfg.provider.clone(), effective_base(&cfg), cfg.api_key, cfg.insecure) + ( + cfg.provider.clone(), + effective_base(&cfg), + cfg.api_key, + cfg.insecure, + ) } }; let url = models_endpoint(&provider, &base); @@ -1003,7 +1011,10 @@ pub async fn llm_transcribe( /// expose this — anthropic + ollama do not (the frontend falls back to the browser's Web Speech voice). /// NOT studio-guarded: the overlay's widgets read it aloud too; the key never crosses the bridge. #[tauri::command] -pub async fn llm_synthesize(app: AppHandle, text: String) -> Result { +pub async fn llm_synthesize( + app: AppHandle, + text: String, +) -> Result { let cfg = load_llm_config(&app)?.ok_or("AI provider not configured")?; if !supports_tts(&cfg.provider) { return Err(format!( @@ -1059,7 +1070,13 @@ pub async fn llm_synthesize(app: AppHandle, text: String) -> Resu Ok(LlmAudio { audio, mime }) } -fn emit_delta(app: &AppHandle, request_id: &str, token: &str, done: bool, error: Option) { +fn emit_delta( + app: &AppHandle, + request_id: &str, + token: &str, + done: bool, + error: Option, +) { let _ = app.emit( LLM_DELTA_EVENT, &LlmDelta { @@ -1073,9 +1090,20 @@ fn emit_delta(app: &AppHandle, request_id: &str, token: &str, don /// The streaming worker: open the streamed response and emit `llm_delta` frames token-by-token, then a /// final `{ done: true }`. Errors emit a `{ done: true, error }` frame so the UI always terminates. -async fn run_stream(app: AppHandle, request_id: String, cfg: LlmConfig, messages: Vec) { +async fn run_stream( + app: AppHandle, + request_id: String, + cfg: LlmConfig, + messages: Vec, +) { if needs_key(&cfg.provider) && cfg.api_key.trim().is_empty() { - emit_delta(&app, &request_id, "", true, Some("no API key configured".into())); + emit_delta( + &app, + &request_id, + "", + true, + Some("no API key configured".into()), + ); return; } let base = effective_base(&cfg); @@ -1182,7 +1210,9 @@ pub async fn llm_cancel( ) -> Result<(), String> { if let Some((_, handle)) = state.streams.lock().await.remove(&request_id) { handle.abort(); - log::info("llm", "stream cancelled").field("id", &request_id).emit(); + log::info("llm", "stream cancelled") + .field("id", &request_id) + .emit(); emit_delta(&app, &request_id, "", true, None); } Ok(()) @@ -1286,7 +1316,10 @@ mod tests { #[test] fn parse_text_per_provider() { let anth = serde_json::json!({ "content": [ { "type": "text", "text": "he" }, { "type": "text", "text": "llo" } ] }); - assert_eq!(parse_chat_text("anthropic", &anth).as_deref(), Some("hello")); + assert_eq!( + parse_chat_text("anthropic", &anth).as_deref(), + Some("hello") + ); let oai = serde_json::json!({ "choices": [ { "message": { "content": "hi there" } } ] }); assert_eq!(parse_chat_text("openai", &oai).as_deref(), Some("hi there")); let oll = serde_json::json!({ "message": { "content": "yo" } }); @@ -1307,8 +1340,19 @@ mod tests { #[test] fn uses_completion_tokens_matches_next_gen_families() { - for m in ["gpt-5", "gpt-5-nano", "gpt-5.1", "o1", "o1-mini", "o3-mini", "o4-mini"] { - assert!(uses_completion_tokens(m), "{m} should use max_completion_tokens"); + for m in [ + "gpt-5", + "gpt-5-nano", + "gpt-5.1", + "o1", + "o1-mini", + "o3-mini", + "o4-mini", + ] { + assert!( + uses_completion_tokens(m), + "{m} should use max_completion_tokens" + ); } for m in ["gpt-4o", "gpt-4o-mini", "gpt-4-turbo", "llama3.2", ""] { assert!(!uses_completion_tokens(m), "{m} should keep max_tokens"); @@ -1344,13 +1388,22 @@ mod tests { #[test] fn openai_sse_lines() { assert_eq!( - stream_event_from_line("openai", "data: {\"choices\":[{\"delta\":{\"content\":\"Hi\"}}]}"), + stream_event_from_line( + "openai", + "data: {\"choices\":[{\"delta\":{\"content\":\"Hi\"}}]}" + ), StreamEvent::Token("Hi".into()) ); - assert_eq!(stream_event_from_line("openai", "data: [DONE]"), StreamEvent::Done); + assert_eq!( + stream_event_from_line("openai", "data: [DONE]"), + StreamEvent::Done + ); // a role-only opening delta carries no content -> Ignore assert_eq!( - stream_event_from_line("openai", "data: {\"choices\":[{\"delta\":{\"role\":\"assistant\"}}]}"), + stream_event_from_line( + "openai", + "data: {\"choices\":[{\"delta\":{\"role\":\"assistant\"}}]}" + ), StreamEvent::Ignore ); assert_eq!(stream_event_from_line("openai", ""), StreamEvent::Ignore); @@ -1379,7 +1432,10 @@ mod tests { #[test] fn ollama_stream_lines() { assert_eq!( - stream_event_from_line("ollama", "{\"message\":{\"content\":\"yo\"},\"done\":false}"), + stream_event_from_line( + "ollama", + "{\"message\":{\"content\":\"yo\"},\"done\":false}" + ), StreamEvent::Token("yo".into()) ); assert_eq!( @@ -1405,7 +1461,10 @@ mod tests { parse_transcription(&serde_json::json!({ "text": "hello world" })).as_deref(), Some("hello world") ); - assert_eq!(parse_transcription(&serde_json::json!({ "text": "" })), None); + assert_eq!( + parse_transcription(&serde_json::json!({ "text": "" })), + None + ); } #[test] @@ -1449,7 +1508,10 @@ mod tests { .unwrap(); assert!(v.get("api_key").is_none() && v.get("apiKey").is_none()); assert_eq!(v["providers"]["openai"]["hasKey"], true); - assert_eq!(v["providers"]["openai"]["baseUrl"], "https://api.openai.com/v1"); + assert_eq!( + v["providers"]["openai"]["baseUrl"], + "https://api.openai.com/v1" + ); assert_eq!(v["maxTokens"], 1024); } @@ -1457,9 +1519,9 @@ mod tests { fn migrates_legacy_flat_config_into_providers_map() { // A pre-multi-provider file (no `providers` key) → one entry, active = its provider, globals kept. let file = parse_config_json( - r#"{ "provider": "anthropic", "api_key": "sk-x", "model": "claude-x", "max_tokens": 2048 }"#, - ) - .unwrap(); + r#"{ "provider": "anthropic", "api_key": "sk-x", "model": "claude-x", "max_tokens": 2048 }"#, + ) + .unwrap(); assert_eq!(file.active, "anthropic"); assert_eq!(file.max_tokens, 2048); let entry = file.providers.get("anthropic").unwrap(); diff --git a/widgetsack/src/log.rs b/widgetsack/src/log.rs index 3771dde..68f0bd1 100644 --- a/widgetsack/src/log.rs +++ b/widgetsack/src/log.rs @@ -221,7 +221,12 @@ pub fn error(target: &'static str, message: impl Into) -> LogBuilder { /// A compact one-liner for the console / panic hook: `LEVEL target: message k=v …`. fn console_line(record: &LogRecord) -> String { - let mut line = format!("{} {}: {}", record.level.label(), record.target, record.message); + let mut line = format!( + "{} {}: {}", + record.level.label(), + record.target, + record.message + ); for (k, v) in &record.fields { line.push_str(&format!(" {k}={v}")); } diff --git a/widgetsack/src/media.rs b/widgetsack/src/media.rs index d14f75e..019bb56 100644 --- a/widgetsack/src/media.rs +++ b/widgetsack/src/media.rs @@ -131,7 +131,7 @@ fn resolve_session( source: Option<&str>, ) -> Result, String> { use windows::Media::Control::GlobalSystemMediaTransportControlsSessionManager as Manager; - use windows::Win32::System::Com::{CoInitializeEx, COINIT_MULTITHREADED}; + use windows::Win32::System::Com::{COINIT_MULTITHREADED, CoInitializeEx}; // Best-effort: S_OK / S_FALSE (already inited) / RPC_E_CHANGED_MODE are all fine to ignore. unsafe { @@ -173,7 +173,11 @@ fn session_for_source( } #[cfg(not(target_os = "windows"))] -async fn control(_action: String, _source: Option, _value: Option) -> Result<(), String> { +async fn control( + _action: String, + _source: Option, + _value: Option, +) -> Result<(), String> { Err("media control is only available on Windows".to_string()) } diff --git a/widgetsack/src/mqtt.rs b/widgetsack/src/mqtt.rs index ddd82ab..0f44662 100644 --- a/widgetsack/src/mqtt.rs +++ b/widgetsack/src/mqtt.rs @@ -103,7 +103,9 @@ fn mqtt_config_path(app: &AppHandle) -> Result { pub fn load_mqtt_config(app: &AppHandle) -> Result, String> { let path = mqtt_config_path(app)?; match std::fs::read_to_string(&path) { - Ok(txt) => serde_json::from_str(&txt).map(Some).map_err(|e| e.to_string()), + Ok(txt) => serde_json::from_str(&txt) + .map(Some) + .map_err(|e| e.to_string()), Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(None), Err(err) => Err(err.to_string()), } @@ -307,12 +309,13 @@ pub async fn run_mqtt_client( } // Record the seen topic (keeps a discovered entry's friendly label if already set). if let Ok(mut cat) = catalog.lock() { - cat.entry(p.topic.clone()).or_insert_with(|| MqttCatalogEntry { - id: topic_to_id(&p.topic), - topic: p.topic.clone(), - label: None, - unit: None, - }); + cat.entry(p.topic.clone()) + .or_insert_with(|| MqttCatalogEntry { + id: topic_to_id(&p.topic), + topic: p.topic.clone(), + label: None, + unit: None, + }); } let batch = payload_to_samples(&p.topic, &payload, now_ms()); let _ = app.emit(TELEMETRY_EVENT, &batch); @@ -483,7 +486,10 @@ mod tests { assert!(ids.contains(&"mqtt.t/dev.name")); // string field → text assert!(ids.contains(&"mqtt.t/dev.on")); // bool field → text let temp = s.iter().find(|x| x.sensor == "mqtt.t/dev.temp").unwrap(); - assert_eq!(serde_json::to_value(temp).unwrap()["value"]["kind"], "scalar"); + assert_eq!( + serde_json::to_value(temp).unwrap()["value"]["kind"], + "scalar" + ); } #[test] diff --git a/widgetsack/src/netconn.rs b/widgetsack/src/netconn.rs index bcfdb96..ebd546b 100644 --- a/widgetsack/src/netconn.rs +++ b/widgetsack/src/netconn.rs @@ -149,7 +149,10 @@ pub fn aggregate(conns: &[RawConn], names: &HashMap) -> (Vec 0 || a.listening > 0) .map(|(pid, a)| ProcConn { - proc: names.get(&pid).cloned().unwrap_or_else(|| format!("pid {pid}")), + proc: names + .get(&pid) + .cloned() + .unwrap_or_else(|| format!("pid {pid}")), pid, established: a.established, listening: a.listening, @@ -168,7 +171,11 @@ pub fn aggregate(conns: &[RawConn], names: &HashMap) -> (Vec) -> Vec { +pub fn build_samples( + ts: u64, + conns: &[RawConn], + names: &HashMap, +) -> Vec { let (rows, totals) = aggregate(conns, names); let list = serde_json::to_value(&rows).unwrap_or(serde_json::Value::Null); vec![ @@ -196,9 +203,8 @@ fn read_tcp_table() -> Vec { let mut size: u32 = 0; // First call sizes the buffer (expects ERROR_INSUFFICIENT_BUFFER). // SAFETY: a sizing call — null table pointer, size-out only. - let rc = unsafe { - GetExtendedTcpTable(None, &mut size, false, AF_INET, TCP_TABLE_OWNER_PID_ALL, 0) - }; + let rc = + unsafe { GetExtendedTcpTable(None, &mut size, false, AF_INET, TCP_TABLE_OWNER_PID_ALL, 0) }; if rc != ERROR_INSUFFICIENT_BUFFER.0 || size == 0 { return Vec::new(); } @@ -238,7 +244,7 @@ fn read_tcp_table() -> Vec { fn process_names() -> HashMap { use windows::Win32::Foundation::CloseHandle; use windows::Win32::System::Diagnostics::ToolHelp::{ - CreateToolhelp32Snapshot, Process32FirstW, Process32NextW, PROCESSENTRY32W, + CreateToolhelp32Snapshot, PROCESSENTRY32W, Process32FirstW, Process32NextW, TH32CS_SNAPPROCESS, }; @@ -254,7 +260,11 @@ fn process_names() -> HashMap { // SAFETY: `pe.dwSize` is set; First/Next fill `pe` until they return Err (end of list). if unsafe { Process32FirstW(snap, &mut pe) }.is_ok() { loop { - let len = pe.szExeFile.iter().position(|&c| c == 0).unwrap_or(pe.szExeFile.len()); + let len = pe + .szExeFile + .iter() + .position(|&c| c == 0) + .unwrap_or(pe.szExeFile.len()); let name = String::from_utf16_lossy(&pe.szExeFile[..len]); if !name.is_empty() { map.insert(pe.th32ProcessID, name); @@ -336,19 +346,57 @@ mod tests { fn aggregate_counts_and_ranks_by_public_talkers() { let conns = vec![ // chrome: two established to public + one to a private LAN host. - RawConn { pid: 100, state: STATE_ESTAB, remote_addr: addr(8, 8, 8, 8), remote_port: port(443) }, - RawConn { pid: 100, state: STATE_ESTAB, remote_addr: addr(1, 1, 1, 1), remote_port: port(443) }, - RawConn { pid: 100, state: STATE_ESTAB, remote_addr: addr(192, 168, 1, 9), remote_port: port(445) }, + RawConn { + pid: 100, + state: STATE_ESTAB, + remote_addr: addr(8, 8, 8, 8), + remote_port: port(443), + }, + RawConn { + pid: 100, + state: STATE_ESTAB, + remote_addr: addr(1, 1, 1, 1), + remote_port: port(443), + }, + RawConn { + pid: 100, + state: STATE_ESTAB, + remote_addr: addr(192, 168, 1, 9), + remote_port: port(445), + }, // svchost: a listener only. - RawConn { pid: 200, state: STATE_LISTEN, remote_addr: addr(0, 0, 0, 0), remote_port: port(135) }, + RawConn { + pid: 200, + state: STATE_LISTEN, + remote_addr: addr(0, 0, 0, 0), + remote_port: port(135), + }, // foo: one established to a public host. - RawConn { pid: 300, state: STATE_ESTAB, remote_addr: addr(93, 184, 216, 34), remote_port: port(80) }, + RawConn { + pid: 300, + state: STATE_ESTAB, + remote_addr: addr(93, 184, 216, 34), + remote_port: port(80), + }, // transient state is ignored. - RawConn { pid: 300, state: 4 /* SYN_SENT */, remote_addr: addr(5, 5, 5, 5), remote_port: port(80) }, + RawConn { + pid: 300, + state: 4, /* SYN_SENT */ + remote_addr: addr(5, 5, 5, 5), + remote_port: port(80), + }, ]; - let (rows, totals) = aggregate(&conns, &names(&[(100, "chrome.exe"), (200, "svchost.exe")])); - - assert_eq!(totals, ConnTotals { established: 4, listening: 1, public: 3 }); + let (rows, totals) = + aggregate(&conns, &names(&[(100, "chrome.exe"), (200, "svchost.exe")])); + + assert_eq!( + totals, + ConnTotals { + established: 4, + listening: 1, + public: 3 + } + ); // chrome (2 public) ranks before foo (1 public) before svchost (0 public, listener). assert_eq!(rows.len(), 3); assert_eq!(rows[0].proc, "chrome.exe"); diff --git a/widgetsack/src/ping.rs b/widgetsack/src/ping.rs index bf96cc5..6884fe0 100644 --- a/widgetsack/src/ping.rs +++ b/widgetsack/src/ping.rs @@ -93,7 +93,7 @@ fn resolve_ipv4(host: &str) -> Option { #[cfg(target_os = "windows")] fn icmp_ping(addr: Ipv4Addr, timeout_ms: u32) -> Option { use windows::Win32::NetworkManagement::IpHelper::{ - IcmpCloseHandle, IcmpCreateFile, IcmpSendEcho, ICMP_ECHO_REPLY, + ICMP_ECHO_REPLY, IcmpCloseHandle, IcmpCreateFile, IcmpSendEcho, }; // SAFETY: IcmpCreateFile returns a handle (Err / INVALID_HANDLE_VALUE on failure). @@ -192,7 +192,12 @@ mod tests { fn active(entries: &[(&str, &[&str])]) -> HashMap> { entries .iter() - .map(|(label, ids)| (label.to_string(), ids.iter().map(|s| s.to_string()).collect())) + .map(|(label, ids)| { + ( + label.to_string(), + ids.iter().map(|s| s.to_string()).collect(), + ) + }) .collect() } @@ -200,7 +205,10 @@ mod tests { fn host_of_strips_prefix_and_suffix_even_with_dotted_ips() { assert_eq!(host_of("net.ping.1.1.1.1.ms"), Some("1.1.1.1")); assert_eq!(host_of("net.ping.8.8.8.8.up"), Some("8.8.8.8")); - assert_eq!(host_of("net.ping.cloudflare.com.ms"), Some("cloudflare.com")); + assert_eq!( + host_of("net.ping.cloudflare.com.ms"), + Some("cloudflare.com") + ); assert_eq!(host_of("net.ping..ms"), None); // empty host assert_eq!(host_of("net.down"), None); assert_eq!(host_of("*"), None); @@ -209,7 +217,10 @@ mod tests { #[test] fn hosts_from_active_dedupes_sorts_and_ignores_wildcard() { let a = active(&[ - ("studio", &["*", "net.ping.1.1.1.1.ms", "net.ping.1.1.1.1.up"]), + ( + "studio", + &["*", "net.ping.1.1.1.1.ms", "net.ping.1.1.1.1.up"], + ), ("main", &["net.ping.8.8.8.8.ms", "cpu.total"]), ]); assert_eq!(hosts_from_active(&a), vec!["1.1.1.1", "8.8.8.8"]); diff --git a/widgetsack/src/recyclebin.rs b/widgetsack/src/recyclebin.rs index e4fcdbd..ffa663b 100644 --- a/widgetsack/src/recyclebin.rs +++ b/widgetsack/src/recyclebin.rs @@ -17,8 +17,8 @@ pub fn recyclebin_samples_from(items: i64, bytes: i64, ts: u64) -> Vec Option<(i64, i64)> { + use windows::Win32::UI::Shell::{SHQUERYRBINFO, SHQueryRecycleBinW}; use windows::core::PCWSTR; - use windows::Win32::UI::Shell::{SHQueryRecycleBinW, SHQUERYRBINFO}; let mut info = SHQUERYRBINFO { cbSize: std::mem::size_of::() as u32, diff --git a/widgetsack/src/rss.rs b/widgetsack/src/rss.rs index 6320920..260d5d1 100644 --- a/widgetsack/src/rss.rs +++ b/widgetsack/src/rss.rs @@ -97,7 +97,10 @@ fn extract_tag(block: &str, tag: &str) -> Option { let close = format!(""); let end = block[gt + 1..].find(&close)? + gt + 1; let raw = block[gt + 1..end].trim(); - if let Some(inner) = raw.strip_prefix("")) { + if let Some(inner) = raw + .strip_prefix("")) + { Some(inner.trim().to_string()) } else { Some(unescape(raw)) @@ -169,7 +172,9 @@ fn rss_config_path(app: &AppHandle) -> Result { pub fn load_rss_config(app: &AppHandle) -> Result, String> { let path = rss_config_path(app)?; match std::fs::read_to_string(&path) { - Ok(txt) => serde_json::from_str(&txt).map(Some).map_err(|e| e.to_string()), + Ok(txt) => serde_json::from_str(&txt) + .map(Some) + .map_err(|e| e.to_string()), Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(None), Err(err) => Err(err.to_string()), } @@ -377,7 +382,13 @@ mod tests { let items = parse_feed_items(xml, 10); assert_eq!(items.len(), 3); // The channel title is NOT picked up — only item titles. - assert_eq!(items[0], FeedItem { title: "First & Foremost".into(), link: "https://ex.com/1".into() }); + assert_eq!( + items[0], + FeedItem { + title: "First & Foremost".into(), + link: "https://ex.com/1".into() + } + ); assert_eq!(items[1].title, "Second story"); // CDATA kept literal assert_eq!(items[1].link, "https://ex.com/2"); } @@ -391,7 +402,13 @@ mod tests { "#; let items = parse_feed_items(xml, 10); assert_eq!(items.len(), 2); - assert_eq!(items[0], FeedItem { title: "Alpha".into(), link: "https://ex.com/a".into() }); + assert_eq!( + items[0], + FeedItem { + title: "Alpha".into(), + link: "https://ex.com/a".into() + } + ); assert_eq!(items[1].link, "https://ex.com/b"); } @@ -408,7 +425,10 @@ mod tests { #[test] fn feed_to_samples_emits_json_list_and_count() { - let items = vec![FeedItem { title: "X".into(), link: "u".into() }]; + let items = vec![FeedItem { + title: "X".into(), + link: "u".into(), + }]; let s = feed_to_samples(&items, 5); assert_eq!(s[0].sensor, "rss.list"); assert!(matches!(s[0].value, SensorValue::Json(_))); @@ -420,7 +440,12 @@ mod tests { #[test] fn has_feed_requires_an_http_url() { - let mk = |u: &str| RssConfig { url: u.into(), count: 8, title: String::new(), poll_interval_secs: 900 }; + let mk = |u: &str| RssConfig { + url: u.into(), + count: 8, + title: String::new(), + poll_interval_secs: 900, + }; assert!(has_feed(&mk("https://example.com/feed.xml"))); assert!(has_feed(&mk("http://lan.local/rss"))); assert!(!has_feed(&mk(""))); diff --git a/widgetsack/src/sensors.rs b/widgetsack/src/sensors.rs index ad5edc6..c5b1836 100644 --- a/widgetsack/src/sensors.rs +++ b/widgetsack/src/sensors.rs @@ -41,7 +41,10 @@ use std::path::Path; use std::sync::Mutex; use std::time::{Duration, SystemTime, UNIX_EPOCH}; -use nvml_wrapper::{enum_wrappers::device::{Clock, TemperatureSensor}, Nvml}; +use nvml_wrapper::{ + Nvml, + enum_wrappers::device::{Clock, TemperatureSensor}, +}; use serde::Serialize; use sysinfo::{Disks, Networks, ProcessesToUpdate, System}; use tauri::{AppHandle, Emitter, Manager, Runtime}; @@ -171,7 +174,9 @@ fn name_matches(proc_name: &str, watched: &str) -> bool { /// Flatten the latest-value map to a `{ id: number|string }` JSON object — Scalar and Text only /// (Series/Json are dropped; not useful in a flat snapshot). Pure seam for the MCP live-state file. -fn flatten_latest(latest: &HashMap) -> serde_json::Map { +fn flatten_latest( + latest: &HashMap, +) -> serde_json::Map { let mut out = serde_json::Map::new(); for (id, v) in latest { match v { @@ -301,19 +306,38 @@ fn perf_info_samples(ts: u64) -> Vec { use windows::Win32::System::ProcessStatus::{GetPerformanceInfo, PERFORMANCE_INFORMATION}; let cb = std::mem::size_of::() as u32; - let mut pi = PERFORMANCE_INFORMATION { cb, ..Default::default() }; + let mut pi = PERFORMANCE_INFORMATION { + cb, + ..Default::default() + }; // SAFETY: pi is a valid owned struct and cb is its byte size; GetPerformanceInfo fills it. if unsafe { GetPerformanceInfo(&mut pi, cb) }.is_err() { return Vec::new(); } let page = pi.PageSize; vec![ - SensorSample::scalar("mem.commit.used", ts, bytes_from_pages(pi.CommitTotal, page)), - SensorSample::scalar("mem.commit.limit", ts, bytes_from_pages(pi.CommitLimit, page)), + SensorSample::scalar( + "mem.commit.used", + ts, + bytes_from_pages(pi.CommitTotal, page), + ), + SensorSample::scalar( + "mem.commit.limit", + ts, + bytes_from_pages(pi.CommitLimit, page), + ), SensorSample::scalar("mem.commit.peak", ts, bytes_from_pages(pi.CommitPeak, page)), SensorSample::scalar("mem.cached", ts, bytes_from_pages(pi.SystemCache, page)), - SensorSample::scalar("mem.kernel.paged", ts, bytes_from_pages(pi.KernelPaged, page)), - SensorSample::scalar("mem.kernel.nonpaged", ts, bytes_from_pages(pi.KernelNonpaged, page)), + SensorSample::scalar( + "mem.kernel.paged", + ts, + bytes_from_pages(pi.KernelPaged, page), + ), + SensorSample::scalar( + "mem.kernel.nonpaged", + ts, + bytes_from_pages(pi.KernelNonpaged, page), + ), SensorSample::scalar("host.handles", ts, f64::from(pi.HandleCount)), SensorSample::scalar("host.threads", ts, f64::from(pi.ThreadCount)), ] @@ -333,7 +357,7 @@ fn perf_info_samples(_ts: u64) -> Vec { #[cfg(target_os = "windows")] fn cpu_freq_samples(ts: u64, logical_cores: usize) -> Vec { use windows::Win32::System::Power::{ - CallNtPowerInformation, ProcessorInformation, PROCESSOR_POWER_INFORMATION, + CallNtPowerInformation, PROCESSOR_POWER_INFORMATION, ProcessorInformation, }; if logical_cores == 0 { @@ -367,8 +391,16 @@ fn cpu_freq_samples(ts: u64, logical_cores: usize) -> Vec { current_max = current_max.max(p.CurrentMhz); rated_max = rated_max.max(p.MaxMhz); } - out.push(SensorSample::scalar("cpu.freq.current", ts, f64::from(current_max))); - out.push(SensorSample::scalar("cpu.freq.max", ts, f64::from(rated_max))); + out.push(SensorSample::scalar( + "cpu.freq.current", + ts, + f64::from(current_max), + )); + out.push(SensorSample::scalar( + "cpu.freq.max", + ts, + f64::from(rated_max), + )); out } @@ -461,14 +493,14 @@ fn utf16_to_string(buf: &[u16]) -> String { /// volume handle is opened with zero access rights (no admin needed). `None` on any failure. #[cfg(target_os = "windows")] fn read_disk_io(letter: &str) -> Option { - use windows::core::PCWSTR; use windows::Win32::Foundation::CloseHandle; use windows::Win32::Storage::FileSystem::{ CreateFileW, FILE_FLAGS_AND_ATTRIBUTES, FILE_SHARE_MODE, FILE_SHARE_READ, FILE_SHARE_WRITE, OPEN_EXISTING, }; - use windows::Win32::System::Ioctl::{DISK_PERFORMANCE, IOCTL_DISK_PERFORMANCE}; use windows::Win32::System::IO::DeviceIoControl; + use windows::Win32::System::Ioctl::{DISK_PERFORMANCE, IOCTL_DISK_PERFORMANCE}; + use windows::core::PCWSTR; let path: Vec = format!("\\\\.\\{}:", letter.to_uppercase()) .encode_utf16() @@ -527,7 +559,7 @@ fn read_disk_io(_letter: &str) -> Option { #[cfg(target_os = "windows")] fn battery_power_samples(ts: u64) -> Vec { use windows::Win32::System::Power::{ - CallNtPowerInformation, SystemBatteryState, SYSTEM_BATTERY_STATE, + CallNtPowerInformation, SYSTEM_BATTERY_STATE, SystemBatteryState, }; // SAFETY: a zeroed POD output buffer, filled by CallNtPowerInformation. @@ -548,10 +580,18 @@ fn battery_power_samples(ts: u64) -> Vec { // Rate is mW, signed (reinterpret the u32 as i32); 0x8000_0000 is the "unknown" sentinel. let rate = sbs.Rate as i32; if rate != i32::MIN { - out.push(SensorSample::scalar("battery.rate", ts, f64::from(rate) / 1000.0)); + out.push(SensorSample::scalar( + "battery.rate", + ts, + f64::from(rate) / 1000.0, + )); } if sbs.MaxCapacity != u32::MAX { - out.push(SensorSample::scalar("battery.capacity.full", ts, f64::from(sbs.MaxCapacity) / 1000.0)); + out.push(SensorSample::scalar( + "battery.capacity.full", + ts, + f64::from(sbs.MaxCapacity) / 1000.0, + )); } if sbs.RemainingCapacity != u32::MAX { out.push(SensorSample::scalar( @@ -600,11 +640,23 @@ fn net_link_samples(ts: u64) -> Vec { let mut out = vec![SensorSample::text( "net.state", ts, - if any_connected { "connected" } else { "disconnected" }, + if any_connected { + "connected" + } else { + "disconnected" + }, )]; if let Some(r) = best { - out.push(SensorSample::scalar("net.linkspeed.rx", ts, (r.ReceiveLinkSpeed / 8) as f64)); - out.push(SensorSample::scalar("net.linkspeed.tx", ts, (r.TransmitLinkSpeed / 8) as f64)); + out.push(SensorSample::scalar( + "net.linkspeed.rx", + ts, + (r.ReceiveLinkSpeed / 8) as f64, + )); + out.push(SensorSample::scalar( + "net.linkspeed.tx", + ts, + (r.TransmitLinkSpeed / 8) as f64, + )); let name = utf16_to_string(&r.Alias); if !name.is_empty() { out.push(SensorSample::text("net.adapter", ts, name)); @@ -648,7 +700,10 @@ pub async fn set_active_sensors( /// counts when the union of every window's reported set is EMPTY (nobody has reported yet, so don't /// blank sensors at startup), OR any window asked for everything (`"*"`), OR any window's id /// satisfies `pred`. -pub(crate) fn any_wanted(active: &HashMap>, pred: impl Fn(&str) -> bool) -> bool { +pub(crate) fn any_wanted( + active: &HashMap>, + pred: impl Fn(&str) -> bool, +) -> bool { if active.values().all(|ids| ids.is_empty()) { return true; } @@ -824,14 +879,32 @@ pub async fn run_system_sensors(app: AppHandle) { } for (i, cpu) in sys.cpus().iter().enumerate() { - batch.push(SensorSample::scalar(core_sensor_id(i), ts, f64::from(cpu.cpu_usage()))); + batch.push(SensorSample::scalar( + core_sensor_id(i), + ts, + f64::from(cpu.cpu_usage()), + )); } // Compute all demand-gates under ONE brief lock, then DROP it before any expensive I/O // (NVML, process enumeration, disk refresh, frequency refresh) — the std Mutex must never be // held across an await or a blocking driver call. #[allow(clippy::type_complexity)] - let (want_gpu, want_disks, want_disk_io, want_procs, proc_w, proc_watch, want_freq, want_perf, want_cpufreq, want_netlink, want_conns, want_wifi, want_recyclebin) = { + let ( + want_gpu, + want_disks, + want_disk_io, + want_procs, + proc_w, + proc_watch, + want_freq, + want_perf, + want_cpufreq, + want_netlink, + want_conns, + want_wifi, + want_recyclebin, + ) = { let active: tauri::State = app.state(); let g = active.0.lock().unwrap_or_else(|e| e.into_inner()); let pw = |p: &str| any_wanted(&g, |id| id.starts_with(p)); @@ -898,10 +971,22 @@ pub async fn run_system_sensors(app: AppHandle) { ts, if count > 0 { 1.0 } else { 0.0 }, )); - batch.push(SensorSample::scalar(format!("proc.watch.{watched}.count"), ts, count as f64)); + batch.push(SensorSample::scalar( + format!("proc.watch.{watched}.count"), + ts, + count as f64, + )); if count > 0 { - batch.push(SensorSample::scalar(format!("proc.watch.{watched}.cpu"), ts, cpu)); - batch.push(SensorSample::scalar(format!("proc.watch.{watched}.mem"), ts, mem)); + batch.push(SensorSample::scalar( + format!("proc.watch.{watched}.cpu"), + ts, + cpu, + )); + batch.push(SensorSample::scalar( + format!("proc.watch.{watched}.mem"), + ts, + mem, + )); } } @@ -966,9 +1051,21 @@ pub async fn run_system_sensors(app: AppHandle) { let total = disk.total_space(); let avail = disk.available_space(); let used = total.saturating_sub(avail); - batch.push(SensorSample::scalar(format!("disk.{letter}.total"), ts, total as f64)); - batch.push(SensorSample::scalar(format!("disk.{letter}.free"), ts, avail as f64)); - batch.push(SensorSample::scalar(format!("disk.{letter}.used"), ts, used as f64)); + batch.push(SensorSample::scalar( + format!("disk.{letter}.total"), + ts, + total as f64, + )); + batch.push(SensorSample::scalar( + format!("disk.{letter}.free"), + ts, + avail as f64, + )); + batch.push(SensorSample::scalar( + format!("disk.{letter}.used"), + ts, + used as f64, + )); batch.push(SensorSample::scalar( format!("disk.{letter}.used.pct"), ts, @@ -989,10 +1086,18 @@ pub async fn run_system_sensors(app: AppHandle) { let _t = timings.start("sensors.gpu"); if let Ok(util) = device.utilization_rates() { batch.push(SensorSample::scalar("gpu.util", ts, f64::from(util.gpu))); - batch.push(SensorSample::scalar("gpu.mem.util", ts, f64::from(util.memory))); + batch.push(SensorSample::scalar( + "gpu.mem.util", + ts, + f64::from(util.memory), + )); } if let Ok(mem) = device.memory_info() { - batch.push(SensorSample::scalar("gpu.vram", ts, percent(mem.used, mem.total))); + batch.push(SensorSample::scalar( + "gpu.vram", + ts, + percent(mem.used, mem.total), + )); batch.push(SensorSample::scalar("gpu.vram.total", ts, mem.total as f64)); batch.push(SensorSample::scalar("gpu.vram.used", ts, mem.used as f64)); batch.push(SensorSample::scalar("gpu.vram.free", ts, mem.free as f64)); @@ -1008,10 +1113,18 @@ pub async fn run_system_sensors(app: AppHandle) { } // NVML reports power in milliwatts; emit watts. NotSupported on some boards → skip. if let Ok(mw) = device.power_usage() { - batch.push(SensorSample::scalar("gpu.power", ts, f64::from(mw) / 1000.0)); + batch.push(SensorSample::scalar( + "gpu.power", + ts, + f64::from(mw) / 1000.0, + )); } if let Ok(mw) = device.enforced_power_limit() { - batch.push(SensorSample::scalar("gpu.power.limit", ts, f64::from(mw) / 1000.0)); + batch.push(SensorSample::scalar( + "gpu.power.limit", + ts, + f64::from(mw) / 1000.0, + )); } // fan_speed is a driver setpoint percent; frequently NotSupported on laptop GPUs. if let Ok(pct) = device.fan_speed(0) { @@ -1209,8 +1322,18 @@ mod tests { #[test] fn disk_io_samples_derive_rates_and_busy() { // query delta = 10_000_000 ×100ns = 1.0s; idle delta = 4_000_000 ×100ns = 0.4s. - let prev = DiskIo { idle: 0, query: 0, read: 0, written: 0 }; - let cur = DiskIo { idle: 4_000_000, query: 10_000_000, read: 2048, written: 1024 }; + let prev = DiskIo { + idle: 0, + query: 0, + read: 0, + written: 0, + }; + let cur = DiskIo { + idle: 4_000_000, + query: 10_000_000, + read: 2048, + written: 1024, + }; let s = disk_io_samples_for("c", 1, prev, cur); assert_eq!(s[0].sensor, "disk.c.busy.pct"); assert_eq!(s[1].sensor, "disk.c.read"); @@ -1225,8 +1348,18 @@ mod tests { // After a demand-gate gap the query delta widens with real time, so the byte delta is spread // over the true elapsed and the rate stays sane — never an N× spike. 5120 B over a 5s gap. - let gap_prev = DiskIo { idle: 0, query: 0, read: 0, written: 0 }; - let gap_cur = DiskIo { idle: 0, query: 50_000_000, read: 5120, written: 0 }; + let gap_prev = DiskIo { + idle: 0, + query: 0, + read: 0, + written: 0, + }; + let gap_cur = DiskIo { + idle: 0, + query: 50_000_000, + read: 5120, + written: 0, + }; let g = disk_io_samples_for("c", 1, gap_prev, gap_cur); assert_eq!(val(&g[1].value), 1024.0); // 5120 B / 5s, not 5120 B/s } @@ -1276,7 +1409,10 @@ mod tests { entries .iter() .map(|(label, ids)| { - (label.to_string(), ids.iter().map(|s| s.to_string()).collect()) + ( + label.to_string(), + ids.iter().map(|s| s.to_string()).collect(), + ) }) .collect() } @@ -1341,22 +1477,34 @@ mod tests { latest.insert("cpu.series".into(), SensorValue::Series(vec![1.0, 2.0])); let flat = flatten_latest(&latest); assert_eq!(flat.get("cpu.total"), Some(&serde_json::json!(42.0))); - assert_eq!(flat.get("net.adapter"), Some(&serde_json::json!("Ethernet"))); + assert_eq!( + flat.get("net.adapter"), + Some(&serde_json::json!("Ethernet")) + ); assert!(!flat.contains_key("cpu.series")); // Series dropped } #[test] fn proc_watch_helpers_parse_and_match() { // Name extraction tolerates dots in the process name + every suffix. - assert_eq!(proc_watch_name_of("proc.watch.chrome.exe.running"), Some("chrome.exe")); + assert_eq!( + proc_watch_name_of("proc.watch.chrome.exe.running"), + Some("chrome.exe") + ); assert_eq!(proc_watch_name_of("proc.watch.obs64.cpu"), Some("obs64")); assert_eq!(proc_watch_name_of("proc.watch.steam.mem"), Some("steam")); assert_eq!(proc_watch_name_of("proc.watch..running"), None); assert_eq!(proc_watch_name_of("proc.cpu.top.name"), None); let a = active(&[ - ("main", &["proc.watch.chrome.exe.running", "proc.watch.chrome.exe.cpu"]), - ("overlay-1", &["proc.watch.Spotify.exe.running", "cpu.total"]), + ( + "main", + &["proc.watch.chrome.exe.running", "proc.watch.chrome.exe.cpu"], + ), + ( + "overlay-1", + &["proc.watch.Spotify.exe.running", "cpu.total"], + ), ]); assert_eq!(proc_watch_names(&a), vec!["Spotify.exe", "chrome.exe"]); diff --git a/widgetsack/src/state.rs b/widgetsack/src/state.rs index de3ad9b..37d7f03 100644 --- a/widgetsack/src/state.rs +++ b/widgetsack/src/state.rs @@ -1,274 +1,283 @@ -use std::{collections::HashMap, time::SystemTime}; - -use serde::Serialize; - -use crate::bridge::{SESSION_CREATE_EVENT, SESSION_DELETE_EVENT, SESSION_UPDATE_EVENT}; -use crate::{event::NpSessionEvent, log, ManagerEventWrapper, SessionUpdateEventWrapper}; - -#[derive(Debug, Clone, Serialize)] -pub struct SessionRecord { - pub session_id: usize, - pub source: Option, - pub timestamp_created: Option, - pub timestamp_updated: Option, - pub last_media_update: Option, - pub last_model_update: Option, -} - -pub fn updater( - sessions: &mut HashMap, - event: NpSessionEvent, -) -> (&str, Option) { - match event { - NpSessionEvent::Create( - _session_id_dupe, - ManagerEventWrapper::SessionCreated { session_id, source }, - ) => { - let new_record = SessionRecord { - session_id, - source: Some(source), - timestamp_created: Some(SystemTime::now()), - timestamp_updated: None, - last_media_update: None, - last_model_update: None, - }; - let _ = (*sessions).insert(session_id, new_record.clone()); - (SESSION_CREATE_EVENT, Some(new_record)) - } - NpSessionEvent::Create(_session_id_dupe, ev) => { - log::warn("session", "unexpected manager event for Create") - .field("event", format!("{ev:?}")) - .emit(); - (SESSION_CREATE_EVENT, None) - } - NpSessionEvent::Update(session_id, ev) => { - // Model/timeline (play/pause/seek) updates carry NO new album art — capture that before `ev` - // is consumed so the emit below can strip the (unchanged) cover bytes rather than re-shipping - // hundreds of KB over the IPC bridge on every tick. - let is_model = matches!(ev, SessionUpdateEventWrapper::Model(_)); - let maybe_existing = (*sessions).get(&session_id); - // TODO: create np-widget-specific models for sessions and map gsmtc to it - - let updated_record = if let Some(existing) = maybe_existing { - let mut record_mut = SessionRecord { - session_id: existing.session_id, - source: existing.source.clone(), - timestamp_created: existing.timestamp_created, - timestamp_updated: Some(SystemTime::now()), - // Check if this can be CoW? - last_media_update: existing.last_media_update.clone(), - last_model_update: existing.last_model_update.clone(), - }; - - match ev { - SessionUpdateEventWrapper::Model(_) => { - record_mut.last_model_update = Some(ev); - } - SessionUpdateEventWrapper::Media(_, _) => { - record_mut.last_media_update = Some(ev); - } - } - - record_mut - } else { - let updated_ev: SessionUpdateEventWrapper = ev; - SessionRecord { - session_id, - source: None, - timestamp_created: Some(SystemTime::now()), - timestamp_updated: Some(SystemTime::now()), - last_media_update: match updated_ev { - SessionUpdateEventWrapper::Model(_) => None, - // FIXME: awful clone here - SessionUpdateEventWrapper::Media(_, _) => Some(updated_ev.clone()), - }, - last_model_update: match updated_ev { - SessionUpdateEventWrapper::Model(_) => Some(updated_ev), - SessionUpdateEventWrapper::Media(_, _) => None, - }, - } - }; - - let _ = (*sessions).insert(session_id, updated_record.clone()); - // The stored record (above) keeps the art; the EMITTED one drops it on a model/timeline - // update so we don't re-serialise + re-send the cover bytes to every webview each tick. The - // frontend carries the previous cover forward by session_id (see stores.ts mergeMediaForward). - // A media update keeps its art so a new cover still reaches the overlay. - let emitted = if is_model { - SessionRecord { - last_media_update: None, - ..updated_record - } - } else { - updated_record - }; - (SESSION_UPDATE_EVENT, Some(emitted)) - } - NpSessionEvent::Delete(session_id, _ev) => { - let maybe_deleted_record = (*sessions).remove(&session_id); - - if let Some(deleted_record) = maybe_deleted_record { - (SESSION_DELETE_EVENT, Some(deleted_record)) - } else { - (SESSION_DELETE_EVENT, None) - } - } - NpSessionEvent::Unsupported(session_id, label) => { - log::debug("gsmtc", "unsupported event") - .field("label", &label) - .field("session_id", format!("{session_id:?}")) - .emit(); - ("unsupported", None) - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - use gsmtc::SessionModel; - - /// A minimal `SessionModel` for building `SessionUpdateEventWrapper` events without any media - /// hardware. Only `source` is meaningful here; the rest are absent (None/empty). - fn model(source: &str) -> SessionModel { - SessionModel { - playback: None, - timeline: None, - media: None, - source: source.to_string(), - } - } - - #[test] - fn create_yields_session_create_with_record_and_inserts() { - let mut sessions = HashMap::new(); - let ev = NpSessionEvent::Create( - 7, - ManagerEventWrapper::SessionCreated { - session_id: 7, - source: "fooplayer".to_string(), - }, - ); - let (kind, delta) = updater(&mut sessions, ev); - assert_eq!(kind, "session_create"); - let record = delta.expect("create should yield a record"); - assert_eq!(record.session_id, 7); - assert_eq!(record.source.as_deref(), Some("fooplayer")); - // The session is now tracked. - assert!(sessions.contains_key(&7)); - } - - #[test] - fn update_after_create_yields_session_update_and_keeps_source() { - let mut sessions = HashMap::new(); - let _ = updater( - &mut sessions, - NpSessionEvent::Create( - 7, - ManagerEventWrapper::SessionCreated { - session_id: 7, - source: "fooplayer".to_string(), - }, - ), - ); - - let ev = NpSessionEvent::Update(7, SessionUpdateEventWrapper::Model(model("fooplayer"))); - let (kind, delta) = updater(&mut sessions, ev); - assert_eq!(kind, "session_update"); - let record = delta.expect("update should yield a record"); - assert_eq!(record.session_id, 7); - // Source carried over from the create; a model update populates last_model_update. - assert_eq!(record.source.as_deref(), Some("fooplayer")); - assert!(record.last_model_update.is_some()); - assert!(record.timestamp_updated.is_some()); - } - - #[test] - fn model_update_strips_album_art_from_emitted_record_but_keeps_it_stored() { - use crate::listener::ImageWrapper; - use std::sync::Arc; - let mut sessions = HashMap::new(); - let _ = updater( - &mut sessions, - NpSessionEvent::Create( - 7, - ManagerEventWrapper::SessionCreated { - session_id: 7, - source: "p".to_string(), - }, - ), - ); - - // A media update lands cover art on the session — and DOES carry it to the bridge. - let media_ev = SessionUpdateEventWrapper::Media( - model("p"), - Some(Arc::new(ImageWrapper::new( - "image/png".to_string(), - vec![1, 2, 3, 4], - ))), - ); - let (_, media_delta) = updater(&mut sessions, NpSessionEvent::Update(7, media_ev)); - assert!( - media_delta.expect("media update yields a record").last_media_update.is_some(), - "a media update must ship its album art" - ); - - // A following model (play/pause/seek) update must NOT re-ship the art on the emitted record… - let (kind, model_delta) = updater( - &mut sessions, - NpSessionEvent::Update(7, SessionUpdateEventWrapper::Model(model("p"))), - ); - assert_eq!(kind, "session_update"); - assert!( - model_delta.expect("model update yields a record").last_media_update.is_none(), - "an emitted model/timeline update must not re-ship the (unchanged) album art" - ); - // …but the STORED record keeps it, so get_initial_sessions / the frontend retain the cover. - assert!( - sessions.get(&7).unwrap().last_media_update.is_some(), - "the stored record must keep the art for later reads" - ); - } - - #[test] - fn delete_yields_session_delete_with_record_and_removes() { - let mut sessions = HashMap::new(); - let _ = updater( - &mut sessions, - NpSessionEvent::Create( - 7, - ManagerEventWrapper::SessionCreated { - session_id: 7, - source: "fooplayer".to_string(), - }, - ), - ); - - let ev = NpSessionEvent::Delete(7, ManagerEventWrapper::SessionRemoved { session_id: 7 }); - let (kind, delta) = updater(&mut sessions, ev); - assert_eq!(kind, "session_delete"); - assert!(delta.is_some(), "deleting a tracked session returns its record"); - // …and it is gone from the map afterwards. - assert!(!sessions.contains_key(&7)); - } - - #[test] - fn delete_unknown_session_yields_session_delete_without_record() { - let mut sessions = HashMap::new(); - let ev = NpSessionEvent::Delete(42, ManagerEventWrapper::SessionRemoved { session_id: 42 }); - let (kind, delta) = updater(&mut sessions, ev); - assert_eq!(kind, "session_delete"); - assert!(delta.is_none()); - } - - #[test] - fn unsupported_event_yields_unsupported_without_record() { - let mut sessions = HashMap::new(); - let ev = NpSessionEvent::Unsupported(Some(3), "CurrentSessionChanged".to_string()); - let (kind, delta) = updater(&mut sessions, ev); - assert_eq!(kind, "unsupported"); - assert!(delta.is_none()); - // An unsupported event must not register a session. - assert!(sessions.is_empty()); - } -} +use std::{collections::HashMap, time::SystemTime}; + +use serde::Serialize; + +use crate::bridge::{SESSION_CREATE_EVENT, SESSION_DELETE_EVENT, SESSION_UPDATE_EVENT}; +use crate::{ManagerEventWrapper, SessionUpdateEventWrapper, event::NpSessionEvent, log}; + +#[derive(Debug, Clone, Serialize)] +pub struct SessionRecord { + pub session_id: usize, + pub source: Option, + pub timestamp_created: Option, + pub timestamp_updated: Option, + pub last_media_update: Option, + pub last_model_update: Option, +} + +pub fn updater( + sessions: &mut HashMap, + event: NpSessionEvent, +) -> (&str, Option) { + match event { + NpSessionEvent::Create( + _session_id_dupe, + ManagerEventWrapper::SessionCreated { session_id, source }, + ) => { + let new_record = SessionRecord { + session_id, + source: Some(source), + timestamp_created: Some(SystemTime::now()), + timestamp_updated: None, + last_media_update: None, + last_model_update: None, + }; + let _ = (*sessions).insert(session_id, new_record.clone()); + (SESSION_CREATE_EVENT, Some(new_record)) + } + NpSessionEvent::Create(_session_id_dupe, ev) => { + log::warn("session", "unexpected manager event for Create") + .field("event", format!("{ev:?}")) + .emit(); + (SESSION_CREATE_EVENT, None) + } + NpSessionEvent::Update(session_id, ev) => { + // Model/timeline (play/pause/seek) updates carry NO new album art — capture that before `ev` + // is consumed so the emit below can strip the (unchanged) cover bytes rather than re-shipping + // hundreds of KB over the IPC bridge on every tick. + let is_model = matches!(ev, SessionUpdateEventWrapper::Model(_)); + let maybe_existing = (*sessions).get(&session_id); + // TODO: create np-widget-specific models for sessions and map gsmtc to it + + let updated_record = if let Some(existing) = maybe_existing { + let mut record_mut = SessionRecord { + session_id: existing.session_id, + source: existing.source.clone(), + timestamp_created: existing.timestamp_created, + timestamp_updated: Some(SystemTime::now()), + // Check if this can be CoW? + last_media_update: existing.last_media_update.clone(), + last_model_update: existing.last_model_update.clone(), + }; + + match ev { + SessionUpdateEventWrapper::Model(_) => { + record_mut.last_model_update = Some(ev); + } + SessionUpdateEventWrapper::Media(_, _) => { + record_mut.last_media_update = Some(ev); + } + } + + record_mut + } else { + let updated_ev: SessionUpdateEventWrapper = ev; + SessionRecord { + session_id, + source: None, + timestamp_created: Some(SystemTime::now()), + timestamp_updated: Some(SystemTime::now()), + last_media_update: match updated_ev { + SessionUpdateEventWrapper::Model(_) => None, + // FIXME: awful clone here + SessionUpdateEventWrapper::Media(_, _) => Some(updated_ev.clone()), + }, + last_model_update: match updated_ev { + SessionUpdateEventWrapper::Model(_) => Some(updated_ev), + SessionUpdateEventWrapper::Media(_, _) => None, + }, + } + }; + + let _ = (*sessions).insert(session_id, updated_record.clone()); + // The stored record (above) keeps the art; the EMITTED one drops it on a model/timeline + // update so we don't re-serialise + re-send the cover bytes to every webview each tick. The + // frontend carries the previous cover forward by session_id (see stores.ts mergeMediaForward). + // A media update keeps its art so a new cover still reaches the overlay. + let emitted = if is_model { + SessionRecord { + last_media_update: None, + ..updated_record + } + } else { + updated_record + }; + (SESSION_UPDATE_EVENT, Some(emitted)) + } + NpSessionEvent::Delete(session_id, _ev) => { + let maybe_deleted_record = (*sessions).remove(&session_id); + + if let Some(deleted_record) = maybe_deleted_record { + (SESSION_DELETE_EVENT, Some(deleted_record)) + } else { + (SESSION_DELETE_EVENT, None) + } + } + NpSessionEvent::Unsupported(session_id, label) => { + log::debug("gsmtc", "unsupported event") + .field("label", &label) + .field("session_id", format!("{session_id:?}")) + .emit(); + ("unsupported", None) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use gsmtc::SessionModel; + + /// A minimal `SessionModel` for building `SessionUpdateEventWrapper` events without any media + /// hardware. Only `source` is meaningful here; the rest are absent (None/empty). + fn model(source: &str) -> SessionModel { + SessionModel { + playback: None, + timeline: None, + media: None, + source: source.to_string(), + } + } + + #[test] + fn create_yields_session_create_with_record_and_inserts() { + let mut sessions = HashMap::new(); + let ev = NpSessionEvent::Create( + 7, + ManagerEventWrapper::SessionCreated { + session_id: 7, + source: "fooplayer".to_string(), + }, + ); + let (kind, delta) = updater(&mut sessions, ev); + assert_eq!(kind, "session_create"); + let record = delta.expect("create should yield a record"); + assert_eq!(record.session_id, 7); + assert_eq!(record.source.as_deref(), Some("fooplayer")); + // The session is now tracked. + assert!(sessions.contains_key(&7)); + } + + #[test] + fn update_after_create_yields_session_update_and_keeps_source() { + let mut sessions = HashMap::new(); + let _ = updater( + &mut sessions, + NpSessionEvent::Create( + 7, + ManagerEventWrapper::SessionCreated { + session_id: 7, + source: "fooplayer".to_string(), + }, + ), + ); + + let ev = NpSessionEvent::Update(7, SessionUpdateEventWrapper::Model(model("fooplayer"))); + let (kind, delta) = updater(&mut sessions, ev); + assert_eq!(kind, "session_update"); + let record = delta.expect("update should yield a record"); + assert_eq!(record.session_id, 7); + // Source carried over from the create; a model update populates last_model_update. + assert_eq!(record.source.as_deref(), Some("fooplayer")); + assert!(record.last_model_update.is_some()); + assert!(record.timestamp_updated.is_some()); + } + + #[test] + fn model_update_strips_album_art_from_emitted_record_but_keeps_it_stored() { + use crate::listener::ImageWrapper; + use std::sync::Arc; + let mut sessions = HashMap::new(); + let _ = updater( + &mut sessions, + NpSessionEvent::Create( + 7, + ManagerEventWrapper::SessionCreated { + session_id: 7, + source: "p".to_string(), + }, + ), + ); + + // A media update lands cover art on the session — and DOES carry it to the bridge. + let media_ev = SessionUpdateEventWrapper::Media( + model("p"), + Some(Arc::new(ImageWrapper::new( + "image/png".to_string(), + vec![1, 2, 3, 4], + ))), + ); + let (_, media_delta) = updater(&mut sessions, NpSessionEvent::Update(7, media_ev)); + assert!( + media_delta + .expect("media update yields a record") + .last_media_update + .is_some(), + "a media update must ship its album art" + ); + + // A following model (play/pause/seek) update must NOT re-ship the art on the emitted record… + let (kind, model_delta) = updater( + &mut sessions, + NpSessionEvent::Update(7, SessionUpdateEventWrapper::Model(model("p"))), + ); + assert_eq!(kind, "session_update"); + assert!( + model_delta + .expect("model update yields a record") + .last_media_update + .is_none(), + "an emitted model/timeline update must not re-ship the (unchanged) album art" + ); + // …but the STORED record keeps it, so get_initial_sessions / the frontend retain the cover. + assert!( + sessions.get(&7).unwrap().last_media_update.is_some(), + "the stored record must keep the art for later reads" + ); + } + + #[test] + fn delete_yields_session_delete_with_record_and_removes() { + let mut sessions = HashMap::new(); + let _ = updater( + &mut sessions, + NpSessionEvent::Create( + 7, + ManagerEventWrapper::SessionCreated { + session_id: 7, + source: "fooplayer".to_string(), + }, + ), + ); + + let ev = NpSessionEvent::Delete(7, ManagerEventWrapper::SessionRemoved { session_id: 7 }); + let (kind, delta) = updater(&mut sessions, ev); + assert_eq!(kind, "session_delete"); + assert!( + delta.is_some(), + "deleting a tracked session returns its record" + ); + // …and it is gone from the map afterwards. + assert!(!sessions.contains_key(&7)); + } + + #[test] + fn delete_unknown_session_yields_session_delete_without_record() { + let mut sessions = HashMap::new(); + let ev = NpSessionEvent::Delete(42, ManagerEventWrapper::SessionRemoved { session_id: 42 }); + let (kind, delta) = updater(&mut sessions, ev); + assert_eq!(kind, "session_delete"); + assert!(delta.is_none()); + } + + #[test] + fn unsupported_event_yields_unsupported_without_record() { + let mut sessions = HashMap::new(); + let ev = NpSessionEvent::Unsupported(Some(3), "CurrentSessionChanged".to_string()); + let (kind, delta) = updater(&mut sessions, ev); + assert_eq!(kind, "unsupported"); + assert!(delta.is_none()); + // An unsupported event must not register a session. + assert!(sessions.is_empty()); + } +} diff --git a/widgetsack/src/stocks.rs b/widgetsack/src/stocks.rs index 1cfa3f3..66f02d4 100644 --- a/widgetsack/src/stocks.rs +++ b/widgetsack/src/stocks.rs @@ -84,7 +84,9 @@ fn stocks_config_path(app: &AppHandle) -> Result pub fn load_stocks_config(app: &AppHandle) -> Result, String> { let path = stocks_config_path(app)?; match std::fs::read_to_string(&path) { - Ok(txt) => serde_json::from_str(&txt).map(Some).map_err(|e| e.to_string()), + Ok(txt) => serde_json::from_str(&txt) + .map(Some) + .map_err(|e| e.to_string()), Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(None), Err(err) => Err(err.to_string()), } @@ -96,7 +98,9 @@ pub fn load_stocks_config(app: &AppHandle) -> Result String { let enc = symbol.trim().to_uppercase().replace('^', "%5E"); - format!("https://query1.finance.yahoo.com/v8/finance/chart/{enc}?range={range}&interval={interval}") + format!( + "https://query1.finance.yahoo.com/v8/finance/chart/{enc}?range={range}&interval={interval}" + ) } /// Percent change of `price` vs `prev_close`, or `None` when prev is missing / zero (avoids a /0 and a @@ -144,7 +148,11 @@ fn quote_to_samples(symbol: &str, chart: &Value, ts_ms: u64) -> Vec Vec.` id /// apart from the `stocks.status` sentinel when reverse-deriving which symbols are in demand. const SYMBOL_FIELDS: &[&str] = &[ - "price", "change", "changeAbs", "prevClose", "currency", "state", "series", + "price", + "change", + "changeAbs", + "prevClose", + "currency", + "state", + "series", ]; /// Reverse-derive the uppercased stock symbols implied by a set of active sensor ids. An id shaped @@ -248,7 +262,12 @@ fn stocks_wanted(app: &AppHandle) -> bool { fn active_stock_symbols(app: &AppHandle) -> Vec { let active: State = app.state(); let guard = active.0.lock().unwrap_or_else(|e| e.into_inner()); - symbols_from_active(guard.values().flat_map(|set| set.iter()).map(String::as_str)) + symbols_from_active( + guard + .values() + .flat_map(|set| set.iter()) + .map(String::as_str), + ) } // ---- connection / poll task ---- @@ -302,7 +321,10 @@ pub async fn run_stocks_client(app: AppHandle, cfg: StocksConfig) .filter(|s| !s.is_empty()) .collect(); if cfg.provider != "yahoo" { - eprintln!("stocks: provider '{}' is not implemented; using yahoo", cfg.provider); + eprintln!( + "stocks: provider '{}' is not implemented; using yahoo", + cfg.provider + ); } // `idle` re-emits a fresh "connecting" when a ticker (re)mounts after an idle gap; `fails` drives @@ -503,15 +525,16 @@ mod tests { assert_eq!(pv["value"]["value"], 110.0); let change = find(&s, "stocks.AAPL.change").unwrap(); - assert_eq!(serde_json::to_value(change).unwrap()["value"]["value"], 10.0); assert_eq!( - serde_json::to_value(find(&s, "stocks.AAPL.changeAbs").unwrap()).unwrap()["value"] - ["value"], + serde_json::to_value(change).unwrap()["value"]["value"], + 10.0 + ); + assert_eq!( + serde_json::to_value(find(&s, "stocks.AAPL.changeAbs").unwrap()).unwrap()["value"]["value"], 10.0 ); assert_eq!( - serde_json::to_value(find(&s, "stocks.AAPL.currency").unwrap()).unwrap()["value"] - ["value"], + serde_json::to_value(find(&s, "stocks.AAPL.currency").unwrap()).unwrap()["value"]["value"], "USD" ); assert_eq!( @@ -548,7 +571,7 @@ mod tests { fn symbols_from_active_extracts_symbols_and_skips_sentinels() { let ids = [ "stocks.NVDA.price", - "stocks.NVDA.change", // duplicate symbol — deduped + "stocks.NVDA.change", // duplicate symbol — deduped "stocks.AAPL.series", "stocks.status", // status sentinel: not a symbol "*", // studio wildcard: not a symbol diff --git a/widgetsack/src/timings.rs b/widgetsack/src/timings.rs index 00bb3a7..b00cee6 100644 --- a/widgetsack/src/timings.rs +++ b/widgetsack/src/timings.rs @@ -8,8 +8,8 @@ //! imposes — e.g. a 5 ms process refresh at 1 Hz = 5 ms/s ≈ 0.5% of one core. use std::collections::HashMap; -use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::Mutex; +use std::sync::atomic::{AtomicBool, Ordering}; use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; use serde::Serialize; diff --git a/widgetsack/src/weather.rs b/widgetsack/src/weather.rs index c6dd6db..1ec62a5 100644 --- a/widgetsack/src/weather.rs +++ b/widgetsack/src/weather.rs @@ -79,10 +79,14 @@ fn weather_config_path(app: &AppHandle) -> Result(app: &AppHandle) -> Result, String> { +pub fn load_weather_config( + app: &AppHandle, +) -> Result, String> { let path = weather_config_path(app)?; match std::fs::read_to_string(&path) { - Ok(txt) => serde_json::from_str(&txt).map(Some).map_err(|e| e.to_string()), + Ok(txt) => serde_json::from_str(&txt) + .map(Some) + .map_err(|e| e.to_string()), Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(None), Err(err) => Err(err.to_string()), } @@ -160,8 +164,14 @@ fn weather_to_samples(body: &Value, unit_letter: &str, ts_ms: u64) -> Vec Vec { if !info.ssid.is_empty() { out.push(SensorSample::text("net.wifi.ssid", ts, info.ssid.clone())); } - out.push(SensorSample::scalar("net.wifi.signal", ts, f64::from(info.quality.min(100)))); - out.push(SensorSample::scalar("net.wifi.rssi", ts, f64::from(rssi_from_quality(info.quality)))); + out.push(SensorSample::scalar( + "net.wifi.signal", + ts, + f64::from(info.quality.min(100)), + )); + out.push(SensorSample::scalar( + "net.wifi.rssi", + ts, + f64::from(rssi_from_quality(info.quality)), + )); // kbps → Mbps for display (Wi-Fi link rates read in Mbps). - out.push(SensorSample::scalar("net.wifi.rx", ts, f64::from(info.rx_kbps) / 1000.0)); - out.push(SensorSample::scalar("net.wifi.tx", ts, f64::from(info.tx_kbps) / 1000.0)); + out.push(SensorSample::scalar( + "net.wifi.rx", + ts, + f64::from(info.rx_kbps) / 1000.0, + )); + out.push(SensorSample::scalar( + "net.wifi.tx", + ts, + f64::from(info.tx_kbps) / 1000.0, + )); if info.channel > 0 { - out.push(SensorSample::scalar("net.wifi.channel", ts, f64::from(info.channel))); + out.push(SensorSample::scalar( + "net.wifi.channel", + ts, + f64::from(info.channel), + )); let band = band_from_channel(info.channel); if !band.is_empty() { out.push(SensorSample::text("net.wifi.band", ts, band)); @@ -97,9 +117,9 @@ fn read_wifi() -> Option { use std::ptr::null_mut; use windows::Win32::Foundation::HANDLE; use windows::Win32::NetworkManagement::WiFi::{ + WLAN_CONNECTION_ATTRIBUTES, WLAN_INTERFACE_INFO_LIST, WlanCloseHandle, WlanEnumInterfaces, + WlanFreeMemory, WlanOpenHandle, WlanQueryInterface, wlan_interface_state_connected, wlan_intf_opcode_channel_number, wlan_intf_opcode_current_connection, - wlan_interface_state_connected, WlanCloseHandle, WlanEnumInterfaces, WlanFreeMemory, - WlanOpenHandle, WlanQueryInterface, WLAN_CONNECTION_ATTRIBUTES, WLAN_INTERFACE_INFO_LIST, }; const WLAN_API_VERSION_2_0: u32 = 2; @@ -116,8 +136,10 @@ fn read_wifi() -> Option { // SAFETY: fills `list_ptr` with a WlanFreeMemory-owned interface list. let info = if WlanEnumInterfaces(handle, None, &mut list_ptr) == 0 && !list_ptr.is_null() { let list = &*list_ptr; - let ifaces = - std::slice::from_raw_parts(list.InterfaceInfo.as_ptr(), list.dwNumberOfItems as usize); + let ifaces = std::slice::from_raw_parts( + list.InterfaceInfo.as_ptr(), + list.dwNumberOfItems as usize, + ); let mut found = None; for iface in ifaces { if iface.isState != wlan_interface_state_connected { @@ -139,7 +161,8 @@ fn read_wifi() -> Option { { let conn = &*(data as *const WLAN_CONNECTION_ATTRIBUTES); let assoc = &conn.wlanAssociationAttributes; - let len = (assoc.dot11Ssid.uSSIDLength as usize).min(assoc.dot11Ssid.ucSSID.len()); + let len = + (assoc.dot11Ssid.uSSIDLength as usize).min(assoc.dot11Ssid.ucSSID.len()); let mut wifi = WifiInfo { ssid: ssid_to_string(&assoc.dot11Ssid.ucSSID[..len]), quality: assoc.wlanSignalQuality, diff --git a/widgetsack/src/windowmgr.rs b/widgetsack/src/windowmgr.rs index 8f0494b..5ebe0ba 100644 --- a/widgetsack/src/windowmgr.rs +++ b/widgetsack/src/windowmgr.rs @@ -66,7 +66,13 @@ const MAX_DWM_BORDER: f64 = 24.0; /// (negative — classic theme / DWM off / no frame; or implausibly large — a bad read) clamp to 0. /// Twin of `frameMargins` in core/snapMath.ts. fn frame_margins(window: ScreenRect, frame: Option) -> (f64, f64, f64) { - let clamp = |m: f64| if (0.0..=MAX_DWM_BORDER).contains(&m) { m } else { 0.0 }; + let clamp = |m: f64| { + if (0.0..=MAX_DWM_BORDER).contains(&m) { + m + } else { + 0.0 + } + }; match frame { None => (0.0, 0.0, 0.0), Some(f) => { @@ -262,8 +268,15 @@ pub struct PointerState { /// (not the studio) polls it during a drag. #[tauri::command] pub fn pointer_probe(app: tauri::AppHandle) -> PointerState { - let (x, y) = app.cursor_position().map(|p| (p.x, p.y)).unwrap_or((0.0, 0.0)); - PointerState { x, y, shift: shift_held() } + let (x, y) = app + .cursor_position() + .map(|p| (p.x, p.y)) + .unwrap_or((0.0, 0.0)); + PointerState { + x, + y, + shift: shift_held(), + } } /// Payload for `win_drag_start` / `win_drag_end`. @@ -308,8 +321,8 @@ fn spawn_drag_pump() { std::thread::spawn(|| unsafe { use windows::Win32::UI::Accessibility::SetWinEventHook; use windows::Win32::UI::WindowsAndMessaging::{ - DispatchMessageW, GetMessageW, EVENT_OBJECT_LOCATIONCHANGE, EVENT_SYSTEM_MOVESIZEEND, - EVENT_SYSTEM_MOVESIZESTART, MSG, WINEVENT_OUTOFCONTEXT, WM_TIMER, + DispatchMessageW, EVENT_OBJECT_LOCATIONCHANGE, EVENT_SYSTEM_MOVESIZEEND, + EVENT_SYSTEM_MOVESIZESTART, GetMessageW, MSG, WINEVENT_OUTOFCONTEXT, WM_TIMER, }; // Hook 1: the standard modal move/size loop (classic titlebars) → real drags. let move_hook = SetWinEventHook( @@ -380,10 +393,11 @@ fn arrangeable_hwnd(hwnd: windows::Win32::Foundation::HWND) -> bool { use std::ffi::c_void; use std::mem::size_of; use windows::Win32::Foundation::RECT; - use windows::Win32::Graphics::Dwm::{DwmGetWindowAttribute, DWMWA_CLOAKED}; + use windows::Win32::Graphics::Dwm::{DWMWA_CLOAKED, DwmGetWindowAttribute}; use windows::Win32::UI::WindowsAndMessaging::{ - GetWindow, GetWindowLongW, GetWindowRect, GetWindowTextLengthW, GetWindowThreadProcessId, - IsWindowVisible, GWL_EXSTYLE, GWL_STYLE, GW_OWNER, WS_CHILD, WS_EX_TOOLWINDOW, + GW_OWNER, GWL_EXSTYLE, GWL_STYLE, GetWindow, GetWindowLongW, GetWindowRect, + GetWindowTextLengthW, GetWindowThreadProcessId, IsWindowVisible, WS_CHILD, + WS_EX_TOOLWINDOW, }; unsafe { let ex_style = GetWindowLongW(hwnd, GWL_EXSTYLE) as u32; @@ -488,11 +502,14 @@ static SNAPPED: std::sync::LazyLock Result, String> { use std::ffi::c_void; use std::mem::size_of; - use windows::core::BOOL; use windows::Win32::Foundation::{HWND, LPARAM, RECT}; - use windows::Win32::Graphics::Dwm::{DwmGetWindowAttribute, DWMWA_CLOAKED}; + use windows::Win32::Graphics::Dwm::{DWMWA_CLOAKED, DwmGetWindowAttribute}; use windows::Win32::UI::WindowsAndMessaging::{ - EnumWindows, GetClassNameW, GetWindow, GetWindowLongW, GetWindowRect, GetWindowTextW, - GetWindowThreadProcessId, IsWindowVisible, GWL_EXSTYLE, GWL_STYLE, GW_OWNER, - WS_CHILD, WS_EX_TOOLWINDOW, + EnumWindows, GW_OWNER, GWL_EXSTYLE, GWL_STYLE, GetClassNameW, GetWindow, GetWindowLongW, + GetWindowRect, GetWindowTextW, GetWindowThreadProcessId, IsWindowVisible, WS_CHILD, + WS_EX_TOOLWINDOW, }; + use windows::core::BOOL; // Collect raw HWNDs first (do nothing heavy inside the enum callback). The body is push-only and // cannot panic, so no `catch_unwind` is needed across the `extern "system"` FFI boundary. @@ -662,12 +689,12 @@ fn list_arrangeable() -> Result, String> { /// protected processes — those return None and matching falls back to class/title. #[cfg(target_os = "windows")] fn exe_path(pid: u32) -> Option { - use windows::core::PWSTR; use windows::Win32::Foundation::CloseHandle; use windows::Win32::System::Threading::{ - OpenProcess, QueryFullProcessImageNameW, PROCESS_NAME_WIN32, - PROCESS_QUERY_LIMITED_INFORMATION, + OpenProcess, PROCESS_NAME_WIN32, PROCESS_QUERY_LIMITED_INFORMATION, + QueryFullProcessImageNameW, }; + use windows::core::PWSTR; unsafe { let handle = OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, false, pid).ok()?; @@ -692,10 +719,10 @@ fn snap(hwnd: i64, zone: ScreenRect) -> Result<(), String> { use std::thread::sleep; use std::time::Duration; use windows::Win32::Foundation::{HWND, RECT}; - use windows::Win32::Graphics::Dwm::{DwmGetWindowAttribute, DWMWA_EXTENDED_FRAME_BOUNDS}; + use windows::Win32::Graphics::Dwm::{DWMWA_EXTENDED_FRAME_BOUNDS, DwmGetWindowAttribute}; use windows::Win32::UI::WindowsAndMessaging::{ - GetWindowRect, IsIconic, IsZoomed, SetWindowPos, ShowWindow, SWP_NOACTIVATE, SWP_NOZORDER, - SW_RESTORE, + GetWindowRect, IsIconic, IsZoomed, SW_RESTORE, SWP_NOACTIVATE, SWP_NOZORDER, SetWindowPos, + ShowWindow, }; let hwnd_id = hwnd; @@ -708,7 +735,7 @@ fn snap(hwnd: i64, zone: ScreenRect) -> Result<(), String> { (Some(x), Some(y)) => { x.left == y.left && x.top == y.top && x.right == y.right && x.bottom == y.bottom } - _ => false + _ => false, }; unsafe { // A maximized/minimized window ignores SetWindowPos (it snaps back), so restore it first — @@ -796,20 +823,39 @@ mod tests { #[test] fn is_arrangeable_requires_visible_titled_real_unowned_window() { - assert!(is_arrangeable(true, false, false, true, 800, 600, false, false)); - assert!(!is_arrangeable(false, false, false, true, 800, 600, false, false)); // hidden - assert!(!is_arrangeable(true, true, false, true, 800, 600, false, false)); // tool window - assert!(!is_arrangeable(true, false, true, true, 800, 600, false, false)); // cloaked - assert!(!is_arrangeable(true, false, false, false, 800, 600, false, false)); // no title - assert!(!is_arrangeable(true, false, false, true, 0, 600, false, false)); // zero width - assert!(!is_arrangeable(true, false, false, true, 800, 600, true, false)); // our own window - assert!(!is_arrangeable(true, false, false, true, 800, 600, false, true)); // owned/child popup + assert!(is_arrangeable( + true, false, false, true, 800, 600, false, false + )); + assert!(!is_arrangeable( + false, false, false, true, 800, 600, false, false + )); // hidden + assert!(!is_arrangeable( + true, true, false, true, 800, 600, false, false + )); // tool window + assert!(!is_arrangeable( + true, false, true, true, 800, 600, false, false + )); // cloaked + assert!(!is_arrangeable( + true, false, false, false, 800, 600, false, false + )); // no title + assert!(!is_arrangeable( + true, false, false, true, 0, 600, false, false + )); // zero width + assert!(!is_arrangeable( + true, false, false, true, 800, 600, true, false + )); // our own window + assert!(!is_arrangeable( + true, false, false, true, 800, 600, false, true + )); // owned/child popup } #[test] fn adjust_returns_zone_unchanged_without_a_frame() { let zone = rect(100.0, 200.0, 800.0, 600.0); - assert_eq!(adjust_for_frame_bounds(zone, rect(0.0, 0.0, 800.0, 600.0), None), zone); + assert_eq!( + adjust_for_frame_bounds(zone, rect(0.0, 0.0, 800.0, 600.0), None), + zone + ); } #[test] @@ -818,7 +864,10 @@ mod tests { let zone = rect(100.0, 200.0, 800.0, 600.0); let window = rect(0.0, 0.0, 814.0, 607.0); let frame = Some(rect(7.0, 0.0, 800.0, 600.0)); - assert_eq!(adjust_for_frame_bounds(zone, window, frame), rect(93.0, 200.0, 814.0, 607.0)); + assert_eq!( + adjust_for_frame_bounds(zone, window, frame), + rect(93.0, 200.0, 814.0, 607.0) + ); } #[test] @@ -841,7 +890,10 @@ mod tests { #[test] fn exe_basename_lowercases_and_strips_dir() { - assert_eq!(exe_basename("C:\\Program Files\\Spotify\\Spotify.exe"), "spotify.exe"); + assert_eq!( + exe_basename("C:\\Program Files\\Spotify\\Spotify.exe"), + "spotify.exe" + ); assert_eq!(exe_basename("/usr/bin/Foo"), "foo"); assert_eq!(exe_basename("Code.exe"), "code.exe"); } @@ -918,13 +970,21 @@ mod tests { exe: "C:\\X\\app.exe".to_string(), class_name: "Chrome_WidgetWin_1".to_string(), title: "Title".to_string(), - rect: ScreenRect { x: 1.0, y: 2.0, w: 3.0, h: 4.0 }, + rect: ScreenRect { + x: 1.0, + y: 2.0, + w: 3.0, + h: 4.0, + }, }; let json = serde_json::to_value(&d).unwrap(); assert_eq!(json["hwnd"], 123); assert_eq!(json["exe"], "C:\\X\\app.exe"); assert_eq!(json["className"], "Chrome_WidgetWin_1"); // camelCase, NOT class_name - assert!(json.get("class_name").is_none(), "snake_case class_name must not leak to the bridge"); + assert!( + json.get("class_name").is_none(), + "snake_case class_name must not leak to the bridge" + ); assert_eq!(json["title"], "Title"); assert_eq!(json["rect"]["x"], 1.0); assert_eq!(json["rect"]["w"], 3.0); @@ -933,7 +993,12 @@ mod tests { #[test] fn pointer_state_serializes_to_the_ts_bridge_shape() { // Mirrors `Pointer` in core/dragSnap.ts ({ x, y, shift }) — the overlay's drag poll reads these. - let json = serde_json::to_value(PointerState { x: 10.0, y: 20.0, shift: true }).unwrap(); + let json = serde_json::to_value(PointerState { + x: 10.0, + y: 20.0, + shift: true, + }) + .unwrap(); assert_eq!(json["x"], 10.0); assert_eq!(json["y"], 20.0); assert_eq!(json["shift"], true); @@ -960,7 +1025,10 @@ mod manual_smoke { } fn rect_of(hwnd: i64) -> Option { - arrangeable().into_iter().find(|w| w.hwnd == hwnd).map(|w| w.rect) + arrangeable() + .into_iter() + .find(|w| w.hwnd == hwnd) + .map(|w| w.rect) } fn close_window(hwnd: i64) { @@ -975,7 +1043,9 @@ mod manual_smoke { #[ignore = "spawns + moves a real Notepad window; run explicitly with --ignored"] fn snap_moves_a_real_window() { let before: HashSet = arrangeable().into_iter().map(|w| w.hwnd).collect(); - let mut child = Command::new("notepad.exe").spawn().expect("failed to spawn notepad.exe"); + let mut child = Command::new("notepad.exe") + .spawn() + .expect("failed to spawn notepad.exe"); // Wait for a NEW arrangeable window (ours) — prefer one whose exe is notepad.exe. let deadline = Instant::now() + Duration::from_secs(5); @@ -993,13 +1063,18 @@ mod manual_smoke { Some(h) => h, None => { let _ = child.kill(); - let _ = child.wait(); // reap the process so clippy's zombie_processes lint is satisfied + let _ = child.wait(); // reap the process so clippy's zombie_processes lint is satisfied panic!("no new window appeared within 5s — did Notepad open?"); } }; let start = rect_of(hwnd); - let target = ScreenRect { x: 200.0, y: 200.0, w: 800.0, h: 600.0 }; + let target = ScreenRect { + x: 200.0, + y: 200.0, + w: 800.0, + h: 600.0, + }; let snapped = snap(hwnd, target); sleep(Duration::from_millis(250)); let end = rect_of(hwnd); @@ -1017,17 +1092,32 @@ mod manual_smoke { snapped.expect("snap returned Err"); let end = end.expect("could not read the window rect after snapping"); // The VISIBLE frame fills ~the target; the window rect sits within a small DWM border of it. - assert!((end.x - target.x).abs() < 16.0, "x off target: {} vs {}", end.x, target.x); - assert!((end.y - target.y).abs() < 16.0, "y off target: {} vs {}", end.y, target.y); - assert!((end.w - target.w).abs() < 32.0, "w off target: {} vs {}", end.w, target.w); + assert!( + (end.x - target.x).abs() < 16.0, + "x off target: {} vs {}", + end.x, + target.x + ); + assert!( + (end.y - target.y).abs() < 16.0, + "y off target: {} vs {}", + end.y, + target.y + ); + assert!( + (end.w - target.w).abs() < 32.0, + "w off target: {} vs {}", + end.w, + target.w + ); } /// Synthesize the left mouse button down/up (so `GetAsyncKeyState(VK_LBUTTON)` — the synthetic-drag /// arming gate — reads it). Injected at the current cursor position. fn send_lmb(down: bool) { use windows::Win32::UI::Input::KeyboardAndMouse::{ - SendInput, INPUT, INPUT_0, INPUT_MOUSE, MOUSEEVENTF_LEFTDOWN, MOUSEEVENTF_LEFTUP, - MOUSEINPUT, + INPUT, INPUT_0, INPUT_MOUSE, MOUSEEVENTF_LEFTDOWN, MOUSEEVENTF_LEFTUP, MOUSEINPUT, + SendInput, }; let input = INPUT { r#type: INPUT_MOUSE, @@ -1036,7 +1126,11 @@ mod manual_smoke { dx: 0, dy: 0, mouseData: 0, - dwFlags: if down { MOUSEEVENTF_LEFTDOWN } else { MOUSEEVENTF_LEFTUP }, + dwFlags: if down { + MOUSEEVENTF_LEFTDOWN + } else { + MOUSEEVENTF_LEFTUP + }, time: 0, dwExtraInfo: 0, }, @@ -1061,7 +1155,7 @@ mod manual_smoke { use std::sync::mpsc; use windows::Win32::Foundation::{HWND, RECT}; use windows::Win32::UI::WindowsAndMessaging::{ - GetWindowRect, SetCursorPos, SetWindowPos, SWP_NOACTIVATE, SWP_NOSIZE, SWP_NOZORDER, + GetWindowRect, SWP_NOACTIVATE, SWP_NOSIZE, SWP_NOZORDER, SetCursorPos, SetWindowPos, }; // Capture drag signals (no Tauri), then start the REAL hook pipeline. @@ -1074,12 +1168,17 @@ mod manual_smoke { // Spawn a throwaway window; identify it by diffing the arrangeable set across the spawn. let before: HashSet = arrangeable().into_iter().map(|w| w.hwnd).collect(); - let mut child = Command::new("notepad.exe").spawn().expect("failed to spawn notepad.exe"); + let mut child = Command::new("notepad.exe") + .spawn() + .expect("failed to spawn notepad.exe"); let deadline = Instant::now() + Duration::from_secs(5); let mut hwnd_id = None; while Instant::now() < deadline && hwnd_id.is_none() { sleep(Duration::from_millis(150)); - hwnd_id = arrangeable().into_iter().find(|w| !before.contains(&w.hwnd)).map(|w| w.hwnd); + hwnd_id = arrangeable() + .into_iter() + .find(|w| !before.contains(&w.hwnd)) + .map(|w| w.hwnd); } let Some(hwnd_id) = hwnd_id else { let _ = child.kill(); @@ -1124,11 +1223,15 @@ mod manual_smoke { let signals: Vec = rx.try_iter().collect(); println!("captured drag signals: {signals:?}"); assert!( - signals.iter().any(|a| matches!(a, DragAction::Start(h) if *h == hwnd_id)), + signals + .iter() + .any(|a| matches!(a, DragAction::Start(h) if *h == hwnd_id)), "expected a synthetic Start for the dragged window; got {signals:?}" ); assert!( - signals.iter().any(|a| matches!(a, DragAction::End(h) if *h == hwnd_id)), + signals + .iter() + .any(|a| matches!(a, DragAction::End(h) if *h == hwnd_id)), "expected an End after the button release; got {signals:?}" ); } From 81fb55651da008b76febc0c8b1e0cf13c8cf06e3 Mon Sep 17 00:00:00 2001 From: Ng Guoyou Date: Thu, 18 Jun 2026 23:56:36 +0800 Subject: [PATCH 02/36] feat: monitor input switcher, --multi dev instance, overlay auto-refit, studio polish MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Widgets - Monitor Switch (`monitorswitch`): switch a monitor's input (HDMI/DisplayPort/…) over DDC/CI VCP 0x60. Backend ddc.rs (list_monitor_inputs / set_monitor_input — async + spawn_blocking so a slow/hung DDC read never touches the UI thread; parses the capability string for supported inputs, EnumDisplaySettingsW for resolution/refresh, masks the 0x60 reply to the low byte). Props-only meter with big touch buttons + a compact-list toggle; bespoke host; friendly sources editor (detected-input checklist + per-input rename); `displayNames` monitor-picker catalog. Pure core/monitorInputs + the Rust caps parser are unit-tested. Dev instance - `--multi` / WIDGETSACK_MULTI runs a second instance alongside the installed release: skips the single-instance lock and isolates config to /multi. A "dev" badge in the studio title bar + a "(dev)" tray-tooltip suffix mark such instances. Overlay re-fit - Overlays now re-fit on display-topology changes (monitor add/remove/move/resize — which fire no per-window scale-change event), via a polled watcher, plus a manual tray "Re-fit overlays to displays". Fixes overlays going stale after rearranging monitors. Tray - Left-click opens the studio; the menu is right-click only (show_menu_on_left_click(false)). Dropped the confusing "Edit layout" item, renamed "Open designer" -> "Open studio", added a "Start at login" toggle + a separator before Quit; tooltip shows version + "(dev)". Studio polish - NowPlaying: assembleStyles recurses into group child trees so a group-nested widget's css (the crossfade) is applied — fixes the demo widget showing two stacked covers. - Checkbox config fields: themed custom control on a single aligned row. - Add-palette: hover preview popover with a live demo render (WidgetPreview) + each widget's description in the chip tooltip. Tooling - cargo fmt --check CI gate (test.yml) + AGENTS.md; docs/widgets.md regenerated (29 widgets). Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/test.yml | 9 + AGENTS.md | 2 + client/src/lib/bridge/contract.ts | 7 + client/src/lib/core/monitorInputs.test.ts | 160 + client/src/lib/core/monitorInputs.ts | 178 + client/src/lib/core/style.test.ts | 17 + client/src/lib/core/style.ts | 24 +- client/src/lib/core/widget.test.ts | 3 +- client/src/lib/core/widget.ts | 58 +- client/src/lib/ddc/monitors.ts | 46 + client/src/lib/overlay.ts | 44 + client/src/lib/widgets/Canvas.css | 18 + client/src/lib/widgets/Canvas.tsx | 31 + client/src/lib/widgets/Inspector.css | 92 + client/src/lib/widgets/Inspector.tsx | 161 +- client/src/lib/widgets/MonitorSwitchHost.tsx | 105 + client/src/lib/widgets/WidgetPreview.tsx | 70 + .../lib/widgets/canvas/useStudioInit.test.ts | 3 +- .../src/lib/widgets/canvas/useStudioInit.ts | 31 +- .../widgets/meters/MonitorSourcesEditor.css | 75 + .../widgets/meters/MonitorSourcesEditor.tsx | 95 + .../src/lib/widgets/meters/MonitorSwitch.css | 118 + .../lib/widgets/meters/MonitorSwitch.test.tsx | 69 + .../src/lib/widgets/meters/MonitorSwitch.tsx | 75 + client/src/lib/widgets/registry.tsx | 2 + docs/widgets.md | 20 + widgetsack/src/bridge.rs | 4 + widgetsack/src/command.rs | 3047 +++++++++-------- widgetsack/src/ddc.rs | 458 +++ widgetsack/src/display.rs | 211 +- widgetsack/src/main.rs | 109 +- 31 files changed, 3707 insertions(+), 1635 deletions(-) create mode 100644 client/src/lib/core/monitorInputs.test.ts create mode 100644 client/src/lib/core/monitorInputs.ts create mode 100644 client/src/lib/ddc/monitors.ts create mode 100644 client/src/lib/widgets/MonitorSwitchHost.tsx create mode 100644 client/src/lib/widgets/WidgetPreview.tsx create mode 100644 client/src/lib/widgets/meters/MonitorSourcesEditor.css create mode 100644 client/src/lib/widgets/meters/MonitorSourcesEditor.tsx create mode 100644 client/src/lib/widgets/meters/MonitorSwitch.css create mode 100644 client/src/lib/widgets/meters/MonitorSwitch.test.tsx create mode 100644 client/src/lib/widgets/meters/MonitorSwitch.tsx create mode 100644 widgetsack/src/ddc.rs diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 3bb6dbe..9566f0f 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -53,6 +53,15 @@ jobs: permissions: checks: write + rustfmt_check: + name: Formatting for Rust backend + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - run: rustup update stable && rustup default stable && rustup component add rustfmt + # Source-only formatting check (no compile / no frontend build needed). Run `cargo fmt` to fix. + - run: cargo fmt --all --check + client_test: name: Tests and linting for React client runs-on: ubuntu-latest diff --git a/AGENTS.md b/AGENTS.md index b8e9d38..3694a6d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -201,7 +201,9 @@ the working-directory-aware tools. | Build | `cargo build` | | Test | `cargo test` | | Lint | `cargo clippy` | +| Format (check / fix) | `cargo fmt --check` / `cargo fmt` (default rustfmt) | | Run full app (dev) | `cargo tauri dev` | +| Run a 2nd dev instance alongside the installed release | `cargo run -- --multi` (or `WIDGETSACK_MULTI=1`) — skips the single-instance lock + isolates config to `/multi` | | Release build → `target/release/widgetsack.exe` | `cargo tauri build` | > ⚠️ **Build order gotcha:** Tauri embeds `client/build` (`frontendDist`), so the frontend diff --git a/client/src/lib/bridge/contract.ts b/client/src/lib/bridge/contract.ts index 35ddd46..043218b 100644 --- a/client/src/lib/bridge/contract.ts +++ b/client/src/lib/bridge/contract.ts @@ -22,6 +22,8 @@ export const EVENTS = { toggleEdit: 'toggle_edit', openStudio: 'open_studio', arrangeZones: 'arrange_zones', + // re-fit overlays to the current display layout (tray "Re-fit overlays" + auto on display change) + refitOverlays: 'refit_overlays', // foreign-window drag watcher (windowmgr.rs) winDragStart: 'win_drag_start', winDragEnd: 'win_drag_end', @@ -36,6 +38,8 @@ export const EVENTS = { export const COMMANDS = { // media / now-playing (command.rs, media.rs) getInitialSessions: 'get_initial_sessions', + // dev/extra-instance flag (main.rs) — drives the studio's "dev" badge + isDevInstance: 'is_dev_instance', mediaControl: 'media_control', mediaCapabilities: 'media_capabilities', // layout persistence + saved layout profiles (command.rs) @@ -76,6 +80,9 @@ export const COMMANDS = { currentWorkArea: 'current_work_area', setOverlayWallpaper: 'set_overlay_wallpaper', listDisplayNames: 'list_display_names', + // monitor input-source switcher — DDC/CI VCP 0x60 (ddc.rs, the Monitor Switch widget) + listMonitorInputs: 'list_monitor_inputs', + setMonitorInput: 'set_monitor_input', // devtools / diagnostics / recovery (command.rs, process_diag.rs, log.rs) openDevtools: 'open_devtools', listWindowLabels: 'list_window_labels', diff --git a/client/src/lib/core/monitorInputs.test.ts b/client/src/lib/core/monitorInputs.test.ts new file mode 100644 index 0000000..090aa0c --- /dev/null +++ b/client/src/lib/core/monitorInputs.test.ts @@ -0,0 +1,160 @@ +import { describe, expect, it } from 'vitest'; +import { + buildSourceSpec, + formatStats, + inputName, + monitorInputRows, + parseSourceSpec, + sourceEditorRows +} from './monitorInputs'; + +describe('inputName', () => { + it('names standard MCCS codes', () => { + expect(inputName(0x0f)).toBe('DisplayPort 1'); + expect(inputName(0x11)).toBe('HDMI 1'); + expect(inputName(0x12)).toBe('HDMI 2'); + }); + + it('falls back to a padded hex label for unknown codes', () => { + expect(inputName(0x1b)).toBe('Input 0x1B'); + expect(inputName(0x03)).toBe('DVI 1'); + }); +}); + +describe('parseSourceSpec', () => { + it('returns [] for blank/undefined', () => { + expect(parseSourceSpec(undefined)).toEqual([]); + expect(parseSourceSpec('')).toEqual([]); + expect(parseSourceSpec(' ')).toEqual([]); + }); + + it('parses decimal, 0x-hex and trailing-h codes', () => { + expect(parseSourceSpec('17, 0x0f, 12h')).toEqual([ + { value: 17, label: inputName(17) }, + { value: 0x0f, label: 'DisplayPort 1' }, + { value: 0x12, label: 'HDMI 2' } + ]); + }); + + it('honours explicit labels and preserves order', () => { + expect(parseSourceSpec('0x11=Desktop, 0x12=Console')).toEqual([ + { value: 0x11, label: 'Desktop' }, + { value: 0x12, label: 'Console' } + ]); + }); + + it('accepts newline separators and trims whitespace', () => { + expect(parseSourceSpec(' 0x0f = DP \n 0x11 = HDMI ')).toEqual([ + { value: 0x0f, label: 'DP' }, + { value: 0x11, label: 'HDMI' } + ]); + }); + + it('drops junk and out-of-range codes', () => { + expect(parseSourceSpec('zzz, 999, -1, 0x11=ok')).toEqual([{ value: 0x11, label: 'ok' }]); + }); +}); + +describe('monitorInputRows', () => { + it('uses discovered inputs when no spec, marking the active one', () => { + const rows = monitorInputRows({ discovered: [0x0f, 0x11, 0x12], current: 0x11 }); + expect(rows).toEqual([ + { value: 0x0f, label: 'DisplayPort 1', active: false }, + { value: 0x11, label: 'HDMI 1', active: true }, + { value: 0x12, label: 'HDMI 2', active: false } + ]); + }); + + it('prefers the spec over discovered inputs (filter + order + rename)', () => { + const rows = monitorInputRows({ + discovered: [0x0f, 0x10, 0x11, 0x12], + spec: '0x12=Console, 0x0f=PC', + current: 0x0f + }); + expect(rows).toEqual([ + { value: 0x12, label: 'Console', active: false }, + { value: 0x0f, label: 'PC', active: true } + ]); + }); + + it('falls back to defaults when nothing is discovered or specced', () => { + const rows = monitorInputRows({ discovered: [], current: null }); + expect(rows.map((r) => r.value)).toEqual([0x0f, 0x10, 0x11, 0x12]); + expect(rows.every((r) => !r.active)).toBe(true); + }); + + it('always includes the active input even if outside the chosen set', () => { + const rows = monitorInputRows({ discovered: [], spec: '0x11', current: 0x1b }); + expect(rows).toEqual([ + { value: 0x11, label: 'HDMI 1', active: false }, + { value: 0x1b, label: 'Input 0x1B', active: true } + ]); + }); + + it('de-duplicates repeated values, keeping the first label', () => { + const rows = monitorInputRows({ discovered: [], spec: '0x11=One, 0x11=Two', current: null }); + expect(rows).toEqual([{ value: 0x11, label: 'One', active: false }]); + }); +}); + +describe('formatStats', () => { + it('formats resolution + refresh', () => { + expect(formatStats({ width: 2560, height: 1440, refreshHz: 144 })).toBe('2560×1440 · 144 Hz'); + }); + + it('drops the refresh when unknown', () => { + expect(formatStats({ width: 1920, height: 1080, refreshHz: 0 })).toBe('1920×1080'); + }); + + it('is empty when the mode is unknown', () => { + expect(formatStats(null)).toBe(''); + expect(formatStats({ width: 0, height: 0, refreshHz: 0 })).toBe(''); + }); +}); + +describe('sourceEditorRows', () => { + it('lists all detected inputs as included with default names when the spec is blank', () => { + expect(sourceEditorRows([0x0f, 0x11], '')).toEqual([ + { value: 0x0f, defaultName: 'DisplayPort 1', label: '', include: true, detected: true }, + { value: 0x11, defaultName: 'HDMI 1', label: '', include: true, detected: true } + ]); + }); + + it('includes only spec-listed inputs and carries custom labels', () => { + const rows = sourceEditorRows([0x0f, 0x11, 0x12], '0x11=Desktop, 0x12'); + expect(rows).toEqual([ + { value: 0x0f, defaultName: 'DisplayPort 1', label: '', include: false, detected: true }, + { value: 0x11, defaultName: 'HDMI 1', label: 'Desktop', include: true, detected: true }, + { value: 0x12, defaultName: 'HDMI 2', label: '', include: true, detected: true } + ]); + }); + + it('appends spec entries the monitor did not report as manual rows', () => { + const rows = sourceEditorRows([0x11], '0x11, 0x1b=Console'); + expect(rows).toEqual([ + { value: 0x11, defaultName: 'HDMI 1', label: '', include: true, detected: true }, + { value: 0x1b, defaultName: 'Input 0x1B', label: 'Console', include: true, detected: false } + ]); + }); +}); + +describe('buildSourceSpec', () => { + it('returns blank (auto) when all detected inputs are included with default names', () => { + expect(buildSourceSpec(sourceEditorRows([0x0f, 0x11], ''))).toBe(''); + }); + + it('emits an explicit code=label spec for a renamed/filtered set', () => { + const rows = sourceEditorRows([0x0f, 0x11, 0x12], ''); + rows[0].include = false; // drop DisplayPort 1 + rows[2].label = 'Switch 2'; // rename HDMI 2 + expect(buildSourceSpec(rows)).toBe('0x11, 0x12=Switch 2'); + }); + + it('round-trips through parseSourceSpec', () => { + const rows = sourceEditorRows([0x11, 0x12], '0x11=Desktop, 0x12=Switch 2'); + expect(parseSourceSpec(buildSourceSpec(rows))).toEqual([ + { value: 0x11, label: 'Desktop' }, + { value: 0x12, label: 'Switch 2' } + ]); + }); +}); diff --git a/client/src/lib/core/monitorInputs.ts b/client/src/lib/core/monitorInputs.ts new file mode 100644 index 0000000..0ab22fe --- /dev/null +++ b/client/src/lib/core/monitorInputs.ts @@ -0,0 +1,178 @@ +// Pure shaping for the Monitor Switch widget (a DDC/CI input-source switcher). No React/Tauri — +// unit-tested. The backend (widgetsack/src/ddc.rs) enumerates monitors and reads/sets VCP 0x60; this +// module names the standard MCCS input codes, parses the user's `sources` config, and merges the +// discovered + configured + current inputs into display rows for the meter. + +export type MonitorInputRow = { value: number; label: string; active: boolean }; +export type SourceSpec = { value: number; label: string }; + +// Standard MCCS v2.x VCP 0x60 ("Input Select") values. Real monitors deviate (these codes are +// vendor-specific in practice), so this is only a friendly FALLBACK name — discovery comes from the +// monitor's own capabilities and the user can rename/choose per source via the `sources` spec. +export const MCCS_INPUT_NAMES: Record = { + 0x01: 'VGA 1', + 0x02: 'VGA 2', + 0x03: 'DVI 1', + 0x04: 'DVI 2', + 0x05: 'Composite 1', + 0x06: 'Composite 2', + 0x07: 'S-Video 1', + 0x08: 'S-Video 2', + 0x09: 'Tuner 1', + 0x0a: 'Tuner 2', + 0x0b: 'Tuner 3', + 0x0c: 'Component 1', + 0x0d: 'Component 2', + 0x0e: 'Component 3', + 0x0f: 'DisplayPort 1', + 0x10: 'DisplayPort 2', + 0x11: 'HDMI 1', + 0x12: 'HDMI 2', + 0x15: 'USB-C' // common vendor extension (USB-C / Thunderbolt on some panels) +}; + +// A usable fallback when neither the user's spec nor capability discovery yields any inputs (some +// monitors don't report a 0x60 list). The user can always narrow this with the `sources` spec. +const DEFAULT_INPUTS = [0x0f, 0x10, 0x11, 0x12]; // DP 1, DP 2, HDMI 1, HDMI 2 + +/** Friendly name for a VCP 0x60 value: the MCCS name, else a hex fallback like `Input 0x1B`. */ +export function inputName(value: number): string { + return MCCS_INPUT_NAMES[value] ?? `Input 0x${value.toString(16).toUpperCase().padStart(2, '0')}`; +} + +/** Parse a single input code: decimal (`17`), `0x`-hex (`0x11`), or trailing-`h` hex (`11h`). Returns + * null for junk or an out-of-byte-range value. */ +function parseCode(s: string): number | null { + const t = s.trim(); + let v: number; + if (/^0x[0-9a-f]+$/i.test(t)) v = parseInt(t.slice(2), 16); + else if (/^[0-9a-f]+h$/i.test(t)) v = parseInt(t.slice(0, -1), 16); + else if (/^\d+$/.test(t)) v = parseInt(t, 10); + else return null; + return Number.isFinite(v) && v >= 0 && v <= 255 ? v : null; +} + +/** Parse the optional `sources` config: a comma/newline-separated list of `code` or `code=label` + * entries (code = decimal `17`, hex `0x11`, or `11h`). Blank/invalid entries are dropped; a missing + * label defaults to the MCCS name. Lets a user choose WHICH inputs appear (and order + rename them) + * without a multi-select control. Pure. */ +export function parseSourceSpec(spec: string | undefined): SourceSpec[] { + if (!spec) return []; + const out: SourceSpec[] = []; + for (const raw of spec.split(/[,\n]/)) { + const entry = raw.trim(); + if (!entry) continue; + const eq = entry.indexOf('='); + const value = parseCode(eq >= 0 ? entry.slice(0, eq) : entry); + if (value === null) continue; + const label = eq >= 0 ? entry.slice(eq + 1).trim() : ''; + out.push({ value, label: label || inputName(value) }); + } + return out; +} + +/** Build the meter's input rows from the monitor's discovered inputs (caps), the user's `sources` + * spec, and the current input. Precedence: an explicit spec wins; else discovered inputs; else a + * sensible default set. The active input is always included (even if outside the chosen set) so the + * user can see — and switch back to — what's selected. De-duplicated by value, order preserved. Pure. */ +export function monitorInputRows(opts: { + discovered: number[]; + spec?: string; + current?: number | null; +}): MonitorInputRow[] { + const { discovered, spec, current = null } = opts; + const parsed = parseSourceSpec(spec); + + let base: SourceSpec[]; + if (parsed.length > 0) base = parsed; + else if (discovered.length > 0) base = discovered.map((v) => ({ value: v, label: inputName(v) })); + else base = DEFAULT_INPUTS.map((v) => ({ value: v, label: inputName(v) })); + + if (current !== null && !base.some((b) => b.value === current)) { + base = [...base, { value: current, label: inputName(current) }]; + } + + const seen = new Set(); + const rows: MonitorInputRow[] = []; + for (const b of base) { + if (seen.has(b.value)) continue; + seen.add(b.value); + rows.push({ value: b.value, label: b.label, active: b.value === current }); + } + return rows; +} + +/** Format the current-mode stats line, e.g. `2560×1440 · 144 Hz`. Empty string when the mode is + * unknown (width/height 0); drops the refresh when it's unknown. Pure. */ +export function formatStats( + stats: { width: number; height: number; refreshHz: number } | null | undefined +): string { + if (!stats || stats.width <= 0 || stats.height <= 0) return ''; + const res = `${stats.width}×${stats.height}`; + return stats.refreshHz > 0 ? `${res} · ${stats.refreshHz} Hz` : res; +} + +// --- Friendly sources editor (the studio Inspector control) ------------------------------------ + +/** One row in the Inspector's sources editor: an input the user can include/exclude and rename. + * `label` is the custom name ('' = use `defaultName`). `detected` is false for a manual entry kept + * from the spec that the monitor didn't report. */ +export type SourceEditorRow = { + value: number; + defaultName: string; + label: string; + include: boolean; + detected: boolean; +}; + +/** Build the editor rows from the monitor's detected inputs + the current `sources` spec. Detected + * inputs come first (checked when the spec is blank, else only those the spec lists); spec entries the + * monitor didn't report follow as manual rows. `label` is '' when it matches the default name. Pure. */ +export function sourceEditorRows(detected: number[], spec: string | undefined): SourceEditorRow[] { + const parsed = parseSourceSpec(spec); + const specEmpty = parsed.length === 0; + const byValue = new Map(parsed.map((p) => [p.value, p.label])); + const rows: SourceEditorRow[] = []; + const seen = new Set(); + for (const value of detected) { + if (seen.has(value)) continue; + seen.add(value); + const custom = byValue.get(value); + const defaultName = inputName(value); + rows.push({ + value, + defaultName, + label: custom && custom !== defaultName ? custom : '', + include: specEmpty || byValue.has(value), + detected: true + }); + } + for (const p of parsed) { + if (seen.has(p.value)) continue; + seen.add(p.value); + const defaultName = inputName(p.value); + rows.push({ + value: p.value, + defaultName, + label: p.label !== defaultName ? p.label : '', + include: true, + detected: false + }); + } + return rows; +} + +/** Build the `sources` spec string from editor rows. Included rows become `0xNN` (or `0xNN=label` + * when renamed). Returns '' (the clean "auto: show all detected" default) when the rows are exactly + * all-detected, all-included, none-renamed. Pure — inverse of `sourceEditorRows`. */ +export function buildSourceSpec(rows: SourceEditorRow[]): string { + const isAuto = rows.length > 0 && rows.every((r) => r.detected && r.include && r.label === ''); + if (isAuto) return ''; + return rows + .filter((r) => r.include) + .map((r) => { + const code = `0x${r.value.toString(16)}`; + return r.label ? `${code}=${r.label}` : code; + }) + .join(', '); +} diff --git a/client/src/lib/core/style.test.ts b/client/src/lib/core/style.test.ts index 552f8de..4425cee 100644 --- a/client/src/lib/core/style.test.ts +++ b/client/src/lib/core/style.test.ts @@ -56,6 +56,23 @@ describe('assembleStyles', () => { expect(css.indexOf('[data-def=')).toBeLessThan(css.indexOf('[data-w="flowA"')); }); + it('collects css of widgets nested INSIDE a group (recurses the group child tree)', () => { + // A group is a leaf in its parent tree but owns a nested child tree; its inner widgets' css must + // still be assembled, scoped to each inner [data-w] — in flow AND floating groups. Regression: + // the demoSeed now-playing widget (a floating group) rendered unstyled (two covers) because the + // inner leaf's crossfade css was skipped. + const monitor: MonitorLayout = { + root: container('root', 'col', [ + leaf(group('gFlow', { w: 1, h: 1 }, leaf(prim('deep', 'color: red')), {})) + ]), + floating: [leaf(group('gFloat', { w: 1, h: 1 }, leaf(prim('floatDeep', 'opacity: 0')), {}))] + }; + const css = assembleStyles({ monitor }); + // Inner ids are namespaced by the group leaf id + '/', matching FlowNode's rendered data-w. + expect(css).toContain(scopeCss('color: red', '[data-w="gFlow/deep"]')); + expect(css).toContain(scopeCss('opacity: 0', '[data-w="gFloat/floatDeep"]')); + }); + it('scopes per-widget token overrides to [data-w], before that widget css', () => { const w: WidgetInstance = { id: 'w1', diff --git a/client/src/lib/core/style.ts b/client/src/lib/core/style.ts index fe32921..39f3b29 100644 --- a/client/src/lib/core/style.ts +++ b/client/src/lib/core/style.ts @@ -58,22 +58,32 @@ export function assembleStyles(opts: { if (s) parts.push(s); } - const leafCss = (lf: Leaf): void => { - const sel = isGroup(lf.unit) ? `[data-group="${lf.id}"]` : `[data-w="${lf.id}"]`; + const leafCss = (lf: Leaf, prefix: string): void => { + const fullId = prefix + lf.id; + const sel = isGroup(lf.unit) ? `[data-group="${fullId}"]` : `[data-w="${fullId}"]`; const tk = lf.unit.tokens; if (tk && Object.keys(tk).length) parts.push(tokensToCss(tk, sel)); const s = scopeCss(lf.unit.css, sel); if (s) parts.push(s); }; - const walk = (node: LayoutNode): void => { + const walk = (node: LayoutNode, prefix: string): void => { if (isContainer(node)) { - node.children.forEach(walk); + node.children.forEach((c) => walk(c, prefix)); return; } - if (isLeaf(node)) leafCss(node); + if (isLeaf(node)) { + leafCss(node, prefix); + // A group is a leaf here but owns a NESTED child tree whose widgets render with ids + // NAMESPACED by the group leaf id + '/' (FlowNode's prefixing — the load-bearing data-id + // invariant). Recurse with that same prefix so the inner css scopes to the [data-w] those + // widgets ACTUALLY render with — without this, anything inside a group (incl. the demoSeed + // templates) renders unstyled (e.g. a now-playing widget showing two stacked covers). + if (isGroup(node.unit)) walk(node.unit.child, `${prefix}${node.id}/`); + } }; - walk(opts.monitor.root); - opts.monitor.floating.forEach(leafCss); + walk(opts.monitor.root, ''); + // Floating items can be groups too — walk (not just leafCss) so their nested widgets are collected. + opts.monitor.floating.forEach((lf) => walk(lf, '')); return parts.join('\n'); } diff --git a/client/src/lib/core/widget.test.ts b/client/src/lib/core/widget.test.ts index f64d52a..a5d8663 100644 --- a/client/src/lib/core/widget.test.ts +++ b/client/src/lib/core/widget.test.ts @@ -74,7 +74,8 @@ describe('meta registry', () => { 'note', 'spacer', 'countdown', - 'timer' + 'timer', + 'monitorswitch' ]); expect(getMeta('gauge')).toMatchObject({ label: 'Gauge', binds: 'scalar' }); expect(getMeta('sparkline')?.binds).toBe('series'); diff --git a/client/src/lib/core/widget.ts b/client/src/lib/core/widget.ts index 159c56a..7d6773c 100644 --- a/client/src/lib/core/widget.ts +++ b/client/src/lib/core/widget.ts @@ -50,7 +50,12 @@ export type ConfigField = // A macro field: an ordered list of {domain, service, data?} action calls (core/macro.ts), edited // as rows in the inspector and run in sequence when the widget is pressed. The value is a // MacroAction[]; the side-effecting dispatch lives in Canvas.onWidgetControl (domain 'macro'). - | ({ key: string; label: string; kind: 'macro' } & FieldMeta); + | ({ key: string; label: string; kind: 'macro' } & FieldMeta) + // A monitor-sources field (the Monitor Switch widget): a friendly checklist of the chosen monitor's + // detected DDC inputs — pick which to show and optionally rename each. The value is the same + // `code=label` spec string `text` would hold (core/monitorInputs); the editor just builds it. The + // inspector renders MonitorSourcesEditor (it detects inputs via a Tauri command — studio-only). + | ({ key: string; label: string; kind: 'monitorSources' } & FieldMeta); export type SensorKind = 'scalar' | 'series' | 'text' | 'json' | 'none'; @@ -1037,6 +1042,57 @@ export const BUILTIN_METAS: WidgetMeta[] = [ text('label', 'label', { help: 'header text' }), color('color', 'color', { help: 'text colour (blank = theme)' }) ] + }, + { + // Monitor input-source switcher (binds:'none', interactive): switches a monitor's active input + // (HDMI / DisplayPort / …) over DDC/CI (VCP 0x60). Bespoke wiring lives in MonitorSwitchHost + // (ddc.rs commands); the meter stays props-only. interactive:true so the rows catch clicks on the + // passive overlay. DDC/CI must be enabled in the OSD; input codes are vendor-specific (auto-detected). + type: 'monitorswitch', + description: + 'Switch a monitor’s input source (HDMI / DisplayPort / …) with a tap, over DDC/CI. Shows the current source and, optionally, the resolution + refresh rate. Requires DDC/CI enabled on the monitor; input codes are vendor-specific (auto-detected). Windows.', + binds: 'none', + interactive: true, + label: 'Monitor Switch', + category: 'Utility', + defaultSize: { w: 220, h: 150 }, + defaultConfig: { showCurrent: true, showStats: false, compact: false }, + configFields: [ + { + key: 'monitor', + label: 'monitor', + kind: 'select', + options: [], + catalog: 'displayNames', + help: 'which monitor to control (blank = the primary monitor)' + }, + { + key: 'sources', + label: 'sources', + kind: 'monitorSources', + help: 'pick which inputs to show, and rename them (e.g. HDMI 2 → Switch 2); blank = show all detected' + }, + text('label', 'label', { help: 'title override (blank = the monitor’s name)' }), + { + key: 'showCurrent', + label: 'show current', + kind: 'toggle', + help: 'highlight the currently-selected input' + }, + { + key: 'showStats', + label: 'show stats', + kind: 'toggle', + help: 'show the current resolution + refresh rate' + }, + { + key: 'compact', + label: 'compact list', + kind: 'toggle', + help: 'show a compact list instead of large touch buttons' + }, + color('color', 'accent') + ] } ]; diff --git a/client/src/lib/ddc/monitors.ts b/client/src/lib/ddc/monitors.ts new file mode 100644 index 0000000..a414077 --- /dev/null +++ b/client/src/lib/ddc/monitors.ts @@ -0,0 +1,46 @@ +// Outer-ring adapter (AGENTS.md §5) for the Monitor Switch widget: list monitors with their current / +// supported DDC input + display mode, and switch the input. Tauri lives here, never in the meter or +// core/. Command names mirror widgetsack/src/ddc.rs; `MonitorInputs` mirrors the Rust struct of the +// same name. + +import { invoke } from '@tauri-apps/api/core'; +import { COMMANDS } from '../bridge/contract'; + +/** One monitor's input-switching state. Mirrors `MonitorInputs` in widgetsack/src/ddc.rs. Keyed by + * `gdi` (`\\.\DISPLAYn`). `currentInput` / `supported` are DDC/CI (VCP 0x60) and only filled for the + * queried target; `width`/`height`/`refreshHz` are the OS display mode (0 if unknown). */ +export type MonitorInputs = { + gdi: string; + friendly: string; + primary: boolean; + current_input: number | null; + supported: number[]; + width: number; + height: number; + refresh_hz: number; +}; + +/** All monitors; DDC (current/supported) is filled only for `target` (a GDI device name) — or the + * primary monitor when omitted. Empty list off-Windows / on failure. */ +export async function listMonitorInputs(target?: string): Promise { + try { + return ( + (await invoke(COMMANDS.listMonitorInputs, { target: target ?? null })) ?? [] + ); + } catch (err) { + console.warn('list_monitor_inputs failed', err); + return []; + } +} + +/** Switch `target` (a GDI device name) to VCP 0x60 input `value`. Resolves true on success, false on + * failure (DDC/CI off, monitor not found, or an unsupported value) so the host can roll back. */ +export async function setMonitorInput(target: string, value: number): Promise { + try { + await invoke(COMMANDS.setMonitorInput, { target, value }); + return true; + } catch (err) { + console.warn('set_monitor_input failed', err); + return false; + } +} diff --git a/client/src/lib/overlay.ts b/client/src/lib/overlay.ts index ca8d6aa..0996a86 100644 --- a/client/src/lib/overlay.ts +++ b/client/src/lib/overlay.ts @@ -299,6 +299,50 @@ export async function fillOwnMonitor(key: string): Promise { } } +/** Poll interval for the display-topology watcher. Relaxed — display changes are rare and a few + * seconds of lag before overlays re-fit is fine (the tray "Re-fit overlays" gives an instant trigger). */ +const DISPLAY_POLL_MS = 4000; + +/** Watch for DISPLAY TOPOLOGY changes (monitors added / removed / moved / resized) and call `onChange`. + * Windows fires no per-window JS event for this (only DPI scale-change is exposed via onScaleChanged), + * so poll `availableMonitors()` on a relaxed cadence + on window focus and fire when the set of monitor + * geometries differs from the last seen. Cheap (one fast call per tick). Returns a cleanup fn. Without + * this, overlays go stale on a topology change — e.g. dragging a monitor in Windows display settings + * leaves an overlay anchored to the old coordinates (clipped/misaligned) until the app restarts. */ +export function watchDisplayChanges(onChange: () => void): () => void { + const sig = (mons: Awaited>): string => + mons + .map( + (m) => + `${m.name ?? ''}:${m.position.x},${m.position.y}:${m.size.width}x${m.size.height}@${ + m.scaleFactor + }` + ) + .sort() + .join('|'); + let last: string | null = null; + let alive = true; + const tick = async (): Promise => { + if (!alive) return; + try { + const s = sig(await availableMonitors()); + if (last !== null && s !== last) onChange(); + last = s; + } catch { + /* transient enumeration failure — retry next tick */ + } + }; + void tick(); // seed `last` without firing + const timer = window.setInterval(() => void tick(), DISPLAY_POLL_MS); + const onFocus = (): void => void tick(); + window.addEventListener('focus', onFocus); + return () => { + alive = false; + window.clearInterval(timer); + window.removeEventListener('focus', onFocus); + }; +} + /** Whole-window click-through: true = clicks pass through (passive overlay). */ export async function setClickThrough(enabled: boolean): Promise { await getCurrentWindow().setIgnoreCursorEvents(enabled); diff --git a/client/src/lib/widgets/Canvas.css b/client/src/lib/widgets/Canvas.css index 862ea93..34fc273 100644 --- a/client/src/lib/widgets/Canvas.css +++ b/client/src/lib/widgets/Canvas.css @@ -586,6 +586,24 @@ } /* The window-control cluster (— ▢ ✕), pushed to the far right; the gap before it is a drag region. */ +/* Dev/extra-instance badge: an amber pill that takes the title bar's free space (margin-left:auto), so + it sits immediately left of the window controls (whose own auto-margin then collapses to 0). */ +.canvas .studio-bar .dev-badge { + margin-left: auto; + align-self: center; + padding: 1px 8px; + border-radius: 999px; + font-size: var(--text-sm, 0.72rem); + font-weight: 700; + letter-spacing: 0.05em; + text-transform: uppercase; + color: #1a1a1a; + background: rgba(230, 170, 0, 0.92); + white-space: nowrap; + user-select: none; + -webkit-user-select: none; +} + .canvas .studio-bar .win-controls { margin-left: auto; display: flex; diff --git a/client/src/lib/widgets/Canvas.tsx b/client/src/lib/widgets/Canvas.tsx index a3e7f8e..b271211 100644 --- a/client/src/lib/widgets/Canvas.tsx +++ b/client/src/lib/widgets/Canvas.tsx @@ -742,6 +742,26 @@ export default function Canvas({ studio = false }: Props) { .catch(() => undefined); }, [studio]); + // Monitors for the monitor-switch widget's monitor picker (studio only). Friendly EDID names where + // known, falling back to the GDI device tag. Empty list just leaves the "Primary monitor" option. + const [displayNames, setDisplayNames] = useState<{ id: string; name: string }[]>([]); + useEffect(() => { + if (!studio) return; + invoke<{ gdi: string; friendly: string }[]>(COMMANDS.listDisplayNames) + .then((list) => setDisplayNames(list.map((d) => ({ id: d.gdi, name: d.friendly || d.gdi })))) + .catch(() => undefined); + }, [studio]); + + // Dev / extra-instance flag — drives the "dev" badge in the studio title bar (main.rs); lets you + // tell a --multi / debug instance apart from the installed release at a glance. + const [devInstance, setDevInstance] = useState(false); + useEffect(() => { + if (!studio) return; + invoke(COMMANDS.isDevInstance) + .then((v) => setDevInstance(Boolean(v))) + .catch(() => undefined); + }, [studio]); + const selectedSet = useMemo(() => new Set(selectedIds), [selectedIds]); // --- multi-selection (2+ widgets) → the common-properties details pane --- @@ -2494,6 +2514,16 @@ export default function Canvas({ studio = false }: Props) { Cancel )} + {/* Dev/extra-instance badge (main.rs is_dev_instance): a --multi or debug build run + alongside the installed release shows this so the two are distinguishable. */} + {devInstance && ( + + dev + + )} {/* Window controls (the borderless window's min / maximize-restore / close). Pushed to the far right (margin-left:auto); the gap before them is a drag region. */}
@@ -3044,6 +3074,7 @@ export default function Canvas({ studio = false }: Props) { sensorMeta={sensorMeta} audioOutputs={audioOutputs} microphones={microphones} + displayNames={displayNames} docked={studio} onOp={handleOp} onDeleteDef={studio ? deleteWidget : undefined} diff --git a/client/src/lib/widgets/Inspector.css b/client/src/lib/widgets/Inspector.css index 384129c..b78edd8 100644 --- a/client/src/lib/widgets/Inspector.css +++ b/client/src/lib/widgets/Inspector.css @@ -216,6 +216,59 @@ min-height: var(--control-h); } +/* Checkboxes: a themed custom control. The generic `width: 100%` above otherwise stretched the native + box full-width with a centered tick (looked off). Drawn as a small accent-on-check box that sits at + its natural size, vertically centered with its label (see the toggle field layout below). */ +.inspector input[type='checkbox'] { + appearance: none; + -webkit-appearance: none; + flex: 0 0 auto; + width: 16px; + height: 16px; + margin: 0; + padding: 0; + border: 1px solid var(--ui-border-strong); + border-radius: 4px; + background: var(--ui-raised); + cursor: pointer; + position: relative; + transition: background var(--motion-fast, 100ms) var(--ease-out, ease-out), + border-color var(--motion-fast, 100ms) var(--ease-out, ease-out); +} +.inspector input[type='checkbox']:hover { + border-color: rgb(var(--ui-accent-rgb)); +} +.inspector input[type='checkbox']:checked { + background: rgb(var(--ui-accent-rgb)); + border-color: rgb(var(--ui-accent-rgb)); +} +/* The tick: a rotated box-corner centered in the 16px box. */ +.inspector input[type='checkbox']:checked::after { + content: ''; + position: absolute; + left: 5px; + top: 2px; + width: 4px; + height: 8px; + border: solid var(--ui-accent-fg, #fff); + border-width: 0 2px 2px 0; + transform: rotate(45deg); +} +.inspector input[type='checkbox']:focus-visible { + outline: none; + box-shadow: 0 0 0 2px rgba(var(--ui-accent-rgb), 0.4); +} + +/* A toggle config field: checkbox + label on one vertically-centered row (.check supplies row + center + + gap), with the reset button still pinned top-right and any help text wrapping below. */ +.inspector .cfg-toggle .check { + cursor: pointer; + padding-right: var(--space-4); +} +.inspector .cfg-toggle .check span { + user-select: none; +} + /* A crisp mint focus ring (the box-shadow IS the focus affordance, so the default outline is off). */ .inspector input:focus, .inspector textarea:focus, @@ -489,3 +542,42 @@ .inspector .data-err { color: var(--ui-danger-fg); } + +/* Add-palette hover preview (Tier 2): a floating card with a LIVE widget render + its name/description. + position:fixed so it escapes the rail's overflow; JS positions it to the left of the hovered chip. + pointer-events:none so it never steals the hover/click from the palette (which would make it flicker). */ +.inspector .palette-preview { + position: fixed; + z-index: 1000; + width: 216px; + display: flex; + flex-direction: column; + gap: var(--space-2); + padding: var(--space-2); + background: var(--ui-raised); + border: 1px solid var(--ui-border-strong); + border-radius: var(--radius-md, 6px); + box-shadow: 0 6px 24px var(--ui-scrim, rgba(0, 0, 0, 0.45)); + pointer-events: none; +} +.inspector .palette-preview .pp-stage { + width: 200px; + height: 120px; + overflow: hidden; + border-radius: var(--radius-sm, 4px); + background: var(--ui-sunken, rgba(0, 0, 0, 0.25)); +} +.inspector .palette-preview .pp-meta { + display: flex; + flex-direction: column; + gap: 2px; +} +.inspector .palette-preview .pp-name { + font-weight: 600; + font-size: var(--text-md); +} +.inspector .palette-preview .pp-desc { + font-size: var(--text-sm); + color: var(--ui-fg-dim); + line-height: 1.3; +} diff --git a/client/src/lib/widgets/Inspector.tsx b/client/src/lib/widgets/Inspector.tsx index 146cfc8..b59aa1b 100644 --- a/client/src/lib/widgets/Inspector.tsx +++ b/client/src/lib/widgets/Inspector.tsx @@ -2,7 +2,7 @@ // the selected node — widget props (sensor / rect / config / dock·float) or container // props (kind / cols / gap / pad / align / justify / grow). Emits a single `op` event; // all state + persistence lives in Canvas. -import { useEffect, useMemo, useState, type DragEvent as ReactDragEvent } from 'react'; +import { useEffect, useMemo, useRef, useState, type DragEvent as ReactDragEvent } from 'react'; import type { Align, AlignH, @@ -22,6 +22,8 @@ import type { ConfigField } from '../core/widget'; import { toYaml } from '../core/yaml'; import { normalizeMacro } from '../core/macro'; import MacroEditor from './MacroEditor'; +import MonitorSourcesEditor from './meters/MonitorSourcesEditor'; +import WidgetPreview from './WidgetPreview'; import CssEditor from './CssEditor'; import BoxField from './BoxField'; import Select, { type SelectOption } from './Select'; @@ -87,6 +89,9 @@ type Props = { audioOutputs?: { id: string; name: string }[]; // Runtime options for a `catalog:'microphones'` select (the transcribe widget's mic picker). microphones?: { id: string; name: string }[]; + // Runtime options for a `catalog:'displayNames'` select (the monitor-switch widget's monitor + // picker). `id` is the GDI device name (\\.\DISPLAYn), `name` the friendly/EDID label. + displayNames?: { id: string; name: string }[]; onOp?: (op: LayoutOp) => void; // Deleting a library def from the Inspector routes through the container (which checks whether the // def is in use and explains the block, matching the Widget-designer list) instead of the reducer's @@ -286,6 +291,7 @@ export default function Inspector({ sensorMeta = {}, audioOutputs = [], microphones = [], + displayNames = [], onOp, onDeleteDef, onPreviewTemplate, @@ -302,6 +308,40 @@ export default function Inspector({ // still user-toggleable in between, and re-opens when nothing is selected (the primary add affordance). const hasSelection = !!(widget || container || groupUnit); const [addOpen, setAddOpen] = useState(!hasSelection); + + // Add-palette hover preview (Tier 2): a floating popover with a live demo render of the hovered + // widget type. Debounced so a quick pass over the chips doesn't spin up a render; positioned to the + // LEFT of the chip (the palette is the right rail) and clamped into the viewport. + const [palettePreview, setPalettePreview] = useState<{ + type: string; + top: number; + left: number; + } | null>(null); + const previewTimer = useRef(null); + const showPreview = (type: string, el: HTMLElement): void => { + if (previewTimer.current !== null) window.clearTimeout(previewTimer.current); + previewTimer.current = window.setTimeout(() => { + const r = el.getBoundingClientRect(); + const pw = 232; + const ph = 196; + const left = Math.max(8, r.left - pw - 10); + const top = Math.min(Math.max(8, r.top - 8), Math.max(8, window.innerHeight - ph - 8)); + setPalettePreview({ type, top, left }); + }, 280); + }; + const hidePreview = (): void => { + if (previewTimer.current !== null) { + window.clearTimeout(previewTimer.current); + previewTimer.current = null; + } + setPalettePreview(null); + }; + useEffect( + () => () => { + if (previewTimer.current !== null) window.clearTimeout(previewTimer.current); + }, + [] + ); useEffect(() => { setAddOpen(!hasSelection); }, [hasSelection]); @@ -614,6 +654,26 @@ export default function Inspector({ return (
+ {/* Add-palette hover preview (Tier 2): a fixed popover with a live demo render of the hovered + widget type, plus its name + one-line description. */} + {palettePreview ? ( +
+
+ +
+
+ + {getMeta(palettePreview.type)?.label ?? palettePreview.type} + + {getMeta(palettePreview.type)?.description ? ( + {getMeta(palettePreview.type)?.description} + ) : null} +
+
+ ) : null}
op({ op: 'addWidget', widgetType: w.type })} - onDragStart={(e: ReactDragEvent) => - e.dataTransfer?.setData('text/x-widget-type', w.type) - } + ] + .filter(Boolean) + .join('\n\n')} + onClick={() => { + hidePreview(); + op({ op: 'addWidget', widgetType: w.type }); + }} + onMouseEnter={(e) => showPreview(w.type, e.currentTarget)} + onMouseLeave={hidePreview} + onDragStart={(e: ReactDragEvent) => { + hidePreview(); + e.dataTransfer?.setData('text/x-widget-type', w.type); + }} > {w.label} @@ -1143,6 +1212,73 @@ export default function Inspector({
); } + // A monitor-sources field is a multi-row editor (checklist + rename), so like the + // macro field it renders outside the single-control
+ ); +} diff --git a/client/src/lib/widgets/meters/MonitorSwitch.css b/client/src/lib/widgets/meters/MonitorSwitch.css new file mode 100644 index 0000000..f649790 --- /dev/null +++ b/client/src/lib/widgets/meters/MonitorSwitch.css @@ -0,0 +1,118 @@ +/* Monitor Switch — a title (+ optional resolution/refresh stats) and one clickable row per input + source; the active input is marked. Rows are bare buttons (overlay-interactive). Themeable via + --np-* / --ms-* tokens (mirrors the Audio Switcher). */ +.monitorswitch { + display: flex; + flex-direction: column; + gap: var(--ms-gap, 3px); + width: 100%; + height: 100%; + box-sizing: border-box; + overflow: hidden; + font-family: var(--np-font-display, 'Bahnschrift', 'Arial Narrow', sans-serif); + color: var(--np-fg, rgb(255, 255, 255)); +} + +.ms-head { + flex: 0 0 auto; + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 8px; +} +.ms-title { + font-size: var(--ms-title-size, 0.66em); + letter-spacing: 0.04em; + text-transform: uppercase; + color: var(--np-label, rgba(255, 255, 255, 0.5)); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +.ms-stats { + flex: 0 0 auto; + font-size: var(--ms-stats-size, 0.6em); + color: var(--np-label, rgba(255, 255, 255, 0.4)); + white-space: nowrap; +} + +.ms-list { + display: flex; + flex-flow: row wrap; + gap: var(--ms-row-gap, 6px); + align-content: flex-start; + min-height: 0; + overflow: auto; +} +.ms-row { + /* Big, tiled, finger-friendly buttons (wrap to fill the widget). */ + flex: 1 1 var(--ms-btn-min, 84px); + min-width: var(--ms-btn-min, 84px); + min-height: var(--ms-btn-h, 44px); /* >= 44px: the common minimum touch target */ + display: flex; + align-items: center; + justify-content: center; + padding: 8px 12px; + border: 1px solid var(--ms-border, rgba(255, 255, 255, 0.16)); + border-radius: var(--ms-radius, 8px); + background: var(--ms-row-bg, rgba(255, 255, 255, 0.06)); + color: inherit; + font: inherit; + font-size: var(--ms-row-size, 0.95em); + text-align: center; + cursor: pointer; + transition: background 0.12s ease, border-color 0.12s ease; + /* Touch ergonomics: no tap flash, no double-tap zoom, no text selection on press. */ + -webkit-tap-highlight-color: transparent; + touch-action: manipulation; + user-select: none; +} +.ms-row:hover { + background: var(--ms-row-hover, rgba(255, 255, 255, 0.14)); +} +.ms-row:active { + transform: translateY(1px); +} +.ms-row[data-active] { + background: var(--ms-active-bg, rgba(119, 196, 211, 0.22)); + border-color: var(--ms-accent, var(--np-accent, rgb(119, 196, 211))); + font-weight: 600; +} +.ms-row[data-busy] { + opacity: 0.6; + cursor: progress; +} + +.ms-name { + min-width: 0; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +/* Compact mode: a tight single-column list (the original look) instead of large touch buttons. */ +.monitorswitch[data-compact] .ms-list { + flex-direction: column; + flex-wrap: nowrap; + gap: var(--ms-row-gap, 2px); +} +.monitorswitch[data-compact] .ms-row { + flex: 0 0 auto; + min-width: 0; + min-height: 0; + justify-content: flex-start; + padding: 2px 8px; + border: 0; + border-radius: var(--ms-radius, 5px); + font-size: var(--ms-row-size-compact, 0.86em); + font-weight: 400; + text-align: left; +} +.monitorswitch[data-compact] .ms-row[data-active] { + font-weight: 600; +} + +.ms-empty { + color: var(--np-label, rgba(255, 255, 255, 0.4)); + font-size: 0.9em; +} diff --git a/client/src/lib/widgets/meters/MonitorSwitch.test.tsx b/client/src/lib/widgets/meters/MonitorSwitch.test.tsx new file mode 100644 index 0000000..d64fb38 --- /dev/null +++ b/client/src/lib/widgets/meters/MonitorSwitch.test.tsx @@ -0,0 +1,69 @@ +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { render, cleanup, fireEvent } from '@testing-library/react'; +import MonitorSwitch from './MonitorSwitch'; +import type { MonitorInputRow } from '../../core/monitorInputs'; + +afterEach(cleanup); + +const rows: MonitorInputRow[] = [ + { value: 0x0f, label: 'DisplayPort 1', active: false }, + { value: 0x11, label: 'HDMI 1', active: true } +]; + +describe('MonitorSwitch meter', () => { + it('renders the title and one row per input, marking the active one', () => { + const { container } = render( + undefined} /> + ); + expect(container.querySelector('.ms-title')?.textContent).toBe('Desk'); + const els = container.querySelectorAll('.ms-row'); + expect(els).toHaveLength(2); + const active = [...els].find((r) => r.getAttribute('data-active') === 'true'); + expect(active?.textContent).toContain('HDMI 1'); + }); + + it('calls onPick with the VCP value on click', () => { + const onPick = vi.fn(); + const { container } = render(); + const dp = [...container.querySelectorAll('.ms-row')].find((r) => + r.textContent?.includes('DisplayPort 1') + ); + fireEvent.click(dp as Element); + expect(onPick).toHaveBeenCalledWith(0x0f); + }); + + it('shows the stats line only when enabled and present', () => { + const { container, rerender } = render( + undefined} /> + ); + expect(container.querySelector('.ms-stats')).toBeNull(); + rerender( + undefined} + /> + ); + expect(container.querySelector('.ms-stats')?.textContent).toBe('2560×1440 · 144 Hz'); + }); + + it('marks the busy input while a switch is in flight', () => { + const { container } = render( + undefined} /> + ); + const busy = [...container.querySelectorAll('.ms-row')].find( + (r) => r.getAttribute('data-busy') === 'true' + ); + expect(busy?.textContent).toContain('DisplayPort 1'); + }); + + it('shows a "monitor not found" hint when missing', () => { + const { container } = render( + undefined} /> + ); + expect(container.querySelector('.ms-empty')?.textContent).toBe('monitor not found'); + expect(container.querySelector('.ms-row')).toBeNull(); + }); +}); diff --git a/client/src/lib/widgets/meters/MonitorSwitch.tsx b/client/src/lib/widgets/meters/MonitorSwitch.tsx new file mode 100644 index 0000000..a93bc69 --- /dev/null +++ b/client/src/lib/widgets/meters/MonitorSwitch.tsx @@ -0,0 +1,75 @@ +// Monitor Switch meter (presentational, props-only). Renders a title (+ optional resolution/refresh +// stats) and one clickable row per selectable input source, marking the active one. The wiring (list +// monitors, read current input, switch via DDC/CI) lives in the sibling MonitorSwitchHost; this just +// renders rows and calls `onPick`. BARE DOM; styled in MonitorSwitch.css via --np-* / --ms-* tokens. +import type { CSSProperties } from 'react'; +import type { MonitorInputRow } from '../../core/monitorInputs'; +import './MonitorSwitch.css'; + +type Props = { + title: string; + rows: MonitorInputRow[]; + stats?: string; + showStats?: boolean; + busyValue?: number | null; + // The configured monitor isn't present (unplugged / switched away / wrong id) — show a hint + // instead of buttons that would target nothing. + missing?: boolean; + // Compact list (small rows) instead of the default large touch buttons. + compact?: boolean; + onPick: (value: number) => void; + color?: string; +}; + +export default function MonitorSwitch({ + title, + rows, + stats, + showStats, + busyValue, + missing, + compact, + onPick, + color +}: Props) { + const vars = color ? ({ '--ms-accent': color } as CSSProperties) : undefined; + + return ( +
+
+ {title} + {showStats && stats ? {stats} : null} +
+ {missing ? ( +
+ monitor not found +
+ ) : rows.length === 0 ? ( +
+ — +
+ ) : ( +
+ {rows.map((r) => ( + + ))} +
+ )} +
+ ); +} diff --git a/client/src/lib/widgets/registry.tsx b/client/src/lib/widgets/registry.tsx index 8a89b3f..4d2bcd6 100644 --- a/client/src/lib/widgets/registry.tsx +++ b/client/src/lib/widgets/registry.tsx @@ -32,6 +32,7 @@ import Timer from './meters/Timer'; import Recyclebin from './meters/Recyclebin'; import StickyNote from './meters/StickyNote'; import AudioSwitcherHost from './AudioSwitcherHost'; +import MonitorSwitchHost from './MonitorSwitchHost'; import VolumeHost from './VolumeHost'; import ImageHost from './ImageHost'; @@ -64,6 +65,7 @@ const components: Record = { timer: asMeter(Timer), recyclebin: asMeter(Recyclebin), audioswitch: asMeter(AudioSwitcherHost), + monitorswitch: asMeter(MonitorSwitchHost), volume: asMeter(VolumeHost), image: asMeter(ImageHost), note: asMeter(StickyNote) diff --git a/docs/widgets.md b/docs/widgets.md index e604eed..d0dcb71 100644 --- a/docs/widgets.md +++ b/docs/widgets.md @@ -503,3 +503,23 @@ A countdown timer or stopwatch with start / pause / reset. A countdown can loop | `loop` | toggle | false | | restart automatically when a countdown reaches zero | | `label` | text | "" | | header text | | `color` | color | "" | | text colour (blank = theme) | + +### Monitor Switch — `monitorswitch` + +![Monitor Switch widget](img/widgets/monitorswitch.png) + +Switch a monitor’s input source (HDMI / DisplayPort / …) with a tap, over DDC/CI. Shows the current source and, optionally, the resolution + refresh rate. Requires DDC/CI enabled on the monitor; input codes are vendor-specific (auto-detected). Windows. + +- **Sensor:** none (self-sourcing) +- **Default size:** 220×150 +- **Interactive:** catches clicks in passive mode (per-widget click-through) + +| key | type | default | options / range | description | +| --- | --- | --- | --- | --- | +| `monitor` | select | | (runtime list) — from `displayNames` | which monitor to control (blank = the primary monitor) | +| `sources` | monitorSources | | | pick which inputs to show, and rename them (e.g. HDMI 2 → Switch 2); blank = show all detected | +| `label` | text | | | title override (blank = the monitor’s name) | +| `showCurrent` | toggle | true | | highlight the currently-selected input | +| `showStats` | toggle | false | | show the current resolution + refresh rate | +| `compact` | toggle | false | | show a compact list instead of large touch buttons | +| `color` | color | | | | diff --git a/widgetsack/src/bridge.rs b/widgetsack/src/bridge.rs index 13a9384..1354e3f 100644 --- a/widgetsack/src/bridge.rs +++ b/widgetsack/src/bridge.rs @@ -32,6 +32,10 @@ pub const CONTROLS_CHANGED_EVENT: &str = "controls_changed"; pub const TOGGLE_EDIT_EVENT: &str = "toggle_edit"; pub const OPEN_STUDIO_EVENT: &str = "open_studio"; pub const ARRANGE_ZONES_EVENT: &str = "arrange_zones"; +/// Re-fit every overlay to the CURRENT display layout (tray "Re-fit overlays" + the manual trigger). +/// Each overlay/studio listens and re-runs its fit/reconcile — used when monitors are +/// added/removed/moved/resized at runtime, which fires no per-window scale-change event. +pub const REFIT_OVERLAYS_EVENT: &str = "refit_overlays"; /// Foreign-window drag watcher (windowmgr.rs → DragSnapLayer.tsx). pub const WIN_DRAG_START_EVENT: &str = "win_drag_start"; diff --git a/widgetsack/src/command.rs b/widgetsack/src/command.rs index 32ea52a..5553d5b 100644 --- a/widgetsack/src/command.rs +++ b/widgetsack/src/command.rs @@ -1,1498 +1,1549 @@ -use std::collections::HashMap; -use std::fs; -use std::path::{Path, PathBuf}; - -use notify::Watcher; -use serde::Serialize; -use tauri::{Emitter, Manager}; - -use crate::bridge::{CONTROLS_CHANGED_EVENT, LAYOUT_CHANGED_EVENT, THEMES_CHANGED_EVENT}; -use crate::{log, AppState, SessionRecord}; - -#[derive(Serialize)] -pub struct UpdateResponse { - pub sessions: HashMap, -} - -#[tauri::command] -pub async fn get_initial_sessions( - _message: String, - state: tauri::State<'_, AppState>, - art: tauri::State<'_, crate::art::ArtState>, -) -> Result { - let sessions = state.sessions.lock().await; - - let mut cloned: HashMap = HashMap::new(); - cloned.clone_from(&sessions); - - // Re-register each session's cover so the URLs in this snapshot resolve for a just-booted - // overlay even if the live media events fired before its webview existed (art.rs). - for record in cloned.values() { - crate::art::note_record(&art, record); - } - - Ok(UpdateResponse { sessions: cloned }) -} - -/// Path to the persisted widget layout (`widgets.json` in the app config dir). -fn layout_path(app: &tauri::AppHandle) -> Result { - let dir = app.path().app_config_dir().map_err(|e| e.to_string())?; - Ok(dir.join("widgets.json")) -} - -/// Read the saved layout file, or `None` if it does not exist yet. The frontend -/// validates/parses the contents (see core/layout.ts) so this stays dumb I/O. -#[tauri::command] -pub async fn load_layout(app: tauri::AppHandle) -> Result, String> { - let path = layout_path(&app)?; - match fs::read_to_string(&path) { - Ok(contents) => Ok(Some(contents)), - Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(None), - Err(err) => Err(err.to_string()), - } -} - -/// Write the layout file, creating the config directory if needed. -#[tauri::command] -pub async fn save_layout(app: tauri::AppHandle, contents: String) -> Result<(), String> { - let path = layout_path(&app)?; - if let Some(parent) = path.parent() { - fs::create_dir_all(parent).map_err(|e| e.to_string())?; - } - fs::write(&path, contents).map_err(|e| e.to_string()) -} - -/// Path to the persisted control remaps (`controls.json` in the app config dir). -fn controls_path(app: &tauri::AppHandle) -> Result { - let dir = app.path().app_config_dir().map_err(|e| e.to_string())?; - Ok(dir.join("controls.json")) -} - -/// Read the saved control overrides, or `None` if none saved yet. The frontend validates/parses -/// the contents (core/controls.ts `parseControlOverrides`) so this stays dumb I/O — mirrors -/// `load_layout`, and an absent/garbage file simply falls back to the code defaults. -#[tauri::command] -pub async fn load_controls(app: tauri::AppHandle) -> Result, String> { - let path = controls_path(&app)?; - match fs::read_to_string(&path) { - Ok(contents) => Ok(Some(contents)), - Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(None), - Err(err) => Err(err.to_string()), - } -} - -/// Write the control overrides file, creating the config directory if needed. -#[tauri::command] -pub async fn save_controls(app: tauri::AppHandle, contents: String) -> Result<(), String> { - let path = controls_path(&app)?; - if let Some(parent) = path.parent() { - fs::create_dir_all(parent).map_err(|e| e.to_string())?; - } - fs::write(&path, contents).map_err(|e| e.to_string()) -} - -/// Open the webview devtools/inspector for the calling window (CSS development from the studio's -/// context menu). `open_devtools` is available because tauri's `devtools` feature is enabled in -/// Cargo.toml (it is also always available in debug builds). -#[tauri::command] -pub fn open_devtools(window: tauri::WebviewWindow) { - window.open_devtools(); -} - -// --- by-label window control (the Diagnostics panel's crash-recovery controls) ----------------------- -// These target ANOTHER window by label from the studio, driven entirely by the backend. That matters -// because the per-window JS event bridge (lib/diag.ts) dies with the window's webview — when an overlay -// OOM-crashes, its JS can no longer answer the poll or obey "open devtools / drop click-through", so the -// crashed window vanishes from the list and stays an un-clickable, uninspectable click-through surface. -// Routing these through the backend (the OS window object outlives the renderer) keeps a crashed overlay -// listable, inspectable, and rescuable. - -/// Every live app window's label (`studio`, `main`, `overlay-1`, …). The Diagnostics panel uses this as -/// the source of truth for which windows exist, so a window whose webview crashed (and therefore stopped -/// reporting over the JS bridge) still appears — marked "not responding" — instead of silently dropping. -#[tauri::command] -pub fn list_window_labels(app: tauri::AppHandle) -> Vec { - app.webview_windows().into_keys().collect() -} - -/// Open devtools for the window with `label` (not necessarily the caller). Lets the studio inspect a -/// crashed/passive overlay it could never reach through that overlay's own (dead) JS. -#[tauri::command] -pub fn open_devtools_for(app: tauri::AppHandle, label: String) { - if let Some(win) = app.get_webview_window(&label) { - win.open_devtools(); - } -} - -/// Toggle whole-window click-through for the window with `label`. `interactive = true` drops -/// click-through (and brings the window forward so you can actually click it — e.g. a crashed overlay's -/// "Reload" page); `false` restores it. No-op if the label is unknown. -#[tauri::command] -pub fn set_window_interactive( - app: tauri::AppHandle, - label: String, - interactive: bool, -) -> Result<(), String> { - if let Some(win) = app.get_webview_window(&label) { - win.set_ignore_cursor_events(!interactive) - .map_err(|e| e.to_string())?; - if interactive { - let _ = win.show(); - let _ = win.set_focus(); - } - } - Ok(()) -} - -/// Make EVERY app window interactive again and bring it forward — the backend "panic button" for a -/// window you can't reach: a click-through overlay, or one whose webview crashed so its own JS can no -/// longer drop click-through. Best-effort per window; never panics. Shared by the rescue hotkey -/// (main.rs) and the `rescue_windows` command, so it's generic over the runtime. -pub fn rescue_all(app: &tauri::AppHandle) { - for win in app.webview_windows().into_values() { - let _ = win.set_ignore_cursor_events(false); - let _ = win.unminimize(); - let _ = win.show(); - let _ = win.set_focus(); - } -} - -/// Command wrapper for [`rescue_all`] (the studio's "Rescue all windows" button). -#[tauri::command] -pub fn rescue_windows(app: tauri::AppHandle) { - rescue_all(&app); -} - -/// Reload the webview of the window with `label` — respawns its renderer, recovering a crashed overlay -/// (the WebView2 "Out of Memory" page) without relaunching the app. No-op if the label is unknown. -#[tauri::command] -pub fn reload_window(app: tauri::AppHandle, label: String) -> Result<(), String> { - if let Some(win) = app.get_webview_window(&label) { - win.reload().map_err(|e| e.to_string())?; - } - Ok(()) -} - -/// Append a window's diagnostics summary to the rotating log file (the memory TRAIL). Each window calls -/// this on an interval (lib/diag.ts `startMemoryTrail`); because it lands on disk, the run-up to an -/// unattended overnight OOM survives the crash — read the last `memtrail` lines to see which metric was -/// climbing. Logged at info so it persists in release builds; the window label is attached as a field. -#[tauri::command] -pub fn log_diag(window: tauri::WebviewWindow, summary: String) { - log::info("memtrail", summary) - .field("window", window.label()) - .emit(); -} - -#[derive(serde::Serialize)] -#[serde(rename_all = "camelCase")] -pub struct SystemFont { - /// Family name (CSS `font-family`). - pub name: String, - /// PostScript name (often a spaceless variant of the family). - pub font_name: String, - /// Absolute path to the font file (for the webview to @font-face via the asset protocol). - pub path: String, -} - -/// Enumerate installed fonts (incl. PER-USER ones) with their file paths. Chromium's sandboxed -/// webview won't render a per-user-installed font by name — but fontdb can find it here, and the -/// frontend then loads the file directly via @font-face + the asset protocol (the approach of -/// tauri-plugin-system-fonts, inlined). The per-user fonts dir is added explicitly (where Windows -/// puts "install for me only" fonts). -#[tauri::command] -pub fn system_fonts() -> Vec { - use fontdb::{Database, Source}; - let mut db = Database::new(); - db.load_system_fonts(); - if let Ok(local) = std::env::var("LOCALAPPDATA") { - db.load_fonts_dir(std::path::Path::new(&local).join("Microsoft\\Windows\\Fonts")); - } - db.faces() - .filter_map(|f| match &f.source { - Source::File(path) => { - let name = f.families.first()?.0.clone(); - if name.starts_with('.') { - return None; // hidden/system aliases - } - Some(SystemFont { - name, - font_name: f.post_script_name.clone(), - path: path.to_string_lossy().into_owned(), - }) - } - _ => None, - }) - .collect() -} - -// ---- themes (Phase 7c): a `themes/.css` plugin folder in the app config dir ---- - -fn themes_dir(app: &tauri::AppHandle) -> Result { - let dir = app.path().app_config_dir().map_err(|e| e.to_string())?; - Ok(dir.join("themes")) -} - -/// Write `contents` to `path` atomically: write a sibling temp file, then rename it onto the target -/// (a rename is atomic on the same volume), so a concurrent reader — or a crash mid-write — never -/// sees a truncated/partial file. The temp name keeps the original and appends `.tmp`, so its -/// extension is `tmp` (not `css`/`json`) and the directory watchers, which filter by extension, -/// ignore it. Best-effort cleanup of the temp file on failure. -fn atomic_write(path: &Path, contents: &str) -> Result<(), String> { - let file_name = path - .file_name() - .and_then(|s| s.to_str()) - .ok_or_else(|| "write path has no file name".to_string())?; - let mut tmp = path.to_path_buf(); - tmp.set_file_name(format!("{file_name}.tmp")); - if let Err(err) = fs::write(&tmp, contents) { - let _ = fs::remove_file(&tmp); - return Err(err.to_string()); - } - fs::rename(&tmp, path).map_err(|e| { - let _ = fs::remove_file(&tmp); - e.to_string() - }) -} - -/// The theme names (file stems of `themes/*.css`), sorted. The frontend adds a synthetic -/// "(default)" option (no theme = the meters' token fallbacks). -#[tauri::command] -pub fn list_themes(app: tauri::AppHandle) -> Result, String> { - let dir = themes_dir(&app)?; - let mut names = Vec::new(); - if let Ok(entries) = fs::read_dir(&dir) { - for entry in entries.flatten() { - let path = entry.path(); - if path.extension().and_then(|x| x.to_str()) == Some("css") - && let Some(stem) = path.file_stem().and_then(|s| s.to_str()) - { - names.push(stem.to_string()); - } - } - } - names.sort(); - Ok(names) -} - -/// The CSS of theme `name` (a bare file stem), or `None` if it doesn't exist. -#[tauri::command] -pub fn load_theme(app: tauri::AppHandle, name: String) -> Result, String> { - if !valid_name(&name) { - return Err("invalid theme name".to_string()); - } - let path = themes_dir(&app)?.join(format!("{name}.css")); - match fs::read_to_string(&path) { - Ok(contents) => Ok(Some(contents)), - Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(None), - Err(err) => Err(err.to_string()), - } -} - -/// Write theme `name` (used by the studio's theme editor + token panel, Phase 7d). Creates -/// `themes/`. Atomic (temp + rename) so a concurrent overlay reload never reads a half-written file. -#[tauri::command] -pub fn save_theme(app: tauri::AppHandle, name: String, contents: String) -> Result<(), String> { - if !valid_name(&name) { - return Err("invalid theme name".to_string()); - } - let dir = themes_dir(&app)?; - fs::create_dir_all(&dir).map_err(|e| e.to_string())?; - atomic_write(&dir.join(format!("{name}.css")), &contents) -} - -/// Delete theme `name` → removes `themes/.css`. Ok even if it's already gone (idempotent), -/// mirroring `delete_layout`. The themes watcher then emits `themes_changed` so the picker refreshes. -#[tauri::command] -pub fn delete_theme(app: tauri::AppHandle, name: String) -> Result<(), String> { - if !valid_name(&name) { - return Err("invalid theme name".to_string()); - } - let path = themes_dir(&app)?.join(format!("{name}.css")); - match fs::remove_file(&path) { - Ok(()) => Ok(()), - Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(()), - Err(err) => Err(err.to_string()), - } -} - -// ---- wallpapers: media for the per-monitor full-screen background layer ---- -// Same "dumb I/O to a fixed folder, no native picker" pattern as themes/sacks: the user drops image -// or video files into `/wallpapers/` (already inside the asset-protocol scope, so the -// webview can load them), and the studio lists them for the Background section. `BackgroundSpec.src` -// stores the bare filename; the frontend resolves it to an asset URL via `wallpaper_path`. - -fn wallpapers_dir(app: &tauri::AppHandle) -> Result { - let dir = app.path().app_config_dir().map_err(|e| e.to_string())?; - Ok(dir.join("wallpapers")) -} - -// Image/video extensions the webview can render (the picker shows only these). -const WALLPAPER_EXTS: &[&str] = &[ - "png", "jpg", "jpeg", "gif", "webp", "bmp", "avif", "mp4", "webm", "mkv", "mov", "m4v", -]; - -/// A wallpaper filename is a single path component with a media extension. Rejects separators / `..` -/// (path traversal) but — unlike `valid_name` — ALLOWS the extension dot. -fn valid_wallpaper_name(name: &str) -> bool { - !name.is_empty() - && name.len() <= 128 - && !name.contains('/') - && !name.contains('\\') - && !name.contains("..") - && std::path::Path::new(name) - .extension() - .and_then(|x| x.to_str()) - .map(|e| WALLPAPER_EXTS.contains(&e.to_ascii_lowercase().as_str())) - .unwrap_or(false) -} - -/// The media filenames in `wallpapers/` (image + video only), sorted. Creates the folder so the -/// studio's "open folder" button always has somewhere to point. -#[tauri::command] -pub fn list_wallpapers(app: tauri::AppHandle) -> Result, String> { - let dir = wallpapers_dir(&app)?; - fs::create_dir_all(&dir).map_err(|e| e.to_string())?; - let mut names = Vec::new(); - if let Ok(entries) = fs::read_dir(&dir) { - for entry in entries.flatten() { - if let Some(name) = entry.file_name().to_str() - && valid_wallpaper_name(name) - { - names.push(name.to_string()); - } - } - } - names.sort(); - Ok(names) -} - -/// The absolute path of wallpaper `name`, for the frontend to feed `convertFileSrc`. Validates the -/// name (no traversal); returns the path even if the file is missing (the /