diff --git a/apps/desktop-gpui/locales/de.toml b/apps/desktop-gpui/locales/de.toml index d1ba2c5ba4..cb71a925e6 100644 --- a/apps/desktop-gpui/locales/de.toml +++ b/apps/desktop-gpui/locales/de.toml @@ -405,6 +405,8 @@ instant_copy_link = "Nur Link kopieren" instant_copy_link_hint = "Nichts wird geöffnet" instant_open_link = "Link öffnen" instant_open_link_hint = "In Ihrem Browser" +instant_show_cap = "Cap anzeigen" +instant_show_cap_hint = "Zurück zum Rekorder" saved_to_library = "In der Bibliothek gespeichert" screenshot_copy = "Kopieren" screenshot_copy_and_edit = "Kopieren und bearbeiten" diff --git a/apps/desktop-gpui/locales/en.toml b/apps/desktop-gpui/locales/en.toml index b5560c51ac..a131b7e53b 100644 --- a/apps/desktop-gpui/locales/en.toml +++ b/apps/desktop-gpui/locales/en.toml @@ -407,6 +407,8 @@ instant_copy_link = "Copy link only" instant_copy_link_hint = "Nothing opens" instant_open_link = "Open link" instant_open_link_hint = "In your browser" +instant_show_cap = "Show Cap" +instant_show_cap_hint = "Back to the recorder" saved_to_library = "Saved to library" screenshot_copy = "Copy" screenshot_copy_and_edit = "Copy and edit" diff --git a/apps/desktop-gpui/locales/es.toml b/apps/desktop-gpui/locales/es.toml index ef1f98c301..a7f14bdb95 100644 --- a/apps/desktop-gpui/locales/es.toml +++ b/apps/desktop-gpui/locales/es.toml @@ -405,6 +405,8 @@ instant_copy_link = "Solo copiar el enlace" instant_copy_link_hint = "No se abre nada" instant_open_link = "Abrir el enlace" instant_open_link_hint = "En tu navegador" +instant_show_cap = "Mostrar Cap" +instant_show_cap_hint = "Volver a la grabadora" saved_to_library = "Se guarda en la biblioteca" screenshot_copy = "Copiar" screenshot_copy_and_edit = "Copiar y editar" diff --git a/apps/desktop-gpui/locales/fr.toml b/apps/desktop-gpui/locales/fr.toml index 0b981c420d..fc0ba2fb48 100644 --- a/apps/desktop-gpui/locales/fr.toml +++ b/apps/desktop-gpui/locales/fr.toml @@ -405,6 +405,8 @@ instant_copy_link = "Copier le lien seulement" instant_copy_link_hint = "Rien ne s'ouvre" instant_open_link = "Ouvrir le lien" instant_open_link_hint = "Dans votre navigateur" +instant_show_cap = "Afficher Cap" +instant_show_cap_hint = "Retour à l'enregistreur" saved_to_library = "Enregistré dans la bibliothèque" screenshot_copy = "Copier" screenshot_copy_and_edit = "Copier et modifier" diff --git a/apps/desktop-gpui/locales/hi.toml b/apps/desktop-gpui/locales/hi.toml index 8bff040928..cb8580cf65 100644 --- a/apps/desktop-gpui/locales/hi.toml +++ b/apps/desktop-gpui/locales/hi.toml @@ -405,6 +405,8 @@ instant_copy_link = "सिर्फ़ लिंक कॉपी करें" instant_copy_link_hint = "कुछ नहीं खुलता" instant_open_link = "लिंक खोलें" instant_open_link_hint = "आपके ब्राउज़र में" +instant_show_cap = "Cap दिखाएँ" +instant_show_cap_hint = "रिकॉर्डर पर वापस" saved_to_library = "लाइब्रेरी में सेव" screenshot_copy = "कॉपी करें" screenshot_copy_and_edit = "कॉपी और एडिट" diff --git a/apps/desktop-gpui/locales/ja.toml b/apps/desktop-gpui/locales/ja.toml index c2854b0058..b399668f13 100644 --- a/apps/desktop-gpui/locales/ja.toml +++ b/apps/desktop-gpui/locales/ja.toml @@ -405,6 +405,8 @@ instant_copy_link = "リンクのコピーのみ" instant_copy_link_hint = "何も開きません" instant_open_link = "リンクを開く" instant_open_link_hint = "ブラウザで" +instant_show_cap = "Capを表示" +instant_show_cap_hint = "レコーダーに戻る" saved_to_library = "ライブラリに保存" screenshot_copy = "コピー" screenshot_copy_and_edit = "コピーして編集" diff --git a/apps/desktop-gpui/locales/zh-CN.toml b/apps/desktop-gpui/locales/zh-CN.toml index ecc9cadf58..74f089d808 100644 --- a/apps/desktop-gpui/locales/zh-CN.toml +++ b/apps/desktop-gpui/locales/zh-CN.toml @@ -405,6 +405,8 @@ instant_copy_link = "仅复制链接" instant_copy_link_hint = "不打开任何内容" instant_open_link = "打开链接" instant_open_link_hint = "在浏览器中" +instant_show_cap = "显示 Cap" +instant_show_cap_hint = "回到录制器" saved_to_library = "保存到资料库" screenshot_copy = "复制" screenshot_copy_and_edit = "复制并编辑" diff --git a/apps/desktop-gpui/src/app_windows.rs b/apps/desktop-gpui/src/app_windows.rs index 93b540b0bc..ec7ee97363 100644 --- a/apps/desktop-gpui/src/app_windows.rs +++ b/apps/desktop-gpui/src/app_windows.rs @@ -278,6 +278,11 @@ pub struct AppWindows { /// copy/save screenshot finish reads it; the cancel reveal keeps the /// Tauri behaviour above. pub main_visible_before_picker: bool, + /// [`hide_main_window`] put the main window away and + /// [`show_main_window_after_capture_pause`] has not brought it back since. + /// State the app owns, unlike a visibility probe that also reads a + /// minimized window or a hidden app as gone. + pub main_hidden_by_app: bool, /// `hiddenForPicker` in the editor's record modal /// (`ClipsSidebar.tsx:311-341, 413-426`): the editor window that hid /// itself for an editor-owned target picker ("Record a new clip"). A @@ -554,6 +559,7 @@ pub fn init(main: WindowHandle, session: Entity, c screenshot_editors: Vec::new(), main_hidden_for_picker: false, main_visible_before_picker: false, + main_hidden_by_app: false, editor_hidden_for_picker: None, camera_park: CameraPark::default(), #[cfg(target_os = "linux")] @@ -613,12 +619,14 @@ pub fn init(main: WindowHandle, session: Entity, c if !restore_clean_capture_ui(cx) { return; } - let (finished_studio, preparing_editor) = session.update(cx, |session, _| { - ( - session.finished_studio.take(), - session.take_preparing_studio_editor(), - ) - }); + let (finished_studio, preparing_editor, finished_instant) = + session.update(cx, |session, _| { + ( + session.finished_studio.take(), + session.take_preparing_studio_editor(), + std::mem::take(&mut session.finished_instant), + ) + }); // The in-editor re-record target is consumed first, before // `postStudioRecordingBehaviour` is even consulted -- the order // `apply_post_studio_editor_behaviour` checks them @@ -657,8 +665,23 @@ pub fn init(main: WindowHandle, session: Entity, c // away. `doNothing` leaves it in the library with Cap out of // the way. Every other path (instant, failures, `showOverlay` // until an overlay exists) reshows the main window as before. - let behaviour = - crate::store::GeneralSettings::load().post_studio_recording_behaviour; + // A clean instant recording reshows it only for "Show Cap"; + // otherwise the browser tab, or the copied link alone, is + // the completion and Cap stays out of the way. + let settings = crate::store::GeneralSettings::load(); + let behaviour = settings.post_studio_recording_behaviour; + // Linux restores the main window through the clean-capture + // lease before this runs, so it keeps reshowing as before. A + // main window reopened mid-recording (even if minimized since) + // takes the reshow too, which resumes its inputs. + let keep_hidden_after_instant = cfg!(not(target_os = "linux")) + && finished_instant + && settings.post_instant_behaviour() + != crate::store::PostInstantBehaviour::ShowCap + && cx.global::().main_hidden_by_app + && !main + .update(cx, |_, window, _| platform::window_is_visible(window)) + .unwrap_or(true); match (finished_studio, behaviour) { (Some(project_dir), crate::store::PostStudioBehaviour::OpenEditor) => { open_editor(project_dir, cx) @@ -667,6 +690,11 @@ pub fn init(main: WindowHandle, session: Entity, c park_idle_camera_preview(cx); crate::menus::schedule_dock_sync(cx); } + (None, _) if keep_hidden_after_instant => { + tracing::info!("main window kept hidden after the Instant recording"); + park_idle_camera_preview(cx); + crate::menus::schedule_dock_sync(cx); + } _ => show_main_window(cx), } } @@ -870,10 +898,12 @@ pub(crate) fn show_main_window_after_capture_pause(cx: &mut App) { return; } } + tracing::info!("showing the main window"); if crate::store::should_show_onboarding() { open_onboarding(cx); return; } + cx.global_mut::().main_hidden_by_app = false; let resume_inputs = RecordingSession::global(cx).read(cx).phase == Phase::Idle; if resume_inputs { crate::feeds::Feeds::global(cx).update(cx, |feeds, cx| feeds.resume_camera_preview(cx)); @@ -920,6 +950,7 @@ pub(crate) fn show_main_window_after_capture_pause(cx: &mut App) { // before the window is visible again. platform::restore_borderless_style(native); platform::show_native(native); + tracing::info!("main window shown"); } // The unit-3 macOS 26 display-link repair, and the reason a window // hidden before its FIRST paint reshows as nothing at all: AppKit @@ -978,6 +1009,7 @@ pub fn heal_main_window_style(cx: &mut App) { /// `getCurrentWindow().hide()` -- same rule, same reason. pub fn hide_main_window(cx: &mut App) { + cx.global_mut::().main_hidden_by_app = true; let main = cx.global::().main; let native = main .update(cx, |_, window, cx| { @@ -2820,6 +2852,7 @@ fn own_windows(cx: &mut App) -> Vec { screenshot_editors, main_hidden_for_picker: _, main_visible_before_picker: _, + main_hidden_by_app: _, editor_hidden_for_picker: _, camera_park: _, #[cfg(target_os = "linux")] diff --git a/apps/desktop-gpui/src/i18n/settings_surface_tests.rs b/apps/desktop-gpui/src/i18n/settings_surface_tests.rs index 1d1cd608f1..75b4ccc4e3 100644 --- a/apps/desktop-gpui/src/i18n/settings_surface_tests.rs +++ b/apps/desktop-gpui/src/i18n/settings_surface_tests.rs @@ -363,6 +363,14 @@ fn settings_surfaces_match_the_literals_they_replaced() { tr::settings::shortcuts::outcome::instant_open_link_hint(), "In your browser", ), + ( + tr::settings::shortcuts::outcome::instant_show_cap(), + "Show Cap", + ), + ( + tr::settings::shortcuts::outcome::instant_show_cap_hint(), + "Back to the recorder", + ), ( tr::settings::shortcuts::outcome::instant_copy_link(), "Copy link only", diff --git a/apps/desktop-gpui/src/session.rs b/apps/desktop-gpui/src/session.rs index 20ef9f7ecc..dadecc0533 100644 --- a/apps/desktop-gpui/src/session.rs +++ b/apps/desktop-gpui/src/session.rs @@ -434,6 +434,10 @@ pub struct RecordingSession { /// Taken by the phase observer to honour `postStudioRecordingBehaviour` /// (`openEditor` by default) the way the Tauri app does. pub finished_studio: Option, + /// An instant recording stopped cleanly and its share link was handed + /// out. Taken by the phase observer, which brings the main window back + /// only for the "Show Cap" Instant outcome. + pub finished_instant: bool, preparing_studio_editor: Option, /// `EditorRecordingTarget` (`src-tauri/src/windows.rs:3679-3697`): the /// open editor project a "Record a new clip" capture must land back in. @@ -497,6 +501,7 @@ impl RecordingSession { controls_open: false, mic_muted: false, finished_studio: None, + finished_instant: false, preparing_studio_editor: None, editor_recording_target: None, started_at: None, @@ -1378,6 +1383,7 @@ impl RecordingSession { let separator = if link.contains('?') { '&' } else { '?' }; cx.open_url(&format!("{link}{separator}recordingStopped=1")); } + this.finished_instant = !low_storage; cx.write_to_clipboard(gpui::ClipboardItem::new_string(link)); } } diff --git a/apps/desktop-gpui/src/settings_pages.rs b/apps/desktop-gpui/src/settings_pages.rs index d7f73b1ed5..29deb0a3a5 100644 --- a/apps/desktop-gpui/src/settings_pages.rs +++ b/apps/desktop-gpui/src/settings_pages.rs @@ -1023,10 +1023,10 @@ const STUDIO_OUTCOMES: [(store::PostStudioBehaviour, OutcomeOption); 3] = [ ), ]; -/// `disableAutoOpenLinks`, phrased as what an Instant recording does. -const INSTANT_OUTCOMES: [(bool, OutcomeOption); 2] = [ +/// [`store::PostInstantBehaviour`], phrased as what an Instant recording does. +const INSTANT_OUTCOMES: [(store::PostInstantBehaviour, OutcomeOption); 3] = [ ( - false, + store::PostInstantBehaviour::OpenLink, OutcomeOption { icon: "icons/globe.svg", label: tr::settings::shortcuts::outcome::instant_open_link, @@ -1034,7 +1034,15 @@ const INSTANT_OUTCOMES: [(bool, OutcomeOption); 2] = [ }, ), ( - true, + store::PostInstantBehaviour::ShowCap, + OutcomeOption { + icon: "icons/app-window.svg", + label: tr::settings::shortcuts::outcome::instant_show_cap, + hint: tr::settings::shortcuts::outcome::instant_show_cap_hint, + }, + ), + ( + store::PostInstantBehaviour::CopyLink, OutcomeOption { icon: "icons/link.svg", label: tr::settings::shortcuts::outcome::instant_copy_link, @@ -1630,14 +1638,17 @@ impl SettingsWindow { tr::settings::shortcuts::after_instant::hint(), &INSTANT_OUTCOMES .iter() - .map(|(disabled, option)| { - (*option, *disabled == self.settings.disable_auto_open_links) + .map(|(value, option)| { + (*option, *value == self.settings.post_instant_behaviour()) }) .collect::>(), |this, index, cx| { - if let Some((disabled, _)) = INSTANT_OUTCOMES.get(index) { - this.settings.disable_auto_open_links = *disabled; - this.write_bool("disableAutoOpenLinks", *disabled, cx); + if let Some((value, _)) = INSTANT_OUTCOMES.get(index) { + let (disable_open, show_main) = value.store_values(); + this.settings.disable_auto_open_links = disable_open; + this.settings.show_main_window_after_instant = Some(show_main); + this.write_bool("disableAutoOpenLinks", disable_open, cx); + this.write_bool(store::SHOW_MAIN_WINDOW_AFTER_INSTANT_KEY, show_main, cx); } }, cx, @@ -8547,12 +8558,13 @@ mod tests { 1 ); } - for disabled in [false, true] { + for value in [ + store::PostInstantBehaviour::OpenLink, + store::PostInstantBehaviour::ShowCap, + store::PostInstantBehaviour::CopyLink, + ] { assert_eq!( - INSTANT_OUTCOMES - .iter() - .filter(|(v, _)| *v == disabled) - .count(), + INSTANT_OUTCOMES.iter().filter(|(v, _)| *v == value).count(), 1 ); } diff --git a/apps/desktop-gpui/src/store.rs b/apps/desktop-gpui/src/store.rs index 782e5fe91b..fccc0918df 100644 --- a/apps/desktop-gpui/src/store.rs +++ b/apps/desktop-gpui/src/store.rs @@ -1017,6 +1017,11 @@ pub struct GeneralSettings { pub enable_notifications: bool, pub instant_mode_max_resolution: u32, pub disable_auto_open_links: bool, + /// GPUI-only `showMainWindowAfterInstantRecording`: whether the main + /// window comes back after an Instant recording whose link did not open. + /// Kept as stored, absent included, so every reader resolves it the same + /// way in [`Self::post_instant_behaviour`]. + pub show_main_window_after_instant: Option, pub studio_recording_quality: StudioQuality, pub recording_countdown: Option, pub main_window_recording_start_behaviour: MainWindowStartBehaviour, @@ -1059,6 +1064,30 @@ pub struct GeneralSettings { pub studio_sound_by_default: bool, } +pub const SHOW_MAIN_WINDOW_AFTER_INSTANT_KEY: &str = "showMainWindowAfterInstantRecording"; + +/// What follows a cleanly stopped Instant recording, the link always copied. +/// Not one key: `disableAutoOpenLinks` (shared with the Tauri app) decides +/// whether the link opens, `showMainWindowAfterInstantRecording` whether the +/// main window comes back when it does not. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PostInstantBehaviour { + OpenLink, + ShowCap, + CopyLink, +} + +impl PostInstantBehaviour { + /// `(disableAutoOpenLinks, showMainWindowAfterInstantRecording)`. + pub fn store_values(self) -> (bool, bool) { + match self { + Self::OpenLink => (false, false), + Self::ShowCap => (true, true), + Self::CopyLink => (true, false), + } + } +} + /// The section names, so the write calls read as the store keys they are. pub const GENERAL_SETTINGS: &str = "general_settings"; pub const RECORDING_START_SAFETY: &str = "recording_start_safety"; @@ -1360,6 +1389,18 @@ impl Default for GeneralSettings { } impl GeneralSettings { + pub fn post_instant_behaviour(&self) -> PostInstantBehaviour { + if !self.disable_auto_open_links { + PostInstantBehaviour::OpenLink + } else if self.show_main_window_after_instant.unwrap_or(true) { + // Absent: "copy link only" brought the main window back before + // the key existed, so a store that never set it keeps doing that. + PostInstantBehaviour::ShowCap + } else { + PostInstantBehaviour::CopyLink + } + } + pub fn load() -> Self { let store = cached_store(&tauri_store_path()).unwrap_or_default(); let empty = Map::new(); @@ -1383,6 +1424,9 @@ impl GeneralSettings { enable_notifications: bool_at(general, "enableNotifications", true), instant_mode_max_resolution: u32_at(general, "instantModeMaxResolution", 1920), disable_auto_open_links: bool_at(general, "disableAutoOpenLinks", false), + show_main_window_after_instant: general + .get(SHOW_MAIN_WINDOW_AFTER_INSTANT_KEY) + .and_then(Value::as_bool), studio_recording_quality: enum_at(general, "studioRecordingQuality"), recording_countdown: opt_u32_at(general, "recordingCountdown"), main_window_recording_start_behaviour: enum_at( @@ -3358,4 +3402,59 @@ mod tests { let cleared = GeneralSettings::from_sections(&general, &Map::new(), &Map::new()); assert!(cleared.excluded_windows.is_empty()); } + + /// A store written before `showMainWindowAfterInstantRecording` keeps + /// what it did: "copy link only" brought the main window back, so it reads + /// as Show Cap; every outcome round-trips through its two keys. + #[test] + fn post_instant_behaviour_reads_old_stores_and_round_trips() { + let settings = |pairs: &[(&str, bool)]| { + let general = pairs + .iter() + .map(|(key, value)| (key.to_string(), Value::Bool(*value))) + .collect::>(); + GeneralSettings::from_sections(&general, &Map::new(), &Map::new()) + .post_instant_behaviour() + }; + assert_eq!(settings(&[]), PostInstantBehaviour::OpenLink); + assert_eq!( + settings(&[("disableAutoOpenLinks", false)]), + PostInstantBehaviour::OpenLink + ); + assert_eq!( + settings(&[("disableAutoOpenLinks", true)]), + PostInstantBehaviour::ShowCap + ); + assert_eq!( + settings(&[ + ("disableAutoOpenLinks", false), + (SHOW_MAIN_WINDOW_AFTER_INSTANT_KEY, true) + ]), + PostInstantBehaviour::OpenLink + ); + // The Quality page's auto-open toggle writes only + // `disableAutoOpenLinks`; the open window and a reload must agree. + let toggled = GeneralSettings { + disable_auto_open_links: true, + ..GeneralSettings::default() + }; + assert_eq!( + toggled.post_instant_behaviour(), + settings(&[("disableAutoOpenLinks", true)]) + ); + for behaviour in [ + PostInstantBehaviour::OpenLink, + PostInstantBehaviour::ShowCap, + PostInstantBehaviour::CopyLink, + ] { + let (disable_open, show_main) = behaviour.store_values(); + assert_eq!( + settings(&[ + ("disableAutoOpenLinks", disable_open), + (SHOW_MAIN_WINDOW_AFTER_INSTANT_KEY, show_main), + ]), + behaviour + ); + } + } } diff --git a/apps/desktop-gpui/src/upload/real_app_tests.rs b/apps/desktop-gpui/src/upload/real_app_tests.rs index 51851157af..da505c78d5 100644 --- a/apps/desktop-gpui/src/upload/real_app_tests.rs +++ b/apps/desktop-gpui/src/upload/real_app_tests.rs @@ -66,6 +66,13 @@ impl Sandbox { Self { root } } + fn set_general(&self, key: &str, value: Value) { + let path = self.root.path().join("store"); + let mut store: Value = serde_json::from_slice(&std::fs::read(&path).unwrap()).unwrap(); + store["general_settings"][key] = value; + std::fs::write(path, serde_json::to_vec(&store).unwrap()).unwrap(); + } + fn launch(&self, name: &str, env: &[(&str, &str)]) -> App { let binary = std::env::var("CAP_GPUI_REAL_APP_BIN") .expect("set CAP_GPUI_REAL_APP_BIN to the built cap-gpui binary"); @@ -303,6 +310,55 @@ async fn real_app_instant_recording_uploads_and_verifies() { assert!(sandbox.journal().is_empty(), "{:?}", sandbox.journal()); } +/// The After an Instant recording outcome decides whether the main window, +/// hidden at start, comes back once the recording finishes: only Show Cap +/// takes the reshow, so opening the link never leaves it on screen behind the +/// browser. The reshow itself lands on onboarding here (the runner grants no +/// Accessibility), so the check reads the decisions the app logs. Linux +/// restores the window through its clean-capture lease instead, so the check +/// is macOS only. +#[cfg(target_os = "macos")] +#[ignore] +#[tokio::test(flavor = "multi_thread")] +async fn real_app_instant_outcome_decides_whether_main_window_returns() { + for (name, disable_open, show_main, returns) in [ + ("open-link", false, false, false), + ("show-cap", true, true, true), + ("copy-link", true, false, false), + ] { + let mock = MockCap::start(Vec::new()).await; + let sandbox = Sandbox::new(&mock.url, 0); + sandbox.set_general("disableAutoOpenLinks", json!(disable_open)); + sandbox.set_general("showMainWindowAfterInstantRecording", json!(show_main)); + let app = sandbox.launch(name, &[("CAP_GPUI_AUTO_RECORD", "instant:3")]); + wait_for_app( + &sandbox, + &app, + "a verified upload", + Duration::from_secs(180), + || { + sandbox + .projects() + .first() + .and_then(|project| state(project)) + .is_some_and(|state| state["phase"] == "Verified") + }, + ) + .await; + tokio::time::sleep(Duration::from_secs(2)).await; + kill(app); + let log = without_ansi(&sandbox.log(name)); + let (_, after) = log + .split_once("recording finished") + .expect("the recording finished"); + let kept_hidden = after.contains("main window kept hidden after the Instant recording"); + let reshown = after.contains("showing the main window"); + assert_eq!(kept_hidden, !returns, "{name}: {after}"); + assert_eq!(reshown, returns, "{name}: {after}"); + assert_eq!(mock.created().len(), 1, "{name}"); + } +} + /// Kills the app at points from the server video's creation through the /// first seconds of capture, then relaunches long after. Each recording's state /// is noted right after its kill, before anything else runs: a recording that