diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 48ccea8..09f400d 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -15,7 +15,7 @@ crate-type = ["staticlib", "cdylib", "rlib"] tauri-build = { version = "2", features = [] } [dependencies] -tauri = { version = "2", features = ["tray-icon"] } +tauri = { version = "2", features = ["tray-icon", "devtools"] } tauri-plugin-opener = "2" tauri-plugin-global-shortcut = "2" tauri-plugin-store = "2" diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json index b096887..7d19346 100644 --- a/src-tauri/capabilities/default.json +++ b/src-tauri/capabilities/default.json @@ -2,7 +2,7 @@ "$schema": "../gen/schemas/desktop-schema.json", "identifier": "default", "description": "Capability for the main window", - "windows": ["main", "overlay", "whiteboard", "cursor-highlight", "spotlight", "zoom"], + "windows": ["main"], "permissions": [ "core:default", "opener:default", diff --git a/src-tauri/capabilities/mode-windows.json b/src-tauri/capabilities/mode-windows.json new file mode 100644 index 0000000..2386f8d --- /dev/null +++ b/src-tauri/capabilities/mode-windows.json @@ -0,0 +1,11 @@ +{ + "$schema": "../gen/schemas/desktop-schema.json", + "identifier": "mode-windows", + "description": "Minimal capability for overlay mode render windows (no global shortcuts, no autostart)", + "windows": ["overlay", "whiteboard", "cursor-highlight", "spotlight", "zoom"], + "permissions": [ + "core:default", + "opener:default", + "store:default" + ] +} diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 2e5d8d8..6816f49 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -165,8 +165,8 @@ pub fn set_zoom_capture_excluded(excluded: bool) -> Result<(), String> { } #[tauri::command] -pub fn freeze_zoom() -> Result<(), String> { - crate::zoom::stop_zoom_stream()?; +pub async fn freeze_zoom() -> Result<(), String> { + crate::zoom::stop_zoom_stream().await?; #[cfg(target_os = "windows")] { crate::window::set_zoom_capture_exclusion(false); diff --git a/src-tauri/src/cursor.rs b/src-tauri/src/cursor.rs index 798faf0..49f9a3b 100644 --- a/src-tauri/src/cursor.rs +++ b/src-tauri/src/cursor.rs @@ -49,9 +49,9 @@ pub fn start_mouse_hook() { ); let hook = SetWindowsHookExW(WH_MOUSE_LL, Some(mouse_hook_proc), None, 0); - let mut msg = std::mem::zeroed(); - while GetMessageW(&mut msg, None, 0, 0).into() {} if let Ok(hook) = hook { + let mut msg = std::mem::zeroed(); + while GetMessageW(&mut msg, None, 0, 0).into() {} if !hook.0.is_null() { let _ = UnhookWindowsHookEx(hook); } diff --git a/src-tauri/src/dxgi_capture.rs b/src-tauri/src/dxgi_capture.rs index c77d99a..cd497ad 100644 --- a/src-tauri/src/dxgi_capture.rs +++ b/src-tauri/src/dxgi_capture.rs @@ -9,6 +9,22 @@ use windows::Win32::Graphics::Dxgi::*; const DXGI_ERROR_WAIT_TIMEOUT: i32 = 0x887A0027u32 as i32; const DXGI_ERROR_ACCESS_LOST: i32 = 0x887A0026u32 as i32; +/** + * RAII guard that unmaps a mapped D3D11 subresource on drop, including on panic/unwind. + */ +struct UnmapGuard<'a> { + context: &'a ID3D11DeviceContext, + resource: &'a ID3D11Texture2D, +} + +impl Drop for UnmapGuard<'_> { + fn drop(&mut self) { + unsafe { + self.context.Unmap(self.resource, 0); + } + } +} + pub struct DxgiDuplicator { device: ID3D11Device, context: ID3D11DeviceContext, @@ -270,6 +286,10 @@ impl DxgiDuplicator { self.context .Map(staging, 0, D3D11_MAP_READ, 0, Some(&mut mapped)) .map_err(|e| format!("Map staging: {e}"))?; + let _unmap_guard = UnmapGuard { + context: &self.context, + resource: staging, + }; let row_pitch = mapped.RowPitch as usize; let pixel_count = (region_w * region_h * 4) as usize; let mut rgba = Vec::with_capacity(pixel_count); @@ -297,13 +317,9 @@ impl DxgiDuplicator { } } - self.context.Unmap(staging, 0); + drop(_unmap_guard); Ok((rgba, region_w, region_h)) } } - - pub fn capture_full_frame(&mut self) -> Result<(Vec, u32, u32), String> { - self.capture_region(0, 0, self.width, self.height) - } } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index a6ef484..2238cee 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -15,30 +15,41 @@ struct ZoomConfig { backend: String, } +/// In-memory zoom backend state. Seeded once from the compiled-in +/// `config/zoom.json` factory default, then overwritten on every app start +/// by `hydrate()` (frontend) via `set_zoom_backend_cmd` static ZOOM_BACKEND_STATE: OnceLock> = OnceLock::new(); +const DEFAULT_ZOOM_BACKEND: &str = "dxgi"; + +fn init_zoom_backend_state() -> RwLock { + let json_str = include_str!("../../config/zoom.json"); + let backend = serde_json::from_str::(json_str) + .map(|config| config.backend) + .unwrap_or_else(|e| { + eprintln!( + "Failed to parse zoom.json, falling back to default backend: {}", + e + ); + DEFAULT_ZOOM_BACKEND.to_string() + }); + RwLock::new(backend) +} + pub fn get_zoom_backend() -> String { - let state = ZOOM_BACKEND_STATE.get_or_init(|| { - let json_str = include_str!("../../config/zoom.json"); - let config: ZoomConfig = serde_json::from_str(json_str).expect("Failed to parse zoom.json"); - RwLock::new(config.backend) - }); + let state = ZOOM_BACKEND_STATE.get_or_init(init_zoom_backend_state); match state.read() { Ok(guard) => guard.clone(), Err(e) => { eprintln!("Failed to read zoom backend state: {}", e); - "dxgi".to_string() + DEFAULT_ZOOM_BACKEND.to_string() } } } pub fn set_zoom_backend_internal(new_backend: String) { - let state = ZOOM_BACKEND_STATE.get_or_init(|| { - let json_str = include_str!("../../config/zoom.json"); - let config: ZoomConfig = serde_json::from_str(json_str).expect("Failed to parse zoom.json"); - RwLock::new(config.backend) - }); + let state = ZOOM_BACKEND_STATE.get_or_init(init_zoom_backend_state); if let Ok(mut guard) = state.write() { *guard = new_backend; @@ -73,6 +84,11 @@ pub fn run() { )) .plugin(tauri_plugin_opener::init()) .setup(|app| { + #[cfg(debug_assertions)] + if let Some(window) = app.get_webview_window("main") { + window.open_devtools(); + } + #[cfg(target_os = "windows")] cursor::start_mouse_hook(); @@ -281,7 +297,6 @@ pub fn run() { zoom::stop_zoom_stream, commands::freeze_zoom, commands::unfreeze_zoom, - zoom::capture_viewport_without_zoom, commands::mag_zoom_show, commands::mag_zoom_hide, commands::mag_zoom_set_config, diff --git a/src-tauri/src/magnifier.rs b/src-tauri/src/magnifier.rs index d09e1ef..0983a26 100644 --- a/src-tauri/src/magnifier.rs +++ b/src-tauri/src/magnifier.rs @@ -309,7 +309,9 @@ unsafe fn apply_transform(mag: HWND, zoom: f32) { unsafe fn set_circle(hwnd: HWND, size: u32) { let rgn = CreateEllipticRgn(0, 0, size as i32, size as i32); - SetWindowRgn(hwnd, Some(rgn), true); + if SetWindowRgn(hwnd, Some(rgn), true) == 0 { + let _ = DeleteObject(rgn.into()); + } } unsafe fn set_square(hwnd: HWND) { diff --git a/src-tauri/src/types.rs b/src-tauri/src/types.rs index b06339f..929e5b9 100644 --- a/src-tauri/src/types.rs +++ b/src-tauri/src/types.rs @@ -1,6 +1,6 @@ use std::collections::HashMap; use std::sync::atomic::AtomicBool; -use std::sync::{Arc, RwLock}; +use std::sync::{Arc, Mutex, RwLock}; use tauri::async_runtime::JoinHandle; #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] @@ -46,15 +46,35 @@ pub struct MonitorContext { pub struct WindowRegistry { pub mode_windows: RwLock>>, pub current_snapshot: RwLock, + mode_locks: HashMap>, } impl WindowRegistry { pub fn new() -> Self { + let mut mode_locks = HashMap::new(); + for mode in [Mode::Overlay, Mode::Spotlight, Mode::Highlight, Mode::Zoom] { + mode_locks.insert(mode, Mutex::new(())); + } + Self { mode_windows: RwLock::new(HashMap::new()), current_snapshot: RwLock::new(String::new()), + mode_locks, } } + + /// Serializes the destroy→build→show sequence for a given [`Mode`] so + /// concurrent callers (monitor-change loop vs. visibility requests) + /// cannot interleave window mutations for the same mode. + pub fn with_mode_lock(&self, mode: Mode, f: impl FnOnce() -> T) -> T { + let _guard = self + .mode_locks + .get(&mode) + .expect("mode_locks initialized for every Mode variant") + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + f() + } } #[derive(serde::Serialize, Clone)] diff --git a/src-tauri/src/window.rs b/src-tauri/src/window.rs index 0984d9a..c5e60f9 100644 --- a/src-tauri/src/window.rs +++ b/src-tauri/src/window.rs @@ -19,6 +19,12 @@ pub fn destroy_mode_windows(app: &AppHandle, mode: Mode) -> Result<(), String> { } mode_windows.remove(&mode); + + #[cfg(target_os = "windows")] + if mode == Mode::Zoom { + clear_zoom_hwnd(); + } + Ok(()) } @@ -141,8 +147,16 @@ pub fn show_mode_windows_inner(app: &AppHandle, mode: Mode, show: bool) -> Resul ); if show { + #[cfg(target_os = "windows")] + { + let _ = window.set_background_color(Some(tauri::webview::Color(0, 0, 0, 0))); + } + window.show().map_err(|e| e.to_string())?; window.set_focus().map_err(|e| e.to_string())?; + + #[cfg(target_os = "windows")] + reassert_transparent_webview_background(&window); } #[cfg(target_os = "windows")] @@ -151,6 +165,56 @@ pub fn show_mode_windows_inner(app: &AppHandle, mode: Mode, show: bool) -> Resul Ok(()) } +/// Reasserts a fully transparent WebView2 background across the next few +/// main-thread ticks after showing a reused window. +/// +/// WebView2 resets its `DefaultBackgroundColor` when a hidden transparent window +/// is shown again, painting an opaque strip that looks like a ghost title bar +/// (tauri-apps/tauri#14764). The reset lands during/after `show`, so the color is +/// re-applied on the immediately following ticks to close the gap before a frame +/// is presented; wry preserves the alpha only when it is exactly `0`. +#[cfg(target_os = "windows")] +fn reassert_transparent_webview_background(window: &tauri::WebviewWindow) { + let window = window.clone(); + std::thread::spawn(move || { + for _ in 0..6 { + let window_for_bg = window.clone(); + let _ = window.run_on_main_thread(move || { + let _ = window_for_bg.set_background_color(Some(tauri::webview::Color(0, 0, 0, 0))); + }); + std::thread::sleep(std::time::Duration::from_millis(4)); + } + }); +} + +#[cfg(target_os = "windows")] +fn force_window_position_native(window: &tauri::WebviewWindow, monitor: &MonitorContext) { + use raw_window_handle::{HasWindowHandle, RawWindowHandle}; + use windows::Win32::Foundation::HWND; + use windows::Win32::UI::WindowsAndMessaging::{SetWindowPos, HWND_TOPMOST, SWP_NOACTIVATE}; + + let Ok(handle) = window.window_handle() else { + return; + }; + + let hwnd = match handle.as_raw() { + RawWindowHandle::Win32(handle) => HWND(handle.hwnd.get() as *mut _), + _ => return, + }; + + unsafe { + let _ = SetWindowPos( + hwnd, + Some(HWND_TOPMOST), + monitor.x, + monitor.y, + monitor.width as i32, + monitor.height as i32, + SWP_NOACTIVATE, + ); + } +} + #[cfg(target_os = "windows")] fn ensure_window_on_monitor_native(window: &tauri::WebviewWindow, monitor: &MonitorContext) { use raw_window_handle::{HasWindowHandle, RawWindowHandle}; @@ -229,34 +293,6 @@ fn ensure_window_on_monitor_native(window: &tauri::WebviewWindow, monitor: &Moni } } -#[cfg(target_os = "windows")] -fn force_window_position_native(window: &tauri::WebviewWindow, monitor: &MonitorContext) { - use raw_window_handle::{HasWindowHandle, RawWindowHandle}; - use windows::Win32::Foundation::HWND; - use windows::Win32::UI::WindowsAndMessaging::{SetWindowPos, HWND_TOPMOST, SWP_NOACTIVATE}; - - let Ok(handle) = window.window_handle() else { - return; - }; - - let hwnd = match handle.as_raw() { - RawWindowHandle::Win32(handle) => HWND(handle.hwnd.get() as *mut _), - _ => return, - }; - - unsafe { - let _ = SetWindowPos( - hwnd, - Some(HWND_TOPMOST), - monitor.x, - monitor.y, - monitor.width as i32, - monitor.height as i32, - SWP_NOACTIVATE, - ); - } -} - pub fn hide_mode_windows_inner(app: &AppHandle, mode: Mode) { let registry = window_registry(); if let Ok(mode_windows) = registry.mode_windows.read() { @@ -312,12 +348,14 @@ pub fn rebuild_mode_windows( monitors: &[MonitorContext], ) -> Result<(), String> { let monitors = monitors.to_vec(); - if let Err(err) = destroy_mode_windows(app, mode) { - eprintln!("[ERROR] Failed to destroy mode windows {:?}: {}", mode, err); - } - if let Err(err) = build_mode_windows_inner(app, mode, &monitors, true) { - eprintln!("[ERROR] Failed to rebuild mode windows {:?}: {}", mode, err); - } + window_registry().with_mode_lock(mode, || { + if let Err(err) = destroy_mode_windows(app, mode) { + eprintln!("[ERROR] Failed to destroy mode windows {:?}: {}", mode, err); + } + if let Err(err) = build_mode_windows_inner(app, mode, &monitors, true) { + eprintln!("[ERROR] Failed to rebuild mode windows {:?}: {}", mode, err); + } + }); Ok(()) } @@ -443,34 +481,36 @@ pub fn handle_mode_visibility(app: &AppHandle, mode: Mode, visible: bool) -> Res } } - let mut reused_existing = false; - if mode_windows_ready(app, mode) { - let should_show = mode != Mode::Zoom; - if let Err(err) = show_mode_windows_inner(app, mode, should_show) { - eprintln!( - "[WARN] Failed to show existing windows for {:?}: {}", - mode, err - ); - } else { - reused_existing = true; + window_registry().with_mode_lock(mode, || { + let mut reused_existing = false; + if mode_windows_ready(app, mode) { + let should_show = mode != Mode::Zoom; + if let Err(err) = show_mode_windows_inner(app, mode, should_show) { + eprintln!( + "[WARN] Failed to show existing windows for {:?}: {}", + mode, err + ); + } else { + reused_existing = true; + } } - } - if !reused_existing { - match detect_monitors(app) { - Ok(monitors) => { - if let Err(err) = destroy_mode_windows(app, mode) { - eprintln!("[ERROR] Failed to destroy windows for {:?}: {}", mode, err); + if !reused_existing { + match detect_monitors(app) { + Ok(monitors) => { + if let Err(err) = destroy_mode_windows(app, mode) { + eprintln!("[ERROR] Failed to destroy windows for {:?}: {}", mode, err); + } + if let Err(err) = build_mode_windows_inner(app, mode, &monitors, true) { + eprintln!("[ERROR] Failed to build windows for {:?}: {}", mode, err); + } } - if let Err(err) = build_mode_windows_inner(app, mode, &monitors, true) { - eprintln!("[ERROR] Failed to build windows for {:?}: {}", mode, err); + Err(err) => { + eprintln!("[ERROR] Failed to detect monitors: {}", err); } } - Err(err) => { - eprintln!("[ERROR] Failed to detect monitors: {}", err); - } } - } + }); } else if mode != Mode::Zoom { hide_mode_windows_inner(app, mode); } @@ -509,7 +549,7 @@ pub fn apply_zoom_capture_exclusion(window: &tauri::WebviewWindow) { pub fn set_zoom_capture_exclusion(excluded: bool) { use windows::Win32::Foundation::HWND; use windows::Win32::UI::WindowsAndMessaging::{ - SetWindowDisplayAffinity, WINDOW_DISPLAY_AFFINITY, + IsWindow, SetWindowDisplayAffinity, WINDOW_DISPLAY_AFFINITY, }; let ptr = ZOOM_HWND.load(Ordering::Relaxed); @@ -519,6 +559,9 @@ pub fn set_zoom_capture_exclusion(excluded: bool) { let hwnd = HWND(ptr); unsafe { + if !IsWindow(Some(hwnd)).as_bool() { + return; + } if excluded { let _ = SetWindowDisplayAffinity(hwnd, WINDOW_DISPLAY_AFFINITY(0x11)); } else { @@ -526,3 +569,8 @@ pub fn set_zoom_capture_exclusion(excluded: bool) { } } } + +#[cfg(target_os = "windows")] +fn clear_zoom_hwnd() { + ZOOM_HWND.store(std::ptr::null_mut(), Ordering::Relaxed); +} diff --git a/src-tauri/src/zoom.rs b/src-tauri/src/zoom.rs index 1ac19e1..3c26a7e 100644 --- a/src-tauri/src/zoom.rs +++ b/src-tauri/src/zoom.rs @@ -12,53 +12,22 @@ use tokio::time::sleep; use windows::Win32::Foundation::POINT; use windows::Win32::UI::WindowsAndMessaging::GetCursorPos; +const MAX_ZOOM_REGION: i32 = 8192; + static ZOOM_STREAM: OnceLock> = OnceLock::new(); pub fn zoom_stream_handle() -> &'static ArcSwapOption { ZOOM_STREAM.get_or_init(ArcSwapOption::empty) } -async fn capture_viewport() -> Result { - tokio::task::spawn_blocking(|| { - let mut duplicator = - DxgiDuplicator::new_for_point(0, 0).map_err(|e| format!("DxgiDuplicator: {e}"))?; - - let (rgba, width, height) = duplicator - .capture_full_frame() - .map_err(|e| format!("capture_full_frame: {e}"))?; - - let mut png_data = Vec::new(); - { - let mut encoder = png::Encoder::new(std::io::Cursor::new(&mut png_data), width, height); - encoder.set_color(png::ColorType::Rgba); - encoder.set_depth(png::BitDepth::Eight); - let mut writer = encoder - .write_header() - .map_err(|e| format!("PNG header: {e}"))?; - writer - .write_image_data(&rgba) - .map_err(|e| format!("PNG write: {e}"))?; - } - - use base64::Engine as _; - let base64_string = base64::engine::general_purpose::STANDARD.encode(&png_data); - let data_url = format!("data:image/png;base64,{}", base64_string); - Ok(data_url) - }) - .await - .map_err(|e| format!("spawn_blocking: {e}"))? -} - use std::sync::Mutex; static CACHED_DUPLICATOR: Mutex> = Mutex::new(None); -fn capture_zoom_region_raw_sync( +fn with_cached_duplicator_for_point( cursor_x: i32, cursor_y: i32, - size: u32, - zoom_level: f32, - _filter: ZoomFilter, -) -> Result { + f: impl FnOnce(&mut DxgiDuplicator) -> Result, +) -> Result { let mut cache = CACHED_DUPLICATOR .lock() .map_err(|_| "Mutex locked".to_string())?; @@ -79,40 +48,73 @@ fn capture_zoom_region_raw_sync( } let dup = cache.as_mut().unwrap(); + f(dup).inspect_err(|e| { + if e.contains("ACCESS_LOST") { + *cache = None; + } + }) +} - let origin_x = dup.origin_x; - let origin_y = dup.origin_y; - let level = zoom_level.max(1.0); - let region = ((size as f32) / level).round().max(1.0) as i32; - let half = region / 2; - let local_x = cursor_x - origin_x - half; - let local_y = cursor_y - origin_y - half; - - let (rgba, width, height) = dup - .capture_region(local_x, local_y, region as u32, region as u32) - .inspect_err(|e| { - if e.contains("ACCESS_LOST") { - *cache = None; - } - })?; +fn capture_zoom_region_raw_sync( + cursor_x: i32, + cursor_y: i32, + size: u32, + zoom_level: f32, + _filter: ZoomFilter, +) -> Result { + with_cached_duplicator_for_point(cursor_x, cursor_y, |dup| { + let origin_x = dup.origin_x; + let origin_y = dup.origin_y; + let level = zoom_level.max(1.0); + let region = ((size as f32) / level) + .round() + .clamp(1.0, MAX_ZOOM_REGION as f32) as i32; + let half = region / 2; + let local_x = cursor_x - origin_x - half; + let local_y = cursor_y - origin_y - half; + + let (rgba, width, height) = + dup.capture_region(local_x, local_y, region as u32, region as u32)?; - use base64::Engine as _; - let data = base64::engine::general_purpose::STANDARD.encode(&rgba); - Ok(ZoomRegionRawPayload { - data, - width, - height, + use base64::Engine as _; + let data = base64::engine::general_purpose::STANDARD.encode(&rgba); + Ok(ZoomRegionRawPayload { + data, + width, + height, + }) }) } +#[cfg(target_os = "windows")] +struct GdiDcGuard { + hdc_screen: windows::Win32::Graphics::Gdi::HDC, + hdc_mem: windows::Win32::Graphics::Gdi::HDC, + hbm: windows::Win32::Graphics::Gdi::HBITMAP, + hbm_old: windows::Win32::Graphics::Gdi::HGDIOBJ, +} + +#[cfg(target_os = "windows")] +impl Drop for GdiDcGuard { + fn drop(&mut self) { + use windows::Win32::Graphics::Gdi::{DeleteDC, DeleteObject, ReleaseDC, SelectObject}; + unsafe { + SelectObject(self.hdc_mem, self.hbm_old); + let _ = DeleteObject(self.hbm.into()); + let _ = DeleteDC(self.hdc_mem); + let _ = ReleaseDC(None, self.hdc_screen); + } + } +} + #[cfg(target_os = "windows")] fn capture_gdi_screenshot(cursor_x: i32, cursor_y: i32) -> Result<(Vec, u32, u32), String> { use std::mem; use windows::Win32::Foundation::POINT; use windows::Win32::Graphics::Gdi::{ - BitBlt, CreateCompatibleBitmap, CreateCompatibleDC, DeleteDC, DeleteObject, GetDC, - GetDIBits, GetMonitorInfoW, MonitorFromPoint, SelectObject, BITMAPINFO, BITMAPINFOHEADER, - BI_RGB, DIB_RGB_COLORS, MONITORINFO, MONITOR_DEFAULTTONEAREST, SRCCOPY, + BitBlt, CreateCompatibleBitmap, CreateCompatibleDC, GetDC, GetDIBits, GetMonitorInfoW, + MonitorFromPoint, SelectObject, BITMAPINFO, BITMAPINFOHEADER, BI_RGB, DIB_RGB_COLORS, + MONITORINFO, MONITOR_DEFAULTTONEAREST, SRCCOPY, }; unsafe { @@ -134,9 +136,15 @@ fn capture_gdi_screenshot(cursor_x: i32, cursor_y: i32) -> Result<(Vec, u32, let hdc_screen = GetDC(None); let hdc_mem = CreateCompatibleDC(Some(hdc_screen)); let hbm = CreateCompatibleBitmap(hdc_screen, width as i32, height as i32); - let hbm_old = SelectObject(hdc_mem, hbm.into()); + let _dc_guard = GdiDcGuard { + hdc_screen, + hdc_mem, + hbm, + hbm_old, + }; + BitBlt( hdc_mem, 0, @@ -150,8 +158,6 @@ fn capture_gdi_screenshot(cursor_x: i32, cursor_y: i32) -> Result<(Vec, u32, ) .map_err(|e| format!("BitBlt failed: {e}"))?; - SelectObject(hdc_mem, hbm_old); - let mut bmi: BITMAPINFO = mem::zeroed(); bmi.bmiHeader.biSize = mem::size_of::() as u32; bmi.bmiHeader.biWidth = width as i32; @@ -172,10 +178,6 @@ fn capture_gdi_screenshot(cursor_x: i32, cursor_y: i32) -> Result<(Vec, u32, DIB_RGB_COLORS, ); - let _ = DeleteObject(hbm.into()); - let _ = DeleteDC(hdc_mem); - let _ = windows::Win32::Graphics::Gdi::ReleaseDC(None, hdc_screen); - if res == 0 { return Err("GetDIBits failed".to_string()); } @@ -351,17 +353,25 @@ pub fn set_zoom_stream_config(size: u32, zoom_level: f32) -> Result<(), String> } #[tauri::command] -pub fn stop_zoom_stream() -> Result<(), String> { +pub async fn stop_zoom_stream() -> Result<(), String> { let handle_store = zoom_stream_handle(); if let Some(existing) = handle_store.swap(None) { existing.stop.store(true, Ordering::Relaxed); - existing.task.abort(); + let ZoomStreamHandle { mut task, .. } = match Arc::try_unwrap(existing) { + Ok(handle) => handle, + Err(shared) => { + shared.task.abort(); + return Ok(()); + } + }; + if tokio::time::timeout(Duration::from_secs(3), &mut task) + .await + .is_err() + { + // Loop task did not wind down in time (e.g. stuck DXGI capture + // holding CACHED_DUPLICATOR); abort as a last resort. + task.abort(); + } } Ok(()) } - -#[tauri::command] -pub async fn capture_viewport_without_zoom(app: tauri::AppHandle) -> Result { - let _ = app; - capture_viewport().await -} diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index dd4aab5..9cd3050 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -16,12 +16,11 @@ "maximized": true, "visible": true, "center": true, - "focus": true, - "devtools": true + "focus": true } ], "security": { - "csp": "default-src 'self'; img-src 'self' asset: https://asset.localhost blob: data:; font-src 'self'; style-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-inline' 'unsafe-eval';" + "csp": "default-src 'self'; img-src 'self' asset: https://asset.localhost blob: data:; font-src 'self'; style-src 'self' 'unsafe-inline'; script-src 'self';" } }, "bundle": { diff --git a/src-tauri/tauri.microsoftstore.conf.json b/src-tauri/tauri.microsoftstore.conf.json index 08be1fd..248d0ea 100644 --- a/src-tauri/tauri.microsoftstore.conf.json +++ b/src-tauri/tauri.microsoftstore.conf.json @@ -16,12 +16,11 @@ "maximized": true, "visible": true, "center": true, - "focus": true, - "devtools": true + "focus": true } ], "security": { - "csp": "default-src 'self'; img-src 'self' asset: https://asset.localhost blob: data:; font-src 'self'; style-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-inline' 'unsafe-eval';" + "csp": "default-src 'self'; img-src 'self' asset: https://asset.localhost blob: data:; font-src 'self'; style-src 'self' 'unsafe-inline'; script-src 'self';" } }, "bundle": { diff --git a/src/App.vue b/src/App.vue index 98bc9bb..1a6ab17 100644 --- a/src/App.vue +++ b/src/App.vue @@ -66,11 +66,18 @@ onMounted(() => { diff --git a/src/components/app/AppPanel.vue b/src/components/app/AppPanel.vue index 003a479..8d0f896 100644 --- a/src/components/app/AppPanel.vue +++ b/src/components/app/AppPanel.vue @@ -28,6 +28,7 @@ const emit = defineEmits<{ (event: "reset-preferences"): void; (event: "reset-shortcuts"): void; (event: "open-config"): void; + (event: "open-onboarding"): void; }>(); const isConfirmModalOpen = ref(false); @@ -115,6 +116,7 @@ function handleResetShortcuts() { :start-with-windows="props.startWithWindows" @toggle-start-with-windows="emit('toggle-start-with-windows', $event)" @request-reset-preferences="handleResetPreferences" + @open-onboarding="emit('open-onboarding')" /> diff --git a/src/components/app/AppShell.vue b/src/components/app/AppShell.vue index 1f173e9..f457ddc 100644 --- a/src/components/app/AppShell.vue +++ b/src/components/app/AppShell.vue @@ -19,6 +19,7 @@ import { useSettingsStore } from "../../stores/settings"; import { useToastStore } from "../../stores/toast"; import { useToolsStore } from "../../stores/tools"; import type { OverlayPayload } from "../../types/overlay"; +import OnboardingModal from "../modals/OnboardingModal.vue"; import AppPanel from "./AppPanel.vue"; const toolsStore = useToolsStore(); @@ -41,6 +42,8 @@ const { cursorHighlightColor, cursorHighlightSize, cursorHighlightShape, + cursorHighlightBorderWidth, + cursorHighlightFillOpacity, shortcutMap, modeShortcutsEnabled, spotlightBackdrop, @@ -55,6 +58,7 @@ const { restorePreferencesOnLaunch, whiteboardGridEnabled, autoEraseEnabled, + onboardingCompleted, } = storeToRefs(settingsStore); const { enabledTools, overlayDockOrientation, selectedTool } = @@ -185,6 +189,20 @@ watch(zoomWindowVisible, (visible) => { }); const tabs = ["Inicio", "Hotkeys", "Configuración"] as const; const activeTab = ref<(typeof tabs)[number]>("Inicio"); + +const showOnboarding = ref(false); + +function openOnboarding() { + showOnboarding.value = true; +} + +function handleOnboardingClose() { + showOnboarding.value = false; + if (!onboardingCompleted.value) { + settingsStore.setOnboardingCompleted(true); + } +} + const { t, locale } = useI18n(); async function updateTrayMenu() { @@ -260,12 +278,20 @@ watch( ); watch( - [cursorHighlightColor, cursorHighlightSize, cursorHighlightShape], + [ + cursorHighlightColor, + cursorHighlightSize, + cursorHighlightShape, + cursorHighlightBorderWidth, + cursorHighlightFillOpacity, + ], () => { emit("cursor-highlight-settings", { cursorHighlightColor: cursorHighlightColor.value, cursorHighlightSize: cursorHighlightSize.value, cursorHighlightShape: cursorHighlightShape.value, + cursorHighlightBorderWidth: cursorHighlightBorderWidth.value, + cursorHighlightFillOpacity: cursorHighlightFillOpacity.value, }); }, { immediate: true }, @@ -339,6 +365,9 @@ onMounted(async () => { overlayStore.hydrate(), settingsStore.hydrate(), ]); + if (!onboardingCompleted.value) { + showOnboarding.value = true; + } unlistenOverlaySyncRequest = await listen("overlay-sync-request", () => { emit("overlay-sync", overlayPayload.value); }); @@ -349,6 +378,8 @@ onMounted(async () => { cursorHighlightColor: cursorHighlightColor.value, cursorHighlightSize: cursorHighlightSize.value, cursorHighlightShape: cursorHighlightShape.value, + cursorHighlightBorderWidth: cursorHighlightBorderWidth.value, + cursorHighlightFillOpacity: cursorHighlightFillOpacity.value, }); }, ); @@ -506,16 +537,18 @@ onBeforeUnmount(() => { @open-whiteboard="toggleWhiteboard" @reset-preferences="handleResetPreferences" @reset-shortcuts="resetShortcuts" + @open-onboarding="openOnboarding" /> + diff --git a/src/components/modals/StyleEditorModal.vue b/src/components/modals/StyleEditorModal.vue index 8c52fba..1cf60ac 100644 --- a/src/components/modals/StyleEditorModal.vue +++ b/src/components/modals/StyleEditorModal.vue @@ -7,6 +7,7 @@ import type { } from "../../composables/useToolState"; import { useToolsStore } from "../../stores/tools"; +import { STROKE_DEFAULT } from "../../theme/tokens"; const props = defineProps<{ isOpen: boolean; @@ -63,7 +64,7 @@ function initializeDrafts() { })); hexDrafts.value = {}; draftSlots.value.forEach((slot, index) => { - const base = slot.color ?? "#5dd2ff"; + const base = slot.color ?? STROKE_DEFAULT; hexDrafts.value[`color-${index}`] = base; slot.gradient?.stops?.forEach((stop, stopIndex) => { hexDrafts.value[`stop-${index}-${stopIndex}`] = stop.color; @@ -78,7 +79,7 @@ function slotPreview(slot: QuickColorSlot) { .join(", "); return `linear-gradient(${slot.gradient.angle}deg, ${stops})`; } - return slot.color ?? "#5dd2ff"; + return slot.color ?? STROKE_DEFAULT; } function normalizeHex(value: string) { @@ -187,7 +188,7 @@ watch( @@ -336,7 +337,7 @@ watch( max-height: 80vh; overflow: hidden; background: #0f131c; - border: 1px solid rgba(93, 210, 255, 0.12); + border: 1px solid rgba(var(--color-accent-soft), 0.12); border-radius: 18px; display: flex; flex-direction: column; @@ -348,7 +349,7 @@ watch( display: flex; align-items: center; justify-content: space-between; - border-bottom: 1px solid rgba(93, 210, 255, 0.08); + border-bottom: 1px solid rgba(var(--color-accent-soft), 0.08); } .modal-header h3 { @@ -359,7 +360,7 @@ watch( } .modal-footer { - border-top: 1px solid rgba(93, 210, 255, 0.08); + border-top: 1px solid rgba(var(--color-accent-soft), 0.08); border-bottom: none; gap: 12px; } @@ -379,7 +380,7 @@ watch( padding: 12px; border-radius: 14px; background: rgba(16, 19, 28, 0.9); - border: 1px solid rgba(93, 210, 255, 0.08); + border: 1px solid rgba(var(--color-accent-soft), 0.08); } .slot-preview { @@ -459,7 +460,7 @@ watch( .select { background: rgba(23, 27, 39, 0.9); - border: 1px solid rgba(93, 210, 255, 0.1); + border: 1px solid rgba(var(--color-accent-soft), 0.1); border-radius: 10px; padding: 8px 10px; color: #e6e9f2; @@ -477,7 +478,7 @@ watch( "Courier New", monospace; text-transform: uppercase; background: rgba(23, 27, 39, 0.9); - border: 1px solid rgba(93, 210, 255, 0.1); + border: 1px solid rgba(var(--color-accent-soft), 0.1); border-radius: 8px; padding: 6px 8px; color: #e6e9f2; @@ -495,7 +496,7 @@ watch( font-size: 13px; font-weight: 500; cursor: pointer; - border: 1px solid rgba(93, 210, 255, 0.2); + border: 1px solid rgba(var(--color-accent-soft), 0.2); background: rgba(12, 16, 24, 0.95); color: #c7cfe2; transition: all 0.2s ease; @@ -503,11 +504,11 @@ watch( .chip:hover { background: rgba(20, 24, 35, 0.95); - border-color: rgba(93, 210, 255, 0.3); + border-color: rgba(var(--color-accent-soft), 0.3); } .chip.active { - background: #5dd2ff; + background: var(--color-accent); color: #0a0c12; border-color: transparent; font-weight: 600; diff --git a/src/components/overlay/CursorHighlightShell.vue b/src/components/overlay/CursorHighlightShell.vue index 3f7e089..1487ccd 100644 --- a/src/components/overlay/CursorHighlightShell.vue +++ b/src/components/overlay/CursorHighlightShell.vue @@ -7,8 +7,21 @@ import { useSettingsStore } from "../../stores/settings"; import type { AppSettings } from "../../types/settings"; const settingsStore = useSettingsStore(); -const { cursorHighlightColor, cursorHighlightSize, cursorHighlightShape } = - storeToRefs(settingsStore); +const { + cursorHighlightColor, + cursorHighlightSize, + cursorHighlightShape, + cursorHighlightBorderWidth, + cursorHighlightFillOpacity, +} = storeToRefs(settingsStore); + +function hexToRgba(hex: string, alpha: number): string { + const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex); + const r = result ? Number.parseInt(result[1], 16) : 255; + const g = result ? Number.parseInt(result[2], 16) : 255; + const b = result ? Number.parseInt(result[3], 16) : 255; + return `rgba(${r}, ${g}, ${b}, ${alpha})`; +} const { toLocalCoordinates /* , monitorContext */ } = useMonitorContext(); @@ -29,10 +42,16 @@ let unlistenVisibility: (() => void) | null = null; const haloStyle = computed(() => { const size = cursorHighlightSize.value; + const fill = hexToRgba( + cursorHighlightColor.value, + cursorHighlightFillOpacity.value, + ); return { width: `${size}px`, height: `${size}px`, + borderWidth: `${cursorHighlightBorderWidth.value}px`, borderColor: cursorHighlightColor.value, + background: `radial-gradient(circle at center, ${fill}, transparent 70%)`, boxShadow: `0 0 30px ${cursorHighlightColor.value}66`, "--x": `${position.value.x - size / 2}px`, "--y": `${position.value.y - size / 2}px`, @@ -56,6 +75,14 @@ onMounted(async () => { if (event.payload.cursorHighlightShape) { cursorHighlightShape.value = event.payload.cursorHighlightShape; } + if (typeof event.payload.cursorHighlightBorderWidth === "number") { + cursorHighlightBorderWidth.value = + event.payload.cursorHighlightBorderWidth; + } + if (typeof event.payload.cursorHighlightFillOpacity === "number") { + cursorHighlightFillOpacity.value = + event.payload.cursorHighlightFillOpacity; + } }, ); @@ -143,11 +170,7 @@ onBeforeUnmount(() => { .cursor-halo { position: absolute; border-radius: 999px; - border: 2px solid; - background: radial-gradient( - circle at center, - rgba(255, 255, 255, 0.1) transparent 70% - ); + border-style: solid; transform: translate(var(--x), var(--y)); } diff --git a/src/components/overlay/OverlayShell.vue b/src/components/overlay/OverlayShell.vue index af2f730..60eadff 100644 --- a/src/components/overlay/OverlayShell.vue +++ b/src/components/overlay/OverlayShell.vue @@ -211,8 +211,8 @@ function handleClear() { emit("overlay-clear"); } -function handleHideDock() { - dockHidden.value = true; +function handleCloseOverlay() { + invoke("set_overlay_visible", { visible: false }); } function handleToggleDock() { @@ -392,9 +392,10 @@ onMounted(async () => { }); keydownListener = (event: KeyboardEvent) => { if (event.key !== "Escape") return; + if (canvasStageRef.value?.isTextEditing) return; event.preventDefault(); event.stopPropagation(); - handleClear(); + handleCloseOverlay(); }; localShortcutListener = (event) => handleLocalShortcut(event); globalThis.addEventListener("keydown", keydownListener); @@ -508,7 +509,7 @@ onBeforeUnmount(() => { @redo="handleRedo" @drag-handle="startDockDrag" @open-config="handleOpenConfig" - @close-dock="handleHideDock" + @close-dock="handleCloseOverlay" /> diff --git a/src/components/overlay/SpotlightShell.vue b/src/components/overlay/SpotlightShell.vue index e5a87fc..9a67140 100644 --- a/src/components/overlay/SpotlightShell.vue +++ b/src/components/overlay/SpotlightShell.vue @@ -120,7 +120,7 @@ onBeforeUnmount(() => { position: absolute; border-radius: 999px; border: 2px solid rgba(255, 255, 255, 0.4); - box-shadow: 0 0 20px rgba(93, 210, 255, 0.3); + box-shadow: 0 0 20px rgba(var(--color-accent-soft), 0.3); transition: transform 0.02s linear; } diff --git a/src/components/overlay/ZoomShell.vue b/src/components/overlay/ZoomShell.vue index 86a1514..5aa40d0 100644 --- a/src/components/overlay/ZoomShell.vue +++ b/src/components/overlay/ZoomShell.vue @@ -360,11 +360,13 @@ onMounted(async () => { ); sourceBuffer = sourceImageData.data; } - if (sourceImageData) { + if (sourceImageData && sourceBuffer) { const decoded = decodeBase64(event.payload.data); - sourceBuffer?.set(decoded); - sourceCtx.putImageData(sourceImageData, 0, 0); - screenshot.value = sourceCanvas; + if (decoded.length === sourceBuffer.length) { + sourceBuffer.set(decoded); + sourceCtx.putImageData(sourceImageData, 0, 0); + screenshot.value = sourceCanvas; + } } position.value = { x: event.payload.cursor_x, y: event.payload.cursor_y }; updateCursorVelocity(event.payload.cursor_x, event.payload.cursor_y); diff --git a/src/components/shared/CanvasStage.vue b/src/components/shared/CanvasStage.vue index 105e558..ac5fd45 100644 --- a/src/components/shared/CanvasStage.vue +++ b/src/components/shared/CanvasStage.vue @@ -1,5 +1,5 @@