From 0534578c03ae70e802700393d0c114eacacc48fe Mon Sep 17 00:00:00 2001 From: Kresna Date: Tue, 21 Jul 2026 21:46:47 +0700 Subject: [PATCH 1/2] fix(recording): keep the window restorable while recording (minimize, not hide) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase C switched hide_main_window to window.hide() for snappier screenshots, but recording keeps the window out of view for the whole session — and a hidden window can't be brought back from the dock, so users got locked out of the app mid-recording (most obvious when recording an extended display, where GWT sits on the still-visible main screen). Add a minimize_main_window command and use it for the recording-start hide so the window can be restored from the dock. Screenshots keep the instant hide() (they auto-restore a moment later). Region-selection hide is short-lived and unchanged. macOS builds clean; 385 JS tests pass. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_013dqouzFP8vy9jaKhTDFj5H --- src-tauri/src/commands.rs | 15 ++++++++++++++- src-tauri/src/main.rs | 1 + src/islands/media/ScreenRecorder.tsx | 13 ++++++++----- 3 files changed, 23 insertions(+), 6 deletions(-) diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index f5bddcb..a891412 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -775,13 +775,26 @@ pub async fn hide_main_window(app: tauri::AppHandle) -> Result<(), String> { if let Some(window) = app.get_webview_window("main") { // hide() removes the window in ~1 frame; minimize() plays a ~250ms // macOS genie animation that has to be waited out before capturing. - // Instant hide is what lets the screenshot flow feel snappy. + // Instant hide is what lets the screenshot flow feel snappy. Used where + // the window is restored automatically a moment later (screenshots). window.hide().map_err(|e: tauri::Error| e.to_string())?; println!("[Window] Main window hidden"); } Ok(()) } +/// Minimize (not hide) the main window. Used for recording, where the window +/// stays out of view for the whole session: a minimized window can be brought +/// back from the dock, but a fully-hidden one can't — which locked users out. +#[tauri::command] +pub async fn minimize_main_window(app: tauri::AppHandle) -> Result<(), String> { + if let Some(window) = app.get_webview_window("main") { + window.minimize().map_err(|e: tauri::Error| e.to_string())?; + println!("[Window] Main window minimized"); + } + Ok(()) +} + #[tauri::command] pub async fn show_main_window(app: tauri::AppHandle) -> Result<(), String> { if let Some(window) = app.get_webview_window("main") { diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index 8d06c75..1c8f488 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -44,6 +44,7 @@ fn main() { commands::show_countdown, commands::close_countdown, commands::hide_main_window, + commands::minimize_main_window, commands::show_main_window, commands::get_cursor_position, commands::check_permissions, diff --git a/src/islands/media/ScreenRecorder.tsx b/src/islands/media/ScreenRecorder.tsx index 8bfdb02..f30a558 100644 --- a/src/islands/media/ScreenRecorder.tsx +++ b/src/islands/media/ScreenRecorder.tsx @@ -156,13 +156,16 @@ export default function ScreenRecorder() { if (inTauriApp) { const { invoke } = await import('@tauri-apps/api/core'); - // Hide window if user enabled the option + // Hide window if user enabled the option. + // Use MINIMIZE (not hide) for recording: the window stays hidden for the + // whole session, and a minimized window can be restored from the dock — + // a fully-hidden one can't, which locked the user out mid-recording. if (hideWindow) { - console.log('[ScreenRecorder] Hiding window for cleaner capture'); - await invoke('hide_main_window'); + console.log('[ScreenRecorder] Minimizing window for cleaner capture'); + await invoke('minimize_main_window'); windowHiddenRef.current = true; - // hide() is instant; a couple of frames is enough for the compositor. - await new Promise(resolve => setTimeout(resolve, 60)); + // Let the minimize animation settle before capture begins. + await new Promise(resolve => setTimeout(resolve, 250)); } else { console.log('[ScreenRecorder] Keeping window visible (user preference)'); windowHiddenRef.current = false; From e394c7183d7c0258f103f9c88e3d45c2a1fb4cca Mon Sep 17 00:00:00 2001 From: Kresna Date: Tue, 21 Jul 2026 21:53:25 +0700 Subject: [PATCH 2/2] =?UTF-8?q?fix(recording):=20stop=20the=20=E2=8C=98?= =?UTF-8?q?=E2=87=A7R=20hotkey=20from=20dying=20after=20start/stop?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The recording hotkey was registered in an effect keyed on [recording, stopping], so it unregistered + re-registered on every start/stop. That async churn raced — the re-register could hit "already registered" and silently fail, leaving ⌘⇧R dead so it no longer toggled recording. Register it ONCE (keyed on [inTauriApp]) and route the callback through a ref that always holds the current toggle logic, so state stays fresh without re-registering. 385 tests pass; file lints clean. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_013dqouzFP8vy9jaKhTDFj5H --- src/islands/media/ScreenRecorder.tsx | 56 ++++++++++++++-------------- 1 file changed, 28 insertions(+), 28 deletions(-) diff --git a/src/islands/media/ScreenRecorder.tsx b/src/islands/media/ScreenRecorder.tsx index f30a558..d579b85 100644 --- a/src/islands/media/ScreenRecorder.tsx +++ b/src/islands/media/ScreenRecorder.tsx @@ -57,6 +57,8 @@ export default function ScreenRecorder() { const extRef = useRef('webm'); const timerRef = useRef | null>(null); const windowHiddenRef = useRef(false); + // Always holds the latest toggle logic so the hotkey callback never goes stale. + const toggleRecordingRef = useRef<() => void>(() => {}); useEffect(() => { // Check if recording is supported (browser or Tauri) @@ -105,41 +107,33 @@ export default function ScreenRecorder() { useEffect(() => () => { if (resultUrl) URL.revokeObjectURL(resultUrl); }, [resultUrl]); - // Register global hotkey for screen recording (Tauri only) + // Register the global recording hotkey ONCE (Tauri only). Registering it in an + // effect keyed on [recording, stopping] re-registered on every start/stop; the + // async unregister/register raced and could hit "already registered", silently + // killing the shortcut. A single stable registration that reads the ref fixes it. useEffect(() => { if (!inTauriApp) return; let hotkeyId: string | null = null; - - const registerHotkey = async () => { - try { - hotkeyId = await hotkeyService.register( - 'CommandOrControl+Shift+R', - () => { - console.log('[ScreenRecorder] Global hotkey triggered'); - // Toggle recording: start if not recording, stop if recording - if (recording) { - stop(); - } else if (!stopping) { - start(); - } - }, - 'Toggle screen recording' - ); - console.log('[ScreenRecorder] Registered global hotkey:', hotkeyId); - } catch (err) { - console.warn('[ScreenRecorder] Failed to register hotkey:', err); - } - }; - - registerHotkey(); + hotkeyService + .register( + 'CommandOrControl+Shift+R', + () => { + console.log('[ScreenRecorder] Recording hotkey triggered'); + toggleRecordingRef.current(); + }, + 'Toggle screen recording', + ) + .then((id) => { + hotkeyId = id; + console.log('[ScreenRecorder] Registered recording hotkey:', id); + }) + .catch((err) => console.warn('[ScreenRecorder] Failed to register hotkey:', err)); return () => { - if (hotkeyId) { - hotkeyService.unregister(hotkeyId).catch(console.warn); - } + if (hotkeyId) hotkeyService.unregister(hotkeyId).catch(console.warn); }; - }, [recording, stopping]); + }, [inTauriApp]); const start = async () => { setError(''); @@ -362,6 +356,12 @@ export default function ScreenRecorder() { downloadService.download(result, `screen-recording.${extRef.current}`); }; + // Keep the hotkey's toggle current with live state (runs every render). + toggleRecordingRef.current = () => { + if (recording) stop(); + else if (!stopping) start(); + }; + const mmss = `${String(Math.floor(elapsed / 60)).padStart(2, '0')}:${String(elapsed % 60).padStart(2, '0')}`; if (!supported) {