diff --git a/Cargo.lock b/Cargo.lock index 6f98ac0d924..c6491320b2d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1342,6 +1342,7 @@ version = "0.1.0" dependencies = [ "anyhow", "bytemuck", + "libloading 0.9.0", "ndarray 0.16.1", "ort", "tracing", @@ -9005,10 +9006,14 @@ dependencies = [ "core-graphics 0.24.0", "image 0.24.9", "objc", + "rustix 1.1.2", "serde", "specta", "tokio", "tracing", + "uuid", + "wayland-client", + "wayland-protocols", "windows 0.60.0", "workspace-hack", "x11rb", @@ -9584,6 +9589,12 @@ dependencies = [ "digest", ] +[[package]] +name = "sha1_smol" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbfa15b3dddfee50a0fff136974b3e1bde555604ba463834a7eb7deb6417705d" + [[package]] name = "sha2" version = "0.10.9" @@ -12041,6 +12052,7 @@ dependencies = [ "getrandom 0.3.3", "js-sys", "serde", + "sha1_smol", "wasm-bindgen", ] diff --git a/apps/cli/src/selftest/playback.rs b/apps/cli/src/selftest/playback.rs index 83845d08aa7..6612739e7a7 100644 --- a/apps/cli/src/selftest/playback.rs +++ b/apps/cli/src/selftest/playback.rs @@ -907,6 +907,8 @@ mod fixture { transitions: Vec::new(), zoom_segments: Vec::new(), scene_segments: Vec::new(), + style_segments: Vec::new(), + image_segments: Vec::new(), mask_segments: Vec::new(), text_segments: Vec::new(), caption_segments: Vec::new(), diff --git a/apps/desktop-gpui/Cargo.lock b/apps/desktop-gpui/Cargo.lock index 504a5feeaff..45b26012843 100644 --- a/apps/desktop-gpui/Cargo.lock +++ b/apps/desktop-gpui/Cargo.lock @@ -1451,6 +1451,7 @@ version = "0.1.0" dependencies = [ "anyhow", "bytemuck", + "libloading 0.9.0", "ndarray 0.16.1", "ort", "tracing", @@ -1592,6 +1593,7 @@ dependencies = [ "tracing-subscriber", "tray-icon", "unicode-segmentation", + "wayland-client", "wgpu 25.0.2", "whisper-rs", "windows-sys 0.59.0", @@ -4552,6 +4554,7 @@ dependencies = [ "util_macros", "uuid", "waker-fn", + "wayland-client", "web-time", "windows 0.61.3", "zed-font-kit", @@ -9293,10 +9296,14 @@ dependencies = [ "core-graphics 0.24.0", "image 0.24.9", "objc", + "rustix 1.1.4", "serde", "specta", "tokio", "tracing", + "uuid", + "wayland-client", + "wayland-protocols", "windows 0.60.0", "workspace-hack", "x11rb", diff --git a/apps/desktop-gpui/Cargo.toml b/apps/desktop-gpui/Cargo.toml index 623a243cad7..83aa68e187b 100644 --- a/apps/desktop-gpui/Cargo.toml +++ b/apps/desktop-gpui/Cargo.toml @@ -23,7 +23,7 @@ name = "Cap GPUI" identifier = "so.cap.desktop.gpui" icon = ["assets/dock-icon.png"] category = "public.app-category.productivity" -osx_minimum_system_version = "11.0" +osx_minimum_system_version = "12.3" osx_info_plist_exts = ["resources/Info.plist"] osx_url_schemes = ["cap-desktop"] @@ -188,6 +188,7 @@ windows-sys = { version = "0.59", features = [ [target.'cfg(target_os = "linux")'.dependencies] ashpd = { version = "0.11", default-features = false, features = ["tokio"] } +wayland-client = "0.31" [target.'cfg(all(unix, not(target_os = "macos")))'.dependencies] raw-window-handle = "0.6" diff --git a/apps/desktop-gpui/patches/zed-linux.patch b/apps/desktop-gpui/patches/zed-linux.patch index e84a49f5d16..8229cb96232 100644 --- a/apps/desktop-gpui/patches/zed-linux.patch +++ b/apps/desktop-gpui/patches/zed-linux.patch @@ -1,5 +1,57 @@ +diff --git a/crates/gpui/Cargo.toml b/crates/gpui/Cargo.toml +--- a/crates/gpui/Cargo.toml ++++ b/crates/gpui/Cargo.toml +@@ -29,7 +29,7 @@ + bench = ["test-support", "dep:criterion", "dep:hdrhistogram"] + inspector = ["gpui_macros/inspector"] + leak-detection = ["backtrace"] +-wayland = [] ++wayland = ["dep:wayland-client"] + x11 = [ + "scap?/x11", + ] +@@ -110,6 +110,9 @@ + uuid = { workspace = true, features = ["js"] } + + ++[target.'cfg(target_os = "linux")'.dependencies] ++wayland-client = { version = "0.31.11", optional = true } ++ + [target.'cfg(target_os = "macos")'.dependencies] + block = "0.1" + cocoa.workspace = true +diff --git a/crates/gpui/src/platform.rs b/crates/gpui/src/platform.rs +--- a/crates/gpui/src/platform.rs ++++ b/crates/gpui/src/platform.rs +@@ -795,6 +795,10 @@ + + #[expect(missing_docs)] + pub trait PlatformWindow: HasWindowHandle + HasDisplayHandle { ++ #[cfg(all(target_os = "linux", feature = "wayland"))] ++ fn wayland_surface(&self) -> Option { ++ None ++ } + fn bounds(&self) -> Bounds; + fn is_maximized(&self) -> bool; + fn window_bounds(&self) -> WindowBounds; +diff --git a/crates/gpui/src/window.rs b/crates/gpui/src/window.rs +--- a/crates/gpui/src/window.rs ++++ b/crates/gpui/src/window.rs +@@ -1931,6 +1931,13 @@ + self.handle + } + ++ /// Returns the native Wayland proxy, which tracks destruction without borrowing a raw pointer. ++ #[cfg(all(target_os = "linux", feature = "wayland"))] ++ pub fn wayland_surface(&self) -> Option { ++ self.platform_window.wayland_surface() ++ } ++ ++ + /// Mark the window as dirty, scheduling it to be redrawn on the next frame. + pub fn refresh(&mut self) { + if self.invalidator.not_drawing() { diff --git a/crates/gpui_linux/src/linux/platform.rs b/crates/gpui_linux/src/linux/platform.rs -index 876f931663fa..062af4e8c9b9 100644 --- a/crates/gpui_linux/src/linux/platform.rs +++ b/crates/gpui_linux/src/linux/platform.rs @@ -87,6 +87,9 @@ @@ -12,22 +64,46 @@ index 876f931663fa..062af4e8c9b9 100644 fn read_from_primary(&self) -> Option; fn read_from_clipboard(&self) -> Option; fn active_window(&self) -> Option; -@@ -735,6 +738,10 @@ - self.inner.write_to_clipboard(item) - } +@@ -733,6 +736,10 @@ -+ fn try_write_to_clipboard(&self, item: ClipboardItem) -> anyhow::Result<()> { -+ self.inner.try_write_to_clipboard(item) + fn write_to_clipboard(&self, item: ClipboardItem) { + self.inner.write_to_clipboard(item) + } + - fn read_from_primary(&self) -> Option { - self.inner.read_from_primary() ++ fn try_write_to_clipboard(&self, item: ClipboardItem) -> anyhow::Result<()> { ++ self.inner.try_write_to_clipboard(item) } + + fn read_from_primary(&self) -> Option { diff --git a/crates/gpui_linux/src/linux/wayland/client.rs b/crates/gpui_linux/src/linux/wayland/client.rs -index fdc40a3de87e..c41fa8d6aebb 100644 --- a/crates/gpui_linux/src/linux/wayland/client.rs +++ b/crates/gpui_linux/src/linux/wayland/client.rs -@@ -1121,6 +1121,30 @@ +@@ -200,6 +200,7 @@ + + #[derive(Clone)] + pub struct Globals { ++ pub registry: wl_registry::WlRegistry, + pub qh: QueueHandle, + pub activation: Option, + pub compositor: wl_compositor::WlCompositor, +@@ -232,6 +233,7 @@ + ) -> Self { + let dialog_v = XdgWmDialogV1::interface().version; + Globals { ++ registry: globals.registry().clone(), + activation: globals.bind(&qh, 1..=1, ()).ok(), + compositor: globals + .bind( +@@ -662,7 +664,7 @@ + global.name, + wl_output_version(global.version), + &qh, +- (), ++ global.name, + ); + in_progress_outputs.insert(output.id(), InProgressOutput::default()); + wl_outputs.insert(output.id(), output); +@@ -1121,6 +1123,30 @@ } } @@ -58,7 +134,16 @@ index fdc40a3de87e..c41fa8d6aebb 100644 fn read_from_primary(&self) -> Option { self.0.borrow_mut().clipboard.read_primary() } -@@ -1301,6 +1325,25 @@ +@@ -1262,7 +1288,7 @@ + name, + wl_output_version(version), + qh, +- (), ++ name, + ); + + state +@@ -1301,6 +1327,25 @@ delegate_noop!(WaylandClientStatePtr: ignore wp_viewporter::WpViewporter); delegate_noop!(WaylandClientStatePtr: ignore wp_viewport::WpViewport); @@ -84,8 +169,22 @@ index fdc40a3de87e..c41fa8d6aebb 100644 impl Dispatch for WaylandClientStatePtr { fn event( state: &mut WaylandClientStatePtr, +@@ -1353,12 +1398,12 @@ + } + } + +-impl Dispatch for WaylandClientStatePtr { ++impl Dispatch for WaylandClientStatePtr { + fn event( + this: &mut Self, + output: &wl_output::WlOutput, + event: ::Event, +- _: &(), ++ _: &u32, + _: &Connection, + _: &QueueHandle, + ) { diff --git a/crates/gpui_linux/src/linux/wayland/clipboard.rs b/crates/gpui_linux/src/linux/wayland/clipboard.rs -index dfedf53f6dfa..fb40a8b391f1 100644 --- a/crates/gpui_linux/src/linux/wayland/clipboard.rs +++ b/crates/gpui_linux/src/linux/wayland/clipboard.rs @@ -1,6 +1,6 @@ @@ -117,6 +216,17 @@ index dfedf53f6dfa..fb40a8b391f1 100644 - pub fn send(&self, _mime_type: String, fd: OwnedFd) { - if let Some(text) = self.contents.as_ref().and_then(|contents| contents.text()) { - self.send_internal(fd, text.as_bytes().to_owned()); +- } +- } +- +- pub fn send_primary(&self, _mime_type: String, fd: OwnedFd) { +- if let Some(text) = self +- .primary_contents +- .as_ref() +- .and_then(|contents| contents.text()) +- { +- self.send_internal(fd, text.as_bytes().to_owned()); +- } + pub(crate) fn mime_types(&self, item: &ClipboardItem) -> anyhow::Result> { + let has_text = item + .entries() @@ -182,16 +292,9 @@ index dfedf53f6dfa..fb40a8b391f1 100644 + Err(error) => { + log::error!("Failed to prepare primary clipboard MIME type {mime_type}: {error}") + } - } - } - -- pub fn send_primary(&self, _mime_type: String, fd: OwnedFd) { -- if let Some(text) = self -- .primary_contents -- .as_ref() -- .and_then(|contents| contents.text()) -- { -- self.send_internal(fd, text.as_bytes().to_owned()); ++ } ++ } ++ + fn bytes_for_mime(item: &ClipboardItem, mime_type: &str) -> anyhow::Result>> { + if TEXT_MIME_TYPES.contains(&mime_type) { + return Ok(text_bytes(item)); @@ -203,7 +306,7 @@ index dfedf53f6dfa..fb40a8b391f1 100644 + return Ok(Some( + format!("copy\n{}", file_uris(item)?.join("\n")).into_bytes(), + )); - } ++ } + for entry in item.entries() { + if let ClipboardEntry::Image(image) = entry + && image.format().mime_type() == mime_type @@ -378,28 +481,38 @@ index dfedf53f6dfa..fb40a8b391f1 100644 + .unwrap() ) - .unwrap(); +- } +-} + .unwrap(), + "copy\nfile:///tmp/one.txt\nfile:///tmp/two.txt" + ); - } - } ++ } ++} diff --git a/crates/gpui_linux/src/linux/wayland/window.rs b/crates/gpui_linux/src/linux/wayland/window.rs -index 993b2ff3fadc..e8916c11025f 100644 --- a/crates/gpui_linux/src/linux/wayland/window.rs +++ b/crates/gpui_linux/src/linux/wayland/window.rs -@@ -7,7 +7,7 @@ +@@ -7,15 +7,15 @@ }; - + use collections::{FxHashMap, HashMap}; -use futures::channel::oneshot::Receiver; +use futures::{FutureExt, channel::oneshot::Receiver}; - + use raw_window_handle as rwh; use wayland_backend::client::ObjectId; +-use wayland_client::WEnum; + use wayland_client::{ + Proxy, + protocol::{wl_output, wl_seat, wl_surface}, + }; ++use wayland_client::{WEnum, globals::GlobalListContents}; + use wayland_protocols::wp::viewporter::client::wp_viewport; + use wayland_protocols::xdg::decoration::zv1::client::zxdg_toplevel_decoration_v1; + use wayland_protocols::xdg::shell::client::xdg_popup; @@ -93,7 +93,126 @@ tiling: Tiling, } - + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum VisibilityPhase { + Visible, @@ -523,7 +636,11 @@ index 993b2ff3fadc..e8916c11025f 100644 surface_state: WaylandSurfaceState, acknowledged_first_configure: bool, parent: Option, -@@ -110,6 +229,8 @@ +@@ -107,9 +226,12 @@ + viewport: Option, + outputs: HashMap, + display: Option<(ObjectId, Output)>, ++ preferred_output: Option, globals: Globals, renderer: WgpuRenderer, bounds: Bounds, @@ -532,9 +649,44 @@ index 993b2ff3fadc..e8916c11025f 100644 scale: f32, input_handler: Option, decorations: WindowDecorations, -@@ -575,7 +696,11 @@ +@@ -539,6 +661,26 @@ + } + + impl WaylandWindowState { ++ fn fullscreen_output(&self) -> Option<&wl_output::WlOutput> { ++ self.preferred_output.as_ref().filter(|output| { ++ let Some(global_name) = output.data::() else { ++ return false; ++ }; ++ output.is_alive() ++ && self ++ .globals ++ .registry ++ .data::() ++ .is_some_and(|globals| { ++ globals.with_list(|list| { ++ list.iter().any(|global| { ++ global.name == *global_name && global.interface == "wl_output" ++ }) ++ }) ++ }) ++ }) ++ } ++ + pub(crate) fn new( + handle: AnyWindowHandle, + surface: wl_surface::WlSurface, +@@ -551,6 +693,7 @@ + compositor_gpu: Option, + options: WindowParams, + parent: Option, ++ preferred_output: Option, + ) -> anyhow::Result { + let renderer = { + let raw_window = RawWindow { +@@ -575,7 +718,11 @@ }; - + if let WaylandSurfaceState::Xdg(ref xdg_state) = surface_state { - if let Some(title) = options.titlebar.and_then(|titlebar| titlebar.title) { + if let Some(title) = options @@ -544,11 +696,11 @@ index 993b2ff3fadc..e8916c11025f 100644 + { xdg_state.toplevel.set_title(title.to_string()); } - -@@ -591,7 +716,16 @@ + +@@ -591,7 +738,16 @@ .set_max_size(max_texture_size, max_texture_size); } - + - Ok(Self { + let mut state = Self { + visibility: RetainedVisibility::default(), @@ -563,8 +715,11 @@ index 993b2ff3fadc..e8916c11025f 100644 surface_state, acknowledged_first_configure: false, parent, -@@ -605,6 +739,8 @@ +@@ -603,8 +759,11 @@ + globals, + outputs: HashMap::default(), display: None, ++ preferred_output, renderer, bounds: options.bounds, + fixed_outer_size: (!options.is_resizable).then_some(options.bounds.size), @@ -572,7 +727,7 @@ index 993b2ff3fadc..e8916c11025f 100644 scale: 1.0, input_handler: None, decorations: WindowDecorations::Client, -@@ -626,7 +762,30 @@ +@@ -626,7 +785,30 @@ window_controls: WindowControls::default(), client_inset: None, accesskit_adapter: None, @@ -602,9 +757,9 @@ index 993b2ff3fadc..e8916c11025f 100644 + self.fixed_geometry_size = Some(geometry_size); + } } - + pub fn is_transparent(&self) -> bool { -@@ -680,6 +839,34 @@ +@@ -680,6 +862,34 @@ impl Drop for WaylandWindow { fn drop(&mut self) { let mut state = self.0.state.borrow_mut(); @@ -639,19 +794,36 @@ index 993b2ff3fadc..e8916c11025f 100644 let surface_id = state.surface.id(); if let Some(parent) = state.parent.as_ref() { parent.state.borrow_mut().children.remove(&surface_id); -@@ -713,7 +900,7 @@ +@@ -713,7 +923,7 @@ // The wl_surface itself should always be destroyed last. state.surface.destroy(); - + - let state_ptr = self.0.clone(); + let state_ptr = self.clone(); state .globals .executor -@@ -838,8 +1025,153 @@ +@@ -754,7 +964,7 @@ + ¶ms, + parent.clone(), + popup_grab, +- target_output, ++ target_output.clone(), + )?; + + if let Some(fractional_scale_manager) = globals.fractional_scale_manager.as_ref() { +@@ -779,6 +989,7 @@ + compositor_gpu, + params, + parent, ++ target_output, + )?)), + callbacks: Rc::new(RefCell::new(Callbacks::default())), + }); +@@ -838,8 +1049,153 @@ state.children.values().any(|&blocking| blocking) } - + + fn visibility_supported(&self) -> bool { + let state = self.state.borrow(); + matches!(&state.surface_state, WaylandSurfaceState::Xdg(xdg) if xdg.dialog.is_none()) @@ -761,7 +933,7 @@ index 993b2ff3fadc..e8916c11025f 100644 + xdg.toplevel.set_maximized(); + } + if state.fullscreen { -+ xdg.toplevel.set_fullscreen(None); ++ xdg.toplevel.set_fullscreen(state.fullscreen_output()); + } + if let Some(decoration) = &xdg.decoration { + decoration.set_mode(state.decorations.to_xdg()); @@ -802,7 +974,7 @@ index 993b2ff3fadc..e8916c11025f 100644 state.surface.frame(&state.globals.qh, state.surface.id()); state.resize_throttle = false; let force_render = state.force_render_after_recovery; -@@ -882,6 +1214,17 @@ +@@ -882,6 +1238,17 @@ pub fn handle_xdg_surface_event(&self, event: xdg_surface::Event) { if let xdg_surface::Event::Configure { serial } = event { { @@ -820,7 +992,7 @@ index 993b2ff3fadc..e8916c11025f 100644 let mut state = self.state.borrow_mut(); if let Some(window_controls) = state.in_progress_window_controls.take() { state.window_controls = window_controls; -@@ -901,6 +1244,7 @@ +@@ -901,6 +1268,7 @@ state.fullscreen = configure.fullscreen; state.maximized = configure.maximized; state.tiling = configure.tiling; @@ -828,10 +1000,10 @@ index 993b2ff3fadc..e8916c11025f 100644 // Limit interactive resizes to once per vblank if configure.resizing && state.resize_throttle { state.surface_state.ack_configure(serial); -@@ -945,6 +1289,43 @@ +@@ -945,6 +1313,43 @@ window_geometry.size.height, ); - + + if matches!(state.visibility.phase, VisibilityPhase::Configuring(_)) + && !state.acknowledged_first_configure + { @@ -872,7 +1044,7 @@ index 993b2ff3fadc..e8916c11025f 100644 let request_frame_callback = !state.acknowledged_first_configure; if request_frame_callback { state.acknowledged_first_configure = true; -@@ -958,7 +1339,11 @@ +@@ -958,7 +1363,11 @@ if let zxdg_toplevel_decoration_v1::Event::Configure { mode } = event { match mode { WEnum::Value(zxdg_toplevel_decoration_v1::Mode::ServerSide) => { @@ -885,7 +1057,7 @@ index 993b2ff3fadc..e8916c11025f 100644 let callback = self.callbacks.borrow_mut().appearance_changed.take(); if let Some(mut fun) = callback { fun(); -@@ -966,7 +1351,11 @@ +@@ -966,7 +1375,11 @@ } } WEnum::Value(zxdg_toplevel_decoration_v1::Mode::ClientSide) => { @@ -898,7 +1070,7 @@ index 993b2ff3fadc..e8916c11025f 100644 // Update background to be transparent let callback = self.callbacks.borrow_mut().appearance_changed.take(); if let Some(mut fun) = callback { -@@ -1271,6 +1660,7 @@ +@@ -1271,6 +1684,7 @@ } if let Some(scale) = scale { state.scale = scale; @@ -906,19 +1078,30 @@ index 993b2ff3fadc..e8916c11025f 100644 } let device_bounds = state.bounds.to_device_pixels(state.scale); state.renderer.update_drawable_size(device_bounds.size); -@@ -1466,7 +1856,7 @@ +@@ -1428,6 +1842,10 @@ + } + + impl PlatformWindow for WaylandWindow { ++ fn wayland_surface(&self) -> Option { ++ Some(self.0.surface()) ++ } ++ + fn bounds(&self) -> Bounds { + self.borrow().bounds + } +@@ -1466,7 +1884,7 @@ } - + fn resize(&mut self, size: Size) { - let state = self.borrow(); + let mut state = self.borrow_mut(); let state_ptr = self.0.clone(); - + // A popup's placement is the compositor's, so a resize re-runs the positioner and the -@@ -1486,6 +1876,11 @@ +@@ -1486,6 +1904,11 @@ return; } - + + if state.fixed_outer_size.is_some() { + state.fixed_outer_size = Some(size); + state.update_size_constraints(); @@ -927,7 +1110,7 @@ index 993b2ff3fadc..e8916c11025f 100644 // Keep window geometry consistent with configure handling. On Wayland, window geometry is // surface-local: resizing should not attempt to translate the window; the compositor // controls placement. We also account for client-side decoration insets and tiling. -@@ -1566,6 +1961,20 @@ +@@ -1566,6 +1989,20 @@ _answers: &[PromptButton], ) -> Option> { None @@ -946,11 +1129,11 @@ index 993b2ff3fadc..e8916c11025f 100644 + ) -> anyhow::Result>> { + self.0.request_visibility(visible) } - + fn activate(&self) { -@@ -1596,7 +2005,9 @@ +@@ -1596,7 +2033,9 @@ } - + fn set_title(&mut self, title: &str) { - if let Some(toplevel) = self.borrow().surface_state.toplevel() { + let mut state = self.borrow_mut(); @@ -959,8 +1142,17 @@ index 993b2ff3fadc..e8916c11025f 100644 toplevel.set_title(title.to_string()); } } -@@ -1706,6 +2117,13 @@ - +@@ -1650,7 +2089,7 @@ + let state = self.borrow(); + if let Some(toplevel) = state.surface_state.toplevel() { + if !state.fullscreen { +- toplevel.set_fullscreen(None); ++ toplevel.set_fullscreen(state.fullscreen_output()); + } else { + toplevel.unset_fullscreen(); + } +@@ -1706,6 +2145,13 @@ + fn draw(&self, scene: &Scene) { let mut state = self.borrow_mut(); + if !state @@ -970,12 +1162,12 @@ index 993b2ff3fadc..e8916c11025f 100644 + { + return; + } - + if state.renderer.device_lost() { let raw_window = RawWindow { -@@ -1730,6 +2148,12 @@ +@@ -1730,6 +2176,12 @@ } - + state.renderer_presented = state.renderer.draw(scene); + if state.renderer_presented + && let VisibilityPhase::Configuring(transition) = state.visibility.phase @@ -983,11 +1175,11 @@ index 993b2ff3fadc..e8916c11025f 100644 + state.visibility.phase = VisibilityPhase::Remapping(transition); + WaylandWindowStatePtr::visibility_sync(&state, transition, true); + } - + if state.renderer.needs_redraw() { state.force_render_after_recovery = true; -@@ -1738,6 +2162,13 @@ - +@@ -1738,6 +2190,13 @@ + fn completed_frame(&self) { let mut state = self.borrow_mut(); + if !state @@ -997,11 +1189,11 @@ index 993b2ff3fadc..e8916c11025f 100644 + { + return; + } - + // Work around a bug in old versions of wlroots where committing without a buffer attached // can cause invalid synchronization that leads to graphical corruption. -@@ -1776,6 +2207,9 @@ - +@@ -1776,6 +2235,9 @@ + fn start_window_resize(&self, edge: gpui::ResizeEdge) { let state = self.borrow(); + if state.fixed_outer_size.is_some() { @@ -1010,7 +1202,7 @@ index 993b2ff3fadc..e8916c11025f 100644 if let Some(toplevel) = state.surface_state.toplevel() { toplevel.resize( &state.globals.seat, -@@ -1835,7 +2269,13 @@ +@@ -1835,7 +2297,13 @@ // Commit so the new input region applies immediately. Otherwise it // waits for the next frame, which could be the very click we want to // allow passing through. @@ -1023,9 +1215,9 @@ index 993b2ff3fadc..e8916c11025f 100644 + state.surface.commit(); + } } - + fn window_decorations(&self) -> Decorations { -@@ -1854,6 +2294,7 @@ +@@ -1854,6 +2322,7 @@ Some(decoration) => { decoration.set_mode(decorations.to_xdg()); state.decorations = decorations; @@ -1033,7 +1225,7 @@ index 993b2ff3fadc..e8916c11025f 100644 update_window(state); } None => { -@@ -1863,6 +2304,7 @@ +@@ -1863,6 +2332,7 @@ ); } state.decorations = WindowDecorations::Client; @@ -1041,7 +1233,7 @@ index 993b2ff3fadc..e8916c11025f 100644 update_window(state); } } -@@ -1876,6 +2318,7 @@ +@@ -1876,6 +2346,7 @@ let mut state = self.borrow_mut(); if Some(inset) != state.client_inset { state.client_inset = Some(inset); @@ -1049,8 +1241,8 @@ index 993b2ff3fadc..e8916c11025f 100644 update_window(state); } } -@@ -2082,3 +2525,337 @@ - +@@ -2082,3 +2553,337 @@ + bounds } + @@ -1388,7 +1580,6 @@ index 993b2ff3fadc..e8916c11025f 100644 + } +} diff --git a/crates/gpui_linux/src/linux/x11/client.rs b/crates/gpui_linux/src/linux/x11/client.rs -index 0c8c9b4e20b8..701ae28fe440 100644 --- a/crates/gpui_linux/src/linux/x11/client.rs +++ b/crates/gpui_linux/src/linux/x11/client.rs @@ -811,11 +811,12 @@ @@ -1454,7 +1645,6 @@ index 0c8c9b4e20b8..701ae28fe440 100644 fn active_window(&self) -> Option { diff --git a/crates/gpui_linux/src/linux/x11/clipboard.rs b/crates/gpui_linux/src/linux/x11/clipboard.rs -index 706e7b096588..591535333fa2 100644 --- a/crates/gpui_linux/src/linux/x11/clipboard.rs +++ b/crates/gpui_linux/src/linux/x11/clipboard.rs @@ -22,6 +22,7 @@ @@ -1545,10 +1735,12 @@ index 706e7b096588..591535333fa2 100644 }]; self.inner.write(data, selection, wait) } -@@ -1138,6 +1182,66 @@ +@@ -1136,6 +1180,66 @@ + Error::Unknown { + description: error.to_string(), } - } - ++} ++ +fn file_uri_list_to_clipboard_data(paths: &[PathBuf], atoms: Atoms) -> Result> { + if paths.is_empty() { + return Err(Error::unknown("clipboard file list is empty")); @@ -1607,28 +1799,167 @@ index 706e7b096588..591535333fa2 100644 + format: atoms.NAUTILUS_FILE_LIST, + }, + ]) -+} -+ + } + /// Clipboard selection - /// - /// Linux has a concept of clipboard "selections" which tend to be used in different contexts. This diff --git a/crates/gpui_linux/src/linux/x11/window.rs b/crates/gpui_linux/src/linux/x11/window.rs --- a/crates/gpui_linux/src/linux/x11/window.rs +++ b/crates/gpui_linux/src/linux/x11/window.rs -@@ -1013,2 +1013,2 @@ +@@ -1010,10 +1010,14 @@ + + impl X11WindowStatePtr { + pub fn should_close(&self) -> bool { - let mut cb = self.callbacks.borrow_mut(); - if let Some(mut should_close) = cb.should_close.take() { + let should_close = self.callbacks.borrow_mut().should_close.take(); + if let Some(mut should_close) = should_close { -@@ -1016 +1016,5 @@ + let result = (should_close)(); - cb.should_close = Some(should_close); + let mut callbacks = self.callbacks.borrow_mut(); + if callbacks.should_close.is_none() { + callbacks.should_close = Some(should_close); + } + drop(callbacks); -@@ -1131,2 +1135,2 @@ + result + } else { + true +@@ -1128,8 +1132,8 @@ + } + } + - let mut callbacks = self.callbacks.borrow_mut(); - if let Some(fun) = callbacks.close.take() { + let close = self.callbacks.borrow_mut().close.take(); + if let Some(fun) = close { + fun() + } + } +diff --git a/crates/gpui_wgpu/src/gpui_wgpu.rs b/crates/gpui_wgpu/src/gpui_wgpu.rs +--- a/crates/gpui_wgpu/src/gpui_wgpu.rs ++++ b/crates/gpui_wgpu/src/gpui_wgpu.rs +@@ -1,4 +1,5 @@ + mod cosmic_text_system; ++mod surface_frame; + mod wgpu_atlas; + mod wgpu_context; + mod wgpu_renderer; +diff --git a/crates/gpui_wgpu/src/surface_frame.rs b/crates/gpui_wgpu/src/surface_frame.rs +new file mode 100644 +--- /dev/null ++++ b/crates/gpui_wgpu/src/surface_frame.rs +@@ -0,0 +1,34 @@ ++pub(super) struct SurfaceFrame { ++ frame: Option, ++ texture: TextureCleanup, ++} ++ ++impl SurfaceFrame { ++ pub(super) fn new(frame: wgpu::SurfaceTexture) -> Self { ++ Self { ++ texture: TextureCleanup(frame.texture.clone()), ++ frame: Some(frame), ++ } ++ } ++ ++ pub(super) fn texture(&self) -> &wgpu::Texture { ++ &self.texture.0 ++ } ++ ++ pub(super) fn present(mut self) { ++ if let Some(frame) = self.frame.take() { ++ frame.present(); ++ } ++ } ++} ++ ++struct TextureCleanup(wgpu::Texture); ++ ++impl Drop for TextureCleanup { ++ fn drop(&mut self) { ++ // wgpu 29 can retain an acquired texture after device loss. Release its raw ++ // resources after present/discard, before the owning surface is destroyed. ++ // https://github.com/gfx-rs/wgpu/issues/9277 ++ self.0.destroy(); ++ } ++} +diff --git a/crates/gpui_wgpu/src/wgpu_renderer.rs b/crates/gpui_wgpu/src/wgpu_renderer.rs +--- a/crates/gpui_wgpu/src/wgpu_renderer.rs ++++ b/crates/gpui_wgpu/src/wgpu_renderer.rs +@@ -1,3 +1,4 @@ ++use crate::surface_frame::SurfaceFrame; + use crate::{CompositorGpuHint, WgpuAtlas, WgpuContext}; + use bytemuck::{Pod, Zeroable}; + use gpui::{ +@@ -1118,24 +1119,15 @@ + + self.atlas.before_frame(); + +- let frame = match self ++ let (frame, suboptimal) = match self + .resources() + .surface + .as_ref() + .expect("Configured surface missing") + .get_current_texture() + { +- wgpu::CurrentSurfaceTexture::Success(frame) => frame, +- wgpu::CurrentSurfaceTexture::Suboptimal(frame) => { +- // Textures must be destroyed before the surface can be reconfigured. +- drop(frame); +- let surface_config = self.surface_config.clone(); +- let resources = self.resources_mut(); +- if let Some(surface) = &resources.surface { +- surface.configure(&resources.device, &surface_config); +- } +- return false; +- } ++ wgpu::CurrentSurfaceTexture::Success(frame) => (SurfaceFrame::new(frame), false), ++ wgpu::CurrentSurfaceTexture::Suboptimal(frame) => (SurfaceFrame::new(frame), true), + wgpu::CurrentSurfaceTexture::Lost | wgpu::CurrentSurfaceTexture::Outdated => { + let surface_config = self.surface_config.clone(); + let resources = self.resources_mut(); +@@ -1158,7 +1150,7 @@ + self.ensure_intermediate_textures(); + + let frame_view = frame +- .texture ++ .texture() + .create_view(&wgpu::TextureViewDescriptor::default()); + + let gamma_params = GammaParams { +@@ -1330,8 +1322,7 @@ + "instance buffer size grew too large: {}", + self.instance_buffer_capacity + ); +- frame.present(); +- return true; ++ return self.present_frame(frame, suboptimal); + } + self.grow_instance_buffer(); + continue; +@@ -1340,9 +1331,22 @@ + self.resources() + .queue + .submit(std::iter::once(encoder.finish())); +- frame.present(); +- return true; +- } ++ return self.present_frame(frame, suboptimal); ++ } ++ } ++ ++ fn present_frame(&mut self, frame: SurfaceFrame, suboptimal: bool) -> bool { ++ frame.present(); ++ if self.device_lost() { ++ return false; ++ } ++ if suboptimal { ++ let resources = self.resources(); ++ if let Some(surface) = &resources.surface { ++ surface.configure(&resources.device, &self.surface_config); ++ } ++ } ++ true + } + + fn draw_quads( diff --git a/apps/desktop-gpui/resources/Info.plist b/apps/desktop-gpui/resources/Info.plist index dd8348c74d9..b9ee3ed15d1 100644 --- a/apps/desktop-gpui/resources/Info.plist +++ b/apps/desktop-gpui/resources/Info.plist @@ -53,7 +53,7 @@ LSApplicationCategoryType public.app-category.productivity LSMinimumSystemVersion - 11.0 + 12.3 NSHighResolutionCapable NSCameraUsageDescription diff --git a/apps/desktop-gpui/src/app_windows.rs b/apps/desktop-gpui/src/app_windows.rs index 65b0204fce4..cd9161e0b4b 100644 --- a/apps/desktop-gpui/src/app_windows.rs +++ b/apps/desktop-gpui/src/app_windows.rs @@ -15,8 +15,8 @@ use std::{ use cap_recording::sources::screen_capture::ScreenCaptureTarget; use gpui::{ - App, AppContext as _, Bounds, Entity, Global, WindowBounds, WindowHandle, WindowKind, - WindowOptions, point, px, size, + App, AppContext as _, Bounds, Entity, Global, Pixels, Size, WindowBounds, WindowHandle, + WindowKind, WindowOptions, point, px, size, }; use scap_targets::DisplayId; @@ -44,6 +44,151 @@ pub const CONTROLS_HEIGHT: f32 = 150.; const CONTROLS_BOTTOM_OFFSET: f64 = 120.; const TARGET_CONTROLS_OFFSET_Y: f64 = 48.; +pub(crate) fn display_work_area( + target: Option<&scap_targets::Display>, + cx: &App, +) -> Option> { + #[cfg(target_os = "macos")] + let display = target + .and_then(|target| target.id().to_string().parse::().ok()) + .and_then(|id| cx.find_display(gpui::DisplayId::new(id))); + #[cfg(not(target_os = "macos"))] + let display = target.and_then(|target| platform_display_for_capture(target, cx)); + #[cfg(target_os = "linux")] + if uses_wayland() { + return target.and_then(capture_display_bounds); + } + let display = display.or_else(|| cx.primary_display())?; + let available = display.visible_bounds(); + #[cfg(target_os = "macos")] + { + let id = u64::from(display.id()).to_string().parse().ok()?; + let bounds = scap_targets::Display::from_id(&id) + .as_ref() + .and_then(capture_display_bounds)?; + let primary_height = cx.primary_display()?.bounds().size.height; + Some(global_macos_work_area(available, bounds, primary_height)) + } + #[cfg(not(target_os = "macos"))] + Some(available) +} + +#[cfg(target_os = "linux")] +fn uses_wayland() -> bool { + std::env::var_os("WAYLAND_DISPLAY").is_some() + && (std::env::var_os("DISPLAY").is_none() + || std::env::var("XDG_SESSION_TYPE") + .is_ok_and(|session| session.eq_ignore_ascii_case("wayland"))) +} + +#[cfg(not(target_os = "macos"))] +fn platform_display_for_capture( + target: &scap_targets::Display, + cx: &App, +) -> Option> { + #[cfg(target_os = "linux")] + if let Some(uuid) = target.raw_handle().wayland_uuid() { + return cx + .displays() + .into_iter() + .find(|display| display.uuid().is_ok_and(|candidate| candidate == uuid)); + } + let bounds = capture_display_bounds(target)?; + let mut matching = cx + .displays() + .into_iter() + .filter(|display| display.bounds().contains(&bounds.center())); + let display = matching.next()?; + #[cfg(target_os = "linux")] + if uses_wayland() && matching.next().is_some() { + return None; + } + Some(display) +} + +fn capture_display_bounds(display: &scap_targets::Display) -> Option> { + let bounds = display.raw_handle().logical_bounds()?; + Some(Bounds { + origin: point( + px(bounds.position().x() as f32), + px(bounds.position().y() as f32), + ), + size: size( + px(bounds.size().width() as f32), + px(bounds.size().height() as f32), + ), + }) +} + +#[cfg(target_os = "macos")] +fn global_macos_work_area( + mut available: Bounds, + display: Bounds, + primary_height: Pixels, +) -> Bounds { + // GPUI's macOS work area uses local x but includes the AppKit screen y. + // Windows opened without a display ID need primary-display coordinates. + let appkit_origin_y = primary_height - display.origin.y - display.size.height; + available.origin.x += display.origin.x; + available.origin.y += display.origin.y - appkit_origin_y; + available +} + +fn inset_work_area(available: Bounds) -> Bounds { + let inset = point( + px(16.).min((available.size.width - px(1.)).max(px(0.)) / 2.), + px(16.).min((available.size.height - px(1.)).max(px(0.)) / 2.), + ); + Bounds { + origin: available.origin + inset, + size: size( + (available.size.width - inset.x * 2.).max(px(1.)), + (available.size.height - inset.y * 2.).max(px(1.)), + ), + } +} + +fn fit_window_bounds(bounds: Bounds, available: Bounds) -> Bounds { + let size = size( + bounds.size.width.min(available.size.width).max(px(1.)), + bounds.size.height.min(available.size.height).max(px(1.)), + ); + Bounds { + origin: point( + bounds.origin.x.clamp( + available.origin.x, + available.origin.x + (available.size.width - size.width).max(px(0.)), + ), + bounds.origin.y.clamp( + available.origin.y, + available.origin.y + (available.size.height - size.height).max(px(0.)), + ), + ), + size, + } +} + +fn opening_window_bounds(preferred: Size, cx: &App) -> Bounds { + let target = scap_targets::Display::get_containing_cursor(); + match display_work_area(target.as_ref(), cx) { + Some(available) => { + let available = inset_work_area(available); + fit_window_bounds( + Bounds::centered_at(available.center(), preferred), + available, + ) + } + None => Bounds::centered(None, preferred, cx), + } +} + +fn fitted_window_min_size(preferred: Size, bounds: Bounds) -> Size { + size( + preferred.width.min(bounds.size.width), + preferred.height.min(bounds.size.height), + ) +} + pub struct AppWindows { pub main: WindowHandle, pub controls: Option>, @@ -929,8 +1074,7 @@ pub fn open_settings(page: Page, cx: &mut App) { return; } - let bounds = Bounds::centered( - None, + let bounds = opening_window_bounds( size( px(settings_window::SETTINGS_WIDTH), px(settings_window::SETTINGS_HEIGHT), @@ -962,9 +1106,12 @@ pub fn open_settings(page: Page, cx: &mut App) { // `.resizable(true).maximized(false)`, and `min_inner_size`. is_resizable: true, is_minimizable: true, - window_min_size: Some(size( - px(settings_window::SETTINGS_MIN_WIDTH), - px(settings_window::SETTINGS_MIN_HEIGHT), + window_min_size: Some(fitted_window_min_size( + size( + px(settings_window::SETTINGS_MIN_WIDTH), + px(settings_window::SETTINGS_MIN_HEIGHT), + ), + bounds, )), // `builder.transparent(true)` on macOS -- the panes paint, the // material shows through the gap. @@ -1079,27 +1226,19 @@ pub fn open_onboarding(cx: &mut App) { } }) .detach(); - hide_main_window(cx); + hide_main_and_park_camera_preview(cx); return; } - let cursor_display = scap_targets::Display::get_containing_cursor() - .and_then(|display| display.raw_handle().logical_bounds()); - let display = cursor_display - .and_then(|bounds| { - let center = point( - px((bounds.position().x() + bounds.size().width() / 2.) as f32), - px((bounds.position().y() + bounds.size().height() / 2.) as f32), - ); - cx.displays() - .into_iter() - .find(|display| display.bounds().contains(¢er)) - }) - .or_else(|| cx.primary_display()); - let bounds = match display { - Some(display) => { - let available = display.visible_bounds(); - let width = (f32::from(display.bounds().size.width) * 0.58) + let display = scap_targets::Display::get_containing_cursor(); + let bounds = match display_work_area(display.as_ref(), cx) { + Some(available) => { + let display_width = display + .as_ref() + .and_then(capture_display_bounds) + .map(|bounds| bounds.size.width) + .unwrap_or(available.size.width); + let width = (f32::from(display_width) * 0.58) .clamp(onboarding_window::ONBOARDING_WIDTH, 1080.) .min((f32::from(available.size.width) - 32.).max(1.)); let height = (width * 0.72) @@ -1163,7 +1302,7 @@ pub fn open_onboarding(cx: &mut App) { } }) .detach(); - hide_main_window(cx); + hide_main_and_park_camera_preview(cx); crate::tray::refresh_menu(cx); } @@ -1258,8 +1397,7 @@ pub fn open_mode_select(cx: &mut App) -> bool { return true; } - let bounds = Bounds::centered( - None, + let bounds = opening_window_bounds( size( px(mode_select_window::MODE_SELECT_WIDTH), px(mode_select_window::MODE_SELECT_HEIGHT), @@ -1378,8 +1516,7 @@ pub fn open_teleprompter(cx: &mut App) { return; } - let bounds = Bounds::centered( - None, + let bounds = opening_window_bounds( size( px(teleprompter_window::TELEPROMPTER_WIDTH), px(teleprompter_window::TELEPROMPTER_HEIGHT), @@ -1407,9 +1544,12 @@ pub fn open_teleprompter(cx: &mut App) { // `resizable: true`, `minWidth: 420, minHeight: 220`. is_resizable: true, is_minimizable: true, - window_min_size: Some(size( - px(teleprompter_window::TELEPROMPTER_MIN_WIDTH), - px(teleprompter_window::TELEPROMPTER_MIN_HEIGHT), + window_min_size: Some(fitted_window_min_size( + size( + px(teleprompter_window::TELEPROMPTER_MIN_WIDTH), + px(teleprompter_window::TELEPROMPTER_MIN_HEIGHT), + ), + bounds, )), // `transparent: true`, `shadow: true`: the shell paints a tint and // the material shows through. @@ -1781,6 +1921,10 @@ fn open_overlays_core(request: OverlayRequest, cx: &mut App) -> bool { // (`target_select_overlay.rs:595-617`): with the main window hidden below // and the overlays non-activating, a plain key handler has nothing to be // delivered to. + if cx.global::().overlays.is_empty() { + disarm_target_selection(cx); + return false; + } platform::register_escape_hotkey(); true } @@ -2060,8 +2204,6 @@ pub fn start_recording_from_overlay(target: ScreenCaptureTarget, cx: &mut App) { } else { release_camera_park(cx); close_target_overlays(cx); - cx.global_mut::().main_hidden_for_picker = false; - cx.global_mut::().editor_hidden_for_picker = None; } let preparing = main @@ -2070,8 +2212,11 @@ pub fn start_recording_from_overlay(target: ScreenCaptureTarget, cx: &mut App) { view.is_preparing_recording() }) .unwrap_or(false); - if retained_area && !preparing && RecordingSession::global(cx).read(cx).phase == Phase::Idle { + if !preparing && RecordingSession::global(cx).read(cx).phase == Phase::Idle { dismiss_target_overlays(cx); + } else if !retained_area { + cx.global_mut::().main_hidden_for_picker = false; + cx.global_mut::().editor_hidden_for_picker = None; } } @@ -2119,6 +2264,30 @@ fn open_overlay( }; let width = bounds.size().width(); let height = bounds.size().height(); + let window_bounds = WindowBounds::Windowed(Bounds { + origin: point(px(0.), px(0.)), + size: size(px(width as f32), px(height as f32)), + }); + #[cfg(target_os = "linux")] + let overlay_display = if uses_wayland() { + let Some(matched) = platform_display_for_capture(display, cx) else { + let capture_display_id = display.id(); + tracing::warn!(%capture_display_id, "could not match capture display to a Wayland output"); + return; + }; + Some(matched) + } else { + None + }; + #[cfg(target_os = "linux")] + let window_bounds = if overlay_display.is_some() { + WindowBounds::Fullscreen(Bounds { + origin: point(px(0.), px(0.)), + size: size(px(width as f32), px(height as f32)), + }) + } else { + window_bounds + }; let handle = cx.open_window( WindowOptions { @@ -2126,10 +2295,9 @@ fn open_overlay( // window-origin math cannot express "cover this display" (see // `platform::set_window_frame_cg`). The size is honoured, and it is // the size the renderer is built for. - window_bounds: Some(WindowBounds::Windowed(Bounds { - origin: point(px(0.), px(0.)), - size: size(px(width as f32), px(height as f32)), - })), + window_bounds: Some(window_bounds), + #[cfg(target_os = "linux")] + display_id: overlay_display.as_ref().map(|display| display.id()), titlebar: None, // `NSWindowStyleMaskNonActivatingPanel` in windows.rs: the overlay // takes clicks without activating the app over the one being @@ -2351,14 +2519,13 @@ fn excluded_own_windows(rules: &[crate::store::WindowExclusion]) -> Vec Vec { excluded_own_windows(rules) .into_iter() .filter(|kind| *kind != OwnWindow::Camera) + // SCK excludes the controls by window ID. NSWindowSharingNone also hides + // them from Screen Sharing, leaving remote users without recording controls. + .filter(|kind| !cfg!(target_os = "macos") || *kind != OwnWindow::Controls) .collect() } @@ -3595,6 +3762,7 @@ pub fn open_camera_window(cx: &mut App) { shadow: false, }, ); + #[cfg(not(target_os = "macos"))] if !inline { platform::show_window_without_focus(window); } @@ -3603,6 +3771,9 @@ pub fn open_camera_window(cx: &mut App) { .ok() .flatten(); remove_popup_window_chrome(native, cx); + #[cfg(target_os = "macos")] + update_camera_presentation(!inline, cx); + #[cfg(not(target_os = "macos"))] sync_camera_presentation(cx); sync_opened_camera_with_picker(cx); refresh_target_overlays(cx); @@ -4349,11 +4520,7 @@ pub fn open_editor(project_path: PathBuf, cx: &mut App) { return; } - // `cursor_monitor.center_position(1275.0, 800.0)` in the Tauri arm; gpui - // centres on the active display, which is the same one in every - // single-pointer case. - let bounds = Bounds::centered( - None, + let bounds = opening_window_bounds( size( px(editor_window::EDITOR_WIDTH), px(editor_window::EDITOR_HEIGHT), @@ -4379,12 +4546,14 @@ pub fn open_editor(project_path: PathBuf, cx: &mut App) { kind: WindowKind::Normal, focus: true, show: true, - // `.maximizable(true)` with `min_inner_size == inner_size`. is_resizable: true, is_minimizable: true, - window_min_size: Some(size( - px(editor_window::EDITOR_WIDTH), - px(editor_window::EDITOR_HEIGHT), + window_min_size: Some(fitted_window_min_size( + size( + px(editor_window::EDITOR_WIDTH), + px(editor_window::EDITOR_HEIGHT), + ), + bounds, )), // Opaque, and no native material: `is_transparent()` // (`windows.rs:1069-1082`) does not list Editor, and @@ -5566,11 +5735,11 @@ fn controls_origin(config: &StartConfig) -> (f64, f64) { return (x, y); } - let display = match &config.target { - ScreenCaptureTarget::Display { id } => scap_targets::Display::from_id(id), - _ => scap_targets::Display::get_containing_cursor(), - } - .unwrap_or_else(scap_targets::Display::primary); + let display = config + .target + .display() + .or_else(scap_targets::Display::get_containing_cursor) + .unwrap_or_else(scap_targets::Display::primary); match display.raw_handle().logical_bounds() { Some(bounds) => ( @@ -5592,14 +5761,22 @@ fn open_controls( return None; } let (x, y) = controls_origin(config); + let bounds = Bounds { + origin: point(px(x as f32), px(y as f32)), + size: size(px(CONTROLS_WIDTH), px(CONTROLS_HEIGHT)), + }; + let display = config + .target + .display() + .or_else(scap_targets::Display::get_containing_cursor); + let bounds = display_work_area(display.as_ref(), cx) + .map(|available| fit_window_bounds(bounds, inset_work_area(available))) + .unwrap_or(bounds); let has_microphone = config.microphone.is_some(); let handle = cx.open_window( WindowOptions { - window_bounds: Some(WindowBounds::Windowed(Bounds { - origin: point(px(x as f32), px(y as f32)), - size: size(px(CONTROLS_WIDTH), px(CONTROLS_HEIGHT)), - })), + window_bounds: Some(WindowBounds::Windowed(bounds)), // No titlebar at all: with one, the panel still draws standard // window buttons floating in the transparent top of the window. titlebar: None, @@ -5742,8 +5919,7 @@ pub fn open_screenshot_editor(path: PathBuf, cx: &mut App) { return; } - let bounds = Bounds::centered( - None, + let bounds = opening_window_bounds( size( px(screenshot_editor::SCREENSHOT_EDITOR_WIDTH), px(screenshot_editor::SCREENSHOT_EDITOR_HEIGHT), @@ -5764,9 +5940,12 @@ pub fn open_screenshot_editor(path: PathBuf, cx: &mut App) { show: true, is_resizable: true, is_minimizable: true, - window_min_size: Some(size( - px(screenshot_editor::SCREENSHOT_EDITOR_MIN_WIDTH), - px(screenshot_editor::SCREENSHOT_EDITOR_MIN_HEIGHT), + window_min_size: Some(fitted_window_min_size( + size( + px(screenshot_editor::SCREENSHOT_EDITOR_MIN_WIDTH), + px(screenshot_editor::SCREENSHOT_EDITOR_MIN_HEIGHT), + ), + bounds, )), ..Default::default() }, @@ -5899,6 +6078,114 @@ mod tests { use super::*; use crate::store::{DEFAULT_EXCLUDED_WINDOW_TITLES, WindowExclusion, default_excluded_windows}; + #[test] + fn small_display_keeps_editor_and_minimum_size_inside_the_work_area() { + let available = inset_work_area(Bounds { + origin: point(px(0.), px(25.)), + size: size(px(1024.), px(684.)), + }); + let preferred = size(px(1275.), px(800.)); + let bounds = fit_window_bounds( + Bounds::centered_at(available.center(), preferred), + available, + ); + assert_eq!(bounds.origin, point(px(16.), px(41.))); + assert_eq!(bounds.size, size(px(992.), px(652.))); + assert_eq!(fitted_window_min_size(preferred, bounds), bounds.size); + } + + #[test] + fn spacious_display_preserves_preferred_editor_size() { + let available = inset_work_area(Bounds { + origin: point(px(0.), px(25.)), + size: size(px(1920.), px(995.)), + }); + let preferred = size(px(1275.), px(800.)); + let centered = Bounds::centered_at(available.center(), preferred); + assert_eq!(fit_window_bounds(centered, available), centered); + assert_eq!(fitted_window_min_size(preferred, centered), preferred); + } + + #[test] + fn controls_for_an_offscreen_target_stay_on_its_negative_origin_display() { + let available = inset_work_area(Bounds { + origin: point(px(-1440.), px(-180.)), + size: size(px(1440.), px(850.)), + }); + for origin in [point(px(-2200.), px(-500.)), point(px(20.), px(800.))] { + let bounds = fit_window_bounds( + Bounds { + origin, + size: size(px(CONTROLS_WIDTH), px(CONTROLS_HEIGHT)), + }, + available, + ); + assert_eq!(bounds.size, size(px(CONTROLS_WIDTH), px(CONTROLS_HEIGHT))); + assert!(bounds.origin.x >= available.origin.x); + assert!(bounds.origin.y >= available.origin.y); + assert!(bounds.right() <= available.right()); + assert!(bounds.bottom() <= available.bottom()); + } + } + + #[test] + fn scaled_work_areas_fit_without_changing_logical_pixel_sizes() { + for logical_size in [size(px(1280.), px(650.)), size(px(853.), px(455.))] { + let available = inset_work_area(Bounds { + origin: point(px(0.), px(0.)), + size: logical_size, + }); + let bounds = fit_window_bounds( + Bounds::centered_at(available.center(), size(px(782.), px(775.))), + available, + ); + assert_eq!(bounds.size.width, px(782.)); + assert_eq!(bounds.size.height, logical_size.height - px(32.)); + let minimum = fitted_window_min_size(size(px(780.), px(560.)), bounds); + assert!(minimum.width <= bounds.size.width); + assert!(minimum.height <= bounds.size.height); + } + } + + #[test] + fn tiny_work_area_cannot_produce_an_inverted_clamp_range() { + let available = inset_work_area(Bounds { + origin: point(px(10.), px(20.)), + size: size(px(1.), px(1.)), + }); + let bounds = fit_window_bounds( + Bounds::centered_at(available.center(), size(px(782.), px(775.))), + available, + ); + assert_eq!(bounds, available); + } + + #[cfg(target_os = "macos")] + #[test] + fn macos_work_area_preserves_horizontal_and_vertical_display_origins() { + for (display_origin, appkit_y) in [ + (point(px(-1440.), px(0.)), 180.), + (point(px(1920.), px(0.)), 180.), + (point(px(0.), px(-900.)), 1080.), + (point(px(0.), px(1080.)), -900.), + ] { + let display = Bounds { + origin: display_origin, + size: size(px(1440.), px(900.)), + }; + let available = global_macos_work_area( + Bounds { + origin: point(px(0.), px(appkit_y + 25.)), + size: size(px(1440.), px(825.)), + }, + display, + px(1080.), + ); + assert_eq!(available.origin, display_origin + point(px(0.), px(25.))); + assert_eq!(available.size, size(px(1440.), px(825.))); + } + } + fn area_target(display: &str, x: f64, y: f64, width: f64, height: f64) -> ScreenCaptureTarget { ScreenCaptureTarget::Area { screen: display.parse().unwrap(), @@ -6876,9 +7163,6 @@ mod tests { assert!(excluded_own_windows(&by_identity).is_empty()); } - /// `apply_content_protection` walks the same rules but skips the camera - /// window outright (`windows.rs:3393-3398`); the camera's protection is the - /// mode's business instead (`recording.rs:1617-1624`). #[test] fn content_protection_skips_the_camera_and_follows_the_mode() { let studio = own_window_exclusion_rules(default_excluded_windows(), RecordingMode::Studio); @@ -6887,6 +7171,7 @@ mod tests { vec![ OwnWindow::Main, OwnWindow::Settings, + #[cfg(not(target_os = "macos"))] OwnWindow::Controls, OwnWindow::ModeSelect, OwnWindow::Teleprompter, diff --git a/apps/desktop-gpui/src/camera_blur.rs b/apps/desktop-gpui/src/camera_blur.rs index d4a4b08eb55..a5adde3da16 100644 --- a/apps/desktop-gpui/src/camera_blur.rs +++ b/apps/desktop-gpui/src/camera_blur.rs @@ -275,9 +275,8 @@ impl Worker { .context("blur output missing")?; self.frame_number = self.frame_number.wrapping_add(1); - let pending = self - .converter - .encode( + let pending = runtime + .block_on(self.converter.encode( &self.device, &mut encoder, output, @@ -285,7 +284,7 @@ impl Worker { height, self.frame_number, 30, - ) + )) .map_err(|error| anyhow!("{error}"))?; self.queue.submit(std::iter::once(encoder.finish())); diff --git a/apps/desktop-gpui/src/camera_window.rs b/apps/desktop-gpui/src/camera_window.rs index a5d1b9f9fc0..9e448a4ef66 100644 --- a/apps/desktop-gpui/src/camera_window.rs +++ b/apps/desktop-gpui/src/camera_window.rs @@ -306,6 +306,69 @@ fn linux_camera_recording_snapshot( mod frame { use cidre::{arc, cf, cv, vt}; + type CreateRotationSession = unsafe extern "C-unwind" fn( + Option<&cf::Allocator>, + *mut Option>, + ) -> cidre::os::Status; + type RotateImage = unsafe extern "C-unwind" fn( + &vt::PixelRotationSession, + &cv::PixelBuf, + &mut cv::PixelBuf, + ) -> cidre::os::Status; + + struct FlipSession { + session: arc::R, + rotate_image: RotateImage, + } + + impl FlipSession { + fn new() -> Option { + // These APIs are macOS 13+. Direct cidre calls create strong imports that + // make dyld terminate the entire app on macOS 12 before any OS guard runs. + let create = unsafe { + libc::dlsym(libc::RTLD_DEFAULT, c"VTPixelRotationSessionCreate".as_ptr()) + }; + let rotate = unsafe { + libc::dlsym( + libc::RTLD_DEFAULT, + c"VTPixelRotationSessionRotateImage".as_ptr(), + ) + }; + let key = unsafe { + libc::dlsym( + libc::RTLD_DEFAULT, + c"kVTPixelRotationPropertyKey_FlipHorizontalOrientation".as_ptr(), + ) + }; + if create.is_null() || rotate.is_null() || key.is_null() { + return None; + } + let create = + unsafe { std::mem::transmute::<*mut libc::c_void, CreateRotationSession>(create) }; + let rotate_image = + unsafe { std::mem::transmute::<*mut libc::c_void, RotateImage>(rotate) }; + let key = unsafe { key.cast::<*const cf::String>().read().as_ref()? }; + let mut session = None; + unsafe { create(None, &mut session).result().ok()? }; + let mut session = session?; + session + .set_prop(key, Some(cf::Boolean::value_true().as_ref())) + .ok()?; + Some(Self { + session, + rotate_image, + }) + } + + fn rotate( + &self, + source: &cv::PixelBuf, + destination: &mut cv::PixelBuf, + ) -> cidre::os::Result { + unsafe { (self.rotate_image)(&self.session, source, destination).result() } + } + } + /// A converted preview frame: the BGRA IOSurface pixel buffer to paint or /// blur, its dimensions, and the ring generation (bumped on every ring /// rebuild so the blur worker's imported-texture cache can never alias a @@ -314,6 +377,7 @@ mod frame { pub buffer: arc::R, pub dims: (usize, usize), pub generation: u64, + pub mirrored: bool, } /// Converts camera frames (typically `420v`) into BGRA IOSurface-backed @@ -342,7 +406,7 @@ mod frame { session: arc::R, /// `None` when unmirrored, or when the rotation session could not be /// created (the preview then degrades to unmirrored, logged once). - flip_session: Option>, + flip_session: Option, ring: Vec>, mirror_ring: Vec>, next: usize, @@ -402,11 +466,7 @@ mod frame { session.set_realtime(true).ok()?; let flip_session = if mirrored { - let flip = vt::PixelRotationSession::new() - .ok() - .and_then(|mut session| { - session.set_horizontal_flip(true).ok().map(|_| session) - }); + let flip = FlipSession::new(); if flip.is_none() { tracing::warn!( "VTPixelRotationSession unavailable; camera preview mirroring disabled" @@ -464,8 +524,14 @@ mod frame { converter.session.transfer(src, &dst).ok()?; let out = if let Some(flip) = &converter.flip_session { let mut flipped = converter.mirror_ring[converter.next].clone(); - flip.rotate(&dst, &mut flipped).ok()?; - flipped + match flip.rotate(&dst, &mut flipped) { + Ok(()) => flipped, + Err(error) => { + tracing::warn!(?error, "camera preview mirroring failed"); + converter.flip_session = None; + dst + } + } } else { dst }; @@ -474,6 +540,7 @@ mod frame { buffer: out, dims: converter.dst_dims, generation: converter.generation, + mirrored: converter.flip_session.is_some(), }) } } @@ -640,6 +707,127 @@ fn camera_issue(error: &str) -> (&'static str, &'static str) { ("Camera unavailable", message) } +#[cfg(target_os = "macos")] +#[derive(Clone, Copy, Default, PartialEq, Eq)] +struct PreviewEffectFailures { + mirror: bool, + blur: bool, +} + +#[cfg(target_os = "macos")] +impl PreviewEffectFailures { + fn issue(self) -> Option<(&'static str, &'static str)> { + match (self.mirror, self.blur) { + (false, false) => None, + (true, false) => Some(("Mirror unavailable", "Your camera preview is not mirrored.")), + (false, true) => Some(("Background blur unavailable", "Your camera is unblurred.")), + (true, true) => Some(( + "Camera effects unavailable", + "Your camera is unblurred and not mirrored.", + )), + } + } + + fn reset_changed(&mut self, before: CameraWindowState, after: CameraWindowState) { + if before.mirrored != after.mirrored { + self.mirror = false; + } + if before.background_blur != after.background_blur { + self.blur = false; + } + } +} + +#[cfg(all(test, target_os = "macos"))] +mod preview_effect_failure_tests { + use super::*; + + #[test] + fn each_failed_effect_has_explicit_feedback() { + assert!(PreviewEffectFailures::default().issue().is_none()); + assert!( + PreviewEffectFailures { + mirror: true, + blur: false + } + .issue() + .unwrap() + .1 + .contains("not mirrored") + ); + assert!( + PreviewEffectFailures { + mirror: false, + blur: true + } + .issue() + .unwrap() + .1 + .contains("unblurred") + ); + assert!( + PreviewEffectFailures { + mirror: true, + blur: true + } + .issue() + .unwrap() + .1 + .contains("unblurred and not mirrored") + ); + } + + #[test] + fn unrelated_changes_preserve_failure_and_requested_recording_blur() { + let before = CameraWindowState { + mirrored: true, + background_blur: BlurMode::Heavy, + ..Default::default() + }; + let after = CameraWindowState { + size: 400., + ..before + }; + let mut failures = PreviewEffectFailures { + mirror: true, + blur: true, + }; + failures.reset_changed(before, after); + assert!(failures.mirror && failures.blur); + assert_eq!(after.background_blur, BlurMode::Heavy); + } + + #[test] + fn toggle_retry_resets_only_the_changed_effect() { + let before = CameraWindowState { + mirrored: true, + background_blur: BlurMode::Heavy, + ..Default::default() + }; + let mut failures = PreviewEffectFailures { + mirror: true, + blur: true, + }; + failures.reset_changed( + before, + CameraWindowState { + mirrored: false, + ..before + }, + ); + assert!(!failures.mirror && failures.blur); + failures.reset_changed( + before, + CameraWindowState { + background_blur: BlurMode::Off, + ..before + }, + ); + assert!(!failures.mirror && !failures.blur); + assert!(failures.issue().is_none()); + } +} + /// The per-frame half of the window: owns the latest converted (or blurred) /// frame and is the only entity notified at camera rate. Chrome invalidation /// goes through the parent [`CameraWindow`] instead, so a frame draw reuses @@ -662,6 +850,7 @@ struct CameraPreviewView { /// ~20Hz whenever a microphone is selected, and each of those would /// repaint the whole preview for a message that did not change. camera_error: Option, + effect_issue: Option<(&'static str, &'static str)>, _feeds_subscription: Subscription, } @@ -693,6 +882,7 @@ impl CameraPreviewView { frame_dims: None, paints, camera_error, + effect_issue: None, _feeds_subscription: feeds_subscription, } } @@ -807,8 +997,12 @@ impl Render for CameraPreviewView { // the preview with a centred, size-scaled title + message. The // `backdrop-blur-xs` behind it has no per-element hook in this gpui // rev (the recording overlay documents the same gap). - if let Some(error) = self.camera_error.clone() { - let (title, message) = camera_issue(&error); + if let Some((title, message)) = self + .camera_error + .as_deref() + .map(camera_issue) + .or(self.effect_issue) + { let metrics = overlay_metrics(self.size); container = container.child( div() @@ -912,11 +1106,8 @@ pub struct CameraWindow { converter: Option, #[cfg(target_os = "macos")] blur: Option, - /// Latched when the worker dies (device/ONNX bring-up failed); cleared - /// when the blur mode changes, which is the retry point -- the - /// `blur_processor_init_attempted` shape (`camera.rs:1500-1518`). #[cfg(target_os = "macos")] - blur_failed: bool, + effect_failures: PreviewEffectFailures, preview: Entity, toolbar: Entity, frame_dims: Option<(usize, usize)>, @@ -984,7 +1175,13 @@ impl CameraWindow { platform::ForcedAppearance::Dark, cx.foreground_executor(), ); - #[cfg(not(target_os = "windows"))] + #[cfg(target_os = "macos")] + platform::apply_window_theme_deferred( + window, + platform::ForcedAppearance::Dark, + cx.foreground_executor(), + ); + #[cfg(not(any(target_os = "macos", target_os = "windows")))] platform::apply_window_theme(window, platform::ForcedAppearance::Dark); let theme = Theme::dark(); let state = store::load().camera_window.unwrap_or_default(); @@ -1024,7 +1221,7 @@ impl CameraWindow { #[cfg(target_os = "macos")] blur: None, #[cfg(target_os = "macos")] - blur_failed: false, + effect_failures: PreviewEffectFailures::default(), preview, toolbar, frame_dims: None, @@ -1103,6 +1300,10 @@ impl CameraWindow { use core_foundation::base::TCFType as _; use core_video::pixel_buffer::{CVPixelBuffer, CVPixelBufferRef}; + let failures_before = self.effect_failures; + if self.state.background_blur != BlurMode::Off && camera_blur::is_low_spec_preview() { + self.effect_failures.blur = true; + } let blur_mode = self.active_blur_mode(); let max_dims = blur_mode.is_some().then_some(camera_blur::BLUR_MAX_DIMS); if let Some(converted) = frame::FrameConverter::convert( @@ -1111,6 +1312,7 @@ impl CameraWindow { max_dims, self.state.mirrored, ) { + self.effect_failures.mirror = self.state.mirrored && !converted.mirrored; let first_frame = self.frame_dims.is_none(); let dims = converted.dims; let dims_changed = self.frame_dims != Some(dims); @@ -1140,7 +1342,7 @@ impl CameraWindow { "camera blur worker unavailable; preview continues unblurred" ); self.blur = None; - self.blur_failed = true; + self.effect_failures.blur = true; paint_raw = true; } } @@ -1163,6 +1365,9 @@ impl CameraWindow { } else if self.frame_dims.is_none() && self.frames_in_window == 0 { tracing::warn!("camera frame could not be converted for preview"); } + if failures_before != self.effect_failures { + self.sync_effect_feedback(cx); + } } #[cfg(not(target_os = "macos"))] { @@ -1205,13 +1410,21 @@ impl CameraWindow { } } - /// The blur mode frames should be processed with right now: `None` when - /// off, latched off after a worker failure, and always `None` on low-spec - /// machines (`ensure_blur_processor`'s early return, `camera.rs:1491-1498` - /// -- the toggle still cycles and persists there too). + #[cfg(target_os = "macos")] + fn sync_effect_feedback(&self, cx: &mut Context) { + let issue = self.effect_failures.issue(); + self.preview.update(cx, |preview, cx| { + if preview.effect_issue != issue { + preview.effect_issue = issue; + cx.notify(); + } + }); + cx.notify(); + } + #[cfg(target_os = "macos")] fn active_blur_mode(&self) -> Option { - if self.blur_failed || camera_blur::is_low_spec_preview() { + if self.effect_failures.blur || camera_blur::is_low_spec_preview() { return None; } match self.state.background_blur { @@ -1317,7 +1530,7 @@ impl CameraWindow { mutate: impl FnOnce(&mut CameraWindowState), ) { #[cfg(target_os = "macos")] - let blur_before = self.state.background_blur; + let before = self.state; self.picker_size = None; mutate(&mut self.state); self.state.size = clamp_size(self.state.size); @@ -1327,11 +1540,11 @@ impl CameraWindow { }); #[cfg(target_os = "macos")] { - if self.state.background_blur != blur_before { - // Changing the mode is the retry point after a failed - // bring-up. - self.blur_failed = false; + self.effect_failures.reset_changed(before, self.state); + if before.mirrored != self.state.mirrored { + self.converter = None; } + self.sync_effect_feedback(cx); if self.state.background_blur == BlurMode::Off { // Ends the worker thread, dropping the ONNX session and every // GPU texture -- `release_blur_resources` @@ -1550,6 +1763,10 @@ impl CameraWindow { fn render_toolbar(&self, cx: &mut Context) -> impl IntoElement { let theme = self.theme; let scale = self.toolbar_scale(); + #[cfg(target_os = "macos")] + let (mirror_failed, blur_failed) = (self.effect_failures.mirror, self.effect_failures.blur); + #[cfg(not(target_os = "macos"))] + let (mirror_failed, blur_failed) = (false, false); let shape_icon = match self.state.shape { CameraShape::Round => "icons/circle.svg", CameraShape::Square => "icons/square.svg", @@ -1627,9 +1844,9 @@ impl CameraWindow { .child(self.toolbar_button( "mirror", "icons/arrows.svg", - self.state.mirrored, + self.state.mirrored && !mirror_failed, scale, - None, + mirror_failed.then_some("!"), cx, |this, window, cx| { this.mutate_state(window, cx, |state| { @@ -1640,9 +1857,13 @@ impl CameraWindow { .child(self.toolbar_button( "blur", "icons/person-standing.svg", - self.state.background_blur != BlurMode::Off, + self.state.background_blur != BlurMode::Off && !blur_failed, scale, - self.state.background_blur.label(), + if blur_failed { + Some("!") + } else { + self.state.background_blur.label() + }, cx, |this, window, cx| { this.mutate_state(window, cx, |state| { diff --git a/apps/desktop-gpui/src/controls_window.rs b/apps/desktop-gpui/src/controls_window.rs index 44777d71384..b5460fe54e3 100644 --- a/apps/desktop-gpui/src/controls_window.rs +++ b/apps/desktop-gpui/src/controls_window.rs @@ -27,6 +27,8 @@ pub struct ControlsWindow { session: Entity, theme: Theme, has_microphone: bool, + #[cfg(target_os = "linux")] + confirmation_pending: bool, /// Repaints the timer. An inactive window is repainted lazily by the /// platform, so the tick both notifies and asks for a frame explicitly. _tick: gpui::Task<()>, @@ -38,6 +40,15 @@ enum DestructiveAction { Delete, } +#[cfg(target_os = "linux")] +fn controls_owner_is_current(owner: gpui::WindowId, window: &Window, cx: &gpui::App) -> bool { + window.window_handle().window_id() == owner + && cx + .try_global::() + .and_then(|windows| windows.controls) + .is_some_and(|controls| controls.window_id() == owner) +} + impl ControlsWindow { pub fn new( session: Entity, @@ -73,6 +84,8 @@ impl ControlsWindow { session, theme, has_microphone, + #[cfg(target_os = "linux")] + confirmation_pending: false, _tick: tick, } } @@ -260,6 +273,47 @@ impl ControlsWindow { ), }; + #[cfg(target_os = "linux")] + { + let owner = window.window_handle().window_id(); + if self.confirmation_pending + || window.has_active_prompt() + || !controls_owner_is_current(owner, window, cx) + { + return; + } + let session_id = self.session.entity_id(); + let Some(ticket) = self.session.read(cx).confirmation_ticket() else { + return; + }; + self.confirmation_pending = true; + let response = + crate::editor_modal::confirm_action(title, message, accept, "Cancel", window, cx); + cx.spawn_in(window, async move |this, cx| { + let confirmed = response.await; + let _ = this.update_in(cx, |this, window, cx| { + this.confirmation_pending = false; + if !confirmed + || !controls_owner_is_current(owner, window, cx) + || this.session.entity_id() != session_id + { + return; + } + this.session.update(cx, |session, cx| { + if !session.confirmation_is_current(&ticket) { + return; + } + match action { + DestructiveAction::Restart => session.restart(cx), + DestructiveAction::Delete => session.delete(cx), + } + }); + }); + }) + .detach(); + } + + #[cfg(not(target_os = "linux"))] cx.spawn_in(window, async move |this, cx| { if !crate::platform::confirm_dialog(title, message, accept, "Cancel", true) { return; diff --git a/apps/desktop-gpui/src/devices.rs b/apps/desktop-gpui/src/devices.rs index 21976bf7bff..6335d10e9eb 100644 --- a/apps/desktop-gpui/src/devices.rs +++ b/apps/desktop-gpui/src/devices.rs @@ -148,6 +148,111 @@ impl DeviceSnapshot { } } +#[derive(Clone, Default)] +pub struct InputEnumerationGate(std::sync::Arc); + +pub struct InputEnumerationPermit(std::sync::Arc); + +impl InputEnumerationGate { + pub fn try_enter(&self) -> Option { + self.0 + .compare_exchange( + false, + true, + std::sync::atomic::Ordering::AcqRel, + std::sync::atomic::Ordering::Acquire, + ) + .ok() + .map(|_| InputEnumerationPermit(self.0.clone())) + } +} + +impl Drop for InputEnumerationPermit { + fn drop(&mut self) { + self.0.store(false, std::sync::atomic::Ordering::Release); + } +} + +pub enum InputSnapshot { + Cameras(Vec), + Microphones(Vec), +} + +impl InputSnapshot { + pub fn cameras() -> Self { + Self::Cameras(list_cameras()) + } + + pub fn microphones() -> Self { + Self::Microphones(list_microphones()) + } + + pub fn install(self, snapshot: &mut DeviceSnapshot) -> bool { + match self { + Self::Cameras(cameras) if snapshot.cameras != cameras => { + snapshot.cameras = cameras; + } + Self::Microphones(microphones) if snapshot.microphones != microphones => { + snapshot.microphones = microphones; + } + _ => return false, + } + true + } +} + +#[cfg(test)] +mod input_enumeration_tests { + use super::*; + + #[test] + fn cancelled_refresh_keeps_its_permit_until_enumeration_finishes() { + let gate = InputEnumerationGate::default(); + let pending = gate.try_enter().expect("first refresh"); + assert!(gate.clone().try_enter().is_none()); + drop(pending); + assert!(gate.try_enter().is_some()); + } + + #[test] + fn enumeration_unwind_releases_the_gate() { + let gate = InputEnumerationGate::default(); + let worker_gate = gate.clone(); + let result = std::panic::catch_unwind(move || { + let _permit = worker_gate.try_enter().expect("first refresh"); + panic!("enumeration fixture"); + }); + assert!(result.is_err()); + assert!(gate.try_enter().is_some()); + } + + #[test] + fn camera_refresh_preserves_other_device_lists() { + let microphone = MicrophoneOption { + name: "Selected microphone".into(), + sample_rate: Some(48_000), + channels: Some(2), + }; + let mut snapshot = DeviceSnapshot { + microphones: vec![microphone.clone()], + ..Default::default() + }; + let camera = CameraOption { + device_id: "new-camera".into(), + model_id: None, + label: "Connected camera".into(), + best_format: None, + formats: Vec::new(), + }; + assert!(InputSnapshot::Cameras(vec![camera.clone()]).install(&mut snapshot)); + assert_eq!(snapshot.cameras, vec![camera.clone()]); + assert_eq!(snapshot.microphones, vec![microphone]); + assert!(!InputSnapshot::Cameras(vec![camera.clone()]).install(&mut snapshot)); + assert!(InputSnapshot::Microphones(Vec::new()).install(&mut snapshot)); + assert_eq!(snapshot.cameras, vec![camera]); + } +} + /// Just the capture targets. /// /// The Tauri app keeps these on their own queries (`listScreens` / @@ -216,6 +321,21 @@ fn list_cameras() -> Vec { /// inserted first so it heads the list, then every other input device is /// appended, deduped by name. fn list_microphones() -> Vec { + // CPAL's configuration lookup opens an input AudioUnit and can prompt for consent. + #[cfg(target_os = "macos")] + if !crate::permissions::check_raw().is_some_and(|permissions| { + permissions.microphone == crate::permissions::MediaAuthorization::Authorized + }) { + return cap_recording::feeds::microphone::MicrophoneFeed::list_names() + .into_iter() + .map(|name| MicrophoneOption { + name, + sample_rate: None, + channels: None, + }) + .collect(); + } + let host = cpal::default_host(); let mut mics: Vec = Vec::new(); diff --git a/apps/desktop-gpui/src/editor_canvas.rs b/apps/desktop-gpui/src/editor_canvas.rs index 88d6876cc65..a2f3f99ae63 100644 --- a/apps/desktop-gpui/src/editor_canvas.rs +++ b/apps/desktop-gpui/src/editor_canvas.rs @@ -447,6 +447,7 @@ pub enum CanvasSelection { Camera, Mask(usize), Text(usize), + Image(usize), } impl CanvasSelection { @@ -456,6 +457,7 @@ impl CanvasSelection { Self::Camera => "Camera".into(), Self::Mask(_) => "Mask".into(), Self::Text(_) => "Text".into(), + Self::Image(_) => "Image".into(), } } @@ -465,6 +467,7 @@ impl CanvasSelection { Self::Camera => "canvas-camera".into(), Self::Mask(index) => format!("canvas-mask-{index}").into(), Self::Text(index) => format!("canvas-text-{index}").into(), + Self::Image(index) => format!("canvas-image-{index}").into(), } } @@ -472,6 +475,7 @@ impl CanvasSelection { match self { Self::Mask(index) => Some((TrackKind::Mask, index)), Self::Text(index) => Some((TrackKind::Text, index)), + Self::Image(index) => Some((TrackKind::Image, index)), _ => None, } } @@ -597,6 +601,24 @@ impl EditorWindow { } let t = self.preview_or_playhead(); if let Some(timeline) = self.project.timeline.as_ref() { + for (index, segment) in timeline.image_segments.iter().enumerate() { + if exclude == CanvasSelection::Image(index) + || !segment.is_active_at(t) + || segment.opacity <= 0. + { + continue; + } + if let Some(rect) = self.element_rect(CanvasSelection::Image(index)) { + rects.push(image_axis_bounds( + rect, + ( + f64::from(layout.output_size[0]), + f64::from(layout.output_size[1]), + ), + f64::from(segment.rotation), + )); + } + } for (index, segment) in timeline.text_segments.iter().enumerate() { if exclude == CanvasSelection::Text(index) { continue; @@ -651,12 +673,17 @@ impl EditorWindow { cx, ); } else if self.canvas_selection != Some(element) { + if self.selected_style_index().is_none() { + self.set_selection(None, cx); + } self.canvas_selection = Some(element); - self.set_selection(None, cx); } let draggable = match element { CanvasSelection::Display => self.display_draggable(), - CanvasSelection::Camera | CanvasSelection::Mask(_) | CanvasSelection::Text(_) => true, + CanvasSelection::Camera + | CanvasSelection::Mask(_) + | CanvasSelection::Text(_) + | CanvasSelection::Image(_) => true, }; if !draggable { cx.notify(); @@ -690,7 +717,7 @@ impl EditorWindow { let resizable = match element { CanvasSelection::Display => self.display_draggable(), CanvasSelection::Camera => self.camera_resizable(), - CanvasSelection::Mask(_) | CanvasSelection::Text(_) => true, + CanvasSelection::Mask(_) | CanvasSelection::Text(_) | CanvasSelection::Image(_) => true, }; if !resizable { return; @@ -702,8 +729,10 @@ impl EditorWindow { cx, ); } else if self.canvas_selection != Some(element) { + if self.selected_style_index().is_none() { + self.set_selection(None, cx); + } self.canvas_selection = Some(element); - self.set_selection(None, cx); } let Some(rect) = self.element_rect(element) else { return; @@ -711,8 +740,12 @@ impl EditorWindow { let Some(layout) = self.frame_layout else { return; }; + let effective = self + .project + .style_at(self.preview_or_playhead()) + .into_owned(); let (max_width, padding_scale) = - display_resize_scales(rect, &layout, self.project.aspect_ratio.is_some()); + display_resize_scales(rect, &layout, effective.aspect_ratio.is_some()); self.history.pause(); self.canvas_drag = Some(CanvasDrag { element, @@ -725,9 +758,9 @@ impl EditorWindow { dir_y, output_width: f64::from(layout.output_size[0]), output_height: f64::from(layout.output_size[1]), - camera_manual: self.project.camera.manual_position, - camera_x: self.project.camera.position.x.clone(), - camera_y: self.project.camera.position.y.clone(), + camera_manual: effective.camera.manual_position, + camera_x: effective.camera.position.x.clone(), + camera_y: effective.camera.position.y.clone(), max_width, padding_scale, }), @@ -820,10 +853,17 @@ impl EditorWindow { ); self.snap_guides = guides; self.canvas_drag_rect = Some(rect); - if (self.project.background.padding - padding).abs() > 1e-6 { - self.project.background.padding = padding; - self.project_changed_live(cx); - } + self.write_canvas_style( + crate::editor_sidebar::StyleGroup::Background, + |project| { + if (project.background.padding - padding).abs() <= 1e-6 { + return false; + } + project.background.padding = padding; + true + }, + cx, + ); } CanvasSelection::Camera => { let (rect, size_pct, guides) = camera_resize_rect( @@ -841,14 +881,42 @@ impl EditorWindow { ); self.snap_guides = guides; self.canvas_drag_camera_rect = Some(rect); - if (f64::from(self.project.camera.size) - size_pct).abs() > 1e-6 { - self.project.camera.size = size_pct as f32; - self.project_changed_live(cx); - } + self.write_canvas_style( + crate::editor_sidebar::StyleGroup::Camera, + |project| { + if (f64::from(project.camera.size) - size_pct).abs() <= 1e-6 { + return false; + } + project.camera.size = size_pct as f32; + true + }, + cx, + ); } - CanvasSelection::Mask(_) | CanvasSelection::Text(_) => { - let (rect, guides) = - overlay_resize_rect(start, size, delta, dir_x, dir_y, &targets, shift); + CanvasSelection::Mask(_) | CanvasSelection::Text(_) | CanvasSelection::Image(_) => { + let (rect, guides) = if let CanvasSelection::Image(index) = element { + let Some(segment) = self + .project + .timeline + .as_ref() + .and_then(|timeline| timeline.image_segments.get(index)) + else { + return; + }; + ( + image_resize_rect( + start, + size, + delta, + (dir_x, dir_y), + f64::from(segment.rotation), + segment.lock_aspect, + ), + Vec::new(), + ) + } else { + overlay_resize_rect(start, size, delta, dir_x, dir_y, &targets, shift) + }; self.snap_guides = guides; self.canvas_overlay_rect = Some(rect); self.write_overlay_rect(element, rect, cx); @@ -860,8 +928,19 @@ impl EditorWindow { let (center, guides) = match element { CanvasSelection::Display => display_drag_center(start, size, delta, &targets, shift), CanvasSelection::Camera => camera_drag_center(start, size, delta, &targets, shift), - CanvasSelection::Mask(_) | CanvasSelection::Text(_) => { - overlay_drag_center(start, size, delta, &targets, shift) + CanvasSelection::Mask(_) | CanvasSelection::Text(_) | CanvasSelection::Image(_) => { + let bounds = if let CanvasSelection::Image(index) = element { + let rotation = self + .project + .timeline + .as_ref() + .and_then(|timeline| timeline.image_segments.get(index)) + .map_or(0., |segment| f64::from(segment.rotation)); + image_axis_bounds(start, size, rotation) + } else { + start + }; + overlay_drag_center(bounds, size, delta, &targets, shift) } }; self.snap_guides = guides; @@ -879,7 +958,7 @@ impl EditorWindow { self.canvas_drag_camera_rect = Some(optimistic); self.write_camera_position(center, cx); } - CanvasSelection::Mask(_) | CanvasSelection::Text(_) => { + CanvasSelection::Mask(_) | CanvasSelection::Text(_) | CanvasSelection::Image(_) => { self.canvas_overlay_rect = Some(optimistic); self.write_overlay_rect(element, optimistic, cx); } @@ -920,6 +999,9 @@ impl EditorWindow { CanvasSelection::Text(index) => { tracing::info!(index, "canvas text drag"); } + CanvasSelection::Image(index) => { + tracing::info!(index, "canvas image drag"); + } } } cx.notify(); @@ -939,6 +1021,18 @@ impl EditorWindow { if !self.canvas_overlay_visible() { return false; } + if let CanvasSelection::Image(index) = selected + && !self + .project + .timeline + .as_ref() + .and_then(|timeline| timeline.image_segments.get(index)) + .is_some_and(|segment| { + segment.is_active_at(self.preview_or_playhead()) && segment.opacity > 0. + }) + { + return false; + } let (Some(canvas), Some(rect)) = (self.canvas_bounds(), self.element_rect(selected)) else { return false; }; @@ -957,7 +1051,7 @@ impl EditorWindow { let center = match selected { CanvasSelection::Display => display_nudge_center(rect, size, direction, shift), CanvasSelection::Camera => camera_nudge_center(rect, size, direction, shift), - CanvasSelection::Mask(_) | CanvasSelection::Text(_) => { + CanvasSelection::Mask(_) | CanvasSelection::Text(_) | CanvasSelection::Image(_) => { overlay_nudge_center(rect, direction, shift) } }; @@ -975,35 +1069,107 @@ impl EditorWindow { self.canvas_drag_camera_rect = Some(optimistic); self.write_camera_position(center, cx); } - CanvasSelection::Mask(_) | CanvasSelection::Text(_) => { + CanvasSelection::Mask(_) | CanvasSelection::Text(_) | CanvasSelection::Image(_) => { self.canvas_overlay_rect = Some(optimistic); self.write_overlay_rect(selected, optimistic, cx); } } - let _ = window; + self.schedule_save(window, cx); true } fn write_display_position(&mut self, center: XY, cx: &mut Context) { - if self.project.background.display_position == Some(center) { - return; - } - self.project.background.display_position = Some(center); - self.project_changed_live(cx); + self.write_canvas_style( + crate::editor_sidebar::StyleGroup::Background, + |project| { + if project.background.display_position == Some(center) { + return false; + } + project.background.display_position = Some(center); + true + }, + cx, + ); } fn write_camera_position(&mut self, center: XY, cx: &mut Context) { - if self.project.camera.manual_position == Some(center) { - return; + self.write_canvas_style( + crate::editor_sidebar::StyleGroup::Camera, + |project| { + if project.camera.manual_position == Some(center) { + return false; + } + project.camera.manual_position = Some(center); + true + }, + cx, + ); + } + + fn write_canvas_style( + &mut self, + group: crate::editor_sidebar::StyleGroup, + change: impl FnOnce(&mut cap_project::ProjectConfiguration) -> bool, + cx: &mut Context, + ) { + use crate::editor_sidebar::StyleGroup; + let time = self.preview_or_playhead(); + let target = self.project.timeline.as_ref().and_then(|timeline| { + timeline + .style_segments + .iter() + .enumerate() + .filter(|(_, segment)| { + segment.is_active_at(time) + && match group { + StyleGroup::Background => segment.overrides.background.is_some(), + StyleGroup::Camera => segment.overrides.camera.is_some(), + StyleGroup::Cursor => segment.overrides.cursor.is_some(), + } + }) + .max_by(|(ai, a), (bi, b)| { + a.track + .cmp(&b.track) + .then(a.start.total_cmp(&b.start)) + .then(ai.cmp(bi)) + }) + .map(|(index, _)| index) + }); + let changed = match self.selected_style_index().or(target) { + Some(index) => crate::editor_sidebar::apply_style_control_change( + &mut self.project, + index, + group, + change, + ), + None => change(&mut self.project), + }; + if changed { + self.project_changed_live(cx); } - self.project.camera.manual_position = Some(center); - self.project_changed_live(cx); } fn element_rect(&self, element: CanvasSelection) -> Option { match element { CanvasSelection::Display => self.display_rect(), CanvasSelection::Camera => self.camera_rect(), + CanvasSelection::Image(index) => { + if self + .canvas_drag + .as_ref() + .is_some_and(|drag| drag.element == element) + && let Some(rect) = self.canvas_overlay_rect + { + return Some(rect); + } + let segment = self.project.timeline.as_ref()?.image_segments.get(index)?; + Some(NormRect { + x: segment.center.x - segment.size.x / 2., + y: segment.center.y - segment.size.y / 2., + w: segment.size.x, + h: segment.size.y, + }) + } CanvasSelection::Mask(index) => { if self .canvas_drag @@ -1055,6 +1221,13 @@ impl EditorWindow { return; }; match element { + CanvasSelection::Image(index) => { + let Some(segment) = timeline.image_segments.get_mut(index) else { + return; + }; + segment.center = center; + segment.size = size; + } CanvasSelection::Mask(index) => { let Some(segment) = timeline.mask_segments.get_mut(index) else { return; @@ -1183,6 +1356,20 @@ impl EditorWindow { } } + if let Some(timeline) = self.project.timeline.as_ref() { + let mut images: Vec<_> = timeline + .image_segments + .iter() + .enumerate() + .filter(|(_, segment)| segment.is_active_at(time) && segment.opacity > 0.) + .collect(); + images.sort_by_key(|(index, segment)| (segment.track, *index)); + for (index, _) in images { + if let Some(rect) = self.element_rect(CanvasSelection::Image(index)) { + layer = layer.child(self.render_image_box(index, rect, (cw, ch), cx)); + } + } + } for guide in &self.snap_guides { let color = gpui::rgb(0xFF3B6B); layer = layer.child(match guide.axis { @@ -1232,7 +1419,9 @@ impl EditorWindow { self.camera_resizable(), (!self.camera_resizable()).then_some("Camera size follows the zoom — drag to move"), ), - CanvasSelection::Mask(_) | CanvasSelection::Text(_) => (true, true, None), + CanvasSelection::Mask(_) | CanvasSelection::Text(_) | CanvasSelection::Image(_) => { + (true, true, None) + } }; let left = rect.x as f32 * canvas.0; @@ -1278,7 +1467,7 @@ impl EditorWindow { // the stop the display would hijack the drag one event later. .on_mouse_down( MouseButton::Left, - cx.listener(move |this, event, window, cx| { + cx.listener(move |this, event: &MouseDownEvent, window, cx| { cx.stop_propagation(); this.begin_canvas_move(element, event, window, cx); }), @@ -1403,7 +1592,7 @@ impl EditorWindow { ) .on_mouse_down( MouseButton::Left, - cx.listener(move |this, event, window, cx| { + cx.listener(move |this, event: &MouseDownEvent, window, cx| { cx.stop_propagation(); this.begin_canvas_resize(element, dir_x, dir_y, event, window, cx); }), @@ -1919,3 +2108,256 @@ mod tests { assert!((guide.end - 0.7).abs() < 1e-9); } } + +fn rotate_point(point: (f64, f64), degrees: f64) -> (f64, f64) { + let (sin, cos) = degrees.to_radians().sin_cos(); + (point.0 * cos - point.1 * sin, point.0 * sin + point.1 * cos) +} + +fn image_corners(rect: NormRect, canvas: (f64, f64), rotation: f64) -> [(f64, f64); 4] { + [(-1., -1.), (1., -1.), (1., 1.), (-1., 1.)].map(|(x, y)| { + let offset = rotate_point( + (x * rect.w * canvas.0 / 2., y * rect.h * canvas.1 / 2.), + rotation, + ); + ( + (rect.x + rect.w / 2.) * canvas.0 + offset.0, + (rect.y + rect.h / 2.) * canvas.1 + offset.1, + ) + }) +} + +fn image_axis_bounds(rect: NormRect, canvas: (f64, f64), rotation: f64) -> NormRect { + let corners = image_corners(rect, canvas, rotation); + let min_x = corners + .iter() + .map(|point| point.0) + .fold(f64::INFINITY, f64::min); + let min_y = corners + .iter() + .map(|point| point.1) + .fold(f64::INFINITY, f64::min); + let max_x = corners + .iter() + .map(|point| point.0) + .fold(f64::NEG_INFINITY, f64::max); + let max_y = corners + .iter() + .map(|point| point.1) + .fold(f64::NEG_INFINITY, f64::max); + NormRect { + x: min_x / canvas.0, + y: min_y / canvas.1, + w: (max_x - min_x) / canvas.0, + h: (max_y - min_y) / canvas.1, + } +} + +fn image_hit(rect: NormRect, canvas: (f64, f64), rotation: f64, point: (f64, f64)) -> bool { + let local = rotate_point( + ( + point.0 - (rect.x + rect.w / 2.) * canvas.0, + point.1 - (rect.y + rect.h / 2.) * canvas.1, + ), + -rotation, + ); + local.0.abs() <= rect.w * canvas.0 / 2. && local.1.abs() <= rect.h * canvas.1 / 2. +} + +fn image_resize_rect( + start: NormRect, + canvas: (f64, f64), + delta: (f64, f64), + direction: (i8, i8), + rotation: f64, + lock_aspect: bool, +) -> NormRect { + let delta = rotate_point(delta, -rotation); + let width = start.w * canvas.0; + let height = start.h * canvas.1; + let mut next_width = (width + delta.0 * f64::from(direction.0)).max(canvas.0 * 0.01); + let mut next_height = (height + delta.1 * f64::from(direction.1)).max(canvas.1 * 0.01); + if lock_aspect && width > 0. && height > 0. { + let sx = next_width / width; + let sy = next_height / height; + let scale = if (sx - 1.).abs() > (sy - 1.).abs() { + sx + } else { + sy + }; + let scale = scale + .max(canvas.0 * 0.01 / width) + .max(canvas.1 * 0.01 / height); + next_width = width * scale; + next_height = height * scale; + } + let offset = rotate_point( + ( + (next_width - width) * f64::from(direction.0) / 2., + (next_height - height) * f64::from(direction.1) / 2., + ), + rotation, + ); + let w = next_width / canvas.0; + let h = next_height / canvas.1; + NormRect { + x: start.x + start.w / 2. + offset.0 / canvas.0 - w / 2., + y: start.y + start.h / 2. + offset.1 / canvas.1 - h / 2., + w, + h, + } +} + +impl EditorWindow { + fn render_image_box( + &self, + index: usize, + rect: NormRect, + canvas: (f32, f32), + cx: &mut Context, + ) -> AnyElement { + let element = CanvasSelection::Image(index); + let rotation = self + .project + .timeline + .as_ref() + .and_then(|timeline| timeline.image_segments.get(index)) + .map_or(0., |segment| f64::from(segment.rotation)); + let size = (f64::from(canvas.0), f64::from(canvas.1)); + let corners = image_corners(rect, size, rotation); + let show = self.canvas_selection == Some(element) || self.hovered_canvas == Some(element); + let color = Hsla::from(self.theme.blue_9); + let mut layer = div() + .id(element.element_id()) + .absolute() + .inset_0() + .on_mouse_move(cx.listener(move |this, event: &MouseMoveEvent, _, cx| { + let Some(bounds) = this.canvas_bounds() else { + return; + }; + let point = ( + f64::from(f32::from(event.position.x - bounds.origin.x)), + f64::from(f32::from(event.position.y - bounds.origin.y)), + ); + this.set_canvas_hover(element, image_hit(rect, size, rotation, point), cx); + })) + .on_mouse_down( + MouseButton::Left, + cx.listener(move |this, event: &MouseDownEvent, window, cx| { + let Some(bounds) = this.canvas_bounds() else { + return; + }; + let point = ( + f64::from(f32::from(event.position.x - bounds.origin.x)), + f64::from(f32::from(event.position.y - bounds.origin.y)), + ); + if image_hit(rect, size, rotation, point) { + cx.stop_propagation(); + this.begin_canvas_move(element, event, window, cx); + } + }), + ); + if show { + layer = layer.child( + gpui::canvas( + |bounds, _, _| bounds, + move |_, bounds, window, _| { + let mut path = gpui::PathBuilder::stroke(px(2.)); + for (index, (x, y)) in corners.into_iter().enumerate() { + let point = gpui::point( + bounds.origin.x + px(x as f32), + bounds.origin.y + px(y as f32), + ); + if index == 0 { + path.move_to(point); + } else { + path.line_to(point); + } + } + path.close(); + if let Ok(path) = path.build() { + window.paint_path(path, color); + } + }, + ) + .absolute() + .inset_0(), + ); + for ((x, y), (dx, dy)) in corners + .into_iter() + .zip([(-1, -1), (1, -1), (1, 1), (-1, 1)]) + { + layer = layer.child( + div() + .id(SharedString::from(format!( + "image-handle-{index}-{dx}-{dy}" + ))) + .absolute() + .left(px(x as f32 - 6.)) + .top(px(y as f32 - 6.)) + .size(px(12.)) + .rounded_full() + .border_1() + .border_color(gpui::white()) + .bg(color) + .cursor(CursorStyle::ResizeUpLeftDownRight) + .on_mouse_down( + MouseButton::Left, + cx.listener(move |this, event: &MouseDownEvent, window, cx| { + cx.stop_propagation(); + this.begin_canvas_resize(element, dx, dy, event, window, cx); + }), + ), + ); + } + } + layer.into_any_element() + } +} + +#[cfg(test)] +mod style_image_tests { + use super::*; + + #[test] + fn style_image_rotated_hit_excludes_empty_bounding_box_corners() { + let rect = NormRect { + x: 0.3, + y: 0.35, + w: 0.4, + h: 0.3, + }; + let canvas = (1200., 600.); + let bounds = image_axis_bounds(rect, canvas, 45.); + assert!(image_hit(rect, canvas, 45., (600., 300.))); + assert!(!image_hit( + rect, + canvas, + 45., + (bounds.x * canvas.0, bounds.y * canvas.1) + )); + } + + #[test] + fn style_image_rotated_resize_keeps_opposite_corner_and_aspect() { + let start = NormRect { + x: 0.3, + y: 0.35, + w: 0.4, + h: 0.3, + }; + let canvas = (1200., 600.); + for rotation in [-135., 0., 35., 90.] { + for (corner, direction) in [(0, (-1, -1)), (1, (1, -1)), (2, (1, 1)), (3, (-1, 1))] { + let next = image_resize_rect(start, canvas, (80., 45.), direction, rotation, true); + assert!((next.w / next.h - start.w / start.h).abs() < 1e-9); + let opposite = (corner + 2) % 4; + let before = image_corners(start, canvas, rotation)[opposite]; + let after = image_corners(next, canvas, rotation)[opposite]; + assert!((before.0 - after.0).abs() < 1e-8); + assert!((before.1 - after.1).abs() < 1e-8); + assert!(next.w > 0. && next.h > 0.); + } + } + } +} diff --git a/apps/desktop-gpui/src/editor_clips.rs b/apps/desktop-gpui/src/editor_clips.rs index d8cc2448c62..9ae9a271052 100644 --- a/apps/desktop-gpui/src/editor_clips.rs +++ b/apps/desktop-gpui/src/editor_clips.rs @@ -229,6 +229,8 @@ pub(crate) fn move_clip( .transitions .retain(|candidate| candidate.segment_index != transition.segment_index); // The source ripples these seven tracks and no others (`:672-682`). + ripple_track(&mut timeline.style_segments, boundary, effective.duration); + ripple_track(&mut timeline.image_segments, boundary, effective.duration); ripple_track(&mut timeline.zoom_segments, boundary, effective.duration); ripple_track(&mut timeline.scene_segments, boundary, effective.duration); ripple_track(&mut timeline.mask_segments, boundary, effective.duration); @@ -1479,7 +1481,7 @@ impl EditorWindow { cx.spawn_in(window, async move |this, cx| { // Blocking modal, so from a spawned task with no borrow held -- // the `save_file_panel` rule. - let Some(path) = pick_existing_recording_path() else { + let Some(path) = pick_existing_recording_path(cx).await else { return; }; this.update_in(cx, |this, window, cx| { @@ -1497,7 +1499,10 @@ impl EditorWindow { cx.spawn_in(window, async move |this, cx| { #[cfg(target_os = "macos")] let source = crate::platform::open_image_panel(&["mp4"]); - #[cfg(not(target_os = "macos"))] + #[cfg(target_os = "linux")] + let source = + crate::platform::open_file_panel_async(&[("MP4 Video", &["mp4"])], None, cx).await; + #[cfg(not(any(target_os = "macos", target_os = "linux")))] let source = rfd::FileDialog::new() .add_filter("MP4 Video", &["mp4"]) .pick_file(); @@ -1985,12 +1990,21 @@ impl PreparedMp4Import { /// a `.cap` filter on macOS (bundles are packages there), a directory picker /// on Windows, both rooted at the recordings directory where the dialog /// supports one. -fn pick_existing_recording_path() -> Option { +async fn pick_existing_recording_path(_cx: &mut gpui::AsyncWindowContext) -> Option { #[cfg(target_os = "macos")] { crate::platform::open_image_panel(&["cap"]) } - #[cfg(not(target_os = "macos"))] + #[cfg(target_os = "linux")] + { + crate::platform::open_file_panel_async( + &[("Cap Recording", &["cap"])], + Some(crate::recording::recordings_dir()), + _cx, + ) + .await + } + #[cfg(not(any(target_os = "macos", target_os = "linux")))] { rfd::FileDialog::new() .set_directory(crate::recording::recordings_dir()) @@ -2365,6 +2379,8 @@ fn ensure_project_timeline<'a>( keyboard_segments: Vec::new(), audio_segments: Vec::new(), camera3d_segments: Vec::new(), + style_segments: Vec::new(), + image_segments: Vec::new(), }); } @@ -3791,12 +3807,31 @@ mod tests { edge_snap_ratio: 0.25, }]; + config.style_segments.push(cap_project::StyleSegment { + start: 15., + end: 18., + ..Default::default() + }); + config.image_segments.push(cap_project::ImageSegment { + start: 15., + end: 18., + path: "content/images/retained.png".into(), + ..Default::default() + }); // Moving clip 0 to the end separates the 0|1 pair, dropping the 1s // transition whose boundary sat at offset(1) + 1.0 = 10.0. assert!(move_clip(&mut config, 0, 3)); assert!(config.transitions.is_empty()); assert_eq!(config.zoom_segments[0].start, 16.0); assert_eq!(config.zoom_segments[0].end, 19.0); + assert_eq!( + (config.style_segments[0].start, config.style_segments[0].end), + (16., 19.) + ); + assert_eq!( + (config.image_segments[0].start, config.image_segments[0].end), + (16., 19.) + ); } /// `computeDropIndex` (`:692-703`): the insertion point is after every diff --git a/apps/desktop-gpui/src/editor_crop.rs b/apps/desktop-gpui/src/editor_crop.rs index 553de486f3c..797348ff1ff 100644 --- a/apps/desktop-gpui/src/editor_crop.rs +++ b/apps/desktop-gpui/src/editor_crop.rs @@ -1041,6 +1041,8 @@ pub fn is_nudge_key(key: &str) -> Option<&'static str> { /// Everything the open dialog owns. `None` on [`EditorWindow`] means the /// dialog is closed, which is `dialog().type !== "crop"`. pub struct CropState { + style_target: Option, + error: Option, /// `targetSize` -- `recordings.segments[0].display`, the raw recording /// resolution and the space `background.crop` is written in. pub target: (u32, u32), @@ -1086,6 +1088,8 @@ impl CropState { /// viewport gives. pub fn new(target: (u32, u32), box_size: (f32, f32), initial: CropBounds) -> Self { let mut state = Self { + style_target: None, + error: None, target, box_size, // Seeded from the border inset so the first frame is already @@ -1448,9 +1452,31 @@ impl EditorWindow { tracing::warn!("crop: no display recording to crop"); return; }; + self.end_field_edit(cx); + self.close_color_picker(cx); + self.dismiss_frame_controls(cx); self.stop_playback_for_crop(cx); - let initial = match &self.project.background.crop { + let style_target = self.selected_style_index().and_then(|index| { + let segment = self.project.timeline.as_ref()?.style_segments.get(index)?; + Some(StyleCropTarget { + index, + fingerprint: serde_json::to_string(segment).ok()?, + background: segment + .overrides + .background + .clone() + .unwrap_or_else(|| self.project.background.clone()), + opting_in: segment.overrides.background.is_none(), + time: self + .preview_or_playhead() + .clamp(segment.start, (segment.end - 0.001).max(segment.start)), + }) + }); + let background = style_target + .as_ref() + .map_or(&self.project.background, |target| &target.background); + let initial = match &background.crop { Some(crop) => CropBounds::new( f64::from(crop.position.x), f64::from(crop.position.y), @@ -1462,7 +1488,11 @@ impl EditorWindow { let viewport = window.viewport_size(); let container = crop_box_size((viewport.width.into(), viewport.height.into()), target); - let state = CropState::new(target, container, initial); + let mut state = CropState::new(target, container, initial); + state.style_target = style_target; + if let Some(target) = &state.style_target { + self.seek_to_time(target.time, cx); + } tracing::info!( target = format!("{}x{}", target.0, target.1), container = format!("{}x{}", container.0, container.1), @@ -1541,9 +1571,18 @@ impl EditorWindow { /// The footer's Save (`Editor.tsx:1414-1432`): **one** `setProject` call, /// so **one** history entry for the whole session, then close. pub(crate) fn save_crop(&mut self, window: &mut Window, cx: &mut Context) { - let Some(state) = self.crop.take() else { + let Some(mut state) = self.crop.take() else { return; }; + if let Some(target) = &state.style_target + && !target.matches(&self.project) + { + state.error = Some("This Style changed while cropping. Cancel and reopen Crop.".into()); + self.crop = Some(state); + self.publish_project(); + cx.notify(); + return; + } let bounds = state.real(); let crop = Crop { position: XY::new(bounds.x.max(0.) as u32, bounds.y.max(0.) as u32), @@ -1556,7 +1595,13 @@ impl EditorWindow { ), "crop saved" ); - self.project.background.crop = Some(crop); + if let Some(target) = state.style_target { + if !target.apply(&mut self.project, crop) { + return; + } + } else { + self.project.background.crop = Some(crop); + } self.project_changed(window, cx); window.refresh(); } @@ -1571,15 +1616,26 @@ impl EditorWindow { return; }; let mut config = self.project.clone(); + let mut time = self.preview_or_playhead(); if let Some(state) = &self.crop { let bounds = state.real(); - config.background.crop = Some(Crop { + let crop = Crop { position: XY::new(bounds.x.max(0.) as u32, bounds.y.max(0.) as u32), - size: XY::new(bounds.width.max(0.) as u32, bounds.height.max(0.) as u32), - }); + size: XY::new(bounds.width.max(1.) as u32, bounds.height.max(1.) as u32), + }; + if let Some(target) = &state.style_target { + if !target.apply(&mut config, crop) { + return; + } + time = target.time; + } else { + config.background.crop = Some(crop); + if let Some(timeline) = config.timeline.as_mut() { + timeline.style_segments.clear(); + } + } } instance.project_config.0.send(config).ok(); - let time = self.preview_or_playhead(); crate::editor_window::request_frame( instance, (time * f64::from(EDITOR_PREVIEW_FPS)).floor() as u32, @@ -2248,6 +2304,8 @@ impl EditorWindow { .border_color(Hsla::from(theme.gray_3)) .bg(Hsla::from(theme.gray_1)) .overflow_hidden() + .children(state.style_target.as_ref().map(|target| div().px(px(20.)).pt(px(16.)).text_size(px(12.)).child(if target.opting_in { format!("Style {} only · Saving enables its background override. Global settings stay unchanged.",target.index+1) } else { format!("Editing Style {} only · Global settings stay unchanged.",target.index+1) }))) + .children(state.error.as_ref().map(|error| div().p(px(16.)).child(error.clone()))) .child(self.render_crop_header(state, cx)) .child(self.render_crop_body(state, cx)) .child(self.render_crop_footer(cx)), @@ -3425,3 +3483,100 @@ mod tests { assert!(crop_menu_choice(10).is_none()); } } + +struct StyleCropTarget { + index: usize, + fingerprint: String, + background: cap_project::BackgroundConfiguration, + opting_in: bool, + time: f64, +} + +impl StyleCropTarget { + fn apply(&self, project: &mut cap_project::ProjectConfiguration, crop: Crop) -> bool { + if !self.matches(project) { + return false; + } + let Some(segment) = project + .timeline + .as_mut() + .and_then(|timeline| timeline.style_segments.get_mut(self.index)) + else { + return false; + }; + let mut background = self.background.clone(); + background.crop = Some(crop); + segment.overrides.background = Some(background); + true + } + + fn matches(&self, project: &cap_project::ProjectConfiguration) -> bool { + project + .timeline + .as_ref() + .and_then(|timeline| timeline.style_segments.get(self.index)) + .and_then(|segment| serde_json::to_string(segment).ok()) + .is_some_and(|value| value == self.fingerprint) + } +} + +#[cfg(test)] +mod style_image_tests { + use super::*; + + #[test] + fn style_image_crop_preview_and_save_preserve_base_and_guard_reordering() { + let mut project: cap_project::ProjectConfiguration = serde_json::from_value(serde_json::json!({"timeline":{"zoomSegments":[],"segments":[],"styleSegments":[{"start":1,"end":5,"name":"A"},{"start":6,"end":9,"name":"B"}]}})).unwrap(); + let before = serde_json::to_value(&project).unwrap(); + let target = StyleCropTarget { + index: 0, + fingerprint: serde_json::to_string( + &project.timeline.as_ref().unwrap().style_segments[0], + ) + .unwrap(), + background: project.background.clone(), + opting_in: true, + time: 2., + }; + let crop = Crop { + position: XY::new(100, 50), + size: XY::new(800, 600), + }; + let mut preview = project.clone(); + assert!(target.apply(&mut preview, crop.clone())); + assert_eq!(serde_json::to_value(&project).unwrap(), before); + assert_eq!( + serde_json::to_value(&preview.background).unwrap(), + serde_json::to_value(&project.background).unwrap() + ); + assert_eq!( + serde_json::to_value(&preview.style_at(2.).background.crop).unwrap(), + serde_json::to_value(&Some(crop.clone())).unwrap() + ); + assert!(target.apply(&mut project, crop)); + assert_eq!( + serde_json::to_value(&project.background).unwrap(), + serde_json::to_value(&preview.background).unwrap() + ); + assert!( + project.timeline.as_ref().unwrap().style_segments[1] + .overrides + .background + .is_none() + ); + project.timeline.as_mut().unwrap().style_segments.swap(0, 1); + assert!(!target.apply( + &mut project, + Crop { + position: XY::new(0, 0), + size: XY::new(10, 10) + } + )); + assert!( + project.timeline.as_ref().unwrap().style_segments[0] + .overrides + .background + .is_none() + ); + } +} diff --git a/apps/desktop-gpui/src/editor_edits.rs b/apps/desktop-gpui/src/editor_edits.rs index b465d9af0f0..87a5ddf43b6 100644 --- a/apps/desktop-gpui/src/editor_edits.rs +++ b/apps/desktop-gpui/src/editor_edits.rs @@ -358,7 +358,7 @@ pub fn min_segment_duration(kind: TrackKind, secs_per_pixel: f64) -> f64 { TrackKind::Zoom => (1., 40.), TrackKind::Scene => (1., 80.), TrackKind::ThreeD => (1., 40.), - TrackKind::Text => (1., 80.), + TrackKind::Text | TrackKind::Style | TrackKind::Image => (1., 80.), TrackKind::Mask => (1., 80.), TrackKind::Audio => (0.5, 60.), TrackKind::Caption => (0.5, 40.), @@ -512,6 +512,8 @@ impl_track_segment!(SceneSegment); impl_track_segment!(Camera3DSegment); impl_track_segment!(MaskSegment, lane: track); impl_track_segment!(TextSegment, lane: track); +impl_track_segment!(cap_project::StyleSegment, lane: track); +impl_track_segment!(cap_project::ImageSegment, lane: track); impl TrackSegmentOps for CaptionTrackSegment { fn start(&self) -> f64 { @@ -705,6 +707,14 @@ macro_rules! with_track { let $segments = &mut $timeline.camera3d_segments; $body } + TrackKind::Style => { + let $segments = &mut $timeline.style_segments; + $body + } + TrackKind::Image => { + let $segments = &mut $timeline.image_segments; + $body + } TrackKind::Text => { let $segments = &mut $timeline.text_segments; $body @@ -738,6 +748,8 @@ pub fn segment_count(timeline: &TimelineConfiguration, kind: TrackKind) -> usize TrackKind::Zoom => timeline.zoom_segments.len(), TrackKind::Scene => timeline.scene_segments.len(), TrackKind::ThreeD => timeline.camera3d_segments.len(), + TrackKind::Style => timeline.style_segments.len(), + TrackKind::Image => timeline.image_segments.len(), TrackKind::Text => timeline.text_segments.len(), TrackKind::Mask => timeline.mask_segments.len(), TrackKind::Audio => timeline.audio_segments.len(), @@ -764,7 +776,9 @@ pub fn set_segment_start( return false; } segment.set_start(start); - sort_track(segments); + if !matches!(kind, TrackKind::Style | TrackKind::Image) { + sort_track(segments); + } true }) } @@ -784,7 +798,9 @@ pub fn set_segment_end( return false; } segment.set_end(end); - sort_track(segments); + if !matches!(kind, TrackKind::Style | TrackKind::Image) { + sort_track(segments); + } true }) } @@ -822,6 +838,20 @@ pub fn delete_segments( indices: &[usize], ) -> bool { match kind { + TrackKind::Image => { + let deleted = delete_indices(&mut timeline.image_segments, indices); + normalize_track(&mut timeline.image_segments, |segment, lane| { + segment.track = lane + }); + deleted + } + TrackKind::Style => { + let deleted = delete_indices(&mut timeline.style_segments, indices); + normalize_track(&mut timeline.style_segments, |segment, lane| { + segment.track = lane + }); + deleted + } TrackKind::Mask => { let deleted = delete_indices(&mut timeline.mask_segments, indices); normalize_track(&mut timeline.mask_segments, |segment, lane| { @@ -874,6 +904,18 @@ pub fn delete_track_lane(timeline: &mut TimelineConfiguration, kind: TrackKind, changed } match kind { + TrackKind::Style => apply( + &mut timeline.style_segments, + lane, + |segment| segment.track, + |segment, value| segment.track = value, + ), + TrackKind::Image => apply( + &mut timeline.image_segments, + lane, + |segment| segment.track, + |segment, value| segment.track = value, + ), TrackKind::Text => apply( &mut timeline.text_segments, lane, @@ -1274,6 +1316,8 @@ pub fn ensure_timeline(project: &mut ProjectConfiguration, clip_display_duration keyboard_segments: Vec::new(), audio_segments: Vec::new(), camera3d_segments: Vec::new(), + style_segments: Vec::new(), + image_segments: Vec::new(), }); true } @@ -1694,6 +1738,18 @@ pub fn snap_split_time( .iter() .map(|segment| (segment.start, segment.end)), ) + .chain( + timeline + .style_segments + .iter() + .map(|segment| (segment.start, segment.end)), + ) + .chain( + timeline + .image_segments + .iter() + .map(|segment| (segment.start, segment.end)), + ) .collect::>() { consider(start); @@ -1807,6 +1863,14 @@ pub fn set_clip_segment_timescale( ) }; + for segment in &mut timeline.style_segments { + segment.start += shift(segment.start); + segment.end += shift(segment.end); + } + for segment in &mut timeline.image_segments { + segment.start += shift(segment.start); + segment.end += shift(segment.end); + } for segment in &mut timeline.zoom_segments { segment.start += shift(segment.start); segment.end += shift(segment.end); @@ -2811,3 +2875,204 @@ mod tests { )); } } + +pub fn insert_style_segment( + timeline: &mut TimelineConfiguration, + segment: cap_project::StyleSegment, +) -> usize { + let start = segment.start; + let track = segment.track; + timeline.style_segments.push(segment); + sort_lane_segments(&mut timeline.style_segments); + timeline + .style_segments + .iter() + .rposition(|item| item.start == start && item.track == track) + .unwrap_or(0) +} + +pub fn insert_image_segment( + timeline: &mut TimelineConfiguration, + segment: cap_project::ImageSegment, +) -> usize { + let start = segment.start; + let track = segment.track; + timeline.image_segments.push(segment); + sort_lane_segments(&mut timeline.image_segments); + timeline + .image_segments + .iter() + .rposition(|item| item.start == start && item.track == track) + .unwrap_or(0) +} + +#[cfg(test)] +mod style_image_tests { + use super::*; + + fn project() -> ProjectConfiguration { + serde_json::from_value(serde_json::json!({"timeline": {"zoomSegments":[], + "segments": [{"start":0,"end":20,"timescale":1}], + "styleSegments": [{"start":2,"end":8,"track":0,"name":"First"}, {"start":1,"end":6,"track":1,"name":"Second"}], + "imageSegments": [{"start":2,"end":8,"track":0,"path":"content/images/retained.png","rotation":35,"flipX":true}] + }})).unwrap() + } + + #[test] + fn style_image_edit_split_delete_and_history_preserve_assets_and_overrides() { + let mut project = project(); + let mut history = ProjectHistory::new(project.clone()); + history.pause(); + for kind in [TrackKind::Style, TrackKind::Image] { + let timeline = project.timeline.as_mut().unwrap(); + assert!(move_segment(timeline, kind, 0, 3., 9.)); + assert!(set_segment_start(timeline, kind, 0, 4.)); + assert!(set_segment_end(timeline, kind, 0, 10.)); + history.record(&project); + } + history.resume(&project); + assert_eq!(history.depth(), 2); + assert_eq!( + history + .undo() + .unwrap() + .timeline + .as_ref() + .unwrap() + .image_segments[0] + .start, + 2. + ); + project = history.redo().unwrap().clone(); + let timeline = project.timeline.as_mut().unwrap(); + for kind in [TrackKind::Style, TrackKind::Image] { + assert!(split_segment(timeline, kind, 0, 3.)); + assert!(!split_segment(timeline, kind, 0, 0.1)); + } + assert_eq!(timeline.style_segments[1].end, 10.); + assert_eq!(timeline.style_segments[2].name, "Second"); + assert!(timeline.style_segments[0].overrides.background.is_none()); + assert_eq!( + timeline.image_segments[1].path, + "content/images/retained.png" + ); + assert_eq!(timeline.image_segments[1].rotation, 35.); + assert!(timeline.image_segments[1].flip_x); + history.record(&project); + assert!(delete_segments( + project.timeline.as_mut().unwrap(), + TrackKind::Image, + &[0, 1] + )); + history.record(&project); + let restored = history.undo().unwrap().timeline.as_ref().unwrap(); + assert_eq!(restored.image_segments.len(), 2); + assert_eq!( + restored.image_segments[0].path, + "content/images/retained.png" + ); + } + + #[test] + fn style_image_trim_keeps_unsorted_loaded_indices_and_delete_normalizes_lanes() { + let mut project = project(); + let timeline = project.timeline.as_mut().unwrap(); + timeline.style_segments.swap(0, 1); + assert!(set_segment_start(timeline, TrackKind::Style, 0, 1.5)); + assert_eq!(timeline.style_segments[0].name, "Second"); + assert!(delete_track_lane(timeline, TrackKind::Style, 0)); + assert_eq!(timeline.style_segments[0].track, 0); + assert_eq!(timeline.style_segments[0].name, "Second"); + } + + #[test] + fn style_image_speed_ripple_and_serialization_keep_both_tracks() { + let mut project = project(); + let timeline = project.timeline.as_mut().unwrap(); + assert!(set_clip_segment_timescale(timeline, 0, 2.)); + assert_eq!( + ( + timeline.style_segments[0].start, + timeline.style_segments[0].end + ), + (1., 4.) + ); + assert_eq!( + ( + timeline.image_segments[0].start, + timeline.image_segments[0].end + ), + (1., 4.) + ); + let json = serde_json::to_value(&project).unwrap(); + assert!(json["timeline"]["styleSegments"][0]["overrides"]["cameraOnlyPadding"].is_null()); + assert_eq!(json["timeline"]["imageSegments"][0]["lockAspect"], true); + let restored: ProjectConfiguration = serde_json::from_value(json).unwrap(); + assert_eq!( + restored.timeline.unwrap().image_segments[0].path, + "content/images/retained.png" + ); + } +} + +pub(crate) fn replace_image_asset( + project: &mut ProjectConfiguration, + index: usize, + fingerprint: &str, + path: String, + name: String, +) -> bool { + let Some(segment) = project + .timeline + .as_mut() + .and_then(|timeline| timeline.image_segments.get_mut(index)) + else { + return false; + }; + if serde_json::to_string(segment).ok().as_deref() != Some(fingerprint) { + return false; + } + segment.path = path; + segment.name = name; + true +} + +#[cfg(test)] +mod style_image_replacement_tests { + use super::*; + #[test] + fn style_image_replace_preserves_geometry_and_history_rejects_stale_target() { + let mut project: ProjectConfiguration = serde_json::from_value(serde_json::json!({"timeline":{"zoomSegments":[],"segments":[],"imageSegments":[{"start":2,"end":8,"track":3,"path":"content/images/old.png","name":"Old","center":{"x":0.3,"y":0.7},"size":{"x":0.2,"y":0.4},"rotation":35,"flipX":true,"opacity":0.6}]}})).unwrap(); + let before = serde_json::to_value(&project).unwrap(); + let mut history = ProjectHistory::new(project.clone()); + let fingerprint = + serde_json::to_string(&project.timeline.as_ref().unwrap().image_segments[0]).unwrap(); + assert!(replace_image_asset( + &mut project, + 0, + &fingerprint, + "content/images/new.gif".into(), + "New".into() + )); + history.record(&project); + let mut expected = before.clone(); + expected["timeline"]["imageSegments"][0]["path"] = "content/images/new.gif".into(); + expected["timeline"]["imageSegments"][0]["name"] = "New".into(); + assert_eq!(serde_json::to_value(&project).unwrap(), expected); + assert!(!replace_image_asset( + &mut project, + 0, + &fingerprint, + "stale.png".into(), + "Stale".into() + )); + assert_eq!( + serde_json::to_value(history.undo().unwrap()).unwrap(), + before + ); + assert_eq!( + serde_json::to_value(history.redo().unwrap()).unwrap(), + expected + ); + } +} diff --git a/apps/desktop-gpui/src/editor_export.rs b/apps/desktop-gpui/src/editor_export.rs index 908904dd8a9..ab14d612859 100644 --- a/apps/desktop-gpui/src/editor_export.rs +++ b/apps/desktop-gpui/src/editor_export.rs @@ -8,7 +8,7 @@ use std::time::Duration; use cap_export::gif::GifExportSettings; use cap_export::mov::MovExportSettings; use cap_export::mp4::{ExportCompression, Mp4ExportSettings}; -use cap_export::preview::{ExportPreviewSettings, render_preview}; +use cap_export::preview::{ExportPreviewSettings, render_preview_with_config}; use cap_export::{ExporterBase, make_cursor_only_project}; use cap_project::{BackgroundSource, RecordingMeta, XY}; use gpui::{ @@ -472,6 +472,7 @@ impl EditorWindow { let Some(ui) = self.export.as_mut() else { return; }; + let project = self.project.clone(); let (width, height) = ui.resolution.size(); let settings = ExportPreviewSettings { fps: ui.fps, @@ -487,7 +488,7 @@ impl EditorWindow { .timer(Duration::from_millis(120)) .await; let result = gpui_tokio::Tokio::spawn(cx, async move { - render_preview(path, time, settings, force).await + render_preview_with_config(path, project, time, settings, force).await }) .await .ok(); @@ -590,12 +591,15 @@ impl EditorWindow { "mp4" }; let default = format!("{pretty_name}.{ext}"); - let chosen = std::env::var_os("CAP_GPUI_AUTO_EXPORT") - .map(PathBuf::from) - .or_else(|| platform::save_file_panel(&default, &[ext])); + let chosen = match std::env::var_os("CAP_GPUI_AUTO_EXPORT") { + Some(path) => Some(PathBuf::from(path)), + None => platform::save_file_panel_async(&default, &[ext], cx).await, + }; if chosen.is_none() { let _ = this.update(cx, |this, cx| { - if let Some(ui) = this.export.as_mut() { + if let Some(ui) = this.export.as_mut() + && Arc::ptr_eq(&ui.cancel, &cancel) + { ui.phase = ExportPhase::Idle; } cx.notify(); @@ -610,10 +614,13 @@ impl EditorWindow { None }; - let started = this.update(cx, |this, cx| { + let started = this.update_in(cx, |this, _, cx| { let Some(ui) = this.export.as_mut() else { return false; }; + if !Arc::ptr_eq(&ui.cancel, &cancel) { + return false; + } if cancel.load(Ordering::Relaxed) { ui.phase = ExportPhase::Idle; cx.notify(); @@ -1982,6 +1989,26 @@ impl EditorWindow { return; }; let cancel = ui.cancel.clone(); + #[cfg(target_os = "linux")] + { + if !ui.phase.is_busy() { + return; + } + let response = crate::editor_modal::confirm_cancel_export(window, cx); + cx.spawn_in(window, async move |this, cx| { + let confirmed = response.await; + let _ = this.update_in(cx, |this, _, cx| { + if let Some(ui) = this.export.as_mut() + && ui.phase.is_busy() + { + cancel_matching_export(&ui.cancel, &cancel, confirmed); + } + cx.notify(); + }); + }) + .detach(); + } + #[cfg(not(target_os = "linux"))] cx.spawn_in(window, async move |this, cx| { let confirmed = platform::confirm_dialog( "Cancel export?", diff --git a/apps/desktop-gpui/src/editor_modal.rs b/apps/desktop-gpui/src/editor_modal.rs new file mode 100644 index 00000000000..245bbef6254 --- /dev/null +++ b/apps/desktop-gpui/src/editor_modal.rs @@ -0,0 +1,347 @@ +use std::future::Future; + +use gpui::{ + App, AppContext as _, Context, EventEmitter, FocusHandle, Focusable, FontWeight, Hsla, + InteractiveElement, IntoElement, KeystrokeEvent, MouseButton, ParentElement, PromptButton, + PromptResponse, Render, ScrollHandle, StatefulInteractiveElement, Styled, Subscription, Window, + div, prelude::FluentBuilder, px, +}; + +use crate::{theme::Theme, ui}; + +#[derive(Clone, Copy)] +enum MessageKind { + Retained, + Confirmation, +} + +pub(crate) fn retained_alert( + message: &str, + window: &mut Window, + cx: &mut App, +) -> impl Future + use<> { + let response = informational_alert("Recording retained", message, window, cx); + async move { + let _ = response.await; + } +} + +pub(crate) fn informational_alert( + title: &str, + message: &str, + window: &mut Window, + cx: &mut App, +) -> impl Future + use<> { + let response = request( + MessageKind::Retained, + title, + message, + vec![PromptButton::ok("OK")], + window, + cx, + ); + async move { response.await == Some(0) } +} + +pub(crate) fn confirm_cancel_export( + window: &mut Window, + cx: &mut App, +) -> impl Future + use<> { + confirm_action( + "Cancel export?", + "Are you sure you want to cancel the export?", + "Cancel export", + "Keep exporting", + window, + cx, + ) +} + +pub(crate) fn confirm_action( + title: &str, + message: &str, + accept: &str, + cancel: &str, + window: &mut Window, + cx: &mut App, +) -> impl Future + use<> { + let response = request( + MessageKind::Confirmation, + title, + message, + vec![ + PromptButton::ok(accept.to_string()), + PromptButton::cancel(cancel.to_string()), + ], + window, + cx, + ); + async move { response.await == Some(0) } +} + +fn request( + kind: MessageKind, + message: &str, + detail: &str, + actions: Vec, + window: &mut Window, + cx: &mut App, +) -> impl Future> + use<> { + let response = if window.has_active_prompt() { + None + } else { + // Cap has no other custom prompt builder. Restore Default before returning; + // this App borrow must not await, reenter, or invoke another prompt. + cx.set_prompt_builder(move |_, message, detail, actions, handle, window, cx| { + let owner = window.window_handle().window_id(); + let modal = cx.new(|cx: &mut Context| { + let weak = cx.entity().downgrade(); + let keyboard = cx.intercept_keystrokes(move |event, window, cx| { + if window.window_handle().window_id() == owner { + let _ = weak.update(cx, |modal, cx| modal.intercept(event, window, cx)); + } + }); + let interaction = Interaction::new(kind); + let scroll = ScrollHandle::new(); + EditorModal { + message: message.to_string(), + detail: detail.unwrap_or_default().to_string(), + actions: actions.to_vec(), + focus: cx.focus_handle(), + interaction, + scroll, + _keyboard: keyboard, + } + }); + handle.with_view(modal, window, cx) + }); + let response = window.prompt( + gpui::PromptLevel::Warning, + message, + Some(detail), + &actions, + cx, + ); + cx.reset_prompt_builder(); + Some(response) + }; + async move { + match response { + Some(response) => response.await.ok(), + None => None, + } + } +} + +struct Interaction { + selected: usize, + cancel: usize, + count: usize, + completed: bool, +} + +impl Interaction { + fn new(kind: MessageKind) -> Self { + let cancel = usize::from(matches!(kind, MessageKind::Confirmation)); + Self { + selected: cancel, + cancel, + count: cancel + 1, + completed: false, + } + } + + fn select(&mut self, index: usize) -> Option { + if self.completed || index >= self.count { + return None; + } + self.completed = true; + Some(index) + } + + fn key(&mut self, key: &str, shift: bool) -> Option { + if self.completed { + return None; + } + match key { + "escape" => self.select(self.cancel), + "enter" | "space" => self.select(self.selected), + "tab" => { + self.selected = if shift { + (self.selected + self.count - 1) % self.count + } else { + (self.selected + 1) % self.count + }; + None + } + _ => None, + } + } +} + +struct EditorModal { + message: String, + detail: String, + actions: Vec, + focus: FocusHandle, + interaction: Interaction, + scroll: ScrollHandle, + _keyboard: Subscription, +} + +impl EditorModal { + fn intercept(&mut self, event: &KeystrokeEvent, window: &mut Window, cx: &mut Context) { + if self.interaction.completed + || !(self.focus.is_focused(window) || self.focus.contains_focused(window, cx)) + { + return; + } + // GPUI dispatches key bindings before element key handlers. The owner-scoped + // interceptor consumes the keystroke before an editor or global action can run. + window.prevent_default(); + cx.stop_propagation(); + let modifiers = event.keystroke.modifiers; + if !modifiers.control && !modifiers.alt && !modifiers.platform && !modifiers.function { + if let Some(index) = self.interaction.key(&event.keystroke.key, modifiers.shift) { + cx.emit(PromptResponse(index)); + } + cx.notify(); + } + } +} + +impl Render for EditorModal { + fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { + let theme = Theme::for_window(window, cx, false); + let viewport = window.viewport_size(); + let width = (f32::from(viewport.width) - 24.).clamp(0., 440.); + let height = (f32::from(viewport.height) - 24.).max(0.); + let padding = (width / 10.).min(if height < 200. { 8. } else { 16. }); + + div() + .id("editor-modal-backdrop") + .absolute() + .top_0() + .left_0() + .size_full() + .occlude() + .track_focus(&self.focus) + .flex() + .items_center() + .justify_center() + .bg(gpui::hsla(0., 0., 0., 0.5)) + .on_any_mouse_down(|_, _, cx| cx.stop_propagation()) + .map(|this| { + MouseButton::all().into_iter().fold(this, |this, button| { + this.on_mouse_up(button, |_, _, cx| cx.stop_propagation()) + }) + }) + .on_scroll_wheel(|_, _, cx| cx.stop_propagation()) + .on_key_down(|_, window, cx| { + window.prevent_default(); + cx.stop_propagation(); + }) + .child( + div() + .id("editor-modal-card") + .occlude() + .w(px(width)) + .max_h(px(height)) + .min_w(px(0.)) + .overflow_hidden() + .on_any_mouse_down(|_, _, cx| cx.stop_propagation()) + .map(|this| { + MouseButton::all().into_iter().fold(this, |this, button| { + this.on_mouse_up(button, |_, _, cx| cx.stop_propagation()) + }) + }) + .flex() + .flex_col() + .gap(px(padding.min(12.))) + .p(px(padding)) + .rounded(px(12.)) + .border_1() + .border_color(Hsla::from(theme.gray_3)) + .bg(Hsla::from(theme.gray_1)) + .text_color(Hsla::from(theme.gray_12)) + .shadow_lg() + .child( + div() + .id("editor-modal-message") + .min_h(px(0.)) + .flex_shrink_1() + .overflow_y_scroll() + .track_scroll(&self.scroll) + .flex() + .flex_col() + .gap(px(padding.min(12.))) + .child( + div() + .flex_shrink_0() + .text_size(px(15.)) + .font_weight(FontWeight::SEMIBOLD) + .child(self.message.clone()), + ) + .child( + div() + .flex_shrink_0() + .text_size(px(13.)) + .text_color(Hsla::from(theme.gray_11)) + .child(self.detail.clone()), + ), + ) + .child( + div() + .id("editor-modal-actions") + .flex() + .flex_shrink_0() + .gap(px(8.)) + .children(self.actions.iter().enumerate().map(|(index, action)| { + div() + .id(("editor-modal-action", index)) + .flex_1() + .min_w(px(0.)) + .rounded(px(10.)) + .p(px(2.)) + .border_1() + .border_color(Hsla::from(theme.gray_3)) + .when(self.interaction.selected == index, |this| { + this.border_color(Hsla::from(theme.gray_12)) + }) + .child( + ui::Button::plain( + &theme, + ("editor-modal-button", index), + if action.is_cancel() { + ui::ButtonVariant::Gray + } else { + ui::ButtonVariant::Primary + }, + ui::ButtonSize::Md, + ) + .label(action.label().clone()) + .radius(px(8.)) + .full_width() + .on_click( + cx.listener(move |modal, _, _, cx| { + if let Some(index) = modal.interaction.select(index) + { + cx.emit(PromptResponse(index)); + } + cx.stop_propagation(); + }), + ), + ) + })), + ), + ) + } +} + +impl EventEmitter for EditorModal {} + +impl Focusable for EditorModal { + fn focus_handle(&self, _: &App) -> FocusHandle { + self.focus.clone() + } +} diff --git a/apps/desktop-gpui/src/editor_panels.rs b/apps/desktop-gpui/src/editor_panels.rs index abafeb5b1c4..73874c555e7 100644 --- a/apps/desktop-gpui/src/editor_panels.rs +++ b/apps/desktop-gpui/src/editor_panels.rs @@ -1762,6 +1762,8 @@ pub fn mask_effect_amount(segment: &MaskSegment) -> f64 { /// panel per segment and each row needs its own track rect. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub enum PanelSlider { + Image(ImageProperty), + StyleCameraOnlyPadding, ZoomAmount, /// The multi-zoom panel's single Amount slider, which writes every selected /// segment at once. @@ -1804,6 +1806,9 @@ pub enum PanelSlider { /// Window` and the sidebar's render chain is threaded with `&self`. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub enum FieldKey { + StyleName(usize), + ImageName(usize), + StyleCrop(usize, u8), /// `HexColorInput`s, which live on the sidebar's `ColorTarget` map and are /// listed here only so a tab can name one. CaptionColor, @@ -1912,6 +1917,28 @@ impl EditorWindow { } let timeline = self.project.timeline.as_ref()?; Some(match key { + FieldKey::StyleName(index) => timeline.style_segments.get(index)?.name.clone(), + FieldKey::ImageName(index) => timeline.image_segments.get(index)?.name.clone(), + FieldKey::StyleCrop(index, axis) => { + let background = timeline + .style_segments + .get(index)? + .overrides + .background + .as_ref()?; + let (width, height) = self.display_resolution()?; + let crop = background.crop.clone().unwrap_or(cap_project::Crop { + position: XY::new(0, 0), + size: XY::new(width, height), + }); + match axis { + 0 => crop.position.x, + 1 => crop.position.y, + 2 => crop.size.x, + _ => crop.size.y, + } + .to_string() + } FieldKey::TextContent(index) => timeline.text_segments.get(index)?.content.clone(), FieldKey::CaptionText(index) => timeline.caption_segments.get(index)?.text.clone(), FieldKey::AudioName(index) => timeline @@ -2031,6 +2058,71 @@ impl EditorWindow { }; let text = input.read(cx).text().to_string(); match key { + FieldKey::StyleName(index) | FieldKey::ImageName(index) => { + let style = matches!(key, FieldKey::StyleName(_)); + self.edit_project("segment-name", window, cx, move |project| { + let Some(timeline) = project.timeline.as_mut() else { + return false; + }; + let name = if style { + timeline + .style_segments + .get_mut(index) + .map(|segment| &mut segment.name) + } else { + timeline + .image_segments + .get_mut(index) + .map(|segment| &mut segment.name) + }; + let Some(name) = name else { + return false; + }; + if *name == text { + return false; + } + *name = text; + true + }); + } + FieldKey::StyleCrop(index, axis) => { + if !final_commit { + return; + } + let Some(value) = ui::parse_number(&text).filter(|value| value.is_finite()) else { + return; + }; + let Some((width, height)) = self.display_resolution() else { + return; + }; + self.edit_style_segment("style-crop", index, window, cx, move |segment| { + let Some(background) = segment.overrides.background.as_mut() else { + return false; + }; + let crop = background.crop.get_or_insert(cap_project::Crop { + position: XY::new(0, 0), + size: XY::new(width, height), + }); + let value = value.max(0.) as u32; + match axis { + 0 => crop.position.x = value.min(width.saturating_sub(1)), + 1 => crop.position.y = value.min(height.saturating_sub(1)), + 2 => crop.size.x = value.max(1), + _ => crop.size.y = value.max(1), + } + crop.size.x = crop + .size + .x + .min(width.saturating_sub(crop.position.x)) + .max(1); + crop.size.y = crop + .size + .y + .min(height.saturating_sub(crop.position.y)) + .max(1); + true + }); + } // `onRawValueChange={(v) => cropperRef?.setCropProperty(field, v)}` // -- per keystroke, straight into the cropper, no project write // and so no history entry (`Editor.tsx:1186`). @@ -2225,6 +2317,16 @@ macro_rules! segment_editor { } segment_editor!(edit_text_segment, text_segments, TextSegment); +segment_editor!( + edit_style_segment, + style_segments, + cap_project::StyleSegment +); +segment_editor!( + edit_image_segment, + image_segments, + cap_project::ImageSegment +); segment_editor!(edit_audio_segment, audio_segments, AudioTrackSegment); impl EditorWindow { @@ -2285,6 +2387,8 @@ impl EditorWindow { pub(crate) fn panel_slider_limits(&self, slider: PanelSlider, index: usize) -> (f32, f32, f32) { match slider { + PanelSlider::Image(property) => property.limits(), + PanelSlider::StyleCameraOnlyPadding => (0., 40., 1.), // `minValue={1} maxValue={4.5} step={0.001}` (`:5601-5603`). PanelSlider::ZoomAmount | PanelSlider::ZoomAmountAll => (1., 4.5, 0.001), PanelSlider::TextLayoutTransition => (0.1, 1.5, 0.05), @@ -2326,6 +2430,15 @@ impl EditorWindow { return 0.; }; match slider { + PanelSlider::Image(property) => timeline + .image_segments + .get(index) + .map_or(0., |segment| property.read(segment)), + PanelSlider::StyleCameraOnlyPadding => timeline + .style_segments + .get(index) + .and_then(|segment| segment.overrides.camera_only_padding) + .unwrap_or(0.) as f32, PanelSlider::ZoomAmount => timeline .zoom_segments .get(index) @@ -2448,6 +2561,18 @@ impl EditorWindow { cx: &mut Context, ) { match slider { + PanelSlider::Image(property) => { + self.edit_image_segment("image-transform", index, window, cx, move |segment| { + property.write(segment, value); + true + }) + } + PanelSlider::StyleCameraOnlyPadding => { + self.edit_style_segment("camera-only-padding", index, window, cx, move |segment| { + segment.overrides.camera_only_padding = Some(f64::from(value.clamp(0., 40.))); + true + }) + } PanelSlider::ZoomAmount => { self.edit_zoom_segment("zoom-amount", index, window, cx, move |segment| { segment.amount = f64::from(value); @@ -2986,6 +3111,20 @@ impl EditorWindow { }; let body: AnyElement = match selection.track { + TrackKind::Style => self.stacked_panel( + "style", + "style", + count(timeline.style_segments.len()), + cx, + |this, index, cx| this.render_style_panel(index, cx), + ), + TrackKind::Image => self.stacked_panel( + "image", + "image", + count(timeline.image_segments.len()), + cx, + |this, index, cx| this.render_image_panel(index, cx), + ), TrackKind::Zoom => { let indices = count(timeline.zoom_segments.len()); let total = timeline.zoom_segments.len(); @@ -6137,6 +6276,25 @@ impl EditorWindow { .collect() }; match selection.track { + TrackKind::Style => { + for index in indices(timeline.style_segments.len()) { + fields.push(FieldKey::StyleName(index)); + if timeline.style_segments[index] + .overrides + .background + .is_some() + { + for axis in 0..4 { + fields.push(FieldKey::StyleCrop(index, axis)); + } + } + } + } + TrackKind::Image => { + for index in indices(timeline.image_segments.len()) { + fields.push(FieldKey::ImageName(index)); + } + } TrackKind::Text => { for index in indices(timeline.text_segments.len()) { fields.push(FieldKey::TextContent(index)); @@ -6648,3 +6806,338 @@ mod tests { assert_eq!(font_family_label("Georgia"), "Georgia"); } } + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum ImageProperty { + X, + Y, + Width, + Height, + Opacity, + Rotation, + Rounding, +} + +impl ImageProperty { + fn label(self) -> &'static str { + match self { + Self::X => "Position X", + Self::Y => "Position Y", + Self::Width => "Width", + Self::Height => "Height", + Self::Opacity => "Opacity", + Self::Rotation => "Rotation", + Self::Rounding => "Rounding", + } + } + fn limits(self) -> (f32, f32, f32) { + match self { + Self::X | Self::Y => (-50., 150., 1.), + Self::Width | Self::Height => (1., 200., 1.), + Self::Rotation => (-180., 180., 1.), + _ => (0., 100., 1.), + } + } + fn read(self, segment: &cap_project::ImageSegment) -> f32 { + match self { + Self::X => (segment.center.x * 100.) as f32, + Self::Y => (segment.center.y * 100.) as f32, + Self::Width => (segment.size.x * 100.) as f32, + Self::Height => (segment.size.y * 100.) as f32, + Self::Opacity => segment.opacity * 100., + Self::Rotation => segment.rotation, + Self::Rounding => segment.rounding, + } + } + fn write(self, segment: &mut cap_project::ImageSegment, value: f32) { + if !value.is_finite() { + return; + } + let (min, max, _) = self.limits(); + let value = value.clamp(min, max); + match self { + Self::X => segment.center.x = f64::from(value) / 100., + Self::Y => segment.center.y = f64::from(value) / 100., + Self::Width => { + let width = f64::from(value) / 100.; + if segment.lock_aspect && segment.size.x > 0. { + segment.size.y *= width / segment.size.x; + } + segment.size.x = width; + } + Self::Height => { + let height = f64::from(value) / 100.; + if segment.lock_aspect && segment.size.y > 0. { + segment.size.x *= height / segment.size.y; + } + segment.size.y = height; + } + Self::Opacity => segment.opacity = value / 100., + Self::Rotation => segment.rotation = value, + Self::Rounding => segment.rounding = value, + } + } +} + +impl EditorWindow { + fn render_image_panel(&self, index: usize, cx: &mut Context) -> AnyElement { + let Some(segment) = self + .timeline() + .and_then(|timeline| timeline.image_segments.get(index)) + else { + return div().into_any_element(); + }; + let mut panel = div() + .flex() + .flex_col() + .gap(px(16.)) + .child(self.labelled_small( + "Name", + self.render_field_input(FieldKey::ImageName(index), None), + )); + panel = panel + .child( + div() + .text_size(px(12.)) + .child("Drag the image in the canvas to move it. Drag a corner to resize."), + ) + .child( + ui::Button::plain( + &self.theme, + SharedString::from(format!("replace-image-{index}")), + ui::ButtonVariant::Gray, + ui::ButtonSize::Md, + ) + .label("Replace image") + .disabled(self.sidebar.picking_image) + .on_click(cx.listener(move |this, _, window, cx| { + this.replace_timeline_image(index, window, cx) + })), + ); + if self + .sidebar + .image_asset_status + .as_ref() + .is_some_and(|(path, present)| path == &segment.path && !present) + { + panel = panel.child(div().text_size(px(12.)).child("Image file is missing. Replace it to restore this segment while keeping its timing and transforms.")); + } + if let Some(error) = &self.sidebar.image_import_error { + panel = panel.child(div().text_size(px(12.)).child(error.clone())); + } + for (key, label, value) in [ + (0, "Enabled", segment.enabled), + (1, "Lock aspect ratio", segment.lock_aspect), + (2, "Flip horizontally", segment.flip_x), + (3, "Flip vertically", segment.flip_y), + ] { + panel = panel.child( + ui::Subfield::plain(&self.theme, label).child( + ui::Toggle::plain( + &self.theme, + SharedString::from(format!("image-{index}-{key}")), + value, + ) + .on_click(cx.listener(move |this, _, window, cx| { + this.edit_image_segment("image-toggle", index, window, cx, move |segment| { + match key { + 0 => segment.enabled = !value, + 1 => segment.lock_aspect = !value, + 2 => segment.flip_x = !value, + _ => segment.flip_y = !value, + }; + true + }) + })), + ), + ); + } + for property in [ + ImageProperty::X, + ImageProperty::Y, + ImageProperty::Width, + ImageProperty::Height, + ImageProperty::Rotation, + ImageProperty::Rounding, + ImageProperty::Opacity, + ] { + panel = panel.child( + self.labelled_small( + property.label(), + self.slider( + SliderKey::Panel(PanelSlider::Image(property), index), + if property == ImageProperty::Rotation { + "°" + } else { + "%" + }, + cx, + ) + .into_any_element(), + ), + ); + } + panel.into_any_element() + } + + fn render_style_panel(&self, index: usize, cx: &mut Context) -> AnyElement { + use crate::editor_sidebar::StyleGroup; + let Some(segment) = self + .timeline() + .and_then(|timeline| timeline.style_segments.get(index)) + else { + return div().into_any_element(); + }; + let enabled = segment.enabled; + let mut panel = div().flex().flex_col().gap(px(16.)) + .child(div().text_size(px(12.)).child("Overrides apply only during this segment. Enable a group to copy its global settings.")) + .child(self.labelled_small("Name", self.render_field_input(FieldKey::StyleName(index), None))) + .child(ui::Subfield::plain(&self.theme,"Enabled").child(ui::Toggle::plain(&self.theme,SharedString::from(format!("style-enabled-{index}")),enabled).on_click(cx.listener(move |this,_,window,cx| this.edit_style_segment("style-enabled",index,window,cx,move |segment| { segment.enabled = !enabled; true }))))); + for (group, active) in [ + ( + StyleGroup::Background, + segment.overrides.background.is_some(), + ), + (StyleGroup::Camera, segment.overrides.camera.is_some()), + (StyleGroup::Cursor, segment.overrides.cursor.is_some()), + ] { + panel = panel.child( + div() + .flex() + .flex_col() + .gap(px(8.)) + .child( + ui::Subfield::plain(&self.theme, group.label()).child( + ui::Toggle::plain( + &self.theme, + SharedString::from(format!("style-{index}-{group:?}")), + active, + ) + .on_click(cx.listener( + move |this, _, window, cx| { + this.edit_project( + "style-override", + window, + cx, + move |project| { + let Some(segment) = + project.timeline.as_mut().and_then(|timeline| { + timeline.style_segments.get_mut(index) + }) + else { + return false; + }; + match group { + StyleGroup::Background => { + segment.overrides.background = (!active) + .then(|| project.background.clone()) + } + StyleGroup::Camera => { + segment.overrides.camera = + (!active).then(|| project.camera.clone()) + } + StyleGroup::Cursor => { + segment.overrides.cursor = + (!active).then(|| project.cursor.clone()) + } + } + true + }, + ); + }, + )), + ), + ) + .children(active.then(|| { + div() + .id(SharedString::from(format!("style-edit-{index}-{group:?}"))) + .cursor_pointer() + .px(px(12.)) + .py(px(8.)) + .rounded(px(6.)) + .bg(Hsla::from(self.theme.gray_3)) + .child(format!("Edit {}", group.label())) + .on_click(cx.listener(move |this, _, window, cx| { + this.open_style_group(index, group, window, cx) + })) + })), + ); + } + if segment.overrides.background.is_some() { + let mut crop = div().flex().flex_col().gap(px(8.)).child( + div() + .text_size(px(12.)) + .child("Screen crop (source pixels)"), + ); + for (axis, label) in [(0, "Left"), (1, "Top"), (2, "Width"), (3, "Height")] { + crop = crop.child(self.labelled_small( + label, + self.render_number_field(FieldKey::StyleCrop(index, axis), "px", 80.), + )); + } + crop = crop.child( + div() + .id(SharedString::from(format!("style-crop-reset-{index}"))) + .cursor_pointer() + .child("Reset crop") + .on_click(cx.listener(move |this, _, window, cx| { + this.edit_style_segment( + "style-crop-reset", + index, + window, + cx, + move |segment| { + if let Some(background) = segment.overrides.background.as_mut() { + background.crop = None; + true + } else { + false + } + }, + ) + })), + ); + panel = panel.child(crop); + } + let padding = segment.overrides.camera_only_padding.is_some(); + panel = panel.child( + ui::Subfield::plain(&self.theme, "Camera Only background").child( + ui::Toggle::plain( + &self.theme, + SharedString::from(format!("style-camera-only-{index}")), + padding, + ) + .on_click(cx.listener(move |this, _, window, cx| { + this.edit_style_segment( + "camera-only-background", + index, + window, + cx, + move |segment| { + segment.overrides.camera_only_padding = (!padding).then_some(10.); + true + }, + ) + })), + ), + ); + if padding { + panel = panel + .child( + self.labelled_small( + "Camera Only padding", + self.slider( + SliderKey::Panel(PanelSlider::StyleCameraOnlyPadding, index), + "%", + cx, + ) + .into_any_element(), + ), + ) + .child(div().text_size(px(11.)).child( + "Use a Camera Only scene. Padding reveals the background around the camera.", + )); + } + panel.into_any_element() + } +} diff --git a/apps/desktop-gpui/src/editor_sidebar.rs b/apps/desktop-gpui/src/editor_sidebar.rs index 3d2ac8a3b33..322750fabec 100644 --- a/apps/desktop-gpui/src/editor_sidebar.rs +++ b/apps/desktop-gpui/src/editor_sidebar.rs @@ -764,6 +764,9 @@ pub enum ColorPickerDrag { /// The sidebar's own state -- everything `ConfigSidebar`'s signals hold that is /// not in the project config. pub struct SidebarState { + pub(crate) style_target: Option<(usize, StyleGroup)>, + pub(crate) image_import_error: Option, + pub(crate) image_asset_status: Option<(String, bool)>, animated_gradient: animated_gradient::AnimatedGradientState, /// `state.selectedTab` (`:563-573`). pub tab: SidebarTab, @@ -862,13 +865,16 @@ pub struct SidebarState { pub importing_desktop: bool, /// Guards the file-picker task: `runModal` spins its own run loop and a /// second panel would stack on the first. - picking_image: bool, + pub(crate) picking_image: bool, picker_task: Option>, } impl SidebarState { pub fn new(config: &ProjectConfiguration) -> Self { Self { + style_target: None, + image_import_error: None, + image_asset_status: None, animated_gradient: animated_gradient::AnimatedGradientState::new(), tab: SidebarTab::Background, source_tab: initial_source_tab(config), @@ -1054,7 +1060,7 @@ impl EditorWindow { self.end_color_history(); } let previous_animated_gradient = self.animated_gradient_config().cloned(); - if !change(&mut self.project) { + if !self.apply_control_change(change) { return; } self.project_changed(window, cx); @@ -1077,7 +1083,7 @@ impl EditorWindow { return; } self.end_color_history(); - if !change(&mut self.project) { + if !self.apply_control_change(change) { return; } self.project_changed(window, cx); @@ -1150,8 +1156,9 @@ impl EditorWindow { } fn notch_value(&self, slider: BgSlider) -> f64 { + let project = self.style_control_project(); let base = self.notch_base(); - let notch = self.project.background.notch.as_ref(); + let notch = project.background.notch.as_ref(); match slider { BgSlider::NotchWidth => notch.and_then(|n| n.width).unwrap_or(base.width), BgSlider::NotchHeight => notch.and_then(|n| n.height).unwrap_or(base.height), @@ -1187,6 +1194,7 @@ impl EditorWindow { } pub(crate) fn slider_value(&self, slider: SliderKey) -> f32 { + let project = self.style_control_project(); match slider { SliderKey::Bg(slider) => self.bg_slider_value(slider), SliderKey::AnimatedGradient(parameter) => self @@ -1199,11 +1207,11 @@ impl EditorWindow { // Every grade slider is `Math.round(value * 100)` in the UI and // `v / 100` back into the config (`ColorCorrectionSection.tsx:181`). SliderKey::Grade(target, slider) => (slider.read(self.grade(target)) * 100.).round(), - SliderKey::Camera(slider) => slider.read(&self.project), - SliderKey::Audio(slider) => slider.read(&self.project), - SliderKey::Cursor(slider) => slider.read(&self.project), - SliderKey::Caption(slider) => slider.read(&self.project), - SliderKey::Keyboard(slider) => slider.read(&self.project), + SliderKey::Camera(slider) => slider.read(&project), + SliderKey::Audio(slider) => slider.read(&project), + SliderKey::Cursor(slider) => slider.read(&project), + SliderKey::Caption(slider) => slider.read(&project), + SliderKey::Keyboard(slider) => slider.read(&project), SliderKey::Panel(slider, index) => self.panel_slider_value(slider, index), } } @@ -1254,12 +1262,13 @@ impl EditorWindow { } fn bg_slider_value(&self, slider: BgSlider) -> f32 { - let background = &self.project.background; + let project = self.style_control_project(); + let background = &project.background; match slider { BgSlider::Blur => background.blur as f32, BgSlider::Padding => background.padding as f32, BgSlider::Rounding => background.rounding as f32, - BgSlider::MotionBlur => self.project.screen_motion_blur, + BgSlider::MotionBlur => project.screen_motion_blur, BgSlider::BorderWidth => background .border .as_ref() @@ -1629,16 +1638,17 @@ impl EditorWindow { } pub(crate) fn color_for(&self, target: ColorTarget) -> Option { + let project = self.style_control_project(); match target { - ColorTarget::BackgroundColor => match &self.project.background.source { + ColorTarget::BackgroundColor => match &project.background.source { BackgroundSource::Color { value, .. } => Some(*value), _ => None, }, - ColorTarget::GradientFrom => match &self.project.background.source { + ColorTarget::GradientFrom => match &project.background.source { BackgroundSource::Gradient { from, .. } => Some(*from), _ => None, }, - ColorTarget::GradientTo => match &self.project.background.source { + ColorTarget::GradientTo => match &project.background.source { BackgroundSource::Gradient { to, .. } => Some(*to), _ => None, }, @@ -1647,13 +1657,13 @@ impl EditorWindow { .and_then(|config| config.color_stops.get(index)) .map(|stop| stop.color), ColorTarget::BorderColor => Some( - self.project + project .background .border .as_ref() .map_or(UI_BORDER_FALLBACK.color, |border| border.color), ), - ColorTarget::CursorRipple => Some(self.project.cursor.ripple.color), + ColorTarget::CursorRipple => Some(project.cursor.ripple.color), _ => self.hex_string_for(target).and_then(|hex| { hex_to_rgb(&hex).map(|rgba| [rgba[0] as u16, rgba[1] as u16, rgba[2] as u16]) }), @@ -2143,8 +2153,9 @@ impl EditorWindow { /// Which file the big preview should be showing: the chosen image on the /// image tab, the imported desktop picture on the desktop tab. fn preview_path(&self) -> Option { + let project = self.style_control_project(); match self.sidebar.source_tab { - SourceTab::Image => match &self.project.background.source { + SourceTab::Image => match &project.background.source { BackgroundSource::Image { path } => path.as_ref().map(PathBuf::from), _ => None, }, @@ -2172,17 +2183,19 @@ impl EditorWindow { /// default padding *and* rounding; a real-to-real switch only ensures /// padding, so an intentionally-square background keeps rounding at 0. fn ensure_background_presentation(&mut self, from_none: bool) -> bool { - let mut changed = false; - let background = &mut self.project.background; - if background.padding == 0. { - background.padding = DEFAULT_BACKGROUND_PADDING; - changed = true; - } - if from_none && background.rounding == 0. { - background.rounding = DEFAULT_BACKGROUND_ROUNDING; - changed = true; - } - changed + self.apply_control_change(|project| { + let mut changed = false; + let background = &mut project.background; + if background.padding == 0. { + background.padding = DEFAULT_BACKGROUND_PADDING; + changed = true; + } + if from_none && background.rounding == 0. { + background.rounding = DEFAULT_BACKGROUND_ROUNDING; + changed = true; + } + changed + }) } /// The source-tab row's `onChange` (`:2189-2263`), verbatim. @@ -2292,6 +2305,8 @@ impl EditorWindow { return; } self.sidebar.picking_image = true; + let style_target = self.sidebar.style_target; + let style_fingerprint = self.style_target_fingerprint(); self.sidebar.picker_task = Some(cx.spawn_in(window, async move |this, cx| { let picked = cx .update(|_, _| crate::platform::open_image_panel(&BACKGROUND_IMAGE_EXTENSIONS)) @@ -2332,6 +2347,12 @@ impl EditorWindow { this.update_in(cx, |this, window, cx| { this.sidebar.picking_image = false; + if this.sidebar.style_target != style_target + || this.style_target_fingerprint() != style_fingerprint + { + cx.notify(); + return; + } match stored { Ok(path) => { this.edit_background( @@ -2365,7 +2386,9 @@ impl EditorWindow { } self.sidebar.importing_desktop = true; let project_path = self.project_path.clone(); - let from_none = is_none_background(&self.project); + let from_none = is_none_background(&self.style_control_project()); + let style_target = self.sidebar.style_target; + let style_fingerprint = self.style_target_fingerprint(); cx.spawn_in(window, async move |this, cx| { let imported = cx .background_executor() @@ -2373,6 +2396,12 @@ impl EditorWindow { .await; this.update_in(cx, |this, window, cx| { this.sidebar.importing_desktop = false; + if this.sidebar.style_target != style_target + || this.style_target_fingerprint() != style_fingerprint + { + cx.notify(); + return; + } match imported { Ok(path) => { this.sidebar.desktop_background = Some(path.clone()); @@ -2461,7 +2490,21 @@ impl EditorWindow { .border_1() .border_color(Hsla::from(theme.gray_3)) .child(rail) - .child(if self.audio_picker.is_some() { + .children(self.sidebar.image_import_error.as_ref().map(|error| { + div() + .p(px(12.)) + .text_size(px(12.)) + .text_color(Hsla::from(theme.gray_12)) + .child(error.clone()) + })) + .children( + self.sidebar + .picking_image + .then(|| div().p(px(12.)).child("Importing image…")), + ) + .child(if let Some((index, group)) = self.sidebar.style_target { + self.render_style_group(index, group, cx) + } else if self.audio_picker.is_some() { self.render_audio_library(cx) } else if self.camera3d_setup.is_some() { self.render_camera3d_setup(cx) @@ -2594,16 +2637,21 @@ impl EditorWindow { .child(self.render_corner_style(cx)), ), ) - .child( + .children(self.sidebar.style_target.is_none().then(|| { ui::Field::plain(&theme, "Motion Blur") .icon("icons/wind.svg") - .child(self.slider(SliderKey::Bg(BgSlider::MotionBlur), "x100%", cx)), - ) + .child(self.slider(SliderKey::Bg(BgSlider::MotionBlur), "x100%", cx)) + })) .child(self.render_border_field(cx)) .child(self.render_notch_field(cx)) .child(self.render_shadow_field(cx)) // `` (`:2962`). - .child(self.render_color_correction(GradeTarget::Screen, cx)) + .children( + self.sidebar + .style_target + .is_none() + .then(|| self.render_color_correction(GradeTarget::Screen, cx)), + ) } // -- Source ------------------------------------------------------------ @@ -4013,6 +4061,8 @@ impl EditorWindow { }; let track = match track.to_ascii_lowercase().as_str() { "zoom" => TrackKind::Zoom, + "style" => TrackKind::Style, + "image" => TrackKind::Image, "text" => TrackKind::Text, "caption" => TrackKind::Caption, "mask" => TrackKind::Mask, @@ -4258,3 +4308,304 @@ mod tests { assert_eq!((rgba.r * 255.).round() as u8, 209); } } + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum StyleGroup { + Background, + Camera, + Cursor, +} + +impl StyleGroup { + pub(crate) fn label(self) -> &'static str { + match self { + Self::Background => "Background & frame", + Self::Camera => "Camera", + Self::Cursor => "Cursor", + } + } +} + +pub(crate) fn apply_style_control_change( + project: &mut ProjectConfiguration, + index: usize, + group: StyleGroup, + change: impl FnOnce(&mut ProjectConfiguration) -> bool, +) -> bool { + let Some(segment) = project + .timeline + .as_ref() + .and_then(|timeline| timeline.style_segments.get(index)) + else { + return false; + }; + let mut scoped = project.clone(); + match group { + StyleGroup::Background => { + let Some(value) = &segment.overrides.background else { + return false; + }; + scoped.background = value.clone(); + } + StyleGroup::Camera => { + let Some(value) = &segment.overrides.camera else { + return false; + }; + scoped.camera = value.clone(); + } + StyleGroup::Cursor => { + let Some(value) = &segment.overrides.cursor else { + return false; + }; + scoped.cursor = value.clone(); + } + } + if !change(&mut scoped) { + return false; + } + let Some(segment) = project + .timeline + .as_mut() + .and_then(|timeline| timeline.style_segments.get_mut(index)) + else { + return false; + }; + match group { + StyleGroup::Background => segment.overrides.background = Some(scoped.background), + StyleGroup::Camera => segment.overrides.camera = Some(scoped.camera), + StyleGroup::Cursor => segment.overrides.cursor = Some(scoped.cursor), + } + true +} + +impl EditorWindow { + pub(crate) fn style_background(&self) -> &cap_project::BackgroundConfiguration { + self.sidebar + .style_target + .filter(|(_, group)| *group == StyleGroup::Background) + .and_then(|(index, _)| { + self.project + .timeline + .as_ref()? + .style_segments + .get(index)? + .overrides + .background + .as_ref() + }) + .unwrap_or(&self.project.background) + } + + pub(crate) fn style_control_project(&self) -> std::borrow::Cow<'_, ProjectConfiguration> { + let Some((index, group)) = self.sidebar.style_target else { + return std::borrow::Cow::Borrowed(&self.project); + }; + let Some(segment) = self + .project + .timeline + .as_ref() + .and_then(|timeline| timeline.style_segments.get(index)) + else { + return std::borrow::Cow::Borrowed(&self.project); + }; + let mut project = self.project.clone(); + match group { + StyleGroup::Background => { + if let Some(value) = &segment.overrides.background { + project.background = value.clone(); + } + } + StyleGroup::Camera => { + if let Some(value) = &segment.overrides.camera { + project.camera = value.clone(); + } + } + StyleGroup::Cursor => { + if let Some(value) = &segment.overrides.cursor { + project.cursor = value.clone(); + } + } + } + std::borrow::Cow::Owned(project) + } + + pub(crate) fn with_style_controls(&mut self, render: impl FnOnce(&mut Self) -> R) -> R { + let std::borrow::Cow::Owned(scoped) = self.style_control_project() else { + return render(self); + }; + // Existing controls read the project while building their elements. Restore the base before any event can publish or save it. + let base = std::mem::replace(&mut self.project, scoped); + let result = render(self); + self.project = base; + result + } + + fn apply_control_change( + &mut self, + change: impl FnOnce(&mut ProjectConfiguration) -> bool, + ) -> bool { + match self.sidebar.style_target { + Some((index, group)) => { + apply_style_control_change(&mut self.project, index, group, change) + } + None => change(&mut self.project), + } + } + + pub(crate) fn open_style_group( + &mut self, + index: usize, + group: StyleGroup, + window: &mut Window, + cx: &mut Context, + ) { + self.dismiss_frame_controls(cx); + self.end_field_edit(cx); + self.close_color_picker(cx); + self.sidebar.menu = None; + self.sidebar.style_target = Some((index, group)); + self.sidebar.tab = match group { + StyleGroup::Background => SidebarTab::Background, + StyleGroup::Camera => SidebarTab::Camera, + StyleGroup::Cursor => SidebarTab::Cursor, + }; + let ripple_enabled = self.style_control_project().cursor.ripple.enabled; + self.sidebar.cursor_ripple_open.set_open(ripple_enabled); + self.sidebar.source_tab = initial_source_tab(&self.style_control_project()); + self.sidebar.scroll.set_offset(gpui::point(px(0.), px(0.))); + cx.notify(); + window.refresh(); + } + + fn render_style_group( + &self, + index: usize, + group: StyleGroup, + cx: &mut Context, + ) -> AnyElement { + div() + .flex() + .flex_col() + .flex_1() + .min_h_0() + .child( + div() + .p(px(12.)) + .flex() + .flex_col() + .gap(px(6.)) + .child(format!("Style {} · {}", index + 1, group.label())) + .child( + div() + .text_size(px(11.)) + .child("Editing this segment only. Global settings are unchanged."), + ) + .child( + div() + .id("style-back") + .cursor_pointer() + .text_size(px(12.)) + .child("← Back to Style") + .on_click(cx.listener(|this, _, window, cx| { + this.end_field_edit(cx); + this.close_color_picker(cx); + this.sidebar.menu = None; + this.sidebar.style_target = None; + cx.notify(); + window.refresh(); + })), + ), + ) + .child(self.render_tab_body(cx)) + .into_any_element() + } +} + +impl EditorWindow { + fn style_target_fingerprint(&self) -> Option { + let index = self.sidebar.style_target?.0; + serde_json::to_string(self.project.timeline.as_ref()?.style_segments.get(index)?).ok() + } + + pub(crate) fn selected_style_index(&self) -> Option { + self.sidebar + .style_target + .map(|target| target.0) + .or_else(|| { + self.selection() + .filter(|selection| { + selection.track == TrackKind::Style && selection.indices.len() == 1 + }) + .map(|selection| selection.indices[0]) + }) + } +} + +#[cfg(test)] +mod style_image_tests { + use super::*; + + #[test] + fn style_image_group_changes_preserve_globals_and_require_opt_in() { + let mut project: ProjectConfiguration = serde_json::from_value( + serde_json::json!({"timeline":{"zoomSegments":[],"segments":[],"styleSegments":[{"start":1,"end":5}]}}), + ) + .unwrap(); + let base = serde_json::to_value(&project).unwrap(); + assert!(!apply_style_control_change( + &mut project, + 0, + StyleGroup::Background, + |project| { + project.background.padding = 30.; + true + } + )); + assert_eq!(serde_json::to_value(&project).unwrap(), base); + let background = project.background.clone(); + let camera = project.camera.clone(); + let cursor = project.cursor.clone(); + let segment = &mut project.timeline.as_mut().unwrap().style_segments[0]; + segment.overrides.background = Some(background); + segment.overrides.camera = Some(camera); + segment.overrides.cursor = Some(cursor); + assert!(apply_style_control_change( + &mut project, + 0, + StyleGroup::Background, + |project| { + project.background.padding = 30.; + project.camera.hide = true; + true + } + )); + assert!(apply_style_control_change( + &mut project, + 0, + StyleGroup::Camera, + |project| { + project.camera.hide = true; + true + } + )); + for enabled in [true, false] { + assert!(apply_style_control_change( + &mut project, + 0, + StyleGroup::Cursor, + |project| { + project.cursor.ripple.enabled = enabled; + true + } + )); + } + let after = serde_json::to_value(&project).unwrap(); + for group in ["background", "camera", "cursor"] { + assert_eq!(after[group], base[group]); + } + let segment = &project.timeline.as_ref().unwrap().style_segments[0]; + assert_eq!(segment.overrides.background.as_ref().unwrap().padding, 30.); + assert!(segment.overrides.camera.as_ref().unwrap().hide); + assert!(!segment.overrides.cursor.as_ref().unwrap().ripple.enabled); + } +} diff --git a/apps/desktop-gpui/src/editor_sidebar/animated_gradient.rs b/apps/desktop-gpui/src/editor_sidebar/animated_gradient.rs index ffda5323b4f..937c8aa3b8c 100644 --- a/apps/desktop-gpui/src/editor_sidebar/animated_gradient.rs +++ b/apps/desktop-gpui/src/editor_sidebar/animated_gradient.rs @@ -385,7 +385,7 @@ fn icon_button( impl EditorWindow { pub(crate) fn animated_gradient_config(&self) -> Option<&AnimatedGradientConfig> { - match &self.project.background.source { + match &self.style_background().source { BackgroundSource::AnimatedGradient { config } => Some(config), _ => None, } @@ -398,7 +398,7 @@ impl EditorWindow { cx: &mut Context, ) { let selection = pending_selection( - &self.project.background.source, + &self.style_background().source, previous, self.sidebar.source_tab, ); diff --git a/apps/desktop-gpui/src/editor_sidebar/cursor.rs b/apps/desktop-gpui/src/editor_sidebar/cursor.rs index b7bbd66fc98..151c6349271 100644 --- a/apps/desktop-gpui/src/editor_sidebar/cursor.rs +++ b/apps/desktop-gpui/src/editor_sidebar/cursor.rs @@ -150,7 +150,7 @@ impl EditorWindow { fn selected_cursor_card(&self) -> CursorCard { selected_card( - self.project.cursor.cursor_type(), + self.style_control_project().cursor.cursor_type(), self.recorded_cursor_family, ) } @@ -295,7 +295,8 @@ impl EditorWindow { pub(crate) fn render_cursor_ripple(&self, cx: &mut Context) -> AnyElement { let theme = self.theme; - let ripple = &self.project.cursor.ripple; + let project = self.style_control_project(); + let ripple = &project.cursor.ripple; let enabled = ripple.enabled; let color = ripple.color; @@ -308,7 +309,7 @@ impl EditorWindow { .value( ui::Toggle::plain(&theme, "cursor-ripple", enabled) .on_click(cx.listener(move |this, _, window, cx| { - let next = !this.project.cursor.ripple.enabled; + let next = !this.style_control_project().cursor.ripple.enabled; this.sidebar.cursor_ripple_open.set_open(next); this.animate_collapsibles(window, cx); this.edit_project("cursor-ripple", window, cx, move |project| { diff --git a/apps/desktop-gpui/src/editor_tabs.rs b/apps/desktop-gpui/src/editor_tabs.rs index a61daa1c11c..4d4a81c2d84 100644 --- a/apps/desktop-gpui/src/editor_tabs.rs +++ b/apps/desktop-gpui/src/editor_tabs.rs @@ -596,7 +596,8 @@ pub struct OpenMenu { impl EditorWindow { /// The rows a menu draws, with a check mark on the value in force. pub(crate) fn sidebar_menu_items(&self, kind: SidebarMenu) -> Vec { - let project = &self.project; + let snapshot = self.style_control_project(); + let project = &snapshot; let captions = caption_settings(project); let keyboard = keyboard_settings(project); match kind { @@ -1240,7 +1241,7 @@ impl EditorWindow { .child(ui::Subfield::plain(&theme, "Hide Camera").child( ui::Toggle::plain(&theme, "camera-hide", camera.hide).on_click( cx.listener(|this, _, window, cx| { - let next = !this.project.camera.hide; + let next = !this.style_control_project().camera.hide; this.edit_project("camera-hide", window, cx, move |p| { p.camera.hide = next; true @@ -1251,7 +1252,7 @@ impl EditorWindow { .child(ui::Subfield::plain(&theme, "Mirror Camera").child( ui::Toggle::plain(&theme, "camera-mirror", camera.mirror).on_click( cx.listener(|this, _, window, cx| { - let next = !this.project.camera.mirror; + let next = !this.style_control_project().camera.mirror; this.edit_project("camera-mirror", window, cx, move |p| { p.camera.mirror = next; true @@ -1294,7 +1295,7 @@ impl EditorWindow { ui::Toggle::plain(&theme, "camera-keep-size", camera.scale_during_zoom >= 1.) .on_click(cx.listener(|this, _, window, cx| { // `keep ? 1 : DEFAULT_CAMERA_SCALE_DURING_ZOOM`. - let keep = this.project.camera.scale_during_zoom >= 1.; + let keep = this.style_control_project().camera.scale_during_zoom >= 1.; let next = if keep { DEFAULT_CAMERA_SCALE_DURING_ZOOM } else { @@ -1331,7 +1332,12 @@ impl EditorWindow { .child(self.render_camera_shadow_settings(cx)), ) // `` (`:3324`). - .child(self.render_color_correction(GradeTarget::Camera, cx)) + .children( + self.sidebar + .style_target + .is_none() + .then(|| self.render_color_correction(GradeTarget::Camera, cx)), + ) .children(manual.map(|_| { // The custom-position reset row, shown only once the camera has // been dragged on the canvas (`:3054-3066`). @@ -1809,7 +1815,7 @@ impl EditorWindow { .value( ui::Toggle::plain(&theme, "cursor-svg", cursor.use_svg) .on_click(cx.listener(|this, _, window, cx| { - let next = !this.project.cursor.use_svg; + let next = !this.style_control_project().cursor.use_svg; this.edit_project("cursor-svg", window, cx, move |p| { p.cursor.use_svg = next; true diff --git a/apps/desktop-gpui/src/editor_timeline.rs b/apps/desktop-gpui/src/editor_timeline.rs index 8bf13e72aac..8df85927561 100644 --- a/apps/desktop-gpui/src/editor_timeline.rs +++ b/apps/desktop-gpui/src/editor_timeline.rs @@ -648,6 +648,8 @@ pub fn waveform_system_color() -> Hsla { /// The nine rows, in the source order `TL/index.tsx:1334-1496` mounts them. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum TrackKind { + Style, + Image, Clip, Caption, Keyboard, @@ -666,6 +668,8 @@ impl TrackKind { // The clip row's gutter label is "Video", not the definition's // "Clip" (`TL/index.tsx:1334`). Self::Clip => "Video", + Self::Style => "Style", + Self::Image => "Image", Self::Caption => "Captions", Self::Keyboard => "Keyboard", Self::Text => "Text", @@ -680,6 +684,8 @@ impl TrackKind { pub fn icon(self) -> &'static str { match self { Self::Clip => "icons/clapperboard.svg", + Self::Style => "icons/palette.svg", + Self::Image => "icons/image.svg", Self::Caption => "icons/captions.svg", Self::Keyboard => "icons/keyboard.svg", Self::Text => "icons/type.svg", @@ -694,6 +700,8 @@ impl TrackKind { pub fn color(self) -> Hsla { gpui::rgb(match self { Self::Clip => track_color::CLIP, + Self::Style => 0xa855f7, + Self::Image => 0xf59e0b, Self::Caption => track_color::CAPTION, Self::Keyboard => track_color::KEYBOARD, Self::Text => track_color::TEXT, @@ -716,6 +724,8 @@ impl TrackKind { pub fn picker_description(self) -> &'static str { match self { Self::Clip => "Your recorded screen footage.", + Self::Style => "Change background, camera and cursor settings over time.", + Self::Image => "Add an image to your recording.", Self::Zoom => "Smooth zoom-ins that follow the action.", Self::Caption => "Auto-transcribe your recording into on-screen subtitles.", Self::Keyboard => "Display key presses on screen as you type.", @@ -735,11 +745,16 @@ impl TrackKind { } pub fn supports_multiple(self) -> bool { - matches!(self, Self::Text | Self::Mask | Self::Audio) + matches!( + self, + Self::Text | Self::Mask | Self::Audio | Self::Style | Self::Image + ) } } pub const ADD_TRACK_OPTIONS: &[TrackKind] = &[ + TrackKind::Style, + TrackKind::Image, TrackKind::Caption, TrackKind::Keyboard, TrackKind::Text, @@ -751,6 +766,8 @@ pub const ADD_TRACK_OPTIONS: &[TrackKind] = &[ #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct TrackLanes { + pub style: u32, + pub image: u32, pub caption: bool, pub keyboard: bool, pub scene: bool, @@ -760,10 +777,25 @@ pub struct TrackLanes { pub audio: u32, } +pub(crate) fn scene_available(config: &ProjectConfiguration, has_camera: bool) -> bool { + has_camera + && (config.requires_camera() + || config + .timeline + .as_ref() + .is_some_and(|timeline| !timeline.scene_segments.is_empty())) +} + impl TrackLanes { pub fn from_project(config: &ProjectConfiguration, has_camera: bool) -> Self { let timeline = config.timeline.as_ref(); Self { + style: timeline.map_or(0, |timeline| { + used_config_lane_count(timeline.style_segments.iter().map(|segment| segment.track)) + }), + image: timeline.map_or(0, |timeline| { + used_config_lane_count(timeline.image_segments.iter().map(|segment| segment.track)) + }), caption: config .captions .as_ref() @@ -775,7 +807,7 @@ impl TrackLanes { .keyboard .as_ref() .is_some_and(|keyboard| keyboard.settings.enabled), - scene: has_camera && !config.camera.hide, + scene: scene_available(config, has_camera), three_d: timeline.is_some_and(|timeline| !timeline.camera3d_segments.is_empty()), text: timeline.map_or(0, |timeline| { used_config_lane_count(timeline.text_segments.iter().map(|segment| segment.track)) @@ -795,6 +827,8 @@ impl TrackLanes { TrackKind::Keyboard => self.keyboard, TrackKind::Scene => self.scene, TrackKind::ThreeD => self.three_d, + TrackKind::Style => self.style > 0, + TrackKind::Image => self.image > 0, TrackKind::Text => self.text > 0, TrackKind::Mask => self.mask > 0, TrackKind::Audio => self.audio > 0, @@ -804,6 +838,8 @@ impl TrackLanes { pub fn count(self, kind: TrackKind) -> u32 { match kind { + TrackKind::Style => self.style, + TrackKind::Image => self.image, TrackKind::Text => self.text, TrackKind::Mask => self.mask, TrackKind::Audio => self.audio, @@ -833,6 +869,14 @@ pub struct Segment { /// per segment per frame through the allocator for nothing. #[derive(Debug, Clone)] pub enum SegmentDetail { + Style { + name: SharedString, + enabled: bool, + }, + Image { + name: SharedString, + enabled: bool, + }, /// `TL/ClipTrack.tsx`. `start`/`end` above are the **output-time** box; /// these carry the recording-domain numbers the label reads. Clip { @@ -852,11 +896,18 @@ pub enum SegmentDetail { holds: Arc<[(f64, f64)]>, }, /// `TL/ZoomTrack.tsx:343-349`. - Zoom { amount: f64, automatic: bool }, + Zoom { + amount: f64, + automatic: bool, + }, /// `TL/SceneTrack.tsx:80-102`. - Scene { mode: SceneMode }, + Scene { + mode: SceneMode, + }, /// `TL/ThreeDTrack.tsx:648-651`. - ThreeD { motion: bool }, + ThreeD { + motion: bool, + }, /// `TL/TextTrack.tsx:428-450`. Text { content: SharedString, @@ -867,7 +918,9 @@ pub enum SegmentDetail { enabled: bool, }, /// `TL/MaskTrack.tsx:349-350`. - Mask { label: &'static str }, + Mask { + label: &'static str, + }, /// `TL/AudioTrack.tsx:449-540`. Audio { name: SharedString, @@ -876,9 +929,13 @@ pub enum SegmentDetail { fade_out: f64, }, /// `TL/CaptionsTrack.tsx:176-273`. - Caption { text: SharedString }, + Caption { + text: SharedString, + }, /// `TL/KeyboardTrack.tsx:168-266`. - Keyboard { text: SharedString }, + Keyboard { + text: SharedString, + }, } impl SegmentDetail { @@ -904,6 +961,8 @@ pub struct TrackRow { /// Everything the timeline draws, derived once per project-config change. #[derive(Debug, Clone, Default)] pub struct TimelineModel { + pub style: Vec, + pub image: Vec, pub rows: Vec, pub clips: Vec, pub zoom: Vec, @@ -952,6 +1011,8 @@ impl TimelineModel { /// them by. Multi-lane tracks keep every lane in one list; the row filters. pub fn segments(&self, kind: TrackKind) -> &[Segment] { match kind { + TrackKind::Style => &self.style, + TrackKind::Image => &self.image, TrackKind::Clip => &self.clips, TrackKind::Caption => &self.caption, TrackKind::Keyboard => &self.keyboard, @@ -1115,7 +1176,35 @@ impl TimelineModel { }) .collect(); + let style = timeline + .style_segments + .iter() + .map(|segment| Segment { + start: segment.start, + end: segment.end, + lane: segment.track, + detail: SegmentDetail::Style { + name: segment.name.clone().into(), + enabled: segment.enabled, + }, + }) + .collect(); + let image = timeline + .image_segments + .iter() + .map(|segment| Segment { + start: segment.start, + end: segment.end, + lane: segment.track, + detail: SegmentDetail::Image { + name: segment.name.clone().into(), + enabled: segment.enabled, + }, + }) + .collect(); let mut model = Self { + style, + image, rows: Vec::new(), clips, zoom, @@ -1189,6 +1278,8 @@ fn build_rows( }); } for (kind, segments, count) in [ + (TrackKind::Style, &model.style, lanes.style), + (TrackKind::Image, &model.image, lanes.image), (TrackKind::Text, &model.text, lanes.text), (TrackKind::Mask, &model.mask, lanes.mask), (TrackKind::Audio, &model.audio, lanes.audio), @@ -1207,7 +1298,7 @@ fn build_rows( lane: 0, }); } - if lanes.scene && has_camera && !config.camera.hide { + if lanes.scene && scene_available(config, has_camera) { rows.push(TrackRow { kind: TrackKind::Scene, lane: 0, @@ -2329,7 +2420,13 @@ fn render_segment( // `!segment.enabled && "opacity-60"` (text, `TL/TextTrack.tsx:365`) and // `"opacity-50"` (audio, `TL/AudioTrack.tsx:457`). let dim = match &segment.detail { - SegmentDetail::Text { enabled, .. } if !enabled => Some(0.6), + SegmentDetail::Text { enabled, .. } + | SegmentDetail::Style { enabled, .. } + | SegmentDetail::Image { enabled, .. } + if !enabled => + { + Some(0.6) + } SegmentDetail::Audio { enabled, .. } if !enabled => Some(0.5), _ => None, }; @@ -2849,6 +2946,25 @@ fn label_body( }; Some(match (&segment.detail, tier) { + ( + SegmentDetail::Style { name, .. } | SegmentDetail::Image { name, .. }, + LabelTier::Full | LabelTier::Compact, + ) => div() + .text_color(on_fill) + .overflow_hidden() + .text_ellipsis() + .child(name.clone()) + .into_any_element(), + (SegmentDetail::Style { .. }, LabelTier::Glyph) => svg() + .path("icons/palette.svg") + .size(px(12.)) + .text_color(on_fill) + .into_any_element(), + (SegmentDetail::Image { .. }, LabelTier::Glyph) => svg() + .path("icons/image.svg") + .size(px(12.)) + .text_color(on_fill) + .into_any_element(), // -- Clip (`TL/ClipTrack.tsx:1255-1279`) -------------------------- ( SegmentDetail::Clip { @@ -3406,7 +3522,9 @@ pub fn selected_border_color(theme: &Theme, kind: TrackKind) -> Hsla { | TrackKind::Zoom | TrackKind::Scene | TrackKind::ThreeD - | TrackKind::Mask => Hsla::from(theme.gray_12), + | TrackKind::Mask + | TrackKind::Style + | TrackKind::Image => Hsla::from(theme.gray_12), // `border-blue-7`: Radix blue-7 is `#205d9e` light / `#8ec8f6` dark. TrackKind::Text => { if theme.is_dark() { @@ -4282,3 +4400,34 @@ mod tests { assert_eq!(right, 0.); } } + +#[cfg(test)] +mod style_image_tests { + use super::*; + + #[test] + fn style_image_scene_availability_uses_source_camera_and_stable_style_requirement() { + let mut project: ProjectConfiguration = serde_json::from_value(serde_json::json!({ + "camera":{"hide":true}, "timeline":{"zoomSegments":[],"segments":[{"start":0,"end":20,"timescale":1}], + "styleSegments":[{"start":3,"end":5,"overrides":{"camera":{"hide":false}}}]} + })) + .unwrap(); + assert!(scene_available(&project, true)); + assert!(!scene_available(&project, false)); + assert!(TrackLanes::from_project(&project, true).scene); + assert!( + TimelineModel::build(&project, true, false) + .rows + .iter() + .any(|row| row.kind == TrackKind::Scene) + ); + project.timeline.as_mut().unwrap().style_segments[0].enabled = false; + assert!(!scene_available(&project, true)); + project.timeline.as_mut().unwrap().scene_segments.push( + serde_json::from_value(serde_json::json!({"start":1,"end":3,"mode":"cameraOnly"})) + .unwrap(), + ); + assert!(scene_available(&project, true)); + assert!(!scene_available(&project, false)); + } +} diff --git a/apps/desktop-gpui/src/editor_window.rs b/apps/desktop-gpui/src/editor_window.rs index 465f718f77b..201db4aff45 100644 --- a/apps/desktop-gpui/src/editor_window.rs +++ b/apps/desktop-gpui/src/editor_window.rs @@ -164,6 +164,38 @@ pub(crate) const SIDEBAR_TAB_BAR_HEIGHT: f32 = 64.; /// `padding = 4` inside `PreviewCanvas` (`Player.tsx:566`). const PLAYER_CANVAS_PADDING: f32 = 4.; +#[derive(Clone, Copy, Debug)] +struct EditorVerticalLayout { + player_min_height: f32, + timeline_height: f32, +} + +impl EditorVerticalLayout { + fn new(viewport_height: f32, preferred_timeline_height: f32) -> Self { + let available = (viewport_height - HEADER_HEIGHT - 8.).max(0.); + let scale = (available / (MIN_PLAYER_HEIGHT + MIN_TIMELINE_HEIGHT)).min(1.); + let player_min_height = MIN_PLAYER_HEIGHT * scale; + let timeline_min_height = MIN_TIMELINE_HEIGHT * scale; + Self { + player_min_height, + timeline_height: preferred_timeline_height.clamp( + timeline_min_height, + (available - player_min_height).max(timeline_min_height), + ), + } + } +} + +fn compact_header_controls(viewport_width: f32, windows: bool, captions: bool) -> bool { + let actions_width = if captions { 416. } else { 304. }; + let caption_controls_width = if windows { 138. } else { 0. }; + viewport_width < actions_width * 2. + 144. + caption_controls_width +} + +fn editor_player_width(viewport_width: f32) -> f32 { + (viewport_width - SIDEBAR_WIDTH - 8. - 16. - 2.).max(0.) +} + // --------------------------------------------------------------------------- // Timeline metrics -- all of them now live in [`crate::editor_timeline`], // which owns the strip itself (`routes/editor/Timeline/index.tsx:62-68`). @@ -368,6 +400,8 @@ pub fn preflight(path: &std::path::Path) -> Result { keyboard_segments: Vec::new(), audio_segments: Vec::new(), camera3d_segments: Vec::new(), + style_segments: Vec::new(), + image_segments: Vec::new(), }); } @@ -658,8 +692,10 @@ impl Render for EditorSectionView { } match self.section { EditorSection::Header => editor.render_header(window, cx).into_any_element(), - EditorSection::Toolbar => editor.render_player_toolbar(cx).into_any_element(), - EditorSection::Transport => editor.render_transport(cx).into_any_element(), + EditorSection::Toolbar => { + editor.render_player_toolbar(window, cx).into_any_element() + } + EditorSection::Transport => editor.render_transport(window, cx).into_any_element(), // The Clips layout mode swaps the config sidebar's column for the // clips sidebar; the config sidebar is hidden, not destroyed // (`Editor.tsx:728-747`). @@ -667,13 +703,16 @@ impl Render for EditorSectionView { if editor.clips.open { editor.render_clips_sidebar(cx).into_any_element() } else { - editor.render_sidebar(cx).into_any_element() + editor.with_style_controls(|editor| { + editor.render_sidebar(cx).into_any_element() + }) } } EditorSection::Timeline => { let viewport_width: f32 = window.viewport_size().width.into(); + let viewport_height: f32 = window.viewport_size().height.into(); editor - .render_timeline(viewport_width, cx) + .render_timeline(viewport_width, viewport_height, cx) .into_any_element() } } @@ -1543,6 +1582,16 @@ impl EditorWindow { }) }); if let Some(message) = blocked { + #[cfg(target_os = "linux")] + if window.root::().flatten().is_some_and(|editor| { + editor.read(cx).export.as_ref().is_some_and(|export| { + export.phase == crate::editor_export::ExportPhase::ChoosingFile + }) + }) { + let response = crate::editor_modal::retained_alert(&message, window, cx); + cx.spawn(async move |_| response.await).detach(); + return false; + } cx.spawn(async move |_| { crate::platform::alert_dialog("Recording retained", &message); }) @@ -1850,6 +1899,10 @@ impl EditorWindow { window: &mut Window, cx: &mut Context, ) { + self.sidebar.style_target = None; + self.sidebar.menu = None; + self.selection = None; + self.canvas_selection = None; self.project = config; self.history = ProjectHistory::new(self.project.clone()); self.tracks = TrackLanes::from_project(&self.project, self.has_camera); @@ -1925,12 +1978,16 @@ impl EditorWindow { window: &mut Window, cx: &mut Context, ) -> bool { + self.end_field_edit(cx); + self.close_color_picker(cx); + self.dismiss_frame_controls(cx); let Some(timeline) = self.project.timeline.as_mut() else { return false; }; if !change(timeline) { return false; } + self.set_selection(None, cx); self.project_changed(window, cx); true } @@ -1995,6 +2052,7 @@ impl EditorWindow { if !self.project_ready() { return; } + self.history.record(&self.project); self.publish_project(); cx.notify(); } @@ -2113,6 +2171,9 @@ impl EditorWindow { /// store. The playhead is not moved and the selection is not restored; /// neither is in the snapshot (`editorState` is a separate store). fn undo(&mut self, window: &mut Window, cx: &mut Context) { + self.close_frame_controls(window, cx); + self.end_field_edit(cx); + self.close_color_picker(cx); let Some(config) = self.history.undo().cloned() else { return; }; @@ -2121,6 +2182,9 @@ impl EditorWindow { } fn redo(&mut self, window: &mut Window, cx: &mut Context) { + self.close_frame_controls(window, cx); + self.end_field_edit(cx); + self.close_color_picker(cx); let Some(config) = self.history.redo().cloned() else { return; }; @@ -2136,6 +2200,12 @@ impl EditorWindow { window: &mut Window, cx: &mut Context, ) { + self.sidebar.style_target = None; + self.sidebar.menu = None; + self.selection = None; + self.sidebar.image_asset_status = None; + self.canvas_selection = None; + self.canvas_drag = None; let previous_animated_gradient = self.animated_gradient_config().cloned(); let animated_background_changed = self.animated_gradient_config() != match &config.background.source { @@ -3067,8 +3137,26 @@ impl EditorWindow { /// `setEditorState("timeline", "selection", ...)`. pub(crate) fn set_selection(&mut self, selection: Option, cx: &mut Context) { if self.selection != selection { + self.dismiss_frame_controls(cx); + self.end_field_edit(cx); + self.close_color_picker(cx); + self.sidebar.menu = None; dismiss_indexed_sidebar_menu(&mut self.sidebar.menu); + self.sidebar.style_target = None; + self.canvas_selection = selection.as_ref().and_then(|selection| { + if selection.indices.len() != 1 { + return None; + } + let index = selection.indices[0]; + match selection.track { + TrackKind::Image => Some(crate::editor_canvas::CanvasSelection::Image(index)), + TrackKind::Text => Some(crate::editor_canvas::CanvasSelection::Text(index)), + TrackKind::Mask => Some(crate::editor_canvas::CanvasSelection::Mask(index)), + _ => None, + } + }); self.selection = selection; + self.refresh_image_asset_status(); cx.notify(); } } @@ -3152,6 +3240,12 @@ impl EditorWindow { cx, ); } + } else if kind == TrackKind::Image { + cx.stop_propagation(); + self.import_image_for_lane(lane, press_time, window, cx); + } else if kind == TrackKind::Style { + cx.stop_propagation(); + self.add_style_at(lane, press_time, window, cx); } else if kind == TrackKind::Audio { cx.stop_propagation(); self.open_audio_picker(lane, cx); @@ -3367,6 +3461,8 @@ impl EditorWindow { targets.push(self.total_duration()); } for track in [ + TrackKind::Style, + TrackKind::Image, TrackKind::Clip, TrackKind::Caption, TrackKind::Keyboard, @@ -3856,9 +3952,7 @@ type GhostClipLayout = (Vec<(f64, f64)>, Option<(f64, f64)>); impl EditorWindow { fn clamp_timeline_height(&self, value: f32, viewport_height: f32) -> f32 { - let available = (viewport_height - HEADER_HEIGHT - 8.).max(MIN_TIMELINE_HEIGHT); - let max_height = (available - MIN_PLAYER_HEIGHT).max(MIN_TIMELINE_HEIGHT); - value.clamp(MIN_TIMELINE_HEIGHT, max_height) + EditorVerticalLayout::new(viewport_height, value).timeline_height } fn edit_live( @@ -3872,6 +3966,7 @@ impl EditorWindow { if !change(timeline) { return false; } + self.history.record(&self.project); self.rebuild_timeline(); self.publish_project(); cx.notify(); @@ -4745,6 +4840,7 @@ impl EditorWindow { window, cx, ) { + self.set_selection(None, cx); self.note_edit("split", Some(kind)); } return; @@ -4763,6 +4859,7 @@ impl EditorWindow { window, cx, ) { + self.set_selection(None, cx); self.note_edit("split", Some(kind)); } } @@ -4812,6 +4909,8 @@ impl EditorWindow { cx, ); let count = match kind { + TrackKind::Style => &mut self.tracks.style, + TrackKind::Image => &mut self.tracks.image, TrackKind::Text => &mut self.tracks.text, TrackKind::Mask => &mut self.tracks.mask, TrackKind::Audio => &mut self.tracks.audio, @@ -4819,6 +4918,8 @@ impl EditorWindow { }; let current = *count; let used = match (kind, self.project.timeline.as_ref()) { + (TrackKind::Style, Some(timeline)) => edits::used_lane_count(&timeline.style_segments), + (TrackKind::Image, Some(timeline)) => edits::used_lane_count(&timeline.image_segments), (TrackKind::Text, Some(timeline)) => edits::used_lane_count(&timeline.text_segments), (TrackKind::Mask, Some(timeline)) => edits::used_lane_count(&timeline.mask_segments), (TrackKind::Audio, Some(timeline)) => edits::used_lane_count(&timeline.audio_segments), @@ -4826,6 +4927,8 @@ impl EditorWindow { }; let next = used.max(current.saturating_sub(1)); match kind { + TrackKind::Style => self.tracks.style = next, + TrackKind::Image => self.tracks.image = next, TrackKind::Text => self.tracks.text = next, TrackKind::Mask => self.tracks.mask = next, TrackKind::Audio => self.tracks.audio = next, @@ -4927,6 +5030,24 @@ impl EditorWindow { /// falls back to the playhead. fn split_at_playhead(&mut self, window: &mut Window, cx: &mut Context) { let time = self.view.preview_time.unwrap_or(self.playhead); + if let Some(selection) = self.selection.clone() + && matches!(selection.track, TrackKind::Style | TrackKind::Image) + && selection.indices.len() == 1 + { + let index = selection.indices[0]; + let Some(segment) = self.timeline.segments(selection.track).get(index) else { + return; + }; + let local = time - segment.start; + if self.edit( + |timeline| edits::split_segment(timeline, selection.track, index, local), + window, + cx, + ) { + self.note_edit("split", Some(selection.track)); + } + return; + } if self.edit( |timeline| edits::split_clip_segment(timeline, time, None), window, @@ -5633,7 +5754,9 @@ impl EditorWindow { // The trigger is `absolute bottom-0 left-0` in the 32px timeline // header, itself under the slot's fixed geometry, so its top edge is a // constant offset from the window's bottom-left corner. - let button_top = f32::from(viewport.height) - self.timeline_height + TIMELINE_TOP_PADDING; + let timeline_height = + self.clamp_timeline_height(self.timeline_height, f32::from(viewport.height)); + let button_top = f32::from(viewport.height) - timeline_height + TIMELINE_TOP_PADDING; let bottom = f32::from(viewport.height) - (button_top - 8.); // `overflowPadding: 64` -- stay clear of the titlebar. let max_height = (button_top - 8. - 64.).max(160.); @@ -5789,7 +5912,7 @@ impl EditorWindow { } fn scene_available(&self) -> bool { - self.has_camera && !self.project.camera.hide + timeline::scene_available(&self.project, self.has_camera) } fn toggle_track( @@ -5860,6 +5983,25 @@ impl EditorWindow { fn add_track_kind(&mut self, kind: TrackKind, window: &mut Window, cx: &mut Context) { match kind { + TrackKind::Style | TrackKind::Image => { + let lane_count = self.tracks.count(kind); + let segments = self.timeline.segments(kind); + let length = self.total_duration().min(3.0); + let lane = (0..lane_count) + .find(|lane| { + !segments.iter().any(|segment| { + segment.lane == *lane + && segment.end > self.playhead + && segment.start < self.playhead + length + }) + }) + .unwrap_or(lane_count); + if kind == TrackKind::Style { + self.add_style_at(lane, self.playhead, window, cx); + } else { + self.import_image_for_lane(lane, self.playhead, window, cx); + } + } TrackKind::Audio => { let segments = self .project @@ -6029,6 +6171,261 @@ impl EditorWindow { self.note_edit("add-track", Some(kind)); } + fn add_style_at(&mut self, lane: u32, time: f64, window: &mut Window, cx: &mut Context) { + if !edits::ensure_timeline(&mut self.project, &self.clip_display_durations) { + return; + } + let Some(timeline) = self.project.timeline.as_ref() else { + return; + }; + let segments: Vec<_> = timeline + .style_segments + .iter() + .filter(|segment| segment.track == lane) + .cloned() + .collect(); + let total = self.total_duration(); + let Some((start, end)) = edits::find_placement(&segments, time, 3.0_f64.min(total), total) + else { + return; + }; + self.tracks.style = self.tracks.style.max(lane + 1); + let mut index = 0; + if self.edit( + |timeline| { + index = edits::insert_style_segment( + timeline, + cap_project::StyleSegment { + start, + end, + track: lane, + ..Default::default() + }, + ); + true + }, + window, + cx, + ) { + self.set_selection(Some(Selection::single(TrackKind::Style, index)), cx); + self.seek_to_time(start, cx); + } + } + + fn import_image_for_lane( + &mut self, + lane: u32, + time: f64, + window: &mut Window, + cx: &mut Context, + ) { + self.pick_timeline_image(None, lane, time, window, cx); + } + + pub(crate) fn replace_timeline_image( + &mut self, + index: usize, + window: &mut Window, + cx: &mut Context, + ) { + let Some(segment) = self + .project + .timeline + .as_ref() + .and_then(|timeline| timeline.image_segments.get(index)) + else { + return; + }; + let Ok(fingerprint) = serde_json::to_string(segment) else { + return; + }; + self.pick_timeline_image( + Some((index, fingerprint)), + segment.track, + segment.start, + window, + cx, + ); + } + + pub(crate) fn refresh_image_asset_status(&mut self) { + self.sidebar.image_asset_status = self + .selection + .as_ref() + .filter(|selection| selection.track == TrackKind::Image && selection.indices.len() == 1) + .and_then(|selection| { + self.project + .timeline + .as_ref()? + .image_segments + .get(selection.indices[0]) + }) + .map(|segment| { + ( + segment.path.clone(), + self.project_path.join(&segment.path).is_file(), + ) + }); + } + + fn pick_timeline_image( + &mut self, + replace: Option<(usize, String)>, + lane: u32, + time: f64, + window: &mut Window, + cx: &mut Context, + ) { + if self.sidebar.picking_image { + return; + } + self.end_field_edit(cx); + self.close_color_picker(cx); + self.sidebar.image_import_error = None; + self.sidebar.picking_image = true; + let project_path = self.project_path.clone(); + cx.spawn_in(window, async move |this, cx| { + #[cfg(target_os = "linux")] + let source = crate::platform::open_file_panel_async( + &[("Images", crate::import::OVERLAY_IMAGE_EXTENSIONS)], + None, + cx, + ) + .await; + #[cfg(not(target_os = "linux"))] + let source = crate::import::pick_import_file( + &[("Images", crate::import::OVERLAY_IMAGE_EXTENSIONS)], + cx, + ) + .await; + if this.update_in(cx, |_, _, _| ()).is_err() { + return; + } + let imported = + match source { + Some(source) => Some( + cx.background_executor() + .spawn(async move { + crate::import::import_editor_image(&project_path, &source) + }) + .await, + ), + None => None, + }; + this.update_in(cx, |this, window, cx| { + this.sidebar.picking_image = false; + match imported { + Some(Ok(imported)) => { + if let Some((index, fingerprint)) = replace { + if edits::replace_image_asset(&mut this.project, index, &fingerprint, imported.path, imported.name) { + this.project_changed(window, cx); + this.refresh_image_asset_status(); + } else { + this.sidebar.image_import_error = Some("This image changed while choosing a replacement. Select it and try again.".into()); + cx.notify(); + } + } else { this.commit_image_import(lane, time, imported, window, cx); } + } + Some(Err(error)) => { + this.sidebar.image_import_error = Some(error); + cx.notify(); + } + None => cx.notify(), + } + }) + .ok(); + }) + .detach(); + cx.notify(); + } + + fn commit_image_import( + &mut self, + mut lane: u32, + time: f64, + imported: crate::import::ImportedEditorImage, + window: &mut Window, + cx: &mut Context, + ) { + if !edits::ensure_timeline(&mut self.project, &self.clip_display_durations) { + self.sidebar.image_import_error = Some( + "The recording is no longer available for this image. Please reopen the project." + .into(), + ); + cx.notify(); + return; + } + let total = self.total_duration(); + if total <= 0.0 { + self.sidebar.image_import_error = Some( + "The recording is no longer available for this image. Please reopen the project." + .into(), + ); + cx.notify(); + return; + } + let Some(timeline) = self.project.timeline.as_ref() else { + self.sidebar.image_import_error = Some( + "The recording is no longer available for this image. Please reopen the project." + .into(), + ); + cx.notify(); + return; + }; + let length = total.min(3.0); + let mut segments: Vec<_> = timeline + .image_segments + .iter() + .filter(|segment| segment.track == lane) + .cloned() + .collect(); + if edits::find_placement(&segments, time, length, total).is_none() { + lane = self + .tracks + .image + .max(edits::used_lane_count(&timeline.image_segments)); + segments.clear(); + } + let Some((start, end)) = edits::find_placement(&segments, time, length, total) else { + return; + }; + let output = self + .frame_layout + .map_or([1920, 1080], |layout| layout.output_size); + let ratio = f64::from(imported.width) / f64::from(imported.height) * f64::from(output[1]) + / f64::from(output[0]); + let size = if ratio >= 1.0 { + cap_project::XY::new(0.3, 0.3 / ratio) + } else { + cap_project::XY::new(0.3 * ratio, 0.3) + }; + self.tracks.image = self.tracks.image.max(lane + 1); + let mut index = 0; + if self.edit( + |timeline| { + index = edits::insert_image_segment( + timeline, + cap_project::ImageSegment { + start, + end, + track: lane, + path: imported.path, + name: imported.name, + size, + ..Default::default() + }, + ); + true + }, + window, + cx, + ) { + self.sidebar.image_import_error = None; + self.set_selection(Some(Selection::single(TrackKind::Image, index)), cx); + self.seek_to_time(start, cx); + } + } + fn import_audio_for_lane(&mut self, lane: u32, window: &mut Window, cx: &mut Context) { let project_path = self.project_path.clone(); cx.spawn_in(window, async move |this, cx| { @@ -7192,6 +7589,17 @@ impl EditorWindow { fn render_header(&self, window: &Window, cx: &mut Context) -> impl IntoElement { let theme = self.theme; let name_focused = self.name_input.read(cx).focus_handle().is_focused(window); + let has_captions = self.project.captions.as_ref().is_some_and(|captions| { + captions + .segments + .iter() + .any(|segment| !segment.words.is_empty()) + }); + let compact = compact_header_controls( + f32::from(window.viewport_size().width), + cfg!(target_os = "windows"), + has_captions, + ); let header = div() .relative() @@ -7214,6 +7622,7 @@ impl EditorWindow { .gap(px(8.)) .items_center() .px(px(16.)) + .when(compact, |group| group.gap(px(4.)).px(px(8.))) .h_full() .when(cfg!(target_os = "windows"), |group| group.occlude()) // The macOS spacer for the inset traffic lights: `h-full w-16`. @@ -7251,7 +7660,9 @@ impl EditorWindow { // `px-px m-0 bg-transparent border-b // border-transparent focus:border-gray-7` div() + .min_w_0() .max_w(px(200.)) + .overflow_hidden() .border_b_1() .border_color(if name_focused { Hsla::from(theme.gray_7) @@ -7275,6 +7686,7 @@ impl EditorWindow { ) .child( div() + .flex_shrink_0() .text_size(px(14.)) .text_color(Hsla::from(theme.gray_11)) .child(".cap"), @@ -7307,8 +7719,10 @@ impl EditorWindow { .flex_row() .items_center() .justify_center() + .flex_shrink_0() .gap(px(8.)) .px(px(16.)) + .when(compact, |group| group.px(px(8.))) .h_full() .border_l_1() .border_r_1() @@ -7317,7 +7731,8 @@ impl EditorWindow { .child( ui::EditorButton::plain(&theme, "presets") .left_icon("icons/presets.svg") - .label("Presets") + .when(!compact, |button| button.label("Presets")) + .tooltip(&theme, "Presets") .disabled(!self.project_ready()) .right_icon("icons/chevron-down.svg") .pressed(self.presets_menu.is_some()) @@ -7334,6 +7749,7 @@ impl EditorWindow { .flex_row() .flex_1() .min_w_0() + .when(compact, |group| group.flex_none()) .items_center() .gap(px(8.)) .pl(px(8.)) @@ -7366,16 +7782,8 @@ impl EditorWindow { // 188`): the pill only exists once a transcript with words // does. .children( - self.project - .captions - .as_ref() - .is_some_and(|captions| { - captions - .segments - .iter() - .any(|segment| !segment.words.is_empty()) - }) - .then(|| self.header_pill("icons/captions.svg", "Captions")), + has_captions + .then(|| self.header_pill("icons/captions.svg", "Captions", compact)), ) .child(self.render_export_button(cx)), ); @@ -7396,9 +7804,17 @@ impl EditorWindow { /// `class="flex gap-1.5 justify-center h-[40px]"` (`Header.tsx:188-209`). /// Inert -- the transcript layout mode does not exist yet. Clips has its /// own live pill (`crate::editor_clips`). - fn header_pill(&self, icon: &'static str, label: &'static str) -> impl IntoElement { + fn header_pill( + &self, + icon: &'static str, + label: &'static str, + compact: bool, + ) -> impl IntoElement { let theme = self.theme; div() + .id("editor-captions-pill") + .tooltip_show_delay(ui::TOOLTIP_SHOW_DELAY) + .tooltip(move |_, cx| ui::Tooltip::new(&theme, label).view(cx)) .flex() .flex_row() .items_center() @@ -7420,7 +7836,7 @@ impl EditorWindow { .flex_shrink_0() .text_color(Hsla::from(theme.gray_12)), ) - .child(label) + .when(!compact, |pill| pill.child(label)) } /// The Export button (`Header.tsx:210-231`): `h-[40px] max-w-[100px] @@ -7503,8 +7919,9 @@ impl EditorWindow { } /// `flex items-center justify-between gap-3 p-3` (`Player.tsx:290`). - fn render_player_toolbar(&self, cx: &mut Context) -> impl IntoElement { + fn render_player_toolbar(&self, window: &Window, cx: &mut Context) -> impl IntoElement { let theme = self.theme; + let narrow = editor_player_width(f32::from(window.viewport_size().width)) < 480.; div() .flex() .flex_row() @@ -7519,10 +7936,15 @@ impl EditorWindow { .flex_row() .items_center() .gap(px(12.)) + .when(narrow, |group| group.gap(px(4.))) .child( ui::EditorButton::plain(&theme, "aspect-ratio") .left_icon("icons/layout.svg") - .label(Self::aspect_ratio_label(self.project.aspect_ratio.as_ref())) + .when(!narrow, |button| { + button.label(Self::aspect_ratio_label( + self.project.aspect_ratio.as_ref(), + )) + }) .tooltip(&theme, "Aspect Ratio") .pressed( self.toolbar_menu @@ -7558,14 +7980,14 @@ impl EditorWindow { .flex_row() .items_center() .gap(px(8.)) - .child( + .children((!narrow).then(|| { // `text-xs font-medium text-gray-11`. div() .text_size(px(12.)) .font_weight(FontWeight::MEDIUM) .text_color(Hsla::from(theme.gray_11)) - .child("Preview quality"), - ) + .child("Preview quality") + })) .child( ui::Select::plain(&theme, "preview-quality", self.preview_quality.label()) .stretch_label() @@ -7755,8 +8177,11 @@ impl EditorWindow { /// The transport row (`Player.tsx:357-481`): `relative flex overflow-hidden /// z-10 flex-row gap-3 justify-between items-center p-5`. - fn render_transport(&self, cx: &mut Context) -> impl IntoElement { + fn render_transport(&self, window: &Window, cx: &mut Context) -> impl IntoElement { let theme = self.theme; + let player_width = editor_player_width(f32::from(window.viewport_size().width)); + let compact = player_width < 600.; + let narrow = player_width < 480.; let total = self.total_duration(); // `Math.max(editorState.previewTime ?? editorState.playbackTime, 0)` // (`Player.tsx:359-365`) -- the clock reads the *hover* time when there @@ -7788,6 +8213,8 @@ impl EditorWindow { .flex_1() .min_w_0() .text_size(px(14.)) + .when(compact, |clock| clock.overflow_hidden()) + .when(narrow, |clock| clock.text_size(px(12.))) .child( div() .text_color(Hsla::from(theme.gray_12)) @@ -7802,8 +8229,8 @@ impl EditorWindow { ) .child( div() - .absolute() - .inset_0() + .when(!compact, |group| group.absolute().inset_0()) + .when(compact, |group| group.flex_shrink_0()) .flex() .flex_row() .items_center() @@ -7815,6 +8242,7 @@ impl EditorWindow { .items_center() .justify_center() .gap(px(32.)) + .when(compact, |group| group.gap(px(16.))) .when(!live, |this| this.opacity(0.5)) .child( div() @@ -7892,6 +8320,7 @@ impl EditorWindow { .flex_row() .flex_1() .gap(px(16.)) + .when(compact, |group| group.gap(px(8.))) .justify_end() .items_center() // The split toggle (`Player.tsx:409-427`): an @@ -7948,7 +8377,7 @@ impl EditorWindow { // step={0.001}`: the 32px row with its 5px track. Fully // left is fully zoomed *out* -- the value is // `1 - zoom / zoomOutLimit()` (`Player.tsx:444-465`). - .child( + .children((!narrow).then(|| { ui::Slider::new( "timeline-zoom", self.view.transform.slider_fraction(total), @@ -7979,14 +8408,19 @@ impl EditorWindow { this.zoom_slider_drag = true; this.apply_zoom_slider(event.position, window, cx); }, - )), - ), + )) + })), ) } // -- Timeline ------------------------------------------------------------ - fn render_timeline(&self, viewport_width: f32, cx: &mut Context) -> impl IntoElement { + fn render_timeline( + &self, + viewport_width: f32, + viewport_height: f32, + cx: &mut Context, + ) -> impl IntoElement { let theme = self.theme; let content_width = timeline::content_width(viewport_width); let live = self.transport.is_some(); @@ -8023,12 +8457,10 @@ impl EditorWindow { .px(px(TIMELINE_SLOT_PADDING)) .overflow_hidden() .relative() - // The persisted height, clamped to `[MIN_TIMELINE_HEIGHT, - // layoutHeight - MIN_PLAYER_HEIGHT]` (`Editor.tsx:421-435`). - // Nothing writes it yet -- the drag handle is inert -- so it sits - // at the default with the floor still expressed. - .h(px(self.timeline_height)) - .min_h(px(MIN_TIMELINE_HEIGHT)) + .h(px(self.clamp_timeline_height( + self.timeline_height, + viewport_height, + ))) .child( div().h_full().child( // `pt-8 relative overflow-hidden flex flex-col gap-2 @@ -8314,7 +8746,11 @@ impl EditorWindow { let delete_label = match kind { TrackKind::Clip => None, TrackKind::Caption | TrackKind::Keyboard => Some("Delete"), - TrackKind::Text | TrackKind::Mask | TrackKind::Audio => Some("Delete"), + TrackKind::Text + | TrackKind::Mask + | TrackKind::Audio + | TrackKind::Style + | TrackKind::Image => Some("Delete"), TrackKind::Zoom | TrackKind::ThreeD | TrackKind::Scene => { (!model.segments(kind).is_empty()).then_some("Clear all") } @@ -8494,7 +8930,9 @@ impl EditorWindow { } TrackKind::Text | TrackKind::Mask - | TrackKind::Audio => { + | TrackKind::Audio + | TrackKind::Style + | TrackKind::Image => { this.delete_track_lane(kind, lane, window, cx); } TrackKind::Zoom @@ -8669,11 +9107,13 @@ impl Render for EditorWindow { // Fields first: a field created this frame has no text yet, and gpui // only renders on invalidation, so syncing before creating would leave // a brand-new box empty until something else asked for a frame. - self.prepare_sidebar_fields(window, cx); - self.prepare_animated_gradient_fields(window, cx); - self.prepare_cursor_fields(window, cx); - self.sync_hex_inputs(window, cx); - self.sync_picker_hex(window, cx); + self.with_style_controls(|this| { + this.prepare_sidebar_fields(window, cx); + this.prepare_animated_gradient_fields(window, cx); + this.prepare_cursor_fields(window, cx); + this.sync_hex_inputs(window, cx); + this.sync_picker_hex(window, cx); + }); self.prepare_frame_fields(window, cx); self.sync_crop_container(window); let theme = self.theme; @@ -8682,6 +9122,11 @@ impl Render for EditorWindow { // assuming the default width. let viewport_width: f32 = window.viewport_size().width.into(); + let layout = EditorVerticalLayout::new( + f32::from(window.viewport_size().height), + self.timeline_height, + ); + // `onMount`'s `checkBounds` (`TL/index.tsx:689-703`): once the // timeline has a width, zoom in until a segment would be at least // 80px. The source retries every 10ms until the bounds exist; here the @@ -8855,7 +9300,7 @@ impl Render for EditorWindow { .min_h_0() .px(px(8.)) .overflow_hidden() - .min_h(px(MIN_PLAYER_HEIGHT)) + .min_h(px(layout.player_min_height)) // The player card: `flex flex-col // rounded-xl border bg-gray-1 // dark:bg-gray-2 border-gray-3 @@ -8875,8 +9320,7 @@ impl Render for EditorWindow { .child(self.render_player(cx)) // The 16px horizontal resize // handle with its three grip bars - // (`Editor.tsx:700-725`). Inert: - // resizing the timeline is E3. + // (`Editor.tsx:700-725`). .child( div() .id("timeline-resize-handle") @@ -8903,10 +9347,13 @@ impl Render for EditorWindow { .on_mouse_down( MouseButton::Left, cx.listener( - |this, event: &MouseDownEvent, _, cx| { + |this, event: &MouseDownEvent, window, cx| { this.timeline_resize = Some(( f32::from(event.position.y), - this.timeline_height, + this.clamp_timeline_height( + this.timeline_height, + f32::from(window.viewport_size().height), + ), )); cx.notify(); }, @@ -8937,7 +9384,7 @@ impl Render for EditorWindow { self.timeline_view.clone().cached( StyleRefinement::default() .w_full() - .h(px(self.timeline_height)), + .h(px(layout.timeline_height)), ), ), ), @@ -9053,12 +9500,12 @@ impl Render for EditorWindow { })) // The open `KSelect` menu, painted last of all so it is over the // sidebar and the drag layers alike. - .children(self.render_sidebar_menu(cx)) + .children(self.with_style_controls(|this| this.render_sidebar_menu(cx))) .children(self.render_toolbar_menu(cx)) .children(self.render_frame_controls(window, cx)) .children(self.render_add_track_popover(cx)) .children(self.render_clip_speed_popover(cx)) - .children(self.render_color_picker_popover(cx)) + .children(self.with_style_controls(|this| this.render_color_picker_popover(cx))) .children(self.render_presets_menu(cx)) .children(self.render_preset_dialog(cx)) // The clips overlays: import menu, record modal, and the card @@ -9248,6 +9695,76 @@ fn hex_to_color(rgba: [u8; 4]) -> cap_project::Color { mod tests { use super::*; + #[test] + fn responsive_editor_fits_the_intel_visible_workarea() { + let layout = EditorVerticalLayout::new(652., DEFAULT_TIMELINE_HEIGHT); + assert_eq!(layout.player_min_height, 336.); + assert_eq!(layout.timeline_height, 252.); + assert_eq!(editor_player_width(992.), 550.); + } + + #[test] + fn responsive_editor_restores_the_preferred_split_after_resize() { + let preferred = 500.; + let sizes = [1200., 652., 600., 480., 1200.]; + let layouts = sizes.map(|height| EditorVerticalLayout::new(height, preferred)); + assert_eq!(layouts[0].timeline_height, preferred); + assert_eq!(layouts[1].timeline_height, 252.); + assert_eq!(layouts[4].timeline_height, preferred); + for (height, layout) in sizes.into_iter().zip(layouts) { + assert!(layout.player_min_height > 156.); + assert!(layout.timeline_height > 160.); + assert!( + layout.player_min_height + layout.timeline_height + HEADER_HEIGHT + 8. + <= height + 0.001 + ); + } + } + + #[test] + fn responsive_editor_clamps_both_ends_of_the_resize_gesture() { + for height in [480., 600., 652., 800., 1200.] { + let low = EditorVerticalLayout::new(height, -1000.); + let high = EditorVerticalLayout::new(height, 10000.); + assert!(low.timeline_height <= high.timeline_height); + assert!(low.timeline_height > 0.); + assert!( + high.player_min_height + high.timeline_height + HEADER_HEIGHT + 8. + <= height + 0.001 + ); + } + assert_eq!( + EditorVerticalLayout::new(800., -1000.).timeline_height, + 240. + ); + assert_eq!( + EditorVerticalLayout::new(800., 10000.).timeline_height, + 400. + ); + } + + #[test] + fn responsive_editor_preserves_the_spacious_layout() { + let layout = EditorVerticalLayout::new(800., DEFAULT_TIMELINE_HEIGHT); + assert_eq!(layout.player_min_height, MIN_PLAYER_HEIGHT); + assert_eq!(layout.timeline_height, DEFAULT_TIMELINE_HEIGHT); + for windows in [false, true] { + for captions in [false, true] { + assert!(!compact_header_controls(1275., windows, captions)); + } + } + } + + #[test] + fn responsive_editor_reserves_space_for_windows_controls_and_captions() { + assert!(compact_header_controls(992., true, true)); + assert!(!compact_header_controls(992., true, false)); + assert!(!compact_header_controls(992., false, true)); + assert!(compact_header_controls(800., true, false)); + assert!(compact_header_controls(800., false, true)); + assert_eq!(editor_player_width(800.), 358.); + } + fn open_sidebar_menu_for_test( kind: crate::editor_tabs::SidebarMenu, ) -> Option { @@ -9320,6 +9837,8 @@ mod tests { let mut project = ProjectConfiguration { timeline: Some(TimelineConfiguration { + style_segments: Vec::new(), + image_segments: Vec::new(), segments: Vec::new(), transitions: Vec::new(), zoom_segments: Vec::new(), diff --git a/apps/desktop-gpui/src/editor_window/frame.rs b/apps/desktop-gpui/src/editor_window/frame.rs index 111fb2b4244..7405d23e071 100644 --- a/apps/desktop-gpui/src/editor_window/frame.rs +++ b/apps/desktop-gpui/src/editor_window/frame.rs @@ -47,6 +47,27 @@ pub(super) struct FrameControls { trigger_bounds: ui::SliderTrack, fields: Option<[Entity; 2]>, editing: Option, + style_target: Option, +} + +struct StyleFrameTarget { + index: usize, + fingerprint: String, +} + +impl StyleFrameTarget { + fn capture(project: &ProjectConfiguration, index: usize) -> Option { + let segment = project.timeline.as_ref()?.style_segments.get(index)?; + Some(Self { + index, + fingerprint: serde_json::to_string(segment).ok()?, + }) + } + + fn matches(&self, project: &ProjectConfiguration) -> bool { + Self::capture(project, self.index) + .is_some_and(|target| target.fingerprint == self.fingerprint) + } } impl FrameControls { @@ -113,6 +134,44 @@ fn apply_frame_change(project: &mut ProjectConfiguration, change: FrameChange) - project.background.frame != previous } +fn apply_targeted_frame_change( + project: &mut ProjectConfiguration, + target: Option<&StyleFrameTarget>, + change: FrameChange, +) -> bool { + let Some(target) = target else { + return apply_frame_change(project, change); + }; + if !target.matches(project) { + return false; + } + let mut scoped = project.clone(); + let Some(segment) = project + .timeline + .as_ref() + .and_then(|timeline| timeline.style_segments.get(target.index)) + else { + return false; + }; + scoped.background = segment + .overrides + .background + .clone() + .unwrap_or_else(|| project.background.clone()); + if !apply_frame_change(&mut scoped, change) { + return false; + } + let Some(segment) = project + .timeline + .as_mut() + .and_then(|timeline| timeline.style_segments.get_mut(target.index)) + else { + return false; + }; + segment.overrides.background = Some(scoped.background); + true +} + fn button_content(style: FrameStyle) -> (&'static str, &'static str) { if style == FrameStyle::None { return ("Frame", "icons/app-window-mac.svg"); @@ -125,8 +184,56 @@ fn button_content(style: FrameStyle) -> (&'static str, &'static str) { } impl EditorWindow { + fn frame_background(&self) -> &cap_project::BackgroundConfiguration { + let index = if self.frame_controls.open { + self.frame_controls + .style_target + .as_ref() + .map(|target| target.index) + } else { + self.selected_style_index() + }; + index + .and_then(|index| { + self.project + .timeline + .as_ref()? + .style_segments + .get(index)? + .overrides + .background + .as_ref() + }) + .unwrap_or(&self.project.background) + } + + pub(crate) fn dismiss_frame_controls(&mut self, cx: &mut Context) { + self.finish_frame_text_edit(cx); + self.frame_controls.open = false; + self.frame_controls.style_target = None; + } + + fn edit_frame(&mut self, change: FrameChange, window: &mut Window, cx: &mut Context) { + if !self.frame_controls.open { + return; + } + if !apply_targeted_frame_change( + &mut self.project, + self.frame_controls.style_target.as_ref(), + change, + ) { + return; + } + if let Some(target) = &mut self.frame_controls.style_target + && let Some(next) = StyleFrameTarget::capture(&self.project, target.index) + { + *target = next; + } + self.project_changed(window, cx); + } + fn frame_style(&self) -> FrameStyle { - FrameConfiguration::active_style(self.project.background.frame.as_ref()) + FrameConfiguration::active_style(self.frame_background().frame.as_ref()) } pub(super) fn render_frame_button(&self, cx: &mut Context) -> impl IntoElement { @@ -150,6 +257,9 @@ impl EditorWindow { this.focus_root(window, cx); this.toolbar_menu = None; this.add_track = None; + this.frame_controls.style_target = this + .selected_style_index() + .and_then(|index| StyleFrameTarget::capture(&this.project, index)); this.frame_controls.open = true; cx.notify(); } @@ -186,7 +296,7 @@ impl EditorWindow { input })); } - let frame = self.project.background.frame.clone().unwrap_or_default(); + let frame = self.frame_background().frame.clone().unwrap_or_default(); if let Some(inputs) = &self.frame_controls.fields { for field in [FrameField::Url, FrameField::Title] { let input = &inputs[field.index()]; @@ -220,9 +330,7 @@ impl EditorWindow { self.frame_controls.editing = Some(field); } let change = FrameChange::Text(field, input.read(cx).text().to_owned()); - self.edit_project("frame-text", window, cx, |project| { - apply_frame_change(project, change) - }); + self.edit_frame(change, window, cx); } ui::TextInputEvent::Blurred => self.finish_frame_text_edit(cx), ui::TextInputEvent::Confirmed => { @@ -243,6 +351,7 @@ impl EditorWindow { pub(super) fn close_frame_controls(&mut self, window: &mut Window, cx: &mut Context) { self.finish_frame_text_edit(cx); self.frame_controls.open = false; + self.frame_controls.style_target = None; self.focus_root(window, cx); cx.notify(); } @@ -250,9 +359,7 @@ impl EditorWindow { fn change_frame(&mut self, change: FrameChange, window: &mut Window, cx: &mut Context) { self.finish_frame_text_edit(cx); self.focus_root(window, cx); - self.edit_project("frame", window, cx, |project| { - apply_frame_change(project, change) - }); + self.edit_frame(change, window, cx); } pub(super) fn render_frame_controls( @@ -267,159 +374,186 @@ impl EditorWindow { let theme = self.theme; let style = self.frame_style(); let frame_theme = self - .project - .background + .frame_background() .frame .as_ref() .map_or(FrameTheme::Dark, |frame| frame.theme); - let panel = - div() - .id("frame-popover") - .occlude() - .flex() - .flex_col() - .w(px(304.).min(window.viewport_size().width - px(24.))) - .max_h(window.viewport_size().height - px(24.)) - .overflow_y_scroll() - .rounded(px(16.)) - .border_1() - .border_color(theme.gray(3)) - .bg(theme.gray(1)) - .shadow_lg() - .on_mouse_down(MouseButton::Left, |_, _, cx| cx.stop_propagation()) - .child( - div() - .flex() - .flex_col() - .gap(px(2.)) - .px(px(16.)) - .pt(px(14.)) - .pb(px(12.)) - .border_b_1() - .border_color(theme.gray(3)) - .child( - div() - .text_size(px(13.)) - .font_weight(FontWeight::SEMIBOLD) - .text_color(theme.gray(12)) - .child("Frame"), - ) - .child( - div() - .text_size(px(11.)) - .text_color(theme.gray(10)) - .child("Wrap your recording in a window or device frame."), - ), - ) - .child(div().flex().flex_col().gap(px(2.)).p(px(6.)).children( - FRAME_STYLES.into_iter().enumerate().map( - |(index, (value, label, description, icon))| { - let selected = value == style; - div() - .id(("frame-style", index)) - .tab_index(0) - .flex() - .items_center() - .gap(px(12.)) - .p(px(8.)) - .rounded(px(12.)) - .cursor_pointer() - .hover(|row| row.bg(theme.gray(3))) - .focus_visible(|row| row.bg(theme.gray(3))) - .child( - div() - .flex() - .items_center() - .justify_center() - .size(px(32.)) - .flex_shrink_0() - .rounded(px(10.)) - .bg(if selected { - theme.blue_9.into() - } else { - theme.gray(3) + let panel = div() + .id("frame-popover") + .occlude() + .flex() + .flex_col() + .w(px(304.).min(window.viewport_size().width - px(24.))) + .max_h(window.viewport_size().height - px(24.)) + .overflow_y_scroll() + .rounded(px(16.)) + .border_1() + .border_color(theme.gray(3)) + .bg(theme.gray(1)) + .shadow_lg() + .on_mouse_down(MouseButton::Left, |_, _, cx| cx.stop_propagation()) + .child( + div() + .flex() + .flex_col() + .gap(px(2.)) + .px(px(16.)) + .pt(px(14.)) + .pb(px(12.)) + .border_b_1() + .border_color(theme.gray(3)) + .child( + div() + .text_size(px(13.)) + .font_weight(FontWeight::SEMIBOLD) + .text_color(theme.gray(12)) + .child("Frame"), + ) + .child( + div().text_size(px(11.)).text_color(theme.gray(10)).child( + match &self.frame_controls.style_target { + Some(target) if !target.matches(&self.project) => { + "This Style changed. Close and reopen Frame.".to_string() + } + Some(target) + if self + .project + .timeline + .as_ref() + .and_then(|timeline| { + timeline.style_segments.get(target.index) }) - .child(svg().path(icon).size(px(16.)).text_color( - if selected { - gpui::white() - } else { - theme.gray(11) - }, - )), - ) - .child( - div() - .flex() - .flex_col() - .flex_1() - .min_w_0() - .child( - div() - .text_size(px(13.)) - .font_weight(FontWeight::MEDIUM) - .text_color(theme.gray(12)) - .child(label), - ) - .child( - div() - .text_size(px(11.)) - .text_color(theme.gray(10)) - .child(description), - ), - ) - .when(selected, |row| { - row.child( - svg() - .path("icons/circle-check.svg") - .size(px(16.)) - .flex_shrink_0() - .text_color(theme.blue_9), + .is_some_and(|segment| { + segment.overrides.background.is_none() + }) => + { + format!( + "Style {} only · Editing enables its background override.", + target.index + 1 ) - }) - .on_click(cx.listener(move |this, _, window, cx| { - this.change_frame(FrameChange::Style(value), window, cx) - })) - }, + } + Some(target) => format!( + "Style {} only · Global settings stay unchanged.", + target.index + 1 + ), + None => { + "Global frame · Applies wherever Style inherits background." + .to_string() + } + }, + ), ), - )) - .when(style != FrameStyle::None, |panel| { - panel.child( + ) + .child(div().flex().flex_col().gap(px(2.)).p(px(6.)).children( + FRAME_STYLES.into_iter().enumerate().map( + |(index, (value, label, description, icon))| { + let selected = value == style; div() + .id(("frame-style", index)) + .tab_index(0) .flex() - .flex_col() + .items_center() .gap(px(12.)) - .p(px(12.)) - .border_t_1() - .border_color(theme.gray(3)) + .p(px(8.)) + .rounded(px(12.)) + .cursor_pointer() + .hover(|row| row.bg(theme.gray(3))) + .focus_visible(|row| row.bg(theme.gray(3))) .child( div() .flex() .items_center() - .justify_between() - .gap(px(12.)) + .justify_center() + .size(px(32.)) + .flex_shrink_0() + .rounded(px(10.)) + .bg(if selected { + theme.blue_9.into() + } else { + theme.gray(3) + }) + .child(svg().path(icon).size(px(16.)).text_color( + if selected { + gpui::white() + } else { + theme.gray(11) + }, + )), + ) + .child( + div() + .flex() + .flex_col() + .flex_1() + .min_w_0() .child( div() - .text_size(px(12.)) + .text_size(px(13.)) .font_weight(FontWeight::MEDIUM) - .text_color(theme.gray(11)) - .child("Theme"), + .text_color(theme.gray(12)) + .child(label), ) .child( div() - .flex() - .w(px(160.)) - .h(px(32.)) - .border_1() - .border_color(theme.gray(3)) - .rounded(px(8.)) - .p(px(1.)) - .children( - [ - (FrameTheme::Light, "Light"), - (FrameTheme::Dark, "Dark"), - ] - .into_iter() - .map(|(value, label)| { + .text_size(px(11.)) + .text_color(theme.gray(10)) + .child(description), + ), + ) + .when(selected, |row| { + row.child( + svg() + .path("icons/circle-check.svg") + .size(px(16.)) + .flex_shrink_0() + .text_color(theme.blue_9), + ) + }) + .on_click(cx.listener(move |this, _, window, cx| { + this.change_frame(FrameChange::Style(value), window, cx) + })) + }, + ), + )) + .when(style != FrameStyle::None, |panel| { + panel.child( + div() + .flex() + .flex_col() + .gap(px(12.)) + .p(px(12.)) + .border_t_1() + .border_color(theme.gray(3)) + .child( + div() + .flex() + .items_center() + .justify_between() + .gap(px(12.)) + .child( + div() + .text_size(px(12.)) + .font_weight(FontWeight::MEDIUM) + .text_color(theme.gray(11)) + .child("Theme"), + ) + .child( + div() + .flex() + .w(px(160.)) + .h(px(32.)) + .border_1() + .border_color(theme.gray(3)) + .rounded(px(8.)) + .p(px(1.)) + .children( + [ + (FrameTheme::Light, "Light"), + (FrameTheme::Dark, "Dark"), + ] + .into_iter() + .map( + |(value, label)| { div() .id(( "frame-theme", @@ -452,40 +586,41 @@ impl EditorWindow { ) }, )) - }), + }, ), + ), + ), + ) + .children(FrameField::for_style(style).and_then(|field| { + let input = &self.frame_controls.fields.as_ref()?[field.index()]; + Some( + div() + .flex() + .items_center() + .justify_between() + .gap(px(12.)) + .child( + div() + .text_size(px(12.)) + .font_weight(FontWeight::MEDIUM) + .text_color(theme.gray(11)) + .child(field.label()), + ) + .child( + ui::TextInput::plain( + &theme, + ("frame-text", field.index()), + input, + ) + .width(px(160.)) + .height(px(32.)) + .radius(px(8.)) + .bg(theme.gray(2)), ), ) - .children(FrameField::for_style(style).and_then(|field| { - let input = &self.frame_controls.fields.as_ref()?[field.index()]; - Some( - div() - .flex() - .items_center() - .justify_between() - .gap(px(12.)) - .child( - div() - .text_size(px(12.)) - .font_weight(FontWeight::MEDIUM) - .text_color(theme.gray(11)) - .child(field.label()), - ) - .child( - ui::TextInput::plain( - &theme, - ("frame-text", field.index()), - input, - ) - .width(px(160.)) - .height(px(32.)) - .radius(px(8.)) - .bg(theme.gray(2)), - ), - ) - })), - ) - }); + })), + ) + }); Some( div() .absolute() @@ -624,3 +759,57 @@ mod tests { ); } } + +#[cfg(test)] +mod style_image_tests { + use super::*; + + #[test] + fn style_image_frame_opt_in_preserves_globals_and_rejects_stale_target() { + let mut project: ProjectConfiguration = serde_json::from_value(serde_json::json!({"timeline":{"zoomSegments":[],"segments":[],"styleSegments":[{"start":1,"end":5,"name":"A"},{"start":7,"end":9,"name":"B"}]}})).unwrap(); + let base = project.background.clone(); + let target = StyleFrameTarget::capture(&project, 0).unwrap(); + assert!(apply_targeted_frame_change( + &mut project, + Some(&target), + FrameChange::Style(FrameStyle::Browser) + )); + assert_eq!( + serde_json::to_value(&project.background).unwrap(), + serde_json::to_value(&base).unwrap() + ); + let target = StyleFrameTarget::capture(&project, 0).unwrap(); + assert!(apply_targeted_frame_change( + &mut project, + Some(&target), + FrameChange::Text(FrameField::Url, "example.com".into()) + )); + let target = StyleFrameTarget::capture(&project, 0).unwrap(); + project.timeline.as_mut().unwrap().style_segments.swap(0, 1); + assert!(!apply_targeted_frame_change( + &mut project, + Some(&target), + FrameChange::Style(FrameStyle::Windows) + )); + assert!( + project.timeline.as_ref().unwrap().style_segments[0] + .overrides + .background + .is_none() + ); + assert_eq!( + serde_json::to_value(&project.background).unwrap(), + serde_json::to_value(&base).unwrap() + ); + let frame = project.timeline.as_ref().unwrap().style_segments[1] + .overrides + .background + .as_ref() + .unwrap() + .frame + .as_ref() + .unwrap(); + assert_eq!(frame.style, FrameStyle::Browser); + assert_eq!(frame.url, "example.com"); + } +} diff --git a/apps/desktop-gpui/src/feeds.rs b/apps/desktop-gpui/src/feeds.rs index 3822268c796..661b6445a68 100644 --- a/apps/desktop-gpui/src/feeds.rs +++ b/apps/desktop-gpui/src/feeds.rs @@ -80,6 +80,30 @@ async fn attach_camera_preview_sender( result.map_err(|error| error.to_string()) } +#[cfg(any(target_os = "macos", test))] +async fn await_current_input_consent( + permission: impl std::future::Future>, + current_epoch: &AtomicU64, + epoch: u64, +) -> Result<(), String> { + tokio::pin!(permission); + let mut changed = tokio::time::interval(Duration::from_millis(50)); + loop { + if current_epoch.load(Ordering::Acquire) != epoch { + return Err("Device selection changed before permission was granted".into()); + } + tokio::select! { + _ = changed.tick() => {}, + result = &mut permission => { + if current_epoch.load(Ordering::Acquire) != epoch { + return Err("Device selection changed before permission was granted".into()); + } + return result; + } + } + } +} + async fn camera_input_operation( gate: &tokio::sync::Mutex<()>, current_epoch: &AtomicU64, @@ -933,6 +957,15 @@ impl Feeds { let current_epoch = self.camera_input_epoch.clone(); let readiness_epoch = current_epoch.clone(); let set = gpui_tokio::Tokio::spawn(cx, async move { + #[cfg(target_os = "macos")] + await_current_input_consent( + crate::permissions::ensure_capture_media_permission( + crate::permissions::OSPermission::Camera, + ), + ¤t_epoch, + epoch, + ) + .await?; let ready = camera_input_operation(&gate, ¤t_epoch, epoch, async { let sender = sender .as_ref() @@ -1050,6 +1083,17 @@ impl Feeds { let current_epoch = self.mic_input_epoch.clone(); let readiness_epoch = current_epoch.clone(); let task = gpui_tokio::Tokio::spawn(cx, async move { + #[cfg(target_os = "macos")] + if label.is_some() { + await_current_input_consent( + crate::permissions::ensure_capture_media_permission( + crate::permissions::OSPermission::Microphone, + ), + ¤t_epoch, + epoch, + ) + .await?; + } let ready = camera_input_operation(&gate, ¤t_epoch, epoch, async { if let Some(label) = label { actor @@ -1841,3 +1885,88 @@ mod tests { assert_eq!(recapped, (16, 8)); } } + +#[cfg(test)] +mod capture_consent_tests { + use super::*; + + #[tokio::test] + async fn stale_selection_never_starts_a_permission_request() { + let epoch = AtomicU64::new(2); + let result = await_current_input_consent( + async { + panic!("An obsolete selection must not request permission"); + }, + &epoch, + 1, + ) + .await; + assert!(result.unwrap_err().contains("selection changed")); + } + + #[tokio::test] + async fn disabled_selection_cancels_while_consent_is_unanswered() { + let epoch = AtomicU64::new(1); + let (send, receive) = tokio::sync::oneshot::channel::<()>(); + let wait = await_current_input_consent( + async { receive.await.map_err(|error| error.to_string()) }, + &epoch, + 1, + ); + tokio::pin!(wait); + assert!(futures_util::poll!(&mut wait).is_pending()); + epoch.store(2, Ordering::Release); + let result = tokio::time::timeout(Duration::from_millis(250), &mut wait) + .await + .unwrap(); + assert!(result.unwrap_err().contains("selection changed")); + assert!(send.send(()).is_err()); + } + + #[tokio::test] + async fn grant_arriving_with_reselection_cannot_revive_old_device() { + let epoch = AtomicU64::new(1); + let result = await_current_input_consent( + async { + epoch.store(2, Ordering::Release); + Ok(()) + }, + &epoch, + 1, + ) + .await; + assert!(result.unwrap_err().contains("selection changed")); + assert!( + await_current_input_consent(async { Ok(()) }, &epoch, 2) + .await + .is_ok() + ); + } + + #[tokio::test] + async fn consent_wait_leaves_device_operations_unlocked() { + let epoch = AtomicU64::new(1); + let gate = tokio::sync::Mutex::new(()); + let (send, receive) = tokio::sync::oneshot::channel::<()>(); + let configure = async { + await_current_input_consent( + async { receive.await.map_err(|error| error.to_string()) }, + &epoch, + 1, + ) + .await?; + camera_input_operation(&gate, &epoch, 1, async { Ok(()) }).await + }; + tokio::pin!(configure); + assert!(futures_util::poll!(&mut configure).is_pending()); + let remove = tokio::time::timeout( + Duration::from_millis(50), + camera_input_operation(&gate, &epoch, 1, async { Ok(()) }), + ) + .await + .unwrap(); + assert_eq!(remove.unwrap(), Some(())); + send.send(()).unwrap(); + assert_eq!(configure.await.unwrap(), Some(())); + } +} diff --git a/apps/desktop-gpui/src/import.rs b/apps/desktop-gpui/src/import.rs index 37367522552..46c37db8302 100644 --- a/apps/desktop-gpui/src/import.rs +++ b/apps/desktop-gpui/src/import.rs @@ -35,6 +35,7 @@ const MEDIA_IMPORT_EXTENSIONS: &[&str] = &[ "mp4", "mov", "avi", "mkv", "webm", "wmv", "m4v", "flv", "png", "jpg", "jpeg", "webp", "gif", "bmp", "tif", "tiff", ]; +pub(crate) const OVERLAY_IMAGE_EXTENSIONS: &[&str] = &["png", "jpg", "jpeg", "webp", "gif", "bmp"]; const MAX_IMAGE_DIMENSION: u32 = 16_384; static ACTIVE_IMPORT_WORKERS: AtomicUsize = AtomicUsize::new(0); @@ -291,7 +292,8 @@ pub fn pick_and_import_video(cx: &mut App) { cx.spawn(async move |cx| { // Blocking modal, so from a spawned task with no borrow held -- the // `save_file_panel` rule. - let Some(path) = pick_import_file(&[("Video Files", VIDEO_IMPORT_EXTENSIONS)]) else { + let Some(path) = pick_import_file(&[("Video Files", VIDEO_IMPORT_EXTENSIONS)], cx).await + else { return; }; cx.update(|cx| import_video_from_path(path, cx)); @@ -301,7 +303,8 @@ pub fn pick_and_import_video(cx: &mut App) { pub fn pick_and_import_image(cx: &mut App) { cx.spawn(async move |cx| { - let Some(path) = pick_import_file(&[("Image Files", IMAGE_IMPORT_EXTENSIONS)]) else { + let Some(path) = pick_import_file(&[("Image Files", IMAGE_IMPORT_EXTENSIONS)], cx).await + else { return; }; cx.update(|cx| import_image_from_path(path, cx)); @@ -313,11 +316,16 @@ pub fn pick_and_import_image(cx: &mut App) { /// extension (`src-tauri/src/tray.rs:839-911`). pub fn pick_and_import_media(cx: &mut App) { cx.spawn(async move |cx| { - let Some(path) = pick_import_file(&[ - ("Media Files", MEDIA_IMPORT_EXTENSIONS), - ("Video Files", VIDEO_IMPORT_EXTENSIONS), - ("Image Files", IMAGE_IMPORT_EXTENSIONS), - ]) else { + let Some(path) = pick_import_file( + &[ + ("Media Files", MEDIA_IMPORT_EXTENSIONS), + ("Video Files", VIDEO_IMPORT_EXTENSIONS), + ("Image Files", IMAGE_IMPORT_EXTENSIONS), + ], + cx, + ) + .await + else { return; }; if is_supported_video_import_path(&path) { @@ -383,7 +391,10 @@ fn spawn_import(cx: &mut App, work: impl FnOnce(flume::Sender) + /// `NSOpenPanel` through the platform helper on macOS (the generic file panel /// behind `open_image_panel`), rfd elsewhere -- the same split the delete /// confirms use. -fn pick_import_file(filters: &[(&str, &[&str])]) -> Option { +pub(crate) async fn pick_import_file( + filters: &[(&str, &[&str])], + _cx: &mut gpui::AsyncApp, +) -> Option { #[cfg(target_os = "macos")] { let extensions: Vec<&str> = filters @@ -392,7 +403,11 @@ fn pick_import_file(filters: &[(&str, &[&str])]) -> Option { .collect(); crate::platform::open_image_panel(&extensions) } - #[cfg(not(target_os = "macos"))] + #[cfg(target_os = "linux")] + { + crate::platform::open_file_panel_from_app_async(filters, _cx).await + } + #[cfg(not(any(target_os = "macos", target_os = "linux")))] { let mut dialog = rfd::FileDialog::new(); for (name, extensions) in filters { @@ -2066,3 +2081,191 @@ mod tests { assert_ne!(converted.data(0).as_ptr(), original.data(0).as_ptr()); } } + +pub(crate) struct ImportedEditorImage { + pub path: String, + pub name: String, + pub width: u32, + pub height: u32, +} + +pub(crate) fn import_editor_image( + project_path: &Path, + source: &Path, +) -> Result { + use image::ImageDecoder; + use std::{ + hash::BuildHasher, + io::{Read, Write}, + }; + const MAX_BYTES: u64 = 64 * 1024 * 1024; + if !project_path.is_dir() || !has_supported_extension(source, OVERLAY_IMAGE_EXTENSIONS) { + return Err("Choose a PNG, JPEG, WebP, GIF or BMP image for this project".into()); + } + let file = std::fs::File::open(source).map_err(|error| error.to_string())?; + let metadata = file.metadata().map_err(|error| error.to_string())?; + if !metadata.is_file() || metadata.len() > MAX_BYTES { + return Err("Image files must be 64 MiB or smaller".into()); + } + let mut encoded = Vec::new(); + file.take(MAX_BYTES + 1) + .read_to_end(&mut encoded) + .map_err(|error| error.to_string())?; + if encoded.len() as u64 > MAX_BYTES { + return Err("Image files must be 64 MiB or smaller".into()); + } + let mut reader = image::ImageReader::new(std::io::Cursor::new(&encoded)) + .with_guessed_format() + .map_err(|error| error.to_string())?; + let extension = match reader.format() { + Some(image::ImageFormat::Png) => "png", + Some(image::ImageFormat::Jpeg) => "jpg", + Some(image::ImageFormat::WebP) => "webp", + Some(image::ImageFormat::Gif) => "gif", + Some(image::ImageFormat::Bmp) => "bmp", + _ => return Err("Choose a PNG, JPEG, WebP, GIF or BMP image".into()), + }; + let mut limits = image::Limits::default(); + limits.max_alloc = Some(128 * 1024 * 1024); + limits.max_image_width = Some(32_768); + limits.max_image_height = Some(32_768); + reader.limits(limits); + let mut decoder = reader.into_decoder().map_err(|error| { + format!("Cannot decode image (maximum 32,768 pixels per side): {error}") + })?; + let (source_width, source_height) = decoder.dimensions(); + if source_width == 0 + || source_height == 0 + || u64::from(source_width) * u64::from(source_height) > 16_777_216 + || decoder.total_bytes() > 128 * 1024 * 1024 + { + return Err("Images must have at most 16,777,216 pixels (32,768 per side) and decode to at most 128 MiB".into()); + } + let orientation = decoder.orientation().map_err(|error| error.to_string())?; + let mut decoded = image::DynamicImage::from_decoder(decoder) + .map_err(|error| format!("Cannot decode image: {error}"))?; + decoded.apply_orientation(orientation); + let (width, height) = (decoded.width(), decoded.height()); + drop(decoded); + let mut bytes = [0u8; 16]; + for chunk in bytes.chunks_exact_mut(8) { + chunk.copy_from_slice( + &std::collections::hash_map::RandomState::new() + .hash_one(source) + .to_be_bytes(), + ); + } + bytes[6] = (bytes[6] & 0x0f) | 0x40; + bytes[8] = (bytes[8] & 0x3f) | 0x80; + let hex: String = bytes.iter().map(|byte| format!("{byte:02x}")).collect(); + let id = format!( + "{}-{}-{}-{}-{}", + &hex[..8], + &hex[8..12], + &hex[12..16], + &hex[16..20], + &hex[20..] + ); + let relative = format!("content/images/{id}.{extension}"); + let destination = project_path.join(&relative); + std::fs::create_dir_all(project_path.join("content/images")) + .map_err(|error| error.to_string())?; + let mut destination_file = std::fs::OpenOptions::new() + .write(true) + .create_new(true) + .open(&destination) + .map_err(|error| error.to_string())?; + if let Err(error) = destination_file + .write_all(&encoded) + .and_then(|()| destination_file.sync_all()) + { + drop(destination_file); + let _ = std::fs::remove_file(&destination); + return Err(format!("Failed to save image: {error}")); + } + Ok(ImportedEditorImage { + path: relative, + name: source + .file_stem() + .and_then(|name| name.to_str()) + .unwrap_or("Image") + .to_string(), + width, + height, + }) +} + +#[cfg(test)] +mod style_image_tests { + use super::*; + + #[test] + fn style_image_import_rotates_exif_copies_source_and_keeps_relative_unique_assets() { + let dir = std::env::temp_dir().join(format!( + "cap-overlay-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + std::fs::create_dir_all(&dir).unwrap(); + let source = dir.join("source.jpg"); + image::RgbImage::from_pixel(3, 2, image::Rgb([255, 30, 20])) + .save(&source) + .unwrap(); + let original = std::fs::read(&source).unwrap(); + let exif = b"Exif\0\0MM\0*\0\0\0\x08\0\x01\x01\x12\0\x03\0\0\0\x01\0\x06\0\0\0\0\0\0"; + let mut oriented = original[..2].to_vec(); + oriented.extend_from_slice(&[0xff, 0xe1]); + oriented.extend_from_slice(&((exif.len() + 2) as u16).to_be_bytes()); + oriented.extend_from_slice(exif); + oriented.extend_from_slice(&original[2..]); + std::fs::write(&source, &oriented).unwrap(); + let first = import_editor_image(&dir, &source).unwrap(); + let second = import_editor_image(&dir, &source).unwrap(); + assert_ne!(first.path, second.path); + assert!(first.path.starts_with("content/images/")); + assert!(!Path::new(&first.path).is_absolute()); + assert_eq!((first.width, first.height), (2, 3)); + assert_eq!( + image::image_dimensions(dir.join(&first.path)).unwrap(), + (3, 2) + ); + assert_eq!(std::fs::read(dir.join(&first.path)).unwrap(), oriented); + assert_eq!(std::fs::read(&source).unwrap(), oriented); + for extension in ["gif", "bmp"] { + let source = dir.join(format!("source.{extension}")); + image::RgbaImage::from_pixel(3, 2, image::Rgba([200, 50, 80, 255])) + .save(&source) + .unwrap(); + let imported = import_editor_image(&dir, &source).unwrap(); + assert_eq!((imported.width, imported.height), (3, 2)); + assert_eq!( + std::fs::read(dir.join(imported.path)).unwrap(), + std::fs::read(source).unwrap() + ); + } + let invalid = dir.join("invalid.png"); + std::fs::write(&invalid, b"not an image").unwrap(); + assert!(import_editor_image(&dir, &invalid).is_err()); + let large = dir.join("large.png"); + std::fs::File::create(&large) + .unwrap() + .set_len(64 * 1024 * 1024 + 1) + .unwrap(); + assert!( + import_editor_image(&dir, &large) + .err() + .unwrap() + .contains("64 MiB") + ); + assert_eq!( + std::fs::read_dir(dir.join("content/images")) + .unwrap() + .count(), + 4 + ); + std::fs::remove_dir_all(dir).unwrap(); + } +} diff --git a/apps/desktop-gpui/src/main.rs b/apps/desktop-gpui/src/main.rs index 84838963eb3..4876bd4120e 100644 --- a/apps/desktop-gpui/src/main.rs +++ b/apps/desktop-gpui/src/main.rs @@ -27,6 +27,8 @@ mod editor_color; mod editor_crop; mod editor_edits; mod editor_export; +#[cfg(target_os = "linux")] +mod editor_modal; mod editor_panels; mod editor_sidebar; mod editor_tabs; @@ -142,27 +144,18 @@ fn init_logging() -> Option { .unwrap_or_else(|_| "cap_gpui=info".into()) }; - let logs_dir = diagnostics::logs_dir(); - let file = match std::fs::create_dir_all(&logs_dir) { - Ok(()) => { - let (writer, guard) = tracing_appender::non_blocking(tracing_appender::rolling::daily( - &logs_dir, - diagnostics::LOG_FILE_PREFIX, - )); - Some(( + let file = create_log_appender(&diagnostics::logs_dir(), diagnostics::LOG_FILE_PREFIX).map( + |appender| { + let (writer, guard) = tracing_appender::non_blocking(appender); + ( tracing_subscriber::fmt::layer() .with_ansi(false) .with_writer(writer) .with_filter(filter()), guard, - )) - } - Err(error) => { - // A log file is a nice-to-have; losing it must never stop the app. - eprintln!("failed to create the logs directory {logs_dir:?}: {error}"); - None - } - }; + ) + }, + ); let (file_layer, guard) = match file { Some((layer, guard)) => (Some(layer), Some(guard)), None => (None, None), @@ -175,6 +168,29 @@ fn init_logging() -> Option { guard } +fn create_log_appender( + directory: &std::path::Path, + prefix: &str, +) -> Option { + use std::io::Write; + + match tracing_appender::rolling::RollingFileAppender::builder() + .rotation(tracing_appender::rolling::Rotation::DAILY) + .filename_prefix(prefix) + .build(directory) + { + Ok(appender) => Some(appender), + Err(error) => { + let _ = writeln!( + std::io::stderr(), + "Could not open {prefix} in {}: {error}; console logging remains enabled", + directory.display() + ); + None + } + } +} + fn main() { #[cfg(target_os = "linux")] if let Some(threads) = cap_utils::linux_runtime::llvmpipe_thread_count() { @@ -209,6 +225,12 @@ fn main() { // registered on the builder -- gpui exposes it nowhere else -- with the // handler guarding against firing before the window registry exists. app.on_reopen(crate::app_windows::handle_dock_reopen); + #[cfg(target_os = "macos")] + app.on_open_urls(|urls| { + for url in urls { + crate::deeplink::submit_deep_link(&url); + } + }); app.run(|cx: &mut App| { gpui_tokio::init(cx); // The dock icon: an unbundled dev binary shows the generic terminal @@ -398,6 +420,9 @@ fn main() { platform::apply_panel_behavior( window, platform::PanelBehavior { + #[cfg(target_os = "macos")] + level: objc2_app_kit::NSFloatingWindowLevel, + #[cfg(not(target_os = "macos"))] level: platform::MAIN_WINDOW_LEVEL, join_all_spaces: true, shadow: true, @@ -617,3 +642,71 @@ fn main() { } }); } + +#[cfg(test)] +mod logging_tests { + use super::create_log_appender; + use std::{io::Write, path::PathBuf}; + + struct LogDirectory(PathBuf); + + impl LogDirectory { + fn new() -> Self { + let nonce = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos(); + let directory = std::env::temp_dir() + .join(format!("cap-gpui-logging-{}-{nonce}", std::process::id())); + std::fs::create_dir_all(&directory).unwrap(); + Self(directory) + } + } + + impl Drop for LogDirectory { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } + } + + #[test] + fn healthy_log_destination_preserves_existing_records() { + let directory = LogDirectory::new(); + let destination = directory.0.join("nested"); + for record in ["first\n", "second\n"] { + let mut appender = create_log_appender(&destination, "cap.log").unwrap(); + appender.write_all(record.as_bytes()).unwrap(); + appender.flush().unwrap(); + } + let records: String = std::fs::read_dir(destination) + .unwrap() + .map(|entry| std::fs::read_to_string(entry.unwrap().path()).unwrap()) + .collect(); + assert!(records.contains("first\n")); + assert!(records.contains("second\n")); + } + + #[test] + fn unavailable_log_directory_disables_only_file_logging() { + let directory = LogDirectory::new(); + let destination = directory.0.join("blocked"); + std::fs::write(&destination, "existing file").unwrap(); + assert!(create_log_appender(&destination, "cap.log").is_none()); + assert_eq!( + std::fs::read_to_string(destination).unwrap(), + "existing file" + ); + } + + #[test] + fn unavailable_daily_log_file_disables_only_file_logging() { + let directory = LogDirectory::new(); + let today = chrono::Utc::now().date_naive(); + for days in [-1, 0, 1] { + let date = today + chrono::Duration::days(days); + std::fs::create_dir(directory.0.join(format!("cap.log.{date}"))).unwrap(); + } + assert!(create_log_appender(&directory.0, "cap.log").is_none()); + assert!(create_log_appender(&directory.0, "other.log").is_some()); + } +} diff --git a/apps/desktop-gpui/src/main_window.rs b/apps/desktop-gpui/src/main_window.rs index 80ec9cd418b..bf12873621b 100644 --- a/apps/desktop-gpui/src/main_window.rs +++ b/apps/desktop-gpui/src/main_window.rs @@ -347,6 +347,55 @@ pub enum DeviceMenu { Microphone, } +fn input_refresh_is_current( + current_generation: u64, + current_menu: Option, + generation: u64, + menu: DeviceMenu, +) -> bool { + current_generation == generation && current_menu == Some(menu) +} + +fn input_refresh_scan_allowed(current: bool, visible_idle_and_owned: bool) -> Option { + current.then_some(visible_idle_and_owned) +} + +#[cfg(test)] +mod input_refresh_tests { + use super::{DeviceMenu, input_refresh_is_current, input_refresh_scan_allowed}; + + #[test] + fn hidden_or_busy_picker_pauses_and_resumes_without_a_scan() { + assert_eq!(input_refresh_scan_allowed(true, false), Some(false)); + assert_eq!(input_refresh_scan_allowed(true, true), Some(true)); + assert_eq!(input_refresh_scan_allowed(false, true), None); + assert_eq!(input_refresh_scan_allowed(false, false), None); + } + + #[test] + fn late_refresh_cannot_replace_a_reopened_picker() { + assert!(input_refresh_is_current( + 2, + Some(DeviceMenu::Camera), + 2, + DeviceMenu::Camera + )); + assert!(!input_refresh_is_current( + 3, + Some(DeviceMenu::Camera), + 2, + DeviceMenu::Camera + )); + assert!(!input_refresh_is_current(2, None, 2, DeviceMenu::Camera)); + assert!(!input_refresh_is_current( + 2, + Some(DeviceMenu::Microphone), + 2, + DeviceMenu::Camera + )); + } +} + #[derive(Clone, Debug, PartialEq)] enum DeviceFormatTarget { Camera(CameraOption), @@ -676,6 +725,9 @@ pub struct MainWindow { /// The `staleTime: 5_000` re-read of the cheap target list that runs while /// a target panel is open, and nothing else. Dropped by `close_panel`. target_poll_task: Option>, + input_poll_task: Option>, + input_poll_generation: u64, + input_enumeration_gate: devices::InputEnumerationGate, /// `scheduleTargetListPrewarm` (`new-main/index.tsx:1897-1965`). prewarm_task: Option>, } @@ -885,6 +937,9 @@ impl MainWindow { display_thumbnail_task: None, window_thumbnail_task: None, target_poll_task: None, + input_poll_task: None, + input_poll_generation: 0, + input_enumeration_gate: devices::InputEnumerationGate::default(), prewarm_task: None, } } @@ -1238,6 +1293,76 @@ impl MainWindow { })); } + fn start_input_poll(&mut self, menu: DeviceMenu, window: &mut Window, cx: &mut Context) { + let generation = self.input_poll_generation; + let gate = self.input_enumeration_gate.clone(); + self.input_poll_task = Some(cx.spawn_in(window, async move |this, cx| { + loop { + let Ok(allowed) = this.update_in(cx, |this, window, cx| { + let current_menu = match this.panel { + Some(Panel::Device(menu)) => Some(menu), + _ => None, + }; + input_refresh_scan_allowed( + input_refresh_is_current( + this.input_poll_generation, + current_menu, + generation, + menu, + ), + !this.device_restore_suspended && this.target_prewarm_allowed(window, cx), + ) + }) else { + return; + }; + let Some(allowed) = allowed else { + return; + }; + if allowed && let Some(permit) = gate.try_enter() { + let snapshot = cx + .background_executor() + .spawn(async move { + let _permit = permit; + match menu { + DeviceMenu::Camera => devices::InputSnapshot::cameras(), + DeviceMenu::Microphone => devices::InputSnapshot::microphones(), + } + }) + .await; + let Ok(current) = this.update_in(cx, |this, window, cx| { + let current_menu = match this.panel { + Some(Panel::Device(menu)) => Some(menu), + _ => None, + }; + if !input_refresh_is_current( + this.input_poll_generation, + current_menu, + generation, + menu, + ) { + return false; + } + if !this.device_restore_suspended + && this.target_prewarm_allowed(window, cx) + && snapshot.install(&mut this.devices) + { + cx.notify(); + } + true + }) else { + return; + }; + if !current { + return; + } + } + cx.background_executor() + .timer(std::time::Duration::from_secs(2)) + .await; + } + })); + } + fn apply_display_events( &mut self, batch: Vec, @@ -3610,6 +3735,8 @@ impl MainWindow { } fn close_panel(&mut self, cx: &mut Context) { + self.input_poll_task = None; + self.input_poll_generation = self.input_poll_generation.wrapping_add(1); self.device_format_target = None; self.device_formats = None; self.device_format_generation += 1; @@ -3663,6 +3790,8 @@ impl MainWindow { } pub fn open_panel(&mut self, panel: Panel, window: &mut Window, cx: &mut Context) { + self.input_poll_task = None; + self.input_poll_generation = self.input_poll_generation.wrapping_add(1); self.clear_mode_hover(); self.device_format_target = None; self.device_formats = None; @@ -3690,6 +3819,9 @@ impl MainWindow { Panel::Target(kind) => self.start_target_poll(kind, window, cx), _ => self.target_poll_task = None, } + if let Panel::Device(menu) = panel { + self.start_input_poll(menu, window, cx); + } cx.notify(); } @@ -4552,8 +4684,12 @@ impl MainWindow { window: &mut Window, cx: &mut Context, ) { - cx.spawn_in(window, async move |_this, _cx| { - let dest = crate::platform::save_file_panel(&format!("{name}.png"), &["png"]); + cx.spawn_in(window, async move |this, cx| { + let dest = + crate::platform::save_file_panel_async(&format!("{name}.png"), &["png"], cx).await; + if this.update_in(cx, |_, _, _| ()).is_err() { + return; + } let Some(dest) = dest else { return; }; diff --git a/apps/desktop-gpui/src/onboarding_window.rs b/apps/desktop-gpui/src/onboarding_window.rs index 36265a54172..bad5687d395 100644 --- a/apps/desktop-gpui/src/onboarding_window.rs +++ b/apps/desktop-gpui/src/onboarding_window.rs @@ -611,8 +611,6 @@ impl OnboardingWindow { this.pending = None; if permission.required() && !this.state.status(permission).permitted() { this.state.note_request_failed(permission); - this.state.note_settings_opened(permission); - permissions::open_permission_settings(permission); } if this.state.all_shown_granted() { this.poll = None; diff --git a/apps/desktop-gpui/src/permissions.rs b/apps/desktop-gpui/src/permissions.rs index d88f7d20df4..462e2a98700 100644 --- a/apps/desktop-gpui/src/permissions.rs +++ b/apps/desktop-gpui/src/permissions.rs @@ -246,6 +246,53 @@ pub fn request_permission(permission: OSPermission) { let _ = permission; } +#[cfg(any(target_os = "macos", test))] +type PendingMediaRequest = + futures_util::future::Shared>>; + +#[cfg(any(target_os = "macos", test))] +fn shared_media_request( + slot: &std::sync::Mutex>, + request: impl FnOnce() -> Result, +) -> Result { + use futures_util::FutureExt as _; + let mut slot = slot + .lock() + .map_err(|_| "Media permission request state is unavailable".to_string())?; + if let Some(pending) = slot.as_ref() + && pending.clone().now_or_never().is_none() + { + return Ok(pending.clone()); + } + let pending = request()?; + *slot = Some(pending.clone()); + Ok(pending) +} + +#[cfg(any(target_os = "macos", test))] +async fn media_permission_result( + permission: OSPermission, + status: MediaAuthorization, + request: impl std::future::Future>, +) -> Result<(), String> { + match status { + MediaAuthorization::Authorized => Ok(()), + MediaAuthorization::NotDetermined if request.await? => Ok(()), + MediaAuthorization::NotDetermined + | MediaAuthorization::Denied + | MediaAuthorization::Restricted => Err(format!( + "{} access is unavailable. Allow Cap in System Settings > Privacy & Security > {}, then select the device again.", + permission.label(), + permission.label() + )), + } +} + +#[cfg(target_os = "macos")] +pub async fn ensure_capture_media_permission(permission: OSPermission) -> Result<(), String> { + macos::ensure_capture_media_permission(permission).await +} + pub fn open_permission_settings(permission: OSPermission) { #[cfg(target_os = "macos")] { @@ -408,24 +455,58 @@ mod macos { } } - /// The AV request blocks until the user answers the dialog, so it gets a - /// plain thread of its own rather than a gpui background-pool thread. The - /// answer lands in TCC; the caller's 1s poll observes it there. - fn request_media(camera: bool) { - std::thread::spawn(move || { - use cidre::av; - let media = if camera { - av::MediaType::video() - } else { - av::MediaType::audio() - }; - if let Ok(runtime) = tokio::runtime::Builder::new_current_thread() - .enable_all() - .build() - { - let _ = runtime.block_on(av::CaptureDevice::request_access_for_media_type(media)); + static CAMERA_REQUEST: std::sync::Mutex> = + std::sync::Mutex::new(None); + static MICROPHONE_REQUEST: std::sync::Mutex> = + std::sync::Mutex::new(None); + + fn shared_media_request(camera: bool) -> Result { + use futures_util::FutureExt as _; + let slot = if camera { + &CAMERA_REQUEST + } else { + &MICROPHONE_REQUEST + }; + super::shared_media_request(slot, || { + let answer = objc2::rc::autoreleasepool(|_| { + use cidre::av; + let media = if camera { + av::MediaType::video() + } else { + av::MediaType::audio() + }; + let (answer, mut completion) = cidre::blocks::comp1(); + av::CaptureDevice::request_access_for_media_type_ch(media, &mut completion) + .map_err(|error| format!("Could not request media access: {error}"))?; + Ok::<_, String>(answer) + })?; + Ok(async move { Ok(answer.await) }.boxed().shared()) + }) + } + + pub async fn ensure_capture_media_permission(permission: OSPermission) -> Result<(), String> { + let camera = match permission { + OSPermission::Camera => true, + OSPermission::Microphone => false, + _ => { + return Err("Only camera and microphone permissions can configure a device".into()); } - }); + }; + let status = objc2::rc::autoreleasepool(|_| media_authorization(camera)); + super::media_permission_result(permission, status, async move { + let granted = shared_media_request(camera)?.await?; + Ok(granted + && objc2::rc::autoreleasepool(|_| { + media_authorization(camera) == MediaAuthorization::Authorized + })) + }) + .await + } + + fn request_media(camera: bool) { + if let Err(error) = shared_media_request(camera) { + tracing::error!("Media permission request failed: {error}"); + } } } @@ -666,3 +747,111 @@ mod tests { ); } } + +#[cfg(test)] +mod capture_consent_tests { + #[tokio::test] + async fn grant_clicks_and_device_selection_share_one_pending_native_request() { + use futures_util::FutureExt as _; + let slot = std::sync::Mutex::new(None); + let (send, receive) = tokio::sync::oneshot::channel(); + let grant_click = shared_media_request(&slot, || { + Ok( + async move { receive.await.map_err(|error| error.to_string()) } + .boxed() + .shared(), + ) + }) + .unwrap(); + drop(grant_click); + for _ in 0..100 { + drop( + shared_media_request(&slot, || panic!("A pending OS request must be shared")) + .unwrap(), + ); + } + let selection = + shared_media_request(&slot, || panic!("Selection must join Grant request")).unwrap(); + send.send(true).unwrap(); + assert!(selection.await.unwrap()); + let fresh = + shared_media_request(&slot, || Ok(async { Ok(false) }.boxed().shared())).unwrap(); + assert!(!fresh.await.unwrap()); + } + + #[tokio::test] + async fn completed_unpolled_grant_is_not_reused_after_a_new_preflight() { + use futures_util::FutureExt as _; + let slot = std::sync::Mutex::new(None); + let (send, receive) = tokio::sync::oneshot::channel(); + drop( + shared_media_request(&slot, || { + Ok( + async move { receive.await.map_err(|error| error.to_string()) } + .boxed() + .shared(), + ) + }) + .unwrap(), + ); + send.send(true).unwrap(); + let fresh = + shared_media_request(&slot, || Ok(async { Ok(false) }.boxed().shared())).unwrap(); + assert!(!fresh.await.unwrap()); + } + + use super::*; + + #[tokio::test] + async fn granted_or_denied_media_never_waits_for_a_new_prompt() { + for permission in [OSPermission::Camera, OSPermission::Microphone] { + for status in [ + MediaAuthorization::Authorized, + MediaAuthorization::Denied, + MediaAuthorization::Restricted, + ] { + let result = media_permission_result(permission, status, async { + panic!("Resolved authorization must not request consent again"); + }) + .await; + assert_eq!(result.is_ok(), status == MediaAuthorization::Authorized); + if let Err(error) = result { + assert!(error.contains(permission.label())); + assert!(error.contains("System Settings")); + } + } + } + } + + #[tokio::test] + async fn undetermined_media_waits_for_the_actual_answer() { + for permission in [OSPermission::Camera, OSPermission::Microphone] { + let (send, receive) = tokio::sync::oneshot::channel(); + let wait = + media_permission_result(permission, MediaAuthorization::NotDetermined, async { + receive.await.map_err(|error| error.to_string()) + }); + tokio::pin!(wait); + assert!(futures_util::poll!(&mut wait).is_pending()); + send.send(true).unwrap(); + assert!(wait.await.is_ok()); + let denied = + media_permission_result(permission, MediaAuthorization::NotDetermined, async { + Ok(false) + }) + .await; + assert!(denied.unwrap_err().contains(permission.label())); + } + } + + #[tokio::test] + async fn request_failure_is_reported_without_claiming_authorization() { + let result = media_permission_result( + OSPermission::Camera, + MediaAuthorization::NotDetermined, + async { Err("native request failed".into()) }, + ) + .await; + assert_eq!(result.unwrap_err(), "native request failed"); + } +} diff --git a/apps/desktop-gpui/src/permissions_ui.rs b/apps/desktop-gpui/src/permissions_ui.rs index 8bd8639ca08..4703c866f59 100644 --- a/apps/desktop-gpui/src/permissions_ui.rs +++ b/apps/desktop-gpui/src/permissions_ui.rs @@ -159,10 +159,11 @@ impl PermissionsState { /// Whether to show the "grants from System Settings may need a relaunch" /// hint -- the Tauri onboarding's "Restart Required" dialog, inline. - /// Visible from the moment a required permission is routed through - /// System Settings until both required grants have actually landed. + /// Visible after a required request fails or its System Settings pane + /// is opened, until both required grants have actually landed. pub fn relaunch_hint(&self) -> bool { - self.sent_to_settings && !self.necessary_granted() + (self.sent_to_settings || self.attempted.screen || self.attempted.accessibility) + && !self.necessary_granted() } } diff --git a/apps/desktop-gpui/src/platform.rs b/apps/desktop-gpui/src/platform.rs index 186b1fde88b..06e04d71d80 100644 --- a/apps/desktop-gpui/src/platform.rs +++ b/apps/desktop-gpui/src/platform.rs @@ -15,6 +15,443 @@ /// auto-hide reveal, below context menus. pub const MAIN_WINDOW_LEVEL: isize = 100; +#[derive(Debug, Clone, PartialEq, Eq)] +#[cfg(any(target_os = "linux", test))] +pub(crate) enum LinuxFileDialogError { + Busy, + BeforeDispatch(String), + NativeResponse(String), + Indeterminate(String), + RestartRequired, +} + +#[cfg(any(target_os = "linux", test))] +impl LinuxFileDialogError { + pub(crate) fn message(&self) -> String { + match self { + Self::Busy => "A file dialog is already open. Finish or cancel it before trying again.".into(), + Self::BeforeDispatch(error) | Self::NativeResponse(error) => format!("The file dialog could not complete: {error}"), + Self::Indeterminate(_) | Self::RestartRequired => "Cap could not confirm that the file dialog closed. Close any remaining file dialog and restart Cap before trying again.".into(), + } + } +} + +#[cfg(any(target_os = "linux", test))] +type LinuxFileDialogResult = Result, LinuxFileDialogError>; + +#[cfg(any(target_os = "linux", test))] +struct FileDialogPermit<'a> { + active: &'a std::sync::atomic::AtomicU8, + completed: bool, +} + +#[cfg(any(target_os = "linux", test))] +impl<'a> FileDialogPermit<'a> { + fn acquire(active: &'a std::sync::atomic::AtomicU8) -> Result { + match active.compare_exchange( + 0, + 1, + std::sync::atomic::Ordering::AcqRel, + std::sync::atomic::Ordering::Acquire, + ) { + Ok(_) => Ok(Self { + active, + completed: false, + }), + Err(2) => Err(LinuxFileDialogError::RestartRequired), + Err(_) => Err(LinuxFileDialogError::Busy), + } + } + + fn allow_reuse(&mut self) { + self.completed = true; + } +} + +#[cfg(any(target_os = "linux", test))] +impl Drop for FileDialogPermit<'_> { + fn drop(&mut self) { + if self.completed { + let _ = self.active.compare_exchange( + 1, + 0, + std::sync::atomic::Ordering::AcqRel, + std::sync::atomic::Ordering::Acquire, + ); + } else { + self.active.store(2, std::sync::atomic::Ordering::Release); + } + } +} + +#[cfg(any(target_os = "linux", test))] +async fn complete_file_dialog( + mut permit: FileDialogPermit<'_>, + dialog: impl std::future::Future, + sender: flume::Sender, +) { + let result = dialog.await; + if !matches!(result, Err(LinuxFileDialogError::Indeterminate(_))) { + permit.allow_reuse(); + } + drop(permit); + let _ = sender.send(result); +} + +#[cfg(target_os = "linux")] +enum LinuxFileDialogParent { + Wayland(wayland_client::protocol::wl_surface::WlSurface), + X11(std::os::raw::c_ulong), +} + +#[cfg(target_os = "linux")] +impl LinuxFileDialogParent { + fn from_window(window: &gpui::Window) -> Option { + if let Some(surface) = window.wayland_surface() { + return Some(Self::Wayland(surface)); + } + use raw_window_handle::{HasWindowHandle, RawWindowHandle}; + match HasWindowHandle::window_handle(window).ok()?.as_raw() { + RawWindowHandle::Xlib(handle) => Some(Self::X11(handle.window)), + RawWindowHandle::Xcb(handle) => Some(Self::X11(handle.window.get().into())), + _ => None, + } + } + + async fn identifier(self) -> Option { + match self { + Self::Wayland(surface) => { + use wayland_client::Proxy; + if !surface.is_alive() { + return None; + } + ashpd::WindowIdentifier::from_wayland(&surface).await + } + Self::X11(id) => Some(ashpd::WindowIdentifier::from_xid(id)), + } + } +} + +#[cfg(target_os = "linux")] +async fn run_linux_file_dialog( + cx: &mut gpui::AsyncApp, + owner: Option<(gpui::AnyWindowHandle, gpui::EntityId)>, + parent: Option, + dialog: F, +) -> LinuxFileDialogResult +where + F: FnOnce(Option) -> Fut + Send + 'static, + Fut: std::future::Future + Send + 'static, +{ + static ACTIVE: std::sync::atomic::AtomicU8 = std::sync::atomic::AtomicU8::new(0); + let permit = FileDialogPermit::acquire(&ACTIVE)?; + let (ready_sender, ready_receiver) = flume::bounded(1); + let (approval_sender, approval_receiver) = flume::bounded(1); + let (sender, receiver) = flume::bounded(1); + // ashpd does not close dispatched requests on drop; retain the worker and + // admission through a native response, or disable reuse when closure is unknown. + gpui_tokio::Tokio::spawn( + cx, + complete_file_dialog( + permit, + async move { + use futures_util::{FutureExt, StreamExt}; + let connection = ashpd::zbus::Connection::session() + .await + .map_err(|error| LinuxFileDialogError::BeforeDispatch(error.to_string()))?; + let proxy = ashpd::zbus::fdo::DBusProxy::new(&connection) + .await + .map_err(|error| LinuxFileDialogError::BeforeDispatch(error.to_string()))?; + let mut owner_changes = proxy + .receive_name_owner_changed_with_args(&[(0, "org.freedesktop.portal.Desktop")]) + .await + .map_err(|error| LinuxFileDialogError::BeforeDispatch(error.to_string()))?; + let identifier = match parent { + Some(parent) => parent.identifier().await, + None => None, + }; + if ready_sender.send(()).is_err() + || approval_receiver.recv_async().await != Ok(true) + { + return Ok(None); + } + while let Some(change) = owner_changes.next().now_or_never() { + let Some(change) = change else { + return Err(LinuxFileDialogError::BeforeDispatch( + "The desktop portal connection closed.".into(), + )); + }; + match change.args() { + Ok(args) if args.old_owner().is_some() => { + return Err(LinuxFileDialogError::BeforeDispatch( + "The desktop portal restarted before the dialog could open.".into(), + )); + } + Ok(_) => {} + Err(error) => { + return Err(LinuxFileDialogError::BeforeDispatch(error.to_string())); + } + } + } + let owner_lost = async { + loop { + let Some(change) = owner_changes.next().await else { + return "The desktop portal connection closed.".to_owned(); + }; + match change.args() { + Ok(args) if args.old_owner().is_some() => { + return "The desktop portal restarted while the dialog was open." + .to_owned(); + } + Ok(_) => {} + Err(error) => return error.to_string(), + } + } + }; + tokio::select! { + biased; + result = dialog(identifier) => result, + error = owner_lost => Err(LinuxFileDialogError::Indeterminate(error)), + } + }, + sender, + ), + ) + .detach(); + if ready_receiver.recv_async().await.is_ok() { + let owner_exists = owner.is_none_or(|(owner, expected_root)| { + cx.update(|cx| { + owner + .update(cx, |root, _, _| root.entity_id() == expected_root) + .unwrap_or(false) + }) + }); + let _ = approval_sender.send(owner_exists); + } + match receiver.recv_async().await { + Ok(result) => result, + Err(error) => { + ACTIVE.store(2, std::sync::atomic::Ordering::Release); + Err(LinuxFileDialogError::Indeterminate(error.to_string())) + } + } +} + +#[cfg(target_os = "linux")] +fn portal_file_response( + request: ashpd::desktop::Request, +) -> LinuxFileDialogResult { + match request.response() { + Ok(response) => response + .uris() + .first() + .map(|uri| { + uri.to_file_path().map_err(|()| { + LinuxFileDialogError::NativeResponse( + "The selected location is not a local file.".into(), + ) + }) + }) + .transpose(), + Err(ashpd::Error::Response(ashpd::desktop::ResponseError::Cancelled)) => Ok(None), + Err(error) => Err(LinuxFileDialogError::NativeResponse(error.to_string())), + } +} + +#[cfg(target_os = "linux")] +fn portal_filters(filters: &[(&str, &[&str])]) -> Vec { + filters + .iter() + .map(|(name, extensions)| { + extensions.iter().fold( + ashpd::desktop::file_chooser::FileFilter::new(*name), + |filter, extension| { + if extension.is_empty() || *extension == "*" { + filter.glob("*") + } else { + filter.glob(&format!("*.{extension}")) + } + }, + ) + }) + .collect() +} + +#[cfg(target_os = "linux")] +async fn linux_open_file_panel( + filters: &[(&str, &[&str])], + directory: Option, + owner: Option<(gpui::AnyWindowHandle, gpui::EntityId)>, + parent: Option, + cx: &mut gpui::AsyncApp, +) -> LinuxFileDialogResult { + let filters = portal_filters(filters); + run_linux_file_dialog(cx, owner, parent, move |identifier| async move { + let request = ashpd::desktop::file_chooser::OpenFileRequest::default() + .identifier(identifier) + .multiple(false) + .filters(filters) + .current_folder::<&std::path::PathBuf>(directory.as_ref()) + .map_err(|error| LinuxFileDialogError::BeforeDispatch(error.to_string()))? + .send() + .await + .map_err(|error| LinuxFileDialogError::Indeterminate(error.to_string()))?; + portal_file_response(request) + }) + .await +} + +#[cfg(target_os = "linux")] +async fn linux_save_file_panel( + suggested: &str, + extensions: &[&str], + owner: Option<(gpui::AnyWindowHandle, gpui::EntityId)>, + parent: Option, + cx: &mut gpui::AsyncApp, +) -> LinuxFileDialogResult { + let name = suggested.to_owned(); + let filters = if extensions.is_empty() { + Vec::new() + } else { + portal_filters(&[("Export", extensions)]) + }; + run_linux_file_dialog(cx, owner, parent, move |identifier| async move { + let request = ashpd::desktop::file_chooser::SaveFileRequest::default() + .identifier(identifier) + .current_name(name.as_str()) + .filters(filters) + .send() + .await + .map_err(|error| LinuxFileDialogError::Indeterminate(error.to_string()))?; + portal_file_response(request) + }) + .await +} + +#[cfg(target_os = "linux")] +async fn finish_linux_file_dialog( + result: LinuxFileDialogResult, + owner: Option<(gpui::AnyWindowHandle, gpui::EntityId)>, + cx: &mut gpui::AsyncApp, +) -> Option { + match result { + Ok(path) => path.filter(|_| { + owner.is_none_or(|(owner, expected_root)| { + cx.update(|cx| { + owner + .update(cx, |root, _, _| root.entity_id() == expected_root) + .unwrap_or(false) + }) + }) + }), + Err(error) => { + tracing::error!(?error, "native file dialog failed"); + let message = error.message(); + let response = cx.update(|cx| { + let (owner, expected_root) = owner?; + owner + .update(cx, |root, window, cx| { + if root.entity_id() != expected_root { + return None; + } + Some(crate::editor_modal::informational_alert( + "File dialog unavailable", + &message, + window, + cx, + )) + }) + .ok() + .flatten() + }); + if let Some(response) = response { + let _ = response.await; + } + None + } + } +} + +#[cfg(target_os = "linux")] +pub(crate) async fn open_file_panel_async( + filters: &[(&str, &[&str])], + directory: Option, + cx: &mut gpui::AsyncWindowContext, +) -> Option { + let (owner, parent) = cx + .update_root(|root, window, _| { + ( + (window.window_handle(), root.entity_id()), + LinuxFileDialogParent::from_window(window), + ) + }) + .ok()?; + let result = linux_open_file_panel(filters, directory, Some(owner), parent, cx).await; + finish_linux_file_dialog(result, Some(owner), cx).await +} + +#[cfg(target_os = "linux")] +pub(crate) async fn open_file_panel_from_app_async( + filters: &[(&str, &[&str])], + cx: &mut gpui::AsyncApp, +) -> Option { + let captured = cx.update(|cx| { + let handle = if let Some(handle) = cx.active_window() { + handle + } else { + if !cx.has_global::() + || crate::session::RecordingSession::global(cx).read(cx).phase + != crate::session::Phase::Idle + { + return None; + } + let windows = cx.global::(); + if windows.main_hidden_for_picker || !windows.overlays.is_empty() { + return None; + } + let main = windows.main; + main.update(cx, |_, _, _| ()).ok()?; + crate::app_windows::show_main_window(cx); + main.into() + }; + handle + .update(cx, |root, window, _| { + ( + (window.window_handle(), root.entity_id()), + LinuxFileDialogParent::from_window(window), + ) + }) + .ok() + }); + let (owner, parent) = captured?; + let result = linux_open_file_panel(filters, None, Some(owner), parent, cx).await; + finish_linux_file_dialog(result, Some(owner), cx).await +} + +pub(crate) async fn save_file_panel_async( + suggested: &str, + extensions: &[&str], + _cx: &mut gpui::AsyncWindowContext, +) -> Option { + #[cfg(target_os = "linux")] + { + let (owner, parent) = _cx + .update_root(|root, window, _| { + ( + (window.window_handle(), root.entity_id()), + LinuxFileDialogParent::from_window(window), + ) + }) + .ok()?; + let result = linux_save_file_panel(suggested, extensions, Some(owner), parent, _cx).await; + finish_linux_file_dialog(result, Some(owner), _cx).await + } + #[cfg(not(target_os = "linux"))] + { + save_file_panel(suggested, extensions) + } +} + #[cfg(any(target_os = "macos", test))] struct DockActivationTiming { last_show: std::time::Instant, @@ -228,13 +665,30 @@ mod mac { } pub fn apply_window_theme(window: &Window, appearance: ForcedAppearance) { + if let Some(native) = native_window(window) { + apply_native_window_theme(&native, appearance); + } + } + + pub fn apply_window_theme_deferred( + window: &Window, + appearance: ForcedAppearance, + executor: &gpui::ForegroundExecutor, + ) { + let Some(native) = native_window(window) else { + return; + }; + // AppKit synchronously invokes GPUI's appearance callback from setAppearance. + executor + .spawn(async move { apply_native_window_theme(&native, appearance) }) + .detach(); + } + + fn apply_native_window_theme(native: &NativeWindow, appearance: ForcedAppearance) { use objc2_app_kit::{ NSAppearance, NSAppearanceCustomization, NSAppearanceNameAqua, NSAppearanceNameDarkAqua, }; - let Some(ns) = ns_window(window) else { - return; - }; let named = match appearance { ForcedAppearance::System => None, ForcedAppearance::Light => { @@ -245,7 +699,7 @@ mod mac { } }; unsafe { - NSAppearanceCustomization::setAppearance(&*ns, named.as_deref()); + NSAppearanceCustomization::setAppearance(&*native.0, named.as_deref()); } } @@ -642,7 +1096,10 @@ mod mac { apply_fullscreen_overlay_behavior(ns_window); // `.shadow(false)` in the Tauri builder. ns_window.setHasShadow(false); - unsafe { ns_window.orderFrontRegardless() }; + unsafe { + ns_window.setAnimationBehavior(objc2_app_kit::NSWindowAnimationBehavior::None); + ns_window.orderFrontRegardless(); + } } /// The window's raw AppKit frame (bottom-left origin). The dev-restore @@ -1934,14 +2391,6 @@ mod stub { pub fn install_url_scheme_handler() {} - pub fn save_file_panel(suggested: &str, extensions: &[&str]) -> Option { - let mut dialog = rfd::FileDialog::new().set_file_name(suggested); - if !extensions.is_empty() { - dialog = dialog.add_filter("Export", extensions); - } - dialog.save_file() - } - pub fn copy_file_to_clipboard(path: &std::path::Path, cx: &gpui::App) -> Result<(), String> { let path = super::clipboard_file_path(path)?; cx.try_write_to_clipboard( @@ -2094,3 +2543,123 @@ mod tests { )); } } + +#[cfg(test)] +mod file_dialog_tests { + use super::*; + use std::{ + future::Future, + sync::atomic::AtomicU8, + task::{Context, Poll, Waker}, + }; + + #[test] + fn active_native_dialog_rejects_repeated_requests() { + let active = AtomicU8::new(0); + let mut permit = FileDialogPermit::acquire(&active).unwrap(); + assert!(matches!( + FileDialogPermit::acquire(&active), + Err(LinuxFileDialogError::Busy) + )); + permit.allow_reuse(); + drop(permit); + assert!(FileDialogPermit::acquire(&active).is_ok()); + } + + fn complete(result: LinuxFileDialogResult) -> (AtomicU8, LinuxFileDialogResult) { + let active = AtomicU8::new(0); + let permit = FileDialogPermit::acquire(&active).unwrap(); + let (sender, receiver) = flume::bounded(1); + { + let mut future = Box::pin(complete_file_dialog(permit, async { result }, sender)); + let mut cx = Context::from_waker(Waker::noop()); + assert_eq!(future.as_mut().poll(&mut cx), Poll::Ready(())); + } + (active, receiver.recv().unwrap()) + } + + #[test] + fn cancel_releases_admission_before_delivering_result() { + let (active, result) = complete(Ok(None)); + assert_eq!(result, Ok(None)); + assert!(FileDialogPermit::acquire(&active).is_ok()); + } + + #[test] + fn accept_releases_admission_before_delivering_path() { + let path = std::path::PathBuf::from("accepted.mp4"); + let (active, result) = complete(Ok(Some(path.clone()))); + assert_eq!(result, Ok(Some(path))); + assert!(FileDialogPermit::acquire(&active).is_ok()); + } + + #[test] + fn before_dispatch_failure_allows_retry() { + let error = LinuxFileDialogError::BeforeDispatch("invalid folder".into()); + let (active, result) = complete(Err(error.clone())); + assert_eq!(result, Err(error)); + assert!(FileDialogPermit::acquire(&active).is_ok()); + } + + #[test] + fn terminal_native_error_allows_retry() { + let error = LinuxFileDialogError::NativeResponse("rejected by portal".into()); + let (active, result) = complete(Err(error.clone())); + assert_eq!(result, Err(error)); + assert!(FileDialogPermit::acquire(&active).is_ok()); + } + + #[test] + fn ambiguous_dispatch_failure_requires_restart() { + let error = LinuxFileDialogError::Indeterminate("response stream lost".into()); + let (active, result) = complete(Err(error.clone())); + assert_eq!(result, Err(error)); + assert!(matches!( + FileDialogPermit::acquire(&active), + Err(LinuxFileDialogError::RestartRequired) + )); + assert!( + LinuxFileDialogError::RestartRequired + .message() + .contains("restart Cap") + ); + } + + #[test] + fn caller_drop_retains_admission_until_native_response() { + let active = AtomicU8::new(0); + let permit = FileDialogPermit::acquire(&active).unwrap(); + let (native_sender, native_receiver) = flume::bounded(1); + let (sender, receiver) = flume::bounded(1); + let mut future = Box::pin(complete_file_dialog( + permit, + async move { native_receiver.recv_async().await.unwrap() }, + sender, + )); + let mut cx = Context::from_waker(Waker::noop()); + assert!(future.as_mut().poll(&mut cx).is_pending()); + drop(receiver); + assert!(matches!( + FileDialogPermit::acquire(&active), + Err(LinuxFileDialogError::Busy) + )); + native_sender.send(Ok(None)).unwrap(); + assert_eq!(future.as_mut().poll(&mut cx), Poll::Ready(())); + assert!(FileDialogPermit::acquire(&active).is_ok()); + } + + #[test] + fn unexpected_worker_drop_does_not_reopen_admission() { + let active = AtomicU8::new(0); + let permit = FileDialogPermit::acquire(&active).unwrap(); + let (sender, _receiver) = flume::bounded(1); + let mut future = Box::pin(complete_file_dialog(permit, std::future::pending(), sender)); + let mut cx = Context::from_waker(Waker::noop()); + assert!(future.as_mut().poll(&mut cx).is_pending()); + drop(future); + assert!(matches!( + FileDialogPermit::acquire(&active), + Err(LinuxFileDialogError::RestartRequired) + )); + } +} diff --git a/apps/desktop-gpui/src/platform/windows/capture_exclusion.rs b/apps/desktop-gpui/src/platform/windows/capture_exclusion.rs index 62a19c7bcd3..8ff01ef860e 100644 --- a/apps/desktop-gpui/src/platform/windows/capture_exclusion.rs +++ b/apps/desktop-gpui/src/platform/windows/capture_exclusion.rs @@ -3,22 +3,8 @@ use std::ffi::c_void; use windows_sys::Win32::UI::WindowsAndMessaging::{GetSystemMetrics, SM_REMOTESESSION}; const ENV_OVERRIDE: &str = "CAP_WINDOW_CAPTURE_EXCLUSION"; -const SMBIOS_MARKERS: &[&str] = &[ - "qemu", - "kvm", - "vmware", - "virtualbox", - "innotek", - "xen", - "bochs", - "parallels", - "virtual machine", - "hvm domu", - "amazon ec2", - "google compute engine", - "openstack", - "shadow", -]; +// Shadow reports S:102 for protected windows; EC2/DCV still displays excluded windows. +const SMBIOS_MARKERS: &[&str] = &["shadow"]; const VIRTUAL_DISPLAY_MARKERS: &[&str] = &[ "parsec", "spacedesk", @@ -70,23 +56,36 @@ fn exclusion_override(value: &str) -> Option { } } -// Match Tauri's streamed-desktop policy: WDA exclusion also hides controls from -// Shadow/RDP viewers, not just from the recording being made. pub(super) fn streamed_display_reason() -> Option { - match std::env::var(ENV_OVERRIDE) - .ok() - .and_then(|value| exclusion_override(&value)) - { + streamed_display_reason_with( + std::env::var(ENV_OVERRIDE).ok().as_deref(), + || unsafe { GetSystemMetrics(SM_REMOTESESSION) != 0 }, + streamed_computer_marker, + virtual_display_adapter, + ) +} + +fn streamed_display_reason_with( + override_value: Option<&str>, + remote_session: impl FnOnce() -> bool, + streamed_computer: impl FnOnce() -> Option, + virtual_display: impl FnOnce() -> Option, +) -> Option { + match override_value.and_then(exclusion_override) { Some(true) => return None, Some(false) => return Some(format!("{ENV_OVERRIDE} env override")), None => {} } - if unsafe { GetSystemMetrics(SM_REMOTESESSION) } != 0 { + if remote_session() { return Some("remote desktop session (SM_REMOTESESSION)".to_string()); } - if let Some(vendor) = hypervisor_guest() { - return Some(format!("hypervisor guest ({vendor})")); + if let Some(marker) = streamed_computer() { + return Some(format!("streamed computer SMBIOS ({marker})")); } + virtual_display().map(|device| format!("virtual display adapter ({device})")) +} + +fn streamed_computer_marker() -> Option { for value in [ "SystemManufacturer", "SystemProductName", @@ -96,53 +95,9 @@ pub(super) fn streamed_display_reason() -> Option { if let Some(text) = bios_value(value) && let Some(marker) = find_marker(&text, SMBIOS_MARKERS) { - return Some(format!( - "virtual machine SMBIOS ({value}=\"{text}\" matched \"{marker}\")" - )); + return Some(format!("{value}=\"{text}\" matched \"{marker}\"")); } } - virtual_display_adapter().map(|device| format!("virtual display adapter ({device})")) -} - -#[cfg(target_arch = "x86_64")] -fn hypervisor_guest() -> Option { - use std::arch::x86_64::__cpuid; - - if __cpuid(1).ecx & (1 << 31) == 0 { - return None; - } - let hypervisor = __cpuid(0x4000_0000); - let mut vendor = [0u8; 12]; - vendor[0..4].copy_from_slice(&hypervisor.ebx.to_le_bytes()); - vendor[4..8].copy_from_slice(&hypervisor.ecx.to_le_bytes()); - vendor[8..12].copy_from_slice(&hypervisor.edx.to_le_bytes()); - let privileges = if &vendor == b"Microsoft Hv" && hypervisor.eax >= 0x4000_0003 { - __cpuid(0x4000_0003).ebx - } else { - 0 - }; - if is_hyperv_root(&vendor, hypervisor.eax, privileges) { - return None; - } - let vendor = String::from_utf8_lossy(&vendor) - .trim_matches([char::from(0), ' ']) - .to_string(); - Some(if vendor.is_empty() { - "unknown hypervisor".to_string() - } else { - vendor - }) -} - -#[cfg(any(target_arch = "x86_64", test))] -fn is_hyperv_root(vendor: &[u8; 12], maximum_leaf: u32, privileges: u32) -> bool { - // VBS/WSL2 exposes Hyper-V on physical hosts; CreatePartitions identifies - // the root partition, which must retain ordinary capture exclusion. - vendor == b"Microsoft Hv" && maximum_leaf >= 0x4000_0003 && privileges & 1 != 0 -} - -#[cfg(not(target_arch = "x86_64"))] -fn hypervisor_guest() -> Option { None } @@ -248,38 +203,125 @@ mod tests { } #[test] - fn known_streamed_displays_match_but_physical_hardware_does_not() { - for value in [ - "Shadow Computer", - "Amazon EC2", - "QEMU Standard PC", - "Virtual Machine", + fn explicit_override_does_not_probe_the_environment() { + for (value, expected) in [ + ("on", None), + ("off", Some(format!("{ENV_OVERRIDE} env override"))), ] { - assert!(find_marker(value, SMBIOS_MARKERS).is_some(), "{value}"); + assert_eq!( + streamed_display_reason_with( + Some(value), + || panic!("override must skip remote-session detection"), + || panic!("override must skip SMBIOS detection"), + || panic!("override must skip display detection"), + ), + expected + ); } - for value in ["Parsec Virtual Display Adapter", "Shadow", "spacedesk"] { + } + + #[test] + fn remote_session_keeps_its_compatibility_exception() { + assert_eq!( + streamed_display_reason_with( + None, + || true, + || panic!("remote session must skip SMBIOS detection"), + || panic!("remote session must skip display detection"), + ), + Some("remote desktop session (SM_REMOTESESSION)".to_string()) + ); + } + + #[test] + fn shadow_keeps_its_compatibility_exception() { + for computer in ["Shadow", "SHADOW COMPUTER"] { + assert_eq!( + streamed_display_reason_with( + None, + || false, + || find_marker(computer, SMBIOS_MARKERS).map(str::to_string), + || panic!("Shadow must skip display detection"), + ), + Some("streamed computer SMBIOS (shadow)".to_string()) + ); + } + } + + #[test] + fn existing_streamed_adapters_keep_their_compatibility_exception() { + for adapter in [ + "Parsec Virtual Display Adapter", + "spacedesk", + "IddSampleDriver", + "Virtual Display", + "usbmmidd", + "Amyuni", + "Shadow", + ] { assert!( - find_marker(value, VIRTUAL_DISPLAY_MARKERS).is_some(), - "{value}" + streamed_display_reason_with( + None, + || false, + || None, + || find_marker(adapter, VIRTUAL_DISPLAY_MARKERS).map(str::to_string), + ) + .is_some(), + "{adapter}" ); } - for value in [ - "Dell Inc.", - "LENOVO", - "NVIDIA GeForce RTX 3080", - "AMD Radeon RX 7900 XTX", + } + + #[test] + fn virtual_machine_hardware_keeps_capture_exclusion() { + for computer in [ + "Amazon EC2", + "QEMU Standard PC (Q35 + ICH9, 2009)", + "KVM", + "VMware", + "VirtualBox", + "innotek", + "Xen", + "Bochs", + "Parallels", + "Microsoft Corporation Virtual Machine", + "HVM domU", + "Google Compute Engine", + "OpenStack", ] { - assert_eq!(find_marker(value, SMBIOS_MARKERS), None); - assert_eq!(find_marker(value, VIRTUAL_DISPLAY_MARKERS), None); + assert_eq!( + streamed_display_reason_with( + None, + || false, + || find_marker(computer, SMBIOS_MARKERS).map(str::to_string), + || None, + ), + None, + "{computer}" + ); } } #[test] - fn hyperv_root_is_not_mistaken_for_a_guest() { - assert!(is_hyperv_root(b"Microsoft Hv", 0x4000_0003, 1)); - assert!(!is_hyperv_root(b"Microsoft Hv", 0x4000_0003, 0)); - assert!(!is_hyperv_root(b"Microsoft Hv", 0x4000_0002, 1)); - assert!(!is_hyperv_root(b"VMwareVMware", 0x4000_0003, 1)); + fn physical_hardware_keeps_capture_exclusion() { + for name in [ + "Dell Inc.", + "ASUSTeK COMPUTER INC.", + "NVIDIA GeForce RTX 3080", + "AMD Radeon RX 7900 XTX", + "Intel(R) UHD Graphics 770", + "LENOVO", + "Micro-Star International Co., Ltd.", + ] { + assert_eq!(find_marker(name, SMBIOS_MARKERS), None, "{name}"); + assert_eq!(find_marker(name, VIRTUAL_DISPLAY_MARKERS), None, "{name}"); + } + for override_value in [None, Some("auto"), Some("unknown")] { + assert_eq!( + streamed_display_reason_with(override_value, || false, || None, || None), + None + ); + } } #[test] diff --git a/apps/desktop-gpui/src/presets.rs b/apps/desktop-gpui/src/presets.rs index 6193b20d10f..ced0821779a 100644 --- a/apps/desktop-gpui/src/presets.rs +++ b/apps/desktop-gpui/src/presets.rs @@ -221,6 +221,8 @@ mod tests { keyboard_segments: Vec::new(), audio_segments: Vec::new(), camera3d_segments: Vec::new(), + style_segments: Vec::new(), + image_segments: Vec::new(), }), ..Default::default() }; diff --git a/apps/desktop-gpui/src/recording.rs b/apps/desktop-gpui/src/recording.rs index fc480200829..f840fe0546f 100644 --- a/apps/desktop-gpui/src/recording.rs +++ b/apps/desktop-gpui/src/recording.rs @@ -72,8 +72,15 @@ enum Handle { type SharedInstantUpload = Arc>>; -/// A live recording. Stopping consumes it; dropping it without stopping leaves -/// the actors to wind down on their own when the refs go away. +struct RecordingOwnedFeed(ActorRef); + +impl Drop for RecordingOwnedFeed { + fn drop(&mut self) { + self.0.kill(); + } +} + +/// A live recording. Stopping consumes it. #[cfg_attr(target_os = "linux", derive(Clone))] pub struct ActiveRecording { handle: Handle, @@ -87,11 +94,8 @@ pub struct ActiveRecording { /// Recording-scoped mic mute (payload zeroing at the consumer seam; the /// stream cadence is unaffected). `None` when the recording has no mic. pub mic_mute: Option>, - // Held for the duration of the recording: dropping an ActorRef early would - // stop the feed under the pipeline. Only populated by the per-recording - // fallback path; app-scoped feeds are owned by `Feeds`. - _mic_feed: Option>, - _camera_feed: Option>, + _mic_feed: Option>>, + _camera_feed: Option>>, // The mic error channel must outlive the stream or error sends panic the // sender side into logs; we keep it and drain nothing. _mic_errors: Option>, @@ -2034,10 +2038,11 @@ async fn setup_camera( id: &DeviceOrModelID, settings: Option, ) -> anyhow::Result<( - ActorRef, + Arc>, cap_recording::feeds::camera::CameraFeedLock, )> { let feed = CameraFeed::spawn(CameraFeed::default()); + let owner = Arc::new(RecordingOwnedFeed(feed.clone())); let ready = feed .ask(camera::SetInput { id: id.clone(), @@ -2050,19 +2055,20 @@ async fn setup_camera( .ask(camera::Lock) .await .map_err(|e| anyhow!("camera lock: {e}"))?; - Ok((feed, lock)) + Ok((owner, lock)) } async fn setup_microphone( label: &str, settings: Option, ) -> anyhow::Result<( - ActorRef, + Arc>, Arc, flume::Receiver, )> { let (error_tx, error_rx) = flume::unbounded(); let feed = MicrophoneFeed::spawn(MicrophoneFeed::new(error_tx)); + let owner = Arc::new(RecordingOwnedFeed(feed.clone())); let ready = feed .ask(microphone::SetInput { label: label.to_string(), @@ -2075,7 +2081,7 @@ async fn setup_microphone( .ask(microphone::Lock) .await .map_err(|e| anyhow!("lock: {e}"))?; - Ok((feed, Arc::new(lock), error_rx)) + Ok((owner, Arc::new(lock), error_rx)) } #[cfg(target_os = "macos")] @@ -3605,3 +3611,137 @@ mod windows_studio_stop_tests { assert_eq!(result.unwrap(), PathBuf::from("preserved.cap")); } } + +#[cfg(test)] +mod recording_owned_feed_tests { + use super::*; + use kameo::{ + actor::{Recipient, WeakActorRef}, + message::{Context, Message}, + }; + use std::{sync::mpsc, time::Duration}; + + #[derive(Actor)] + struct TestFeed { + _native_stop: mpsc::Sender<()>, + } + + struct NativeFrame; + + impl Message for TestFeed { + type Reply = (); + + async fn handle(&mut self, _: NativeFrame, _: &mut Context) {} + } + + struct NativeFeedFixture { + actor: WeakActorRef, + stopped: flume::Receiver<()>, + worker: std::thread::JoinHandle<()>, + } + + fn spawn_native_feed() -> (ActorRef, NativeFeedFixture) { + let (stop_tx, stop_rx) = mpsc::channel(); + let actor = TestFeed::spawn(TestFeed { + _native_stop: stop_tx, + }); + let native_callback: Recipient = actor.clone().recipient(); + let weak = actor.downgrade(); + let (stopped_tx, stopped_rx) = flume::bounded(1); + let worker = std::thread::spawn(move || { + let _ = stop_rx.recv(); + drop(native_callback); + stopped_tx.send(()).unwrap(); + }); + ( + actor, + NativeFeedFixture { + actor: weak, + stopped: stopped_rx, + worker, + }, + ) + } + + fn native_feed_fixture() -> (Arc>, NativeFeedFixture) { + let (actor, native) = spawn_native_feed(); + (Arc::new(RecordingOwnedFeed(actor)), native) + } + + impl NativeFeedFixture { + async fn cleaned_up(self) -> bool { + let stopped = + tokio::time::timeout(Duration::from_millis(250), self.stopped.recv_async()) + .await + .is_ok(); + if !stopped && let Some(actor) = self.actor.upgrade() { + actor.kill(); + } + if !stopped { + tokio::time::timeout(Duration::from_secs(1), self.stopped.recv_async()) + .await + .unwrap() + .unwrap(); + } + self.worker.join().unwrap(); + stopped + } + } + + #[tokio::test] + async fn fallback_feed_stops_after_startup_failure() { + let (owner, native) = native_feed_fixture(); + let result: Result<(), ()> = async move { + let _owner = owner; + Err(()) + } + .await; + assert!(result.is_err()); + assert!(native.cleaned_up().await); + } + + #[tokio::test] + async fn cancelling_recording_startup_stops_fallback_feed() { + let (owner, native) = native_feed_fixture(); + let startup = tokio::spawn(async move { + let _owner = owner; + std::future::pending::<()>().await; + }); + tokio::task::yield_now().await; + startup.abort(); + assert!(startup.await.unwrap_err().is_cancelled()); + assert!(native.cleaned_up().await); + } + + #[tokio::test] + async fn fallback_feed_stops_after_final_recording_clone() { + let (owner, native) = native_feed_fixture(); + let recording_clone = owner.clone(); + drop(owner); + assert!( + tokio::time::timeout(Duration::from_millis(25), native.stopped.recv_async()) + .await + .is_err() + ); + drop(recording_clone); + assert!(native.cleaned_up().await); + } + + #[tokio::test] + async fn releasing_fallback_preserves_app_scoped_feed() { + let (app_feed, app_native) = spawn_native_feed(); + let (fallback, fallback_native) = native_feed_fixture(); + drop(fallback); + let fallback_stopped = fallback_native.cleaned_up().await; + let app_still_running = + tokio::time::timeout(Duration::from_millis(25), app_native.stopped.recv_async()) + .await + .is_err(); + app_feed.kill(); + drop(app_feed); + let app_stopped = app_native.cleaned_up().await; + assert!(fallback_stopped); + assert!(app_still_running); + assert!(app_stopped); + } +} diff --git a/apps/desktop-gpui/src/screenshot_editor.rs b/apps/desktop-gpui/src/screenshot_editor.rs index f059bf6e745..dce5d148a19 100644 --- a/apps/desktop-gpui/src/screenshot_editor.rs +++ b/apps/desktop-gpui/src/screenshot_editor.rs @@ -1986,8 +1986,15 @@ impl ScreenshotEditorWindow { .ok(); } ExportDestination::File => { - let dest = - crate::platform::save_file_panel(&format!("{name}.png"), &["png"]); + let dest = crate::platform::save_file_panel_async( + &format!("{name}.png"), + &["png"], + cx, + ) + .await; + if this.update_in(cx, |_, _, _| ()).is_err() { + return; + } if let Some(dest) = dest { let written = cx .background_executor() diff --git a/apps/desktop-gpui/src/session.rs b/apps/desktop-gpui/src/session.rs index e6b5b56f063..791fd0697b4 100644 --- a/apps/desktop-gpui/src/session.rs +++ b/apps/desktop-gpui/src/session.rs @@ -161,6 +161,16 @@ struct RecordingOwner { project_dir: std::path::PathBuf, } +#[cfg(target_os = "linux")] +#[derive(PartialEq, Eq)] +pub(crate) struct RecordingConfirmationTicket { + owner: RecordingOwner, + operation: u64, + phase: Phase, + pause_sequence: u64, + clean_sequence: u64, +} + #[cfg(target_os = "linux")] #[derive(Clone)] struct TerminalTicket { @@ -1157,6 +1167,28 @@ impl RecordingSession { }) } + #[cfg(target_os = "linux")] + pub(crate) fn confirmation_ticket(&self) -> Option { + if !matches!(self.phase, Phase::Recording { .. }) + || self.pause_unavailable() + || self.stop_requested + { + return None; + } + Some(RecordingConfirmationTicket { + owner: self.recording_owner()?, + operation: self.terminal_operation, + phase: self.phase, + pause_sequence: self.pause_control.sequence, + clean_sequence: self.clean_control.sequence, + }) + } + + #[cfg(target_os = "linux")] + pub(crate) fn confirmation_is_current(&self, ticket: &RecordingConfirmationTicket) -> bool { + self.confirmation_ticket().as_ref() == Some(ticket) + } + #[cfg(target_os = "linux")] pub fn instant_cleanup_safe(&self) -> bool { self.instant_attempt.as_ref().is_none_or(|attempt| { diff --git a/apps/desktop-gpui/src/settings_window.rs b/apps/desktop-gpui/src/settings_window.rs index e092c01936a..05b9a97d048 100644 --- a/apps/desktop-gpui/src/settings_window.rs +++ b/apps/desktop-gpui/src/settings_window.rs @@ -1167,8 +1167,12 @@ impl SettingsWindow { window: &mut Window, cx: &mut Context, ) { - cx.spawn_in(window, async move |_this, _cx| { - let dest = crate::platform::save_file_panel(&format!("{name}.png"), &["png"]); + cx.spawn_in(window, async move |this, cx| { + let dest = + crate::platform::save_file_panel_async(&format!("{name}.png"), &["png"], cx).await; + if this.update_in(cx, |_, _, _| ()).is_err() { + return; + } let Some(dest) = dest else { return; }; diff --git a/apps/desktop-gpui/src/target_overlay.rs b/apps/desktop-gpui/src/target_overlay.rs index 409b84f3635..86361ef65ec 100644 --- a/apps/desktop-gpui/src/target_overlay.rs +++ b/apps/desktop-gpui/src/target_overlay.rs @@ -70,6 +70,23 @@ fn required_window_title_matches(required: Option<&str>, actual: Option<&str>) - required.is_none_or(|required| actual == Some(required)) } +fn overlay_display_is_active( + cursor_display: Option<&DisplayId>, + display: &DisplayId, + only_overlay: bool, + window_hovered: bool, +) -> bool { + cursor_display.map_or(only_overlay || window_hovered, |cursor| cursor == display) +} + +fn overlay_viewport_size(viewport: (f32, f32), previous: (f32, f32)) -> (f32, f32) { + if viewport.0.is_finite() && viewport.1.is_finite() && viewport.0 > 0. && viewport.1 > 0. { + viewport + } else { + previous + } +} + fn recording_devices_available(cx: &App) -> bool { crate::session::RecordingSession::global(cx).read(cx).phase == crate::session::Phase::Idle && !app_windows::clean_capture_owned(cx) @@ -94,6 +111,26 @@ const AREA_HANDLE_GRAB: f32 = 12.; const CLUSTER_WIDTH: f32 = 416.; const CLUSTER_HEIGHT: f32 = 88.; +fn area_controls_position(crop: AreaRect, available: AreaRect, cluster_height: f32) -> (f32, f32) { + let left = available.x + 16.; + let right = (available.right() - CLUSTER_WIDTH - 16.).max(left); + let top = available.y + 40.; + let bottom = (available.bottom() - cluster_height - 16.).max(top); + let below = crop.bottom() + 16.; + let above = crop.y - cluster_height - 16.; + let y = if below <= bottom { + below + } else if above >= top { + above + } else { + crop.y + 40. + }; + ( + (crop.x + crop.width / 2. - CLUSTER_WIDTH / 2.).clamp(left, right), + y.clamp(top, bottom), + ) +} + const COUNTDOWN_OPTIONS: &[(u32, &str)] = &[ (0, "Off"), (3, "3 seconds"), @@ -546,6 +583,7 @@ pub struct OverlayWindow { /// Logical size, which is this window's size and the space every /// coordinate here lives in. display_size: (f32, f32), + controls_work_area: AreaRect, /// Physical pixels, for the `1920x1080 · 60FPS` line. physical_size: Option<(u32, u32)>, refresh_rate: f64, @@ -615,6 +653,24 @@ impl OverlayWindow { .map(|size| (size.width() as f32, size.height() as f32)) .unwrap_or((1920., 1080.)); + let controls_work_area = display + .raw_handle() + .logical_bounds() + .zip(app_windows::display_work_area(Some(display), cx)) + .map(|(bounds, available)| AreaRect { + x: f32::from(available.origin.x) - bounds.position().x() as f32, + y: f32::from(available.origin.y) - bounds.position().y() as f32, + width: f32::from(available.size.width), + height: f32::from(available.size.height), + }) + .unwrap_or(AreaRect { + x: 0., + y: 0., + width: logical.0, + height: logical.1, + }) + .clamped(logical); + Self { theme, select, @@ -623,6 +679,7 @@ impl OverlayWindow { .name() .unwrap_or_else(|| format!("Display {}", display.id())), display_size: logical, + controls_work_area, physical_size: display .physical_size() .map(|size| (size.width() as u32, size.height() as u32)), @@ -787,8 +844,13 @@ impl OverlayWindow { /// True when the cursor is on this overlay's display -- `data-over` in the /// display variant, `isActiveDisplay` in the area variant. - fn is_active_display(&self, cx: &App) -> bool { - self.select.read(cx).cursor_display.as_ref() == Some(&self.display_id) + fn is_active_display(&self, window: &Window, cx: &App) -> bool { + overlay_display_is_active( + self.select.read(cx).cursor_display.as_ref(), + &self.display_id, + cx.global::().overlays.len() == 1, + cfg!(target_os = "linux") && window.is_window_hovered(), + ) } } @@ -796,6 +858,18 @@ impl Render for OverlayWindow { fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { self.sync_appearance(window, cx); self.sync_inline_camera(cx); + let viewport = window.viewport_size(); + let display_size = overlay_viewport_size( + (f32::from(viewport.width), f32::from(viewport.height)), + self.display_size, + ); + if !self.recording_area && self.display_size != display_size { + self.display_size = display_size; + self.crop = self.crop.map(|crop| crop.clamped(display_size)); + self.drag = None; + self.snap_guides.clear(); + self.sync_area_camera(cx); + } let mode = self.select.read(cx).mode; let root = div() @@ -855,12 +929,12 @@ impl Render for OverlayWindow { .text_color(gpui::white()); let root = if self.recording_area { - root.child(self.render_area_variant(cx)) + root.child(self.render_area_variant(window, cx)) } else { match mode { - Some(TargetType::Display) => root.child(self.render_display_variant(cx)), + Some(TargetType::Display) => root.child(self.render_display_variant(window, cx)), Some(TargetType::Window) => root.child(self.render_window_variant(cx)), - Some(TargetType::Area) => root.child(self.render_area_variant(cx)), + Some(TargetType::Area) => root.child(self.render_area_variant(window, cx)), Some(TargetType::CameraOnly) => root.child(self.render_camera_variant(cx)), None => root, } @@ -1170,8 +1244,8 @@ impl OverlayWindow { /// `data-[over='true']:bg-blue-600/40` over `bg-black/60`, centered /// monitor art, name, resolution. - fn render_display_variant(&self, cx: &mut Context) -> impl IntoElement { - let over = self.is_active_display(cx); + fn render_display_variant(&self, window: &Window, cx: &mut Context) -> impl IntoElement { + let over = self.is_active_display(window, cx); let resolution = self.physical_size.map(|(width, height)| { // `${size().width}x${size().height} · ${display.refresh_rate}FPS` format!("{width}x{height} · {}FPS", self.refresh_rate) @@ -1400,10 +1474,14 @@ impl OverlayWindow { /// The crop overlay: `bg-black/45` outside the selection, a `border-white/50` /// region with eight handles, a size readout on top and the start cluster /// placed against the crop. - fn render_area_variant(&self, cx: &mut Context) -> gpui::Stateful { + fn render_area_variant( + &self, + window: &Window, + cx: &mut Context, + ) -> gpui::Stateful { let interacting = self.drag.is_some(); // `shouldShowOverlay = isInteracting() || isActiveDisplay()`. - let visible = self.recording_area || interacting || self.is_active_display(cx); + let visible = self.recording_area || interacting || self.is_active_display(window, cx); let crop = self.crop; let min = area_min_size(self.select.read(cx).recording_mode); let valid = crop.is_some_and(|crop| crop.is_valid_for(min)); @@ -1426,8 +1504,8 @@ impl OverlayWindow { })) .on_mouse_up( MouseButton::Left, - cx.listener(|this, _: &MouseUpEvent, _window, cx| { - this.area_mouse_up(cx); + cx.listener(|this, event: &MouseUpEvent, _window, cx| { + this.area_mouse_up(event.position, cx); }), ) }); @@ -1784,43 +1862,21 @@ impl OverlayWindow { valid: bool, cx: &mut Context, ) -> impl IntoElement { - const SIDE_MARGIN: f32 = 16.; - const MARGIN_BELOW: f32 = 16.; - const MARGIN_TOP_OUTSIDE: f32 = 16.; - // `macos ? 40 : 28` / `macos ? 40 : 10`. - const MARGIN_TOP_INSIDE: f32 = 40.; - const TOP_SAFE_MARGIN: f32 = 40.; - let crop = crop.unwrap_or(AreaRect { x: 0., y: 0., width: 0., height: 0., }); - let (screen_width, screen_height) = self.display_size; let min = area_min_size(self.select.read(cx).recording_mode); - - let below = crop.bottom() + MARGIN_BELOW; + let available = self.controls_work_area.clamped(self.display_size); let cluster_height = CLUSTER_HEIGHT + if self.select.read(cx).recording_mode == Mode::Screenshot { 0. } else { 78. }; - let y = if below + cluster_height <= screen_height { - below - } else { - let above = crop.y - cluster_height - MARGIN_TOP_OUTSIDE; - if above >= TOP_SAFE_MARGIN { - above - } else { - crop.y + MARGIN_TOP_INSIDE - } - }; - let x = (crop.x + crop.width / 2. - CLUSTER_WIDTH / 2.).clamp( - SIDE_MARGIN, - (screen_width - CLUSTER_WIDTH - SIDE_MARGIN).max(SIDE_MARGIN), - ); + let (x, y) = area_controls_position(crop, available, cluster_height); div() .absolute() @@ -2320,10 +2376,11 @@ impl OverlayWindow { cx.notify(); } - fn area_mouse_up(&mut self, cx: &mut Context) { + fn area_mouse_up(&mut self, position: Point, cx: &mut Context) { if self.recording_area { return; } + self.area_mouse_move(position, cx); let Some(drag) = self.drag.take() else { return; }; @@ -2355,6 +2412,88 @@ impl OverlayWindow { mod tests { use super::*; + #[test] + fn a_single_overlay_remains_interactive_without_a_global_cursor_probe() { + let display = "1".parse::().unwrap(); + let other = "2".parse::().unwrap(); + assert!(overlay_display_is_active(None, &display, true, false)); + assert!(!overlay_display_is_active(None, &display, false, false)); + assert!(overlay_display_is_active( + Some(&display), + &display, + false, + false + )); + assert!(!overlay_display_is_active( + Some(&other), + &display, + true, + false + )); + } + + #[test] + fn native_window_hover_activates_the_correct_overlay_without_global_coordinates() { + let display = "1".parse::().unwrap(); + let other = "2".parse::().unwrap(); + assert!(overlay_display_is_active(None, &display, false, true)); + assert!(!overlay_display_is_active(None, &display, false, false)); + assert!(!overlay_display_is_active( + Some(&other), + &display, + false, + true + )); + } + + #[test] + fn area_controls_stay_above_the_dock_on_a_small_display() { + let available = rect(0., 25., 1024., 684.); + let height = CLUSTER_HEIGHT + 78.; + let (x, y) = area_controls_position(rect(202., 216., 641., 361.), available, height); + assert!(x >= available.x && x + CLUSTER_WIDTH <= available.right()); + assert!(y >= available.y && y + height <= available.bottom()); + assert!(y < 577.); + } + + #[test] + fn area_controls_respect_side_taskbars_and_bottom_edge_crops() { + let available = rect(64., 24., 736., 416.); + for crop in [rect(0., 0., 800., 480.), rect(600., 350., 180., 120.)] { + let (x, y) = area_controls_position(crop, available, CLUSTER_HEIGHT + 78.); + assert!(x >= available.x && x + CLUSTER_WIDTH <= available.right()); + assert!(y >= available.y && y + CLUSTER_HEIGHT + 78. <= available.bottom()); + } + } + + #[test] + fn area_controls_keep_available_space_below_or_above_a_crop() { + let available = rect(0., 0., 1280., 800.); + assert_eq!( + area_controls_position(rect(100., 100., 640., 360.), available, 166.).1, + 476. + ); + assert_eq!( + area_controls_position(rect(100., 500., 640., 240.), available, 166.).1, + 318. + ); + } + + #[test] + fn compositor_viewport_replaces_placeholder_size_and_clamps_the_selection() { + let viewport = overlay_viewport_size((1024., 768.), (1920., 1080.)); + assert_eq!(viewport, (1024., 768.)); + let crop = rect(700., 400., 640., 480.).clamped(viewport); + assert!(crop.x >= 0. && crop.y >= 0.); + assert!(crop.right() <= 1024. && crop.bottom() <= 768.); + assert_eq!(overlay_viewport_size((0., 0.), viewport), viewport); + assert_eq!(overlay_viewport_size((f32::NAN, 768.), viewport), viewport); + assert_eq!( + overlay_viewport_size((1024., f32::INFINITY), viewport), + viewport + ); + } + #[test] fn mode_menu_matches_menu_modes() { // `menuModes` order (`target-select-overlay.tsx:2064-2090`) with the diff --git a/apps/desktop-gpui/src/transcription.rs b/apps/desktop-gpui/src/transcription.rs index 4f27fe5943f..70034639482 100644 --- a/apps/desktop-gpui/src/transcription.rs +++ b/apps/desktop-gpui/src/transcription.rs @@ -314,7 +314,9 @@ fn delete_model_files(model: &str) -> Result<(), String> { invalidate_parakeet_cache_for_dir(&path); std::fs::remove_dir_all(&path).map_err(|e| format!("Failed to delete model directory: {e}")) } else { - std::fs::remove_file(&path).map_err(|e| format!("Failed to delete model file: {e}")) + std::fs::remove_file(&path).map_err(|e| format!("Failed to delete model file: {e}"))?; + invalidate_whisper_cache_for_path(&path); + Ok(()) } } @@ -769,9 +771,27 @@ struct CachedWhisperContext { static WHISPER_CONTEXT: LazyLock>> = LazyLock::new(|| Mutex::new(None)); +fn invalidate_whisper_cache_for_path(model_path: &Path) { + let removed = { + let mut guard = WHISPER_CONTEXT + .lock() + .unwrap_or_else(PoisonError::into_inner); + if guard + .as_ref() + .is_some_and(|cached| Path::new(&cached.model_path) == model_path) + { + guard.take() + } else { + None + } + }; + drop(removed); +} + /// `get_whisper_context_blocking` (`captions.rs:691-707`), keyed by model path /// so switching small -> medium reloads instead of reusing the stale context. fn get_whisper_context(model_path: &str) -> Result, String> { + cap_utils::local_captions::ensure_whisper_cpu_support()?; let mut guard = WHISPER_CONTEXT .lock() .unwrap_or_else(PoisonError::into_inner); @@ -1497,6 +1517,7 @@ fn process_with_parakeet( model } else { tracing::info!("Loading Parakeet TDT model from: {model_dir}"); + cap_camera_effects::initialize_onnx_runtime().map_err(|error| format!("{error:#}"))?; let model = ParakeetTDT::from_pretrained(model_dir, None).map_err(|e| format!("{e}"))?; let loaded_model = Arc::new(Mutex::new(model)); @@ -2298,6 +2319,8 @@ pub fn apply_caption_result( keyboard_segments: Vec::new(), audio_segments: Vec::new(), camera3d_segments: Vec::new(), + style_segments: Vec::new(), + image_segments: Vec::new(), }); } let timeline = project diff --git a/apps/desktop/src-tauri/Cargo.toml b/apps/desktop/src-tauri/Cargo.toml index 9ecd5508999..7bc6bba7d97 100644 --- a/apps/desktop/src-tauri/Cargo.toml +++ b/apps/desktop/src-tauri/Cargo.toml @@ -170,6 +170,7 @@ windows = { workspace = true, features = [ "Storage_Streams", "Win32_Foundation", "Win32_System", + "Win32_Storage_FileSystem", "Win32_System_Power", "Win32_System_Threading", "Win32_System_WinRT", diff --git a/apps/desktop/src-tauri/examples/desktop-display-transport-benchmark.rs b/apps/desktop/src-tauri/examples/desktop-display-transport-benchmark.rs index 47cac690d83..efb5684eb91 100644 --- a/apps/desktop/src-tauri/examples/desktop-display-transport-benchmark.rs +++ b/apps/desktop/src-tauri/examples/desktop-display-transport-benchmark.rs @@ -163,6 +163,8 @@ async fn load_recording( transitions: Vec::new(), zoom_segments: Vec::new(), scene_segments: Vec::new(), + style_segments: Vec::new(), + image_segments: Vec::new(), mask_segments: Vec::new(), text_segments: Vec::new(), caption_segments: Vec::new(), diff --git a/apps/desktop/src-tauri/src/captions.rs b/apps/desktop/src-tauri/src/captions.rs index cf76122e00c..dd261cc6dad 100644 --- a/apps/desktop/src-tauri/src/captions.rs +++ b/apps/desktop/src-tauri/src/captions.rs @@ -53,11 +53,16 @@ impl Default for CaptionData { } lazy_static::lazy_static! { - static ref WHISPER_CONTEXT: Arc>>> = Arc::new(Mutex::new(None)); + static ref WHISPER_CONTEXT: Arc>> = Arc::new(Mutex::new(None)); static ref MODEL_DOWNLOADS: Mutex> = Mutex::new(HashMap::new()); static ref TRANSCRIPTION_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(()); } +struct CachedWhisperContext { + model_path: String, + context: Arc, +} + #[cfg(not(all(target_os = "macos", target_arch = "x86_64")))] lazy_static::lazy_static! { static ref PARAKEET_CONTEXT: Mutex> = Mutex::new(None); @@ -103,6 +108,22 @@ async fn invalidate_parakeet_cache_for_dir(model_dir: &Path) { #[cfg(all(target_os = "macos", target_arch = "x86_64"))] async fn invalidate_parakeet_cache_for_dir(_model_dir: &Path) {} +async fn invalidate_whisper_cache_for_path(model_path: &Path) { + let cached_context = { + let mut context = WHISPER_CONTEXT.lock().await; + if context + .as_ref() + .is_some_and(|cached| Path::new(&cached.model_path) == model_path) + { + tracing::info!("Releasing deleted Whisper model from cache"); + context.take() + } else { + None + } + }; + drop(cached_context); +} + pub async fn release_ml_models() { { let mut ctx = WHISPER_CONTEXT.lock().await; @@ -689,11 +710,14 @@ fn lock_transcription_worker_slot() -> std::sync::MutexGuard<'static, ()> { } fn get_whisper_context_blocking(model_path: &str) -> Result, String> { + cap_utils::local_captions::ensure_whisper_cpu_support()?; let mut context_guard = WHISPER_CONTEXT.blocking_lock(); - if let Some(ref existing) = *context_guard { + if let Some(ref existing) = *context_guard + && existing.model_path == model_path + { log::info!("Reusing cached Whisper context"); - return Ok(existing.clone()); + return Ok(existing.context.clone()); } log::info!("Initializing Whisper context with model: {model_path}"); @@ -701,7 +725,10 @@ fn get_whisper_context_blocking(model_path: &str) -> Result, .map_err(|e| format!("Failed to load Whisper model: {e}"))?; let ctx_arc = Arc::new(ctx); - *context_guard = Some(ctx_arc.clone()); + *context_guard = Some(CachedWhisperContext { + model_path: model_path.to_string(), + context: ctx_arc.clone(), + }); Ok(ctx_arc) } @@ -1228,6 +1255,7 @@ fn process_with_parakeet( model } else { tracing::info!("Loading Parakeet TDT model from: {model_dir}"); + cap_camera_effects::initialize_onnx_runtime().map_err(|error| format!("{error:#}"))?; let model = ParakeetTDT::from_pretrained(model_dir, None).map_err(|e| format!("{e}"))?; let loaded_model = Arc::new(std::sync::Mutex::new(model)); @@ -2246,6 +2274,8 @@ pub async fn delete_whisper_model(app: AppHandle, model_path: String) -> Result< .await .map_err(|e| format!("Failed to delete model file: {e}"))?; + invalidate_whisper_cache_for_path(&validated_path).await; + Ok(()) } diff --git a/apps/desktop/src-tauri/src/clean_capture.rs b/apps/desktop/src-tauri/src/clean_capture.rs index 509ec1affbd..ddce0399c3c 100644 --- a/apps/desktop/src-tauri/src/clean_capture.rs +++ b/apps/desktop/src-tauri/src/clean_capture.rs @@ -1,4 +1,11 @@ -use std::{path::PathBuf, sync::Mutex, time::Duration}; +use std::{ + path::PathBuf, + sync::{ + Arc, Mutex, + atomic::{AtomicBool, Ordering}, + }, + time::Duration, +}; use serde::Serialize; use specta::Type; @@ -131,6 +138,77 @@ struct ControlError { message: String, } +#[derive(Clone)] +pub(crate) struct StopNoticeOwner { + sequence: u64, + directory: PathBuf, + generation: Option, + confirmed: Arc, +} + +impl StopNoticeOwner { + pub(crate) fn is_confirmed(&self) -> bool { + self.confirmed.load(Ordering::Acquire) + } + + pub(crate) fn same_attempt(&self, other: &Self) -> bool { + Arc::ptr_eq(&self.confirmed, &other.confirmed) + } +} + +#[derive(Clone)] +pub(crate) struct StopNoticeTicket { + sequence: u64, + pub(crate) owner: StopNoticeOwner, +} + +#[derive(Clone, Copy, PartialEq, Eq)] +pub(crate) enum StopNoticeKind { + Unconfirmed, + ConfirmedFailure, + ControlFailure, +} + +struct StopNotice { + ticket: StopNoticeTicket, + kind: StopNoticeKind, + message: String, + previous_message: Option, + restoration: Option>, +} + +impl StopNotice { + fn error(&self) -> String { + let confirmed = + self.ticket.owner.is_confirmed() || self.kind == StopNoticeKind::ConfirmedFailure; + let status = if confirmed { + match (&self.restoration, self.ticket.owner.generation) { + (Some(Err(_)), _) => "Recording stopped; window restoration failed", + (None, Some(_)) => "Recording stopped; window restoration is pending", + _ => "Recording stopped", + } + } else { + "Stop could not be confirmed" + }; + let mut message = format!( + "{status} ({}): {}", + self.ticket.owner.directory.display(), + self.message + ); + if let Some(previous) = &self.previous_message { + message.push_str("\n"); + message.push_str(previous); + } + if let Some(Err(error)) = &self.restoration + && !self.message.contains(error) + { + message.push_str("\n"); + message.push_str(error); + } + message + } +} + struct RestorationReceipt { generation: u32, result: Result<(), String>, @@ -139,9 +217,13 @@ struct RestorationReceipt { } impl RestorationReceipt { + fn restoration_result(&self) -> Result<(), String> { + self.result.clone() + } + #[cfg(target_os = "linux")] fn restart_result(&self) -> Result<(), String> { - self.result.clone()?; + self.restoration_result()?; if self.stop_requested { Err("Recording restart was cancelled by Stop".into()) } else { @@ -156,6 +238,8 @@ struct Inner { lease: Option, control_error: Option, restored: Option, + stop_notice_sequence: u64, + stop_notice: Option, #[cfg(target_os = "linux")] x11_cleanup_sequence: u64, #[cfg(target_os = "linux")] @@ -276,11 +360,7 @@ impl Inner { return None; } let succeeded = result.is_ok(); - self.restored = Some(RestorationReceipt { - generation, - result, - stop_requested: self.lease.as_ref().unwrap().stop_requested, - }); + let _ = self.record_stop_restoration(generation, result); if succeeded { let owned = self.lease.take().unwrap().registered_shortcut; self.generation = self.generation.wrapping_add(1); @@ -290,6 +370,108 @@ impl Inner { } } + fn record_stop_restoration(&mut self, generation: u32, result: Result<(), String>) -> bool { + let Some(lease) = self + .lease + .as_ref() + .filter(|lease| lease.generation == generation && lease.phase == Phase::Restoring) + else { + return false; + }; + if let Some(notice) = &mut self.stop_notice + && notice.ticket.owner.generation == Some(generation) + && lease.recording_dir.as_ref() == Some(¬ice.ticket.owner.directory) + { + notice.restoration = Some(result.clone()); + } + self.restored = Some(RestorationReceipt { + generation, + result, + #[cfg(target_os = "linux")] + stop_requested: lease.stop_requested, + }); + true + } + + fn next_stop_notice_sequence(&mut self) -> u64 { + self.stop_notice_sequence = self + .stop_notice_sequence + .checked_add(1) + .expect("Stop notice identity exhausted"); + self.stop_notice_sequence + } + + fn reserve_stop_notice_owner( + &mut self, + directory: PathBuf, + generation: Option, + ) -> StopNoticeOwner { + StopNoticeOwner { + sequence: self.next_stop_notice_sequence(), + directory, + generation, + confirmed: Arc::new(AtomicBool::new(false)), + } + } + + fn reserve_stop_notice_ticket(&mut self, owner: StopNoticeOwner) -> StopNoticeTicket { + StopNoticeTicket { + sequence: self.next_stop_notice_sequence(), + owner, + } + } + + fn retain_stop_notice( + &mut self, + ticket: &StopNoticeTicket, + kind: StopNoticeKind, + message: String, + ) -> bool { + if let Some(previous) = &mut self.stop_notice { + if previous.ticket.sequence == ticket.sequence + && previous.ticket.owner.same_attempt(&ticket.owner) + { + if previous.message == message + || previous.previous_message.as_ref() == Some(&message) + { + return false; + } + if kind == StopNoticeKind::ConfirmedFailure { + previous.previous_message = + Some(std::mem::replace(&mut previous.message, message)); + previous.kind = kind; + } else { + previous.previous_message = Some(message); + } + tracing::info!(project = %ticket.owner.directory.display(), "Retained distinct failure from the same Stop cohort"); + return true; + } + if (previous.ticket.owner.sequence, previous.ticket.sequence) + >= (ticket.owner.sequence, ticket.sequence) + { + return false; + } + tracing::info!( + previous_project = %previous.ticket.owner.directory.display(), + next_project = %ticket.owner.directory.display(), + "Retained Stop notice superseded by a newer failure" + ); + } + let restoration = self + .restored + .as_ref() + .filter(|receipt| Some(receipt.generation) == ticket.owner.generation) + .map(RestorationReceipt::restoration_result); + self.stop_notice = Some(StopNotice { + ticket: ticket.clone(), + kind, + message, + previous_message: None, + restoration, + }); + true + } + fn owner(&self, dir: &std::path::Path) -> Option { self.lease.as_ref().and_then(|lease| { (lease.recording_dir.as_deref() == Some(dir)).then_some(lease.generation) @@ -360,6 +542,35 @@ impl Inner { } fn snapshot(&self) -> Snapshot { + let mut errors = Vec::with_capacity(4); + if let Some(error) = self.control_error.as_ref().filter(|error| { + error.generation == self.generation + && self + .lease + .as_ref() + .is_none_or(|lease| lease.recording_dir.as_ref() == Some(&error.dir)) + }) { + errors.push(error.message.clone()); + } + for error in [ + self.lease + .as_ref() + .and_then(|lease| lease.stop_error.clone()), + self.restored.as_ref().and_then(|receipt| { + self.lease + .as_ref() + .filter(|lease| lease.generation == receipt.generation) + .and_then(|_| receipt.result.as_ref().err().cloned()) + }), + self.stop_notice.as_ref().map(StopNotice::error), + ] + .into_iter() + .flatten() + { + if !errors.contains(&error) { + errors.push(error); + } + } Snapshot { generation: self.generation, phase: self.lease.as_ref().map(|lease| lease.phase), @@ -373,30 +584,7 @@ impl Inner { } }) }), - error: self - .control_error - .as_ref() - .filter(|error| { - error.generation == self.generation - && self - .lease - .as_ref() - .is_none_or(|lease| lease.recording_dir.as_ref() == Some(&error.dir)) - }) - .map(|error| error.message.clone()) - .or_else(|| { - self.lease - .as_ref() - .and_then(|lease| lease.stop_error.clone()) - }) - .or_else(|| { - self.restored.as_ref().and_then(|receipt| { - self.lease - .as_ref() - .filter(|lease| lease.generation == receipt.generation) - .and_then(|_| receipt.result.as_ref().err().cloned()) - }) - }), + error: (!errors.is_empty()).then(|| errors.join("\n")), } } @@ -509,6 +697,51 @@ fn notify(app: &AppHandle) { let _ = CurrentRecordingChanged.emit(app); } +pub(crate) fn reserve_stop_notice_owner( + app: &AppHandle, + directory: PathBuf, + generation: Option, +) -> StopNoticeOwner { + app.state::() + .inner + .lock() + .unwrap() + .reserve_stop_notice_owner(directory, generation) +} + +pub(crate) fn reserve_stop_notice_ticket( + app: &AppHandle, + owner: StopNoticeOwner, +) -> StopNoticeTicket { + app.state::() + .inner + .lock() + .unwrap() + .reserve_stop_notice_ticket(owner) +} + +pub(crate) fn retain_stop_notice( + app: &AppHandle, + ticket: &StopNoticeTicket, + kind: StopNoticeKind, + message: String, +) { + let retained = app + .state::() + .inner + .lock() + .unwrap() + .retain_stop_notice(ticket, kind, message); + if retained { + notify(app); + } +} + +pub(crate) fn confirm_stop_notice(app: &AppHandle, owner: &StopNoticeOwner) { + owner.confirmed.store(true, Ordering::Release); + notify(app); +} + pub fn set_phase(app: &AppHandle, generation: u32, phase: Phase) -> bool { let state = app.state::(); let mut inner = state.inner.lock().unwrap(); @@ -565,6 +798,27 @@ pub fn queue_stop(app: &AppHandle) -> bool { deferred } +pub(crate) fn queue_owned_studio_stop( + app: &AppHandle, + generation: u32, + directory: &std::path::Path, +) -> bool { + let state = app.state::(); + let mut inner = state.inner.lock().unwrap(); + if inner.lease.as_ref().is_none_or(|lease| { + lease.generation != generation + || lease.mode != cap_recording::RecordingMode::Studio + || lease.recording_dir.as_deref() != Some(directory) + || !(lease.phase.can_stop() || lease.phase == Phase::Stopping) + }) { + return false; + } + let _ = inner.queue_stop(); + drop(inner); + notify(app); + true +} + pub fn handle_shortcut(app: &AppHandle, pressed: bool) -> bool { handle_stop_input(app, pressed, None) } @@ -622,6 +876,21 @@ fn handle_stop_input(app: &AppHandle, pressed: bool, route: Option<(u32, StopRou .as_ref() .is_some_and(|lease| lease.phase != Phase::AwaitingShortcut); let handled = inner.shortcut(pressed); + let studio_stop = pressed + .then(|| { + inner.lease.as_ref().and_then(|lease| { + (lease.mode == cap_recording::RecordingMode::Studio + && (lease.phase.can_stop() || lease.phase == Phase::Stopping)) + .then(|| { + lease + .recording_dir + .clone() + .map(|dir| (lease.generation, dir)) + }) + .flatten() + }) + }) + .flatten(); let stop = handled && pressed && inner @@ -634,7 +903,9 @@ fn handle_stop_input(app: &AppHandle, pressed: bool, route: Option<(u32, StopRou if handled { notify(app); } - if stop { + if let Some((generation, directory)) = studio_stop { + crate::recording::queue_clean_studio_stop(app, generation, directory); + } else if stop { let app = app.clone(); drop(tauri::async_runtime::spawn(async move { if let Err(error) = crate::recording::stop_recording(app.clone(), app.state()).await { @@ -876,7 +1147,13 @@ pub fn control( }, restore: restore_paused_main(app, generation, dir.clone()), stop: async { - Box::pin(crate::recording::stop_recording(app.clone(), app.state())).await + Box::pin(crate::recording::stop_clean_studio_recording( + app.clone(), + handle.clone(), + generation, + dir.clone(), + )) + .await }, notify: || notify(app), } @@ -1760,6 +2037,29 @@ async fn restore_pass_acknowledged( scheduled && matches!(acknowledgement.await, Ok(Ok(()))) } +fn restore_saved_window(app: &AppHandle, saved: &SavedWindow) -> Result<(), String> { + let window = app.get_webview_window(&saved.label).ok_or_else(|| { + format!( + "Recording window {} disappeared before restoration", + saved.label + ) + })?; + let visible = saved.visibility_for(native_id(&window)?).ok_or_else(|| { + format!( + "Recording window {} was replaced before restoration", + saved.label + ) + })?; + set_native_visibility(&window, visible)?; + if window.is_visible().map_err(|error| error.to_string())? != visible { + return Err(format!( + "Recording window {} restoration was not acknowledged", + saved.label + )); + } + Ok(()) +} + fn release_inner( app: &AppHandle, generation: u32, @@ -1830,6 +2130,8 @@ fn release_inner( let app = app.clone(); drop(tauri::async_runtime::spawn(async move { let (tx, rx) = tokio::sync::oneshot::channel::>(); + let restoration_error = Arc::new(Mutex::new(None::)); + let first_error = restoration_error.clone(); let handle = app.clone(); let scheduled = app.run_on_main_thread(move || { #[cfg(target_os = "linux")] @@ -1860,15 +2162,10 @@ fn release_inner( }; if let Some((saved, owned)) = saved { crate::hotkeys::release_clean_capture_stop(&handle, owned); - if !editor_took_foreground - && let Some(saved) = saved - && let Some(window) = handle.get_webview_window(&saved.label) - && let Some(visible) = native_id(&window) - .ok() - .and_then(|id| saved.visibility_for(id)) - { - let result = set_native_visibility(&window, visible); + if !editor_took_foreground && let Some(saved) = saved { + let result = restore_saved_window(&handle, &saved); if let Err(error) = result { + *first_error.lock().unwrap() = Some(error.to_string()); tracing::warn!(%error, "Could not restore Main after recording"); } } @@ -1914,19 +2211,20 @@ fn release_inner( { continue; } - if let Some(window) = handle.get_webview_window(&saved.label) - && let Some(visible) = native_id(&window) - .ok() - .and_then(|id| saved.visibility_for(id)) - { - let result = set_native_visibility(&window, visible); - if let Err(error) = result { - tracing::warn!(%error, "Could not restore clean capture window"); - } + if let Err(error) = restore_saved_window(&handle, &saved) { + restoration_error + .lock() + .unwrap() + .get_or_insert_with(|| error.to_string()); + tracing::warn!(%error, "Could not restore clean capture window"); } } { let mut inner = state.inner.lock().unwrap(); + let result = restoration_error.lock().unwrap().take().map_or(Ok(()), Err); + if !inner.record_stop_restoration(generation, result) { + return; + } inner.lease = None; inner.generation = inner.generation.wrapping_add(1); } @@ -3708,11 +4006,7 @@ fn set_native_visibility(window: &WebviewWindow, visible: bool) -> Result<(), St // Tao queues GTK visibility changes even on the UI thread; this gate needs // the native change to complete before checking its acknowledgement. if visible { - if cap_recording::screenshot::uses_wayland_portal() { - gtk.show(); - } else { - gtk.show_all(); - } + gtk.show_all(); } else { gtk.hide(); } @@ -3923,6 +4217,10 @@ pub(crate) fn wayland_stop_lost(app: &AppHandle, generation: u32, route: StopRou return; }; let stop = lease.lose_stop_route(route); + let studio_stop = (lease.mode == cap_recording::RecordingMode::Studio + && (stop || lease.stop_requested && lease.phase == Phase::Stopping)) + .then(|| lease.recording_dir.clone()) + .flatten(); if lease.stop_requested { lease.stop_error = Some(error); } else if route == StopRoute::Tray { @@ -3930,7 +4228,9 @@ pub(crate) fn wayland_stop_lost(app: &AppHandle, generation: u32, route: StopRou } drop(inner); notify(app); - if stop { + if let Some(directory) = studio_stop { + crate::recording::queue_clean_studio_stop(app, generation, directory); + } else if stop { let app = app.clone(); drop(tauri::async_runtime::spawn(async move { if let Err(error) = crate::recording::stop_recording(app.clone(), app.state()).await { @@ -4241,7 +4541,7 @@ fn restore_wayland_windows( continue; } if wanted { - saved.window.show(); + saved.window.show_all(); if saved.requested_during_stop && saved .label diff --git a/apps/desktop/src-tauri/src/editor_window.rs b/apps/desktop/src-tauri/src/editor_window.rs index 96a9fdba5ce..2b9da332a1e 100644 --- a/apps/desktop/src-tauri/src/editor_window.rs +++ b/apps/desktop/src-tauri/src/editor_window.rs @@ -1,4 +1,14 @@ -use std::{collections::HashMap, ops::Deref, path::PathBuf, sync::Arc, time::Instant}; +use std::{ + collections::HashMap, + ops::Deref, + path::PathBuf, + str::FromStr, + sync::{ + Arc, + atomic::{AtomicU8, Ordering}, + }, + time::Instant, +}; use tauri::{AppHandle, Listener, Manager, Runtime, Window, ipc::CommandArg}; use tokio::sync::{RwLock, watch}; use tokio_util::sync::CancellationToken; @@ -9,6 +19,7 @@ use tauri_specta::Event; use crate::{ FrameLayoutEvent, create_editor_instance_impl, frame_ws::{WSFrame, WSFrameFormat, create_watch_frame_ws}, + windows::{CapWindowId, EditorWindowIds}, }; /// Forwards rendered frames to the preview websocket and mirrors each frame's @@ -68,26 +79,106 @@ pub struct EditorInstance { render_frame_event_id: tauri::EventId, } -type PendingResult = Result, String>; +type PendingResult = Result, String>; type PendingReceiver = tokio::sync::watch::Receiver>; +pub(crate) struct EditorInstanceDelivery { + instance: Arc, + cleanup_runtime: tokio::runtime::Handle, + state: AtomicU8, +} + +impl EditorInstanceDelivery { + const PENDING: u8 = 0; + const ADOPTED: u8 = 1; + const RETIRED: u8 = 2; + + fn new(instance: Arc, cleanup_runtime: tokio::runtime::Handle) -> Arc { + Arc::new(Self { + instance, + cleanup_runtime, + state: AtomicU8::new(Self::PENDING), + }) + } + + fn adopt_into( + &self, + instances: &mut HashMap>, + window_label: &str, + ) -> Result, String> { + self.state + .compare_exchange( + Self::PENDING, + Self::ADOPTED, + Ordering::AcqRel, + Ordering::Acquire, + ) + .map_err(|_| "Editor instance delivery is no longer pending".to_string())?; + let instance = self.instance.clone(); + let _ = instances.insert(window_label.to_string(), instance.clone()); + Ok(instance) + } + + fn retire(&self) -> Option> { + self.state + .compare_exchange( + Self::PENDING, + Self::RETIRED, + Ordering::AcqRel, + Ordering::Acquire, + ) + .ok()?; + let instance = self.instance.clone(); + Some(self.cleanup_runtime.spawn(async move { + instance.dispose().await; + })) + } + + async fn dispose(&self) { + if let Some(cleanup) = self.retire() { + let _ = cleanup.await; + } + } +} + +impl Drop for EditorInstanceDelivery { + fn drop(&mut self) { + drop(self.retire()); + } +} + #[derive(Clone, Default)] pub struct PendingEditorInstances(Arc>>); -async fn do_prewarm(app: AppHandle, path: PathBuf) -> PendingResult { +async fn do_prewarm(app: AppHandle, path: PathBuf) -> Result, String> { let (frame_tx, frame_rx) = watch::channel(None); let (ws_port, ws_shutdown_token) = create_watch_frame_ws(frame_rx, Default::default()).await; + let ws_guard = ws_shutdown_token.clone().drop_guard(); let (inner, render_frame_event_id) = create_editor_instance_impl(&app, path, make_frame_callback(app.clone(), frame_tx)).await?; - Ok(Arc::new(EditorInstance { + let instance = Arc::new(EditorInstance { inner, ws_port, ws_shutdown_token, app_handle: app, render_frame_event_id, - })) + }); + ws_guard.disarm(); + Ok(instance) +} + +fn with_registered_editor( + window_ids: &EditorWindowIds, + id: u32, + action: impl FnOnce() -> T, +) -> Result { + let ids = window_ids.ids.lock().map_err(|error| error.to_string())?; + if !ids.iter().any(|(_, registered_id)| *registered_id == id) { + return Err("Editor window is no longer registered".to_string()); + } + Ok(action()) } impl PendingEditorInstances { @@ -96,32 +187,47 @@ impl PendingEditorInstances { Some(s) => (*s).clone(), None => { let pending = Self::default(); - app.manage(pending.clone()); - pending + app.manage(pending); + (*app.state::()).clone() } } } pub async fn start_prewarm(app: &AppHandle, window_label: String, path: PathBuf) { + let Ok(CapWindowId::Editor { id }) = CapWindowId::from_str(&window_label) else { + return; + }; + let window_ids = EditorWindowIds::get(app); let pending = Self::get(app); let app = app.clone(); - - { - let instances = pending.0.read().await; - if instances.contains_key(&window_label) { - return; - } - } - - let (tx, rx) = tokio::sync::watch::channel(None); - - { + let tx = { let mut instances = pending.0.write().await; - instances.insert(window_label.clone(), rx); - } + let admitted = with_registered_editor(&window_ids, id, || { + use std::collections::hash_map::Entry; + match instances.entry(window_label) { + Entry::Vacant(entry) => { + let (tx, rx) = watch::channel(None); + entry.insert(rx); + Some(tx) + } + Entry::Occupied(_) => None, + } + }); + match admitted { + Ok(Some(tx)) => tx, + Ok(None) => return, + Err(error) => { + tracing::debug!(%error, "Skipping prewarm for a retired editor"); + return; + } + } + }; + let cleanup_runtime = tokio::runtime::Handle::current(); tokio::spawn(async move { - let result = do_prewarm(app, path).await; + let result = do_prewarm(app, path) + .await + .map(|instance| EditorInstanceDelivery::new(instance, cleanup_runtime)); tx.send(Some(result)).ok(); }); } @@ -327,56 +433,64 @@ impl EditorInstances { window: &Window, path: PathBuf, ) -> Result, String> { + let CapWindowId::Editor { id } = + CapWindowId::from_str(window.label()).map_err(|error| error.to_string())? + else { + return Err("Invalid editor window".to_string()); + }; + let window_ids = EditorWindowIds::get(window.app_handle()); + with_registered_editor(&window_ids, id, || ())?; let instances = match window.try_state::() { Some(s) => (*s).clone(), None => { - let instances = Self(Arc::new(RwLock::new(HashMap::new()))); - window.manage(instances.clone()); - instances + window.manage(Self(Arc::new(RwLock::new(HashMap::new())))); + (*window.state::()).clone() } }; - let mut instances = instances.0.write().await; + if let Some(instance) = + with_registered_editor(&window_ids, id, || instances.get(window.label()).cloned())? + { + return Ok(instance); + } - use std::collections::hash_map::Entry; - - match instances.entry(window.label().to_string()) { - Entry::Vacant(entry) => { - let requested_at = Instant::now(); - let pending = PendingEditorInstances::get(window.app_handle()); - - if let Some(mut prewarmed_rx) = pending.take_prewarmed(window.label()).await { - loop { - if let Some(result) = prewarmed_rx.borrow_and_update().clone() { - let instance = result?; - entry.insert(instance.clone()); - tracing::info!( - wait_ms = requested_at.elapsed().as_millis() as u64, - "Editor open: instance served from prewarm" - ); - return Ok(instance); - } - if prewarmed_rx.changed().await.is_err() { - break; - } - } - tracing::warn!( - "Editor open: prewarm channel closed without a result, building on demand" - ); + let requested_at = Instant::now(); + let pending = PendingEditorInstances::get(window.app_handle()); + let mut prewarmed = None; + if let Some(mut prewarmed_rx) = pending.take_prewarmed(window.label()).await { + loop { + let result = prewarmed_rx.borrow_and_update().clone(); + if let Some(result) = result { + prewarmed = Some(result?); + break; } - + if prewarmed_rx.changed().await.is_err() { + break; + } + } + if prewarmed.is_none() { + tracing::warn!( + "Editor open: prewarm channel closed without a result, building on demand" + ); + } + } + let was_prewarmed = prewarmed.is_some(); + let instance = match prewarmed { + Some(instance) => instance, + None => { + with_registered_editor(&window_ids, id, || ())?; + let cleanup_runtime = tokio::runtime::Handle::current(); let (frame_tx, frame_rx) = watch::channel(None); - let (ws_port, ws_shutdown_token) = create_watch_frame_ws(frame_rx, Default::default()).await; + let ws_guard = ws_shutdown_token.clone().drop_guard(); let app_handle = window.app_handle().clone(); let (inner, render_frame_event_id) = create_editor_instance_impl( window.app_handle(), - path, + path.clone(), make_frame_callback(app_handle.clone(), frame_tx), ) .await?; - let instance = Arc::new(EditorInstance { inner, ws_port, @@ -384,18 +498,34 @@ impl EditorInstances { app_handle, render_frame_event_id, }); + ws_guard.disarm(); + EditorInstanceDelivery::new(instance, cleanup_runtime) + } + }; - entry.insert(instance.clone()); - - tracing::info!( - build_ms = requested_at.elapsed().as_millis() as u64, - "Editor open: instance built on demand (no prewarm hit)" - ); - - Ok(instance) + let published = with_registered_editor(&window_ids, id, || { + instance.adopt_into(&mut instances, window.label()) + }); + drop(instances); + let instance = match published { + Ok(Ok(instance)) => instance, + Ok(Err(error)) | Err(error) => { + instance.dispose().await; + return Err(error); } - Entry::Occupied(entry) => Ok(entry.get().clone()), + }; + if was_prewarmed { + tracing::info!( + wait_ms = requested_at.elapsed().as_millis() as u64, + "Editor open: instance served from prewarm" + ); + } else { + tracing::info!( + build_ms = requested_at.elapsed().as_millis() as u64, + "Editor open: instance built on demand (no prewarm hit)" + ); } + Ok(instance) } /// Project paths of every currently open editor. Used to avoid touching diff --git a/apps/desktop/src-tauri/src/export.rs b/apps/desktop/src-tauri/src/export.rs index aa5c89efa50..a47696922f5 100644 --- a/apps/desktop/src-tauri/src/export.rs +++ b/apps/desktop/src-tauri/src/export.rs @@ -22,7 +22,9 @@ use std::{ atomic::{AtomicBool, AtomicUsize, Ordering}, }, }; +#[cfg(not(target_os = "linux"))] use tauri::Manager; +#[cfg(not(target_os = "linux"))] use tauri_plugin_dialog::DialogExt; use tokio::io::AsyncBufReadExt; use tokio_util::sync::CancellationToken; @@ -180,7 +182,12 @@ impl ExportWorkerMode { struct ExportProgress(tauri::ipc::Channel); struct ExportSaveDialogRequest { + #[cfg(not(target_os = "linux"))] app: tauri::AppHandle, + #[cfg(target_os = "linux")] + parent: tauri::Window, + #[cfg(target_os = "linux")] + cancel_token: CancellationToken, file_name: String, file_type: String, } @@ -932,7 +939,7 @@ fn export_project_config( if cursor_only { make_cursor_only_project(project_config) } else { - project_config + cap_export::prepare_project_for_export(project_config) } } @@ -1105,6 +1112,7 @@ pub async fn export_video_to_file( file_type: String, editor: OptionalWindowEditorInstance, ) -> Result { + #[cfg(not(target_os = "linux"))] let app = window.app_handle().clone(); let window_label = window.label().to_string(); Box::pin(run_export_command(move || async move { @@ -1118,7 +1126,12 @@ pub async fn export_video_to_file( editor, ExportProgress(progress), ExportSaveDialogRequest { + #[cfg(not(target_os = "linux"))] app, + #[cfg(target_os = "linux")] + parent: window, + #[cfg(target_os = "linux")] + cancel_token: cancellation_guard.token(), file_name, file_type, }, @@ -1138,12 +1151,7 @@ async fn export_video_to_file_inner( cancel_token: CancellationToken, ) -> Result { let _session_guard = ExportSessionGuard::new(); - let ExportSaveDialogRequest { - app, - file_name, - file_type, - } = save_dialog; - let Some(save_path) = show_export_save_dialog(&app, file_name, file_type).await? else { + let Some(save_path) = show_export_save_dialog(save_dialog).await? else { return Err("Save dialog cancelled".to_string()); }; @@ -1267,10 +1275,18 @@ async fn export_video_inner( } async fn show_export_save_dialog( - app: &tauri::AppHandle, - file_name: String, - file_type: String, + request: ExportSaveDialogRequest, ) -> Result, String> { + let ExportSaveDialogRequest { + #[cfg(not(target_os = "linux"))] + app, + #[cfg(target_os = "linux")] + parent, + #[cfg(target_os = "linux")] + cancel_token, + file_name, + file_type, + } = request; info!(file_name, file_type, "Save file dialog requested"); let (name, extension) = match file_type.as_str() { @@ -1285,21 +1301,134 @@ async fn show_export_save_dialog( info!(file_name, name, extension, "Showing save file dialog"); - let (tx, rx) = tokio::sync::oneshot::channel(); - app.dialog() - .file() - .set_title("Save File") - .set_file_name(file_name) - .add_filter(name, &[extension]) - .save_file(move |path| { - let _ = tx.send(path.and_then(|p| p.as_path().map(PathBuf::from))); - }); - - rx.await.map_err(|e| e.to_string()).inspect(|result| { + #[cfg(target_os = "linux")] + let result = show_linux_save_dialog(parent, cancel_token, file_name, name, extension).await; + #[cfg(not(target_os = "linux"))] + let result = { + let (tx, rx) = tokio::sync::oneshot::channel(); + app.dialog() + .file() + .set_title("Save File") + .set_file_name(file_name) + .add_filter(name, &[extension]) + .save_file(move |path| { + let _ = tx.send(path.and_then(|p| p.as_path().map(PathBuf::from))); + }); + rx.await.map_err(|error| error.to_string()) + }; + result.inspect(|result| { info!(path = ?result, "Save file dialog completed"); }) } +#[cfg(target_os = "linux")] +pub(crate) async fn show_linux_save_dialog( + parent: tauri::Window, + cancel_token: CancellationToken, + file_name: String, + name: &'static str, + extension: &'static str, +) -> Result, String> { + let (tx, rx) = tokio::sync::oneshot::channel(); + let window = parent.clone(); + parent + .run_on_main_thread(move || { + use gtk::prelude::*; + + let prepared = window + .gtk_window() + .map_err(|error| format!("Export window is unavailable: {error}")) + .map(|parent| parent.upcast::()) + .and_then(|parent| { + create_linux_export_save_dialog(&parent, &file_name, name, extension) + .map(|dialog| (parent, dialog)) + }); + match prepared { + Ok((parent, dialog)) => { + run_linux_export_save_dialog(parent, dialog, cancel_token, tx); + } + Err(error) => { + let _ = tx.send(Err(error)); + } + } + }) + .map_err(|error| format!("Unable to show export save dialog: {error}"))?; + rx.await.map_err(|error| error.to_string())? +} + +#[cfg(target_os = "linux")] +fn create_linux_export_save_dialog( + parent: >k::Window, + file_name: &str, + filter_name: &str, + extension: &str, +) -> Result { + use gtk::prelude::*; + + if parent.in_destruction() || !parent.is_mapped() { + return Err("Export window is no longer available".to_string()); + } + + // rfd's GTK backend discards its parent, so tiled compositors can clip the save controls. + let dialog = gtk::FileChooserNative::new( + Some("Save File"), + Some(parent), + gtk::FileChooserAction::Save, + None, + None, + ); + dialog.set_do_overwrite_confirmation(true); + if !file_name.contains('\0') { + dialog.set_current_name(file_name); + } + let filter = gtk::FileFilter::new(); + filter.set_name(Some(filter_name)); + filter.add_pattern(&format!("*.{extension}")); + dialog.add_filter(filter); + Ok(dialog) +} + +#[cfg(target_os = "linux")] +fn run_linux_export_save_dialog( + parent: gtk::Window, + dialog: gtk::FileChooserNative, + cancel_token: CancellationToken, + mut result_tx: tokio::sync::oneshot::Sender, String>>, +) { + use gtk::prelude::*; + + let (destroy_tx, destroyed) = tokio::sync::oneshot::channel(); + let destroy_tx = std::cell::Cell::new(Some(destroy_tx)); + let parent_destroyed = std::rc::Rc::new(std::cell::Cell::new(false)); + let destroyed_flag = parent_destroyed.clone(); + let destroy_handler = parent.connect_destroy(move |_| { + destroyed_flag.set(true); + if let Some(sender) = destroy_tx.take() { + let _ = sender.send(()); + } + }); + std::mem::drop(gtk::glib::MainContext::default().spawn_local(async move { + let result = tokio::select! { + biased; + _ = cancel_token.cancelled() => Err("Export cancelled".to_string()), + _ = result_tx.closed() => Err("Export cancelled".to_string()), + _ = destroyed => Err("Export window was closed".to_string()), + response = dialog.run_future() => { + Ok(if response == gtk::ResponseType::Accept { + dialog.filename() + } else { + None + }) + } + }; + if !parent_destroyed.get() { + parent.disconnect(destroy_handler); + } + dialog.destroy(); + let _ = result_tx.send(result); + })); +} + async fn copy_export_to_path(src: &Path, dst: &Path) -> Result<(), String> { info!( src = %src.display(), @@ -1746,6 +1875,108 @@ mod tests { use super::*; use tempfile::tempdir; + #[tokio::test] + async fn export_preview_reads_saved_caption_choice_after_stale_live_preview_update() { + for export_with_subtitles in [false, true] { + let dir = tempdir().unwrap(); + let mut saved = cap_project::ProjectConfiguration { + captions: Some(cap_project::CaptionsData { + settings: cap_project::CaptionSettings { + enabled: true, + export_with_subtitles, + ..Default::default() + }, + ..Default::default() + }), + ..Default::default() + }; + saved.write(dir.path()).unwrap(); + let (sender, receiver) = tokio::sync::watch::channel(saved.clone()); + saved + .captions + .as_mut() + .unwrap() + .settings + .export_with_subtitles = !export_with_subtitles; + saved.captions.as_mut().unwrap().settings.enabled = false; + sender.send(saved).unwrap(); + + let preview = load_export_preview_config(dir.path().to_path_buf(), false) + .await + .unwrap(); + let settings = preview.captions.unwrap().settings; + assert_eq!(settings.enabled, export_with_subtitles); + assert_eq!(settings.export_with_subtitles, export_with_subtitles); + assert!( + !receiver + .borrow() + .captions + .as_ref() + .unwrap() + .settings + .enabled + ); + let persisted = cap_project::ProjectConfiguration::load(dir.path()).unwrap(); + assert!(persisted.captions.as_ref().unwrap().settings.enabled); + } + } + + #[tokio::test] + async fn export_preview_reports_missing_or_malformed_saved_config() { + let dir = tempdir().unwrap(); + let missing = load_export_preview_config(dir.path().to_path_buf(), false) + .await + .unwrap_err(); + assert!(missing.contains("Failed to read saved project config")); + std::fs::write(dir.path().join("project-config.json"), "{not valid json").unwrap(); + let malformed = load_export_preview_config(dir.path().to_path_buf(), false) + .await + .unwrap_err(); + assert!(malformed.contains("Failed to read saved project config")); + } + + #[test] + fn export_preview_rejects_unloaded_incoming_and_outgoing_media() { + let medias = ["first", "second"]; + assert_eq!(export_preview_media(&medias, 0).unwrap(), &"first"); + assert_eq!(export_preview_media(&medias, 1).unwrap(), &"second"); + assert!(export_preview_media(&medias, 2).is_err()); + assert!(export_preview_media(&medias, u32::MAX).is_err()); + assert!(export_preview_media::<()>(&[], 0).is_err()); + } + + #[test] + fn export_preview_caption_policy_preserves_editor_and_cursor_only_states() { + for enabled in [false, true] { + for export in [false, true] { + for cursor_only in [false, true] { + let editor = cap_project::ProjectConfiguration { + captions: Some(cap_project::CaptionsData { + settings: cap_project::CaptionSettings { + enabled, + export_with_subtitles: export, + ..Default::default() + }, + ..Default::default() + }), + ..Default::default() + }; + let original = serde_json::to_value(&editor).unwrap(); + let preview = export_project_config(editor.clone(), cursor_only); + if cursor_only { + assert!(preview.captions.is_none()); + } else { + assert_eq!( + preview.captions.unwrap().settings.enabled, + enabled && export + ); + } + assert_eq!(serde_json::to_value(editor).unwrap(), original); + } + } + } + } + #[test] fn export_estimates_use_source_duration_without_a_timeline() { assert_eq!( @@ -1883,6 +2114,29 @@ pub async fn generate_export_preview_fast( } } +async fn load_export_preview_config( + project_path: PathBuf, + cursor_only: bool, +) -> Result { + tokio::task::spawn_blocking(move || { + cap_project::ProjectConfiguration::load(&project_path) + .map(|config| export_project_config(config, cursor_only)) + .map_err(|error| { + format!("Failed to read saved project config for export preview: {error}") + }) + }) + .await + .map_err(|error| format!("Failed to load export preview config: {error}"))? +} + +fn export_preview_media(medias: &[T], recording_clip: u32) -> Result<&T, String> { + medias.get(recording_clip as usize).ok_or_else(|| { + format!( + "Recording clip {recording_clip} is unavailable in this editor. Reopen the project before exporting." + ) + }) +} + #[instrument(skip_all)] async fn generate_export_preview_fast_inner( editor: WindowEditorInstance, @@ -1898,10 +2152,8 @@ async fn generate_export_preview_fast_inner( let _preview_guard = ExportPreviewActiveGuard::try_new(&editor.export_preview_active)?; - let project_config = export_project_config( - editor.project_config.1.borrow().clone(), - settings.cursor_only, - ); + let project_config = + load_export_preview_config(editor.project_path.clone(), settings.cursor_only).await?; let transition_mapping = project_config.timeline.as_ref().and_then(|timeline| { if timeline.transitions.is_empty() { return None; @@ -1921,7 +2173,7 @@ async fn generate_export_preview_fast_inner( return Err("Frame time is outside video duration".to_string()); }; - let segment_media = &editor.segment_medias[segment.recording_clip as usize]; + let segment_media = export_preview_media(&editor.segment_medias, segment.recording_clip)?; let clip_config = project_config .clips .iter() @@ -1976,7 +2228,8 @@ async fn generate_export_preview_fast_inner( ); let frame = if let Some((outgoing, kind, progress)) = transition_mapping { - let outgoing_media = &editor.segment_medias[outgoing.segment.recording_clip as usize]; + let outgoing_media = + export_preview_media(&editor.segment_medias, outgoing.segment.recording_clip)?; let outgoing_offsets = project_config .clips .iter() diff --git a/apps/desktop/src-tauri/src/fake_window.rs b/apps/desktop/src-tauri/src/fake_window.rs index 7bb49faf476..edf20ad9d61 100644 --- a/apps/desktop/src-tauri/src/fake_window.rs +++ b/apps/desktop/src-tauri/src/fake_window.rs @@ -491,8 +491,14 @@ pub fn spawn_fake_window_listener(app: AppHandle, window: WebviewWindow) { consecutive_errors = 0; #[cfg(target_os = "macos")] - let mouse_position = match window.primary_monitor().ok().flatten().and_then(|monitor| { - macos_cursor_in_window_scale(mouse_position, monitor.scale_factor(), scale_factor) + let mouse_position = match objc2::rc::autoreleasepool(|_| { + window.primary_monitor().ok().flatten().and_then(|monitor| { + macos_cursor_in_window_scale( + mouse_position, + monitor.scale_factor(), + scale_factor, + ) + }) }) { Some(position) => position, None => { diff --git a/apps/desktop/src-tauri/src/frame_ws.rs b/apps/desktop/src-tauri/src/frame_ws.rs index 4c78a129620..33d05b8a949 100644 --- a/apps/desktop/src-tauri/src/frame_ws.rs +++ b/apps/desktop/src-tauri/src/frame_ws.rs @@ -204,16 +204,23 @@ async fn create_watch_frame_ws_inner( watch::Receiver>>, Arc, Option>, + CancellationToken, ); #[axum::debug_handler] async fn ws_handler( ws: WebSocketUpgrade, Query(query): Query, - State((state, subscribers, instant_subscribers)): State, + State((state, subscribers, instant_subscribers, shutdown)): State, ) -> impl IntoResponse { let instant_subscribers = query.instant.then_some(instant_subscribers).flatten(); - ws.on_upgrade(move |socket| handle_socket(socket, state, subscribers, instant_subscribers)) + ws.on_upgrade(move |socket| async move { + tokio::select! { + biased; + _ = shutdown.cancelled() => {}, + _ = handle_socket(socket, state, subscribers, instant_subscribers) => {}, + } + }) } async fn handle_socket( @@ -285,7 +292,10 @@ async fn create_watch_frame_ws_inner( } } }, - _ = camera_rx.changed() => { + changed = camera_rx.changed() => { + if changed.is_err() { + break; + } let frame_arc = camera_rx.borrow_and_update().clone(); if let Some(ref frame) = frame_arc { let width = frame.width; @@ -353,41 +363,46 @@ async fn create_watch_frame_ws_inner( tracing::info!("Websocket closing after {elapsed:.2?}"); } + let cancel_token = CancellationToken::new(); + let server_shutdown = cancel_token.child_token(); let router = axum::Router::new().route("/", get(ws_handler)).with_state(( frame_rx, subscribers, instant_subscribers, + server_shutdown.clone(), )); - - let cancel_token = CancellationToken::new(); - let cancel_token_child = cancel_token.child_token(); let listener = match tokio::net::TcpListener::bind("127.0.0.1:0").await { Ok(listener) => listener, Err(err) => { tracing::error!("Failed to bind watch frame websocket listener: {err}"); - return (0, cancel_token_child); + cancel_token.cancel(); + return (0, cancel_token); } }; let port = match listener.local_addr() { Ok(addr) => addr.port(), Err(err) => { tracing::error!("Failed to read watch frame websocket listener address: {err}"); - return (0, cancel_token_child); + cancel_token.cancel(); + return (0, cancel_token); } }; tracing::info!("WebSocket server listening on port {}", port); tokio::spawn(async move { - let server = axum::serve(listener, router.into_make_service()); + let _shutdown_guard = server_shutdown.clone().drop_guard(); + let server = axum::serve(listener, router.into_make_service()) + .with_graceful_shutdown(server_shutdown.clone().cancelled_owned()); tokio::select! { - _ = server => {}, - _ = cancel_token.cancelled() => { + biased; + _ = server_shutdown.cancelled() => { tracing::info!("WebSocket server shutting down"); - } + }, + _ = server => {}, } }); - (port, cancel_token_child) + (port, cancel_token) } pub async fn create_frame_ws(frame_tx: broadcast::Sender) -> (u16, CancellationToken) { @@ -400,15 +415,21 @@ pub async fn create_frame_ws(frame_tx: broadcast::Sender) -> (u16, Canc routing::get, }; - type RouterState = broadcast::Sender; + type RouterState = (broadcast::Sender, CancellationToken); #[axum::debug_handler] async fn ws_handler( ws: WebSocketUpgrade, - State(state): State, + State((state, shutdown)): State, ) -> impl IntoResponse { let rx = state.subscribe(); - ws.on_upgrade(move |socket| handle_socket(socket, rx)) + ws.on_upgrade(move |socket| async move { + tokio::select! { + biased; + _ = shutdown.cancelled() => {}, + _ = handle_socket(socket, rx) => {}, + } + }) } async fn handle_socket(mut socket: WebSocket, mut camera_rx: broadcast::Receiver) { @@ -475,39 +496,43 @@ pub async fn create_frame_ws(frame_tx: broadcast::Sender) -> (u16, Canc tracing::info!("Websocket closing after {elapsed:.2?}"); } + let cancel_token = CancellationToken::new(); + let server_shutdown = cancel_token.child_token(); let router = axum::Router::new() .route("/", get(ws_handler)) - .with_state(frame_tx); - - let cancel_token = CancellationToken::new(); - let cancel_token_child = cancel_token.child_token(); + .with_state((frame_tx, server_shutdown.clone())); let listener = match tokio::net::TcpListener::bind("127.0.0.1:0").await { Ok(listener) => listener, Err(err) => { tracing::error!("Failed to bind frame websocket listener: {err}"); - return (0, cancel_token_child); + cancel_token.cancel(); + return (0, cancel_token); } }; let port = match listener.local_addr() { Ok(addr) => addr.port(), Err(err) => { tracing::error!("Failed to read frame websocket listener address: {err}"); - return (0, cancel_token_child); + cancel_token.cancel(); + return (0, cancel_token); } }; tracing::info!("WebSocket server listening on port {}", port); tokio::spawn(async move { - let server = axum::serve(listener, router.into_make_service()); + let _shutdown_guard = server_shutdown.clone().drop_guard(); + let server = axum::serve(listener, router.into_make_service()) + .with_graceful_shutdown(server_shutdown.clone().cancelled_owned()); tokio::select! { - _ = server => {}, - _ = cancel_token.cancelled() => { + biased; + _ = server_shutdown.cancelled() => { tracing::info!("WebSocket server shutting down"); - } + }, + _ = server => {}, } }); - (port, cancel_token_child) + (port, cancel_token) } #[cfg(test)] @@ -563,3 +588,351 @@ mod tests { assert_eq!(instant_subscribers.load(Ordering::Acquire), 0); } } + +#[cfg(test)] +mod shutdown_tests { + use super::*; + use std::time::Duration; + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + use tokio::net::{TcpListener, TcpStream}; + + fn frame() -> WSFrame { + WSFrame { + data: Arc::new(vec![1, 2, 3, 4]), + width: 1, + height: 1, + stride: 4, + frame_number: 42, + target_time_ns: 1234, + format: WSFrameFormat::Rgba, + created_at: Instant::now(), + } + } + + async fn wait_until(condition: impl Fn() -> bool) { + tokio::time::timeout(Duration::from_secs(2), async { + while !condition() { + tokio::time::sleep(Duration::from_millis(5)).await; + } + }) + .await + .expect("shutdown condition did not settle"); + } + + async fn connect(port: u16, instant: bool) -> TcpStream { + assert_ne!(port, 0); + tokio::time::timeout(Duration::from_secs(2), async { + let mut stream = TcpStream::connect(("127.0.0.1", port)).await.unwrap(); + let path = if instant { "/?instant=true" } else { "/" }; + let request = format!( + "GET {path} HTTP/1.1\r\nHost: 127.0.0.1:{port}\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\r\nSec-WebSocket-Version: 13\r\n\r\n" + ); + stream.write_all(request.as_bytes()).await.unwrap(); + let mut response = Vec::new(); + while !response.ends_with(b"\r\n\r\n") { + assert!(response.len() < 4096); + response.push(stream.read_u8().await.unwrap()); + } + assert!(response.starts_with(b"HTTP/1.1 101")); + stream + }) + .await + .expect("websocket handshake timed out") + } + + async fn read_binary(stream: &mut TcpStream) -> Vec { + tokio::time::timeout(Duration::from_secs(2), async { + assert_eq!(stream.read_u8().await.unwrap(), 0x82); + let length = stream.read_u8().await.unwrap(); + assert!(length < 126); + let mut data = vec![0; usize::from(length)]; + stream.read_exact(&mut data).await.unwrap(); + data + }) + .await + .expect("frame delivery timed out") + } + + async fn assert_disconnected(stream: &mut TcpStream) { + tokio::time::timeout(Duration::from_secs(2), async { + let mut buffer = [0; 8192]; + loop { + match stream.read(&mut buffer).await { + Ok(0) | Err(_) => break, + Ok(_) => {} + } + } + }) + .await + .expect("connected websocket did not close"); + } + + async fn assert_listener_closed(port: u16) { + tokio::time::timeout(Duration::from_secs(2), async { + while TcpStream::connect(("127.0.0.1", port)).await.is_ok() { + tokio::time::sleep(Duration::from_millis(5)).await; + } + }) + .await + .expect("websocket listener still accepts connections"); + } + + async fn assert_unused_port_released(port: u16) { + let listener = tokio::time::timeout(Duration::from_secs(2), async { + loop { + if let Ok(listener) = TcpListener::bind(("127.0.0.1", port)).await { + break listener; + } + tokio::time::sleep(Duration::from_millis(5)).await; + } + }) + .await + .expect("websocket listener still owns its port"); + drop(listener); + assert_listener_closed(port).await; + } + + #[tokio::test] + async fn watch_listener_releases_port_after_cancel_before_server_poll() { + let (_tx, rx) = watch::channel(None); + let (port, shutdown) = create_watch_frame_ws(rx, Default::default()).await; + shutdown.cancel(); + assert_unused_port_released(port).await; + } + + #[tokio::test] + async fn broadcast_listener_releases_port_after_cancel_before_server_poll() { + let (tx, _rx) = broadcast::channel(2); + let (port, shutdown) = create_frame_ws(tx).await; + shutdown.cancel(); + assert_unused_port_released(port).await; + } + + #[tokio::test] + async fn watch_shutdown_drains_existing_clients_and_subscriber_counts() { + let (tx, rx) = watch::channel(None); + let subscribers = Arc::new(AtomicUsize::new(0)); + let instant = Arc::new(AtomicUsize::new(0)); + let (port, shutdown) = + create_watch_frame_ws_with_instant_tracking(rx, subscribers.clone(), instant.clone()) + .await; + let mut first = connect(port, false).await; + let mut second = connect(port, true).await; + wait_until(|| subscribers.load(Ordering::Acquire) == 2).await; + assert_eq!(instant.load(Ordering::Acquire), 1); + let frame = frame(); + let expected = pack_ws_frame(&frame); + tx.send(Some(Arc::new(frame))).unwrap(); + assert_eq!(read_binary(&mut first).await, expected); + assert_eq!(read_binary(&mut second).await, expected); + shutdown.cancel(); + assert_disconnected(&mut first).await; + assert_disconnected(&mut second).await; + wait_until(|| subscribers.load(Ordering::Acquire) == 0 && tx.receiver_count() == 0).await; + assert_eq!(instant.load(Ordering::Acquire), 0); + assert_listener_closed(port).await; + } + + #[tokio::test] + async fn broadcast_shutdown_drains_existing_clients() { + let (tx, rx) = broadcast::channel(2); + drop(rx); + let (port, shutdown) = create_frame_ws(tx.clone()).await; + let mut first = connect(port, false).await; + let mut second = connect(port, false).await; + let frame = frame(); + let expected = pack_ws_frame(&frame); + assert!(matches!(tx.send(frame), Ok(2))); + assert_eq!(read_binary(&mut first).await, expected); + assert_eq!(read_binary(&mut second).await, expected); + shutdown.cancel(); + assert_disconnected(&mut first).await; + assert_disconnected(&mut second).await; + wait_until(|| tx.receiver_count() == 0).await; + assert_listener_closed(port).await; + } + + #[tokio::test] + async fn watch_source_close_does_not_resend_retained_frame() { + let frame = Arc::new(frame()); + let (tx, rx) = watch::channel(Some(frame.clone())); + let subscribers = Arc::new(AtomicUsize::new(0)); + let (port, shutdown) = create_watch_frame_ws(rx, subscribers.clone()).await; + let mut client = connect(port, false).await; + assert_eq!(read_binary(&mut client).await, pack_ws_frame(&frame)); + drop(tx); + wait_until(|| subscribers.load(Ordering::Acquire) == 0).await; + assert_disconnected(&mut client).await; + shutdown.cancel(); + assert_listener_closed(port).await; + } + + #[tokio::test] + async fn watch_shutdown_interrupts_stalled_initial_send() { + let mut frame = frame(); + frame.width = 4096; + frame.height = 1024; + frame.stride = 4096 * 4; + frame.data = Arc::new(vec![0; frame.stride as usize * frame.height as usize]); + let weak_data = Arc::downgrade(&frame.data); + let (tx, rx) = watch::channel(Some(Arc::new(frame))); + let subscribers = Arc::new(AtomicUsize::new(0)); + let (port, shutdown) = create_watch_frame_ws(rx, subscribers.clone()).await; + let client = connect(port, false).await; + wait_until(|| subscribers.load(Ordering::Acquire) == 1).await; + tokio::time::sleep(Duration::from_millis(20)).await; + shutdown.cancel(); + wait_until(|| subscribers.load(Ordering::Acquire) == 0 && tx.receiver_count() == 0).await; + drop(tx); + assert!(weak_data.upgrade().is_none()); + drop(client); + assert_listener_closed(port).await; + } + + #[tokio::test] + async fn cancelling_owner_child_does_not_stop_sibling_server() { + let (tx, rx) = watch::channel(None); + let (port, shutdown) = create_watch_frame_ws(rx, Default::default()).await; + shutdown.child_token().cancel(); + assert!(!shutdown.is_cancelled()); + let mut client = connect(port, false).await; + let frame = frame(); + let expected = pack_ws_frame(&frame); + tx.send(Some(Arc::new(frame))).unwrap(); + assert_eq!(read_binary(&mut client).await, expected); + shutdown.cancel(); + assert_disconnected(&mut client).await; + assert_listener_closed(port).await; + } + + #[tokio::test] + async fn dropping_owner_token_preserves_app_scoped_camera_server() { + let (tx, rx) = watch::channel(None); + let (port, shutdown) = create_watch_frame_ws(rx, Default::default()).await; + drop(shutdown); + let mut client = connect(port, false).await; + let frame = frame(); + let expected = pack_ws_frame(&frame); + tx.send(Some(Arc::new(frame))).unwrap(); + assert_eq!(read_binary(&mut client).await, expected); + } + + #[tokio::test] + async fn aborted_construction_drop_guard_releases_listener() { + let (port_tx, port_rx) = tokio::sync::oneshot::channel(); + let construction = tokio::spawn(async move { + let (_tx, rx) = watch::channel(None); + let (port, shutdown) = create_watch_frame_ws(rx, Default::default()).await; + let _guard = shutdown.clone().drop_guard(); + port_tx.send(port).unwrap(); + std::future::pending::<()>().await; + }); + let port = port_rx.await.unwrap(); + construction.abort(); + assert!(construction.await.unwrap_err().is_cancelled()); + assert_unused_port_released(port).await; + } + + #[tokio::test] + async fn successful_construction_disarms_guard_until_owner_cancels() { + let (tx, rx) = watch::channel(None); + let (port, shutdown) = create_watch_frame_ws(rx, Default::default()).await; + let guard = shutdown.clone().drop_guard(); + drop(guard.disarm()); + let mut client = connect(port, false).await; + let frame = frame(); + let expected = pack_ws_frame(&frame); + tx.send(Some(Arc::new(frame))).unwrap(); + assert_eq!(read_binary(&mut client).await, expected); + shutdown.cancel(); + assert_disconnected(&mut client).await; + assert_listener_closed(port).await; + } + + #[tokio::test] + async fn repeated_watch_open_cancel_releases_every_port() { + for _ in 0..20 { + let (_tx, rx) = watch::channel(None); + let (port, shutdown) = create_watch_frame_ws(rx, Default::default()).await; + let mut client = connect(port, false).await; + shutdown.cancel(); + assert_disconnected(&mut client).await; + assert_listener_closed(port).await; + } + } + + async fn connect_http_client(port: u16) -> TcpStream { + tokio::time::timeout(Duration::from_secs(2), async { + let mut client = TcpStream::connect(("127.0.0.1", port)).await.unwrap(); + client + .write_all(b"GET / HTTP/1.1\r\nHost: localhost\r\n\r\n") + .await + .unwrap(); + let mut response = Vec::new(); + while !response.ends_with(b"\r\n\r\n") { + assert!(response.len() < 4096); + response.push(client.read_u8().await.unwrap()); + } + assert!(response.starts_with(b"HTTP/1.1 400")); + let headers = std::str::from_utf8(&response).unwrap(); + let length = headers + .lines() + .filter_map(|line| line.split_once(':')) + .find(|(name, _)| name.eq_ignore_ascii_case("content-length")) + .unwrap() + .1 + .trim() + .parse::() + .unwrap(); + assert!(length < 4096); + client.read_exact(&mut vec![0; length]).await.unwrap(); + client + }) + .await + .expect("HTTP connection did not become ready") + } + + #[tokio::test] + async fn watch_shutdown_drains_incomplete_http_upgrade() { + let (tx, rx) = watch::channel(None); + let (port, shutdown) = create_watch_frame_ws(rx, Default::default()).await; + let mut client = connect_http_client(port).await; + client + .write_all(b"GET / HTTP/1.1\r\nUpgrade: websocket\r\n") + .await + .unwrap(); + tokio::task::yield_now().await; + shutdown.cancel(); + assert_disconnected(&mut client).await; + wait_until(|| tx.receiver_count() == 0).await; + assert_listener_closed(port).await; + } + + #[tokio::test] + async fn broadcast_shutdown_drains_incomplete_http_upgrade() { + let (tx, rx) = broadcast::channel(2); + drop(rx); + let (port, shutdown) = create_frame_ws(tx.clone()).await; + let mut client = connect_http_client(port).await; + client + .write_all(b"GET / HTTP/1.1\r\nUpgrade: websocket\r\n") + .await + .unwrap(); + tokio::task::yield_now().await; + shutdown.cancel(); + assert_disconnected(&mut client).await; + wait_until(|| tx.strong_count() == 1).await; + assert_listener_closed(port).await; + } + + #[tokio::test] + async fn watch_shutdown_drains_http_keep_alive_connection() { + let (tx, rx) = watch::channel(None); + let (port, shutdown) = create_watch_frame_ws(rx, Default::default()).await; + let mut client = connect_http_client(port).await; + shutdown.cancel(); + assert_disconnected(&mut client).await; + wait_until(|| tx.receiver_count() == 0).await; + assert_listener_closed(port).await; + } +} diff --git a/apps/desktop/src-tauri/src/general_settings.rs b/apps/desktop/src-tauri/src/general_settings.rs index 1ef943637ae..7d74201f07d 100644 --- a/apps/desktop/src-tauri/src/general_settings.rs +++ b/apps/desktop/src-tauri/src/general_settings.rs @@ -2,9 +2,11 @@ use crate::updates::UpdateChannel; use crate::window_exclusion::WindowExclusion; use scap_targets::DisplayId; use serde::{Deserialize, Serialize}; -use serde_json::json; +use serde_json::{Value, json}; use specta::Type; use std::collections::BTreeMap; +use std::io::Write; +use std::path::{Path, PathBuf}; #[cfg(target_os = "macos")] use tauri::Listener; use tauri::{AppHandle, Manager, Wry}; @@ -375,6 +377,175 @@ pub enum AppTheme { Dark, } +struct GeneralSettingsSnapshot { + original: Option, + settings: GeneralSettingsStore, + invalid_fields: Vec, +} + +impl GeneralSettingsSnapshot { + fn load(original: Option) -> Result { + let Some(raw) = original.as_ref() else { + return Ok(Self { + original, + settings: GeneralSettingsStore::default(), + invalid_fields: Vec::new(), + }); + }; + if raw.is_object() + && let Ok(settings) = serde_json::from_value(raw.clone()) + { + return Ok(Self { + original, + settings, + invalid_fields: Vec::new(), + }); + } + + let mut invalid_fields = Vec::new(); + let settings = if let Some(fields) = raw.as_object() { + let mut recovered = fields.clone(); + for (key, value) in fields { + let field = Value::Object([(key.clone(), value.clone())].into_iter().collect()); + if serde_json::from_value::(field).is_err() { + let _ = recovered.remove(key); + invalid_fields.push(key.clone()); + } + } + serde_json::from_value(Value::Object(recovered)) + .map_err(|_| "Could not recover general settings fields".to_string())? + } else { + invalid_fields.push("general_settings".to_string()); + GeneralSettingsStore::default() + }; + + Ok(Self { + original, + settings, + invalid_fields, + }) + } + + fn persist( + &self, + settings: &GeneralSettingsStore, + backup: impl FnOnce(&Value) -> Result<(), String>, + save: impl FnOnce(Value) -> Result<(), String>, + ) -> Result<(), String> { + let before = serde_json::to_value(&self.settings).map_err(|error| error.to_string())?; + let after = serde_json::to_value(settings).map_err(|error| error.to_string())?; + let mut original = self.original.clone().unwrap_or(Value::Null); + if let Some(fields) = original.as_object_mut() { + for key in &self.invalid_fields { + let _ = fields.remove(key); + } + } + let merged = merge_settings_changes(&original, &before, &after); + + if !self.invalid_fields.is_empty() + && let Some(raw) = self.original.as_ref() + { + backup(raw)?; + } + save(merged) + } +} + +fn merge_settings_changes(original: &Value, before: &Value, after: &Value) -> Value { + if let (Some(original), Some(before), Some(after)) = + (original.as_object(), before.as_object(), after.as_object()) + { + let mut merged = original.clone(); + for (key, value) in after { + let next = match (original.get(key), before.get(key)) { + (Some(original), Some(before)) if before == value => original.clone(), + (Some(original), Some(before)) => merge_settings_changes(original, before, value), + _ => value.clone(), + }; + let _ = merged.insert(key.clone(), next); + } + for key in before.keys() { + if !after.contains_key(key) { + let _ = merged.remove(key); + } + } + Value::Object(merged) + } else if let (Some(original), Some(before), Some(after)) = + (original.as_array(), before.as_array(), after.as_array()) + { + let mut used = vec![false; before.len()]; + Value::Array( + after + .iter() + .map(|value| { + let matching = before + .iter() + .enumerate() + .find(|(index, previous)| !used[*index] && *previous == value); + if let Some((index, _)) = matching { + used[index] = true; + original + .get(index) + .cloned() + .unwrap_or_else(|| value.clone()) + } else { + value.clone() + } + }) + .collect(), + ) + } else { + after.clone() + } +} + +fn backup_general_settings(directory: &Path, original: &Value) -> Result { + std::fs::create_dir_all(directory).map_err(|error| error.to_string())?; + let path = directory.join(format!("general-settings-recovery-{}.json", Uuid::new_v4())); + let mut options = std::fs::OpenOptions::new(); + options.write(true).create_new(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + options.mode(0o600); + } + let mut file = options.open(&path).map_err(|error| error.to_string())?; + let bytes = serde_json::to_vec_pretty(original).map_err(|error| error.to_string())?; + file.write_all(&bytes).map_err(|error| error.to_string())?; + file.sync_all().map_err(|error| error.to_string())?; + Ok(path) +} + +fn persist_general_settings( + app: &AppHandle, + store: &tauri_plugin_store::Store, + snapshot: &GeneralSettingsSnapshot, + settings: &GeneralSettingsStore, +) -> Result<(), String> { + snapshot.persist( + settings, + |original| { + let directory = app + .path() + .app_data_dir() + .map_err(|error| error.to_string())?; + let path = backup_general_settings(&directory, original).map_err(|error| { + format!("Could not back up malformed general settings: {error}") + })?; + tracing::warn!( + fields = ?snapshot.invalid_fields, + backup = %path.display(), + "Recovered malformed general settings fields" + ); + Ok(()) + }, + |value| { + store.set("general_settings", value); + store.save().map_err(|error| error.to_string()) + }, + ) +} + impl GeneralSettingsStore { pub fn recordings_dir(app: &AppHandle) -> std::path::PathBuf { let custom = Self::get(app) @@ -426,12 +597,8 @@ impl GeneralSettingsStore { pub fn get(app: &AppHandle) -> Result, String> { match app.store("store").map(|s| s.get("general_settings")) { - Ok(Some(store)) => { - // Handle potential deserialization errors gracefully - match serde_json::from_value(store) { - Ok(settings) => Ok(Some(settings)), - Err(e) => Err(format!("Failed to deserialize general settings store: {e}")), - } + Ok(Some(raw)) => { + GeneralSettingsSnapshot::load(Some(raw)).map(|snapshot| Some(snapshot.settings)) } _ => Ok(None), } @@ -443,10 +610,10 @@ impl GeneralSettingsStore { return Err("Store not found".to_string()); }; - let mut settings = Self::get(app)?.unwrap_or_default(); + let snapshot = GeneralSettingsSnapshot::load(store.get("general_settings"))?; + let mut settings = snapshot.settings.clone(); update(&mut settings); - store.set("general_settings", json!(settings)); - store.save().map_err(|e| e.to_string())?; + persist_general_settings(app, &store, &snapshot, &settings)?; crate::telemetry::set_telemetry_enabled(settings.enable_telemetry); @@ -461,8 +628,8 @@ impl GeneralSettingsStore { return Err("Store not found".to_string()); }; - store.set("general_settings", json!(self)); - store.save().map_err(|e| e.to_string()) + let snapshot = GeneralSettingsSnapshot::load(store.get("general_settings"))?; + persist_general_settings(app, &store, &snapshot, self) } } @@ -596,6 +763,200 @@ pub fn get_default_excluded_windows() -> Vec { mod tests { use super::*; + fn settings_with_preserved_values() -> Value { + json!({ + "instanceId": "123e4567-e89b-42d3-a456-426614174000", + "commercialLicense": { + "licenseKey": "test-license", + "expiryDate": null, + "refresh": 123.0, + "activatedOn": 100.0, + "futureLicenseField": { "preserve": true } + }, + "recordingsPath": "/Volumes/Test Recordings", + "previousRecordingsPaths": ["/Volumes/Previous"], + "theme": "dark", + "futureSetting": { "preserve": [1, 2, 3] }, + "mainWindowPosition": { "x": 20.0, "y": 30.0, "futurePositionField": true }, + "excludedWindows": [{ "windowTitle": "Custom", "futureExclusionField": true }] + }) + } + + #[test] + fn malformed_field_recovery_preserves_identity_license_paths_and_unknown_fields() { + let mut original = settings_with_preserved_values(); + original["theme"] = json!({ "system": {}, "dark": {} }); + original["maxFps"] = json!("invalid"); + let snapshot = GeneralSettingsSnapshot::load(Some(original.clone())).unwrap(); + assert_eq!(snapshot.invalid_fields, ["maxFps", "theme"]); + assert_eq!( + snapshot.settings.instance_id.to_string(), + original["instanceId"] + ); + assert_eq!( + snapshot.settings.recordings_path.as_deref(), + original["recordingsPath"].as_str() + ); + snapshot + .persist( + &snapshot.settings, + |backup| { + assert_eq!(backup, &original); + Ok(()) + }, + |saved| { + for key in [ + "instanceId", + "commercialLicense", + "recordingsPath", + "previousRecordingsPaths", + "futureSetting", + "mainWindowPosition", + "excludedWindows", + ] { + assert_eq!(saved[key], original[key], "{key}"); + } + assert_eq!(saved["theme"], "system"); + assert_eq!(saved["maxFps"], cap_recording::DEFAULT_STUDIO_MAX_FPS); + assert!(serde_json::from_value::(saved).is_ok()); + Ok(()) + }, + ) + .unwrap(); + } + + #[test] + fn settings_updates_preserve_unknown_nested_fields_and_unchanged_array_entries() { + let original = settings_with_preserved_values(); + let snapshot = GeneralSettingsSnapshot::load(Some(original.clone())).unwrap(); + let mut updated = snapshot.settings.clone(); + updated.hide_dock_icon = true; + updated.main_window_position.as_mut().unwrap().x = 90.0; + updated.commercial_license.as_mut().unwrap().refresh = 456.0; + append_missing_default_excluded_windows(&mut updated.excluded_windows); + snapshot + .persist( + &updated, + |_| panic!("valid settings must not need recovery"), + |saved| { + assert_eq!(saved["hideDockIcon"], true); + assert_eq!(saved["mainWindowPosition"]["x"], 90.0); + assert_eq!(saved["mainWindowPosition"]["futurePositionField"], true); + assert_eq!(saved["commercialLicense"]["refresh"], 456.0); + assert_eq!( + saved["commercialLicense"]["futureLicenseField"], + original["commercialLicense"]["futureLicenseField"] + ); + assert_eq!(saved["futureSetting"], original["futureSetting"]); + assert_eq!(saved["excludedWindows"][0], original["excludedWindows"][0]); + Ok(()) + }, + ) + .unwrap(); + } + + #[test] + fn healthy_settings_keep_existing_values_and_do_not_create_backups() { + let original = settings_with_preserved_values(); + let snapshot = GeneralSettingsSnapshot::load(Some(original.clone())).unwrap(); + snapshot + .persist( + &snapshot.settings, + |_| panic!("valid settings must not need recovery"), + |saved| { + for (key, value) in original.as_object().unwrap() { + assert_eq!(&saved[key], value, "{key}"); + } + Ok(()) + }, + ) + .unwrap(); + } + + #[test] + fn failed_backup_does_not_overwrite_malformed_section() { + let original = json!({ "theme": "future-theme", "recordingsPath": "/Volumes/Test" }); + let snapshot = GeneralSettingsSnapshot::load(Some(original.clone())).unwrap(); + let result = snapshot.persist( + &snapshot.settings, + |backup| { + assert_eq!(backup, &original); + Err("injected backup failure".into()) + }, + |_| panic!("the original section must survive a failed backup"), + ); + assert_eq!(result.unwrap_err(), "injected backup failure"); + } + + #[test] + fn non_object_section_is_backed_up_before_recovery() { + let original = json!(["unreadable", 123]); + let snapshot = GeneralSettingsSnapshot::load(Some(original.clone())).unwrap(); + let backed_up = std::cell::Cell::new(false); + snapshot + .persist( + &snapshot.settings, + |backup| { + assert_eq!(backup, &original); + backed_up.set(true); + Ok(()) + }, + |saved| { + assert!(backed_up.get()); + assert!(serde_json::from_value::(saved).is_ok()); + Ok(()) + }, + ) + .unwrap(); + } + + #[test] + fn missing_section_keeps_new_install_defaults_without_recovery() { + let snapshot = GeneralSettingsSnapshot::load(None).unwrap(); + assert!(!snapshot.settings.has_completed_onboarding); + assert!(!snapshot.settings.has_completed_startup); + assert_eq!(snapshot.settings.recording_countdown, Some(3)); + snapshot + .persist( + &snapshot.settings, + |_| panic!("new settings must not need recovery"), + |saved| { + assert_eq!(saved["hasCompletedOnboarding"], false); + assert_eq!(saved["recordingCountdown"], 3); + Ok(()) + }, + ) + .unwrap(); + } + + #[test] + fn recovery_backups_are_exact_unique_and_private() { + let directory = tempfile::tempdir().unwrap(); + let original = settings_with_preserved_values(); + let first = backup_general_settings(directory.path(), &original).unwrap(); + let second = backup_general_settings(directory.path(), &json!("another section")).unwrap(); + assert_ne!(first, second); + let saved: Value = serde_json::from_slice(&std::fs::read(&first).unwrap()).unwrap(); + assert_eq!(saved, original); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + assert_eq!( + std::fs::metadata(first).unwrap().permissions().mode() & 0o777, + 0o600 + ); + } + } + + #[test] + fn recovery_backup_reports_unwritable_destination() { + let directory = tempfile::tempdir().unwrap(); + let blocker = directory.path().join("file"); + std::fs::write(&blocker, "keep").unwrap(); + assert!(backup_general_settings(&blocker, &json!({ "theme": [] })).is_err()); + assert_eq!(std::fs::read_to_string(blocker).unwrap(), "keep"); + } + fn title_exclusion(title: &str) -> WindowExclusion { WindowExclusion { bundle_identifier: None, diff --git a/apps/desktop/src-tauri/src/hotkeys.rs b/apps/desktop/src-tauri/src/hotkeys.rs index 39a3fb00017..cd8cd786996 100644 --- a/apps/desktop/src-tauri/src/hotkeys.rs +++ b/apps/desktop/src-tauri/src/hotkeys.rs @@ -156,7 +156,27 @@ fn should_confirm_without_microphone(enabled: bool, microphone_available: bool) enabled && !microphone_available } -fn should_confirm_direct_recording(app: &AppHandle) -> bool { +#[cfg(any(target_os = "macos", test))] +fn microphone_available_for_confirmation( + name: Option<&str>, + permission: impl FnOnce() -> Result<(), String>, + contains: impl FnOnce(&str) -> bool, +) -> Result { + let Some(name) = name else { + return Ok(false); + }; + permission().map_err(|error| { + format!("{error} To record without a microphone, turn the microphone Off in Cap.") + })?; + if !contains(name) { + return Err(format!( + "Selected microphone '{name}' is no longer available. Reconnect it, select another microphone, or turn the microphone Off to record without it." + )); + } + Ok(true) +} + +fn should_confirm_direct_recording(app: &AppHandle) -> Result { let enabled = app .store("store") .ok() @@ -165,24 +185,49 @@ fn should_confirm_direct_recording(app: &AppHandle) -> bool { .unwrap_or_default() .confirm_before_recording_without_microphone; + #[cfg(not(target_os = "macos"))] if !enabled { - return false; + return Ok(false); } let microphone_name = RecordingSettingsStore::get(app) .ok() .flatten() .and_then(|settings| settings.mic_name); + #[cfg(target_os = "macos")] + let microphone_available = microphone_available_for_confirmation( + microphone_name.as_deref(), + crate::permissions::check_microphone_access, + |name| { + MicrophoneFeed::list_names() + .iter() + .any(|device| device == name) + }, + )?; + #[cfg(not(target_os = "macos"))] let microphone_available = microphone_name .as_deref() .is_some_and(|name| MicrophoneFeed::list().contains_key(name)); - should_confirm_without_microphone(enabled, microphone_available) + Ok(should_confirm_without_microphone( + enabled, + microphone_available, + )) } async fn confirm_direct_recording_without_microphone(app: &AppHandle) -> bool { - if !should_confirm_direct_recording(app) { - return true; + match should_confirm_direct_recording(app) { + Ok(false) => return true, + Ok(true) => {} + Err(message) => { + app.dialog() + .message(message) + .title("Microphone unavailable") + .kind(MessageDialogKind::Warning) + .buttons(MessageDialogButtons::Ok) + .show(|_| {}); + return false; + } } let (sender, receiver) = tokio::sync::oneshot::channel(); @@ -678,9 +723,53 @@ async fn run_wayland_tray( #[cfg(test)] mod tests { - use super::{should_confirm_without_microphone, spawn_shortcut_task}; + use super::{ + microphone_available_for_confirmation, should_confirm_without_microphone, + spawn_shortcut_task, + }; use std::time::Duration; + #[test] + fn microphone_confirmation_does_not_probe_without_permission_or_selection() { + assert!( + microphone_available_for_confirmation( + Some("Saved microphone"), + || Err("Permission denied".into()), + |_| panic!("denied microphone must not be enumerated"), + ) + .unwrap_err() + .contains("turn the microphone Off") + ); + assert!( + !microphone_available_for_confirmation( + None, + || panic!("disabled microphone does not need permission"), + |_| panic!("disabled microphone must not be enumerated"), + ) + .unwrap() + ); + } + + #[test] + fn microphone_confirmation_checks_only_the_requested_name_after_grant() { + assert!( + microphone_available_for_confirmation( + Some("Saved microphone"), + || Ok(()), + |name| name == "Saved microphone", + ) + .unwrap() + ); + let error = microphone_available_for_confirmation( + Some("Disconnected microphone"), + || Ok(()), + |_| false, + ) + .unwrap_err(); + assert!(error.contains("Reconnect it")); + assert!(error.contains("turn the microphone Off")); + } + #[test] fn clean_capture_reuses_only_an_existing_stop_binding() { let hotkey = super::Hotkey { diff --git a/apps/desktop/src-tauri/src/import.rs b/apps/desktop/src-tauri/src/import.rs index 3d543b216e6..c139420ece8 100644 --- a/apps/desktop/src-tauri/src/import.rs +++ b/apps/desktop/src-tauri/src/import.rs @@ -374,6 +374,8 @@ fn ensure_project_timeline<'a>( transitions: Vec::new(), zoom_segments: Vec::new(), scene_segments: Vec::new(), + style_segments: Vec::new(), + image_segments: Vec::new(), mask_segments: Vec::new(), text_segments: Vec::new(), caption_segments: Vec::new(), diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs index 96112732946..d1f8f6fdeb6 100644 --- a/apps/desktop/src-tauri/src/lib.rs +++ b/apps/desktop/src-tauri/src/lib.rs @@ -148,8 +148,221 @@ use tauri::menu::{ AboutMetadata, HELP_SUBMENU_ID, Menu, MenuItem, PredefinedMenuItem, Submenu, WINDOW_SUBMENU_ID, }; -type FinalizingRecordingsMap = - std::collections::HashMap, watch::Receiver)>; +type FinalizationResult = Option>; +const MAX_SETTLED_FINALIZATIONS: usize = 32; + +#[derive(Default)] +struct FinalizingRecordingsMap { + attempts: std::collections::HashMap>, + settled: std::collections::VecDeque<(ProjectObjectId, String)>, +} + +#[derive(Clone, Copy, PartialEq, Eq)] +enum FinalizationOrigin { + Recording, + Recovery, +} + +struct FinalizationAttempt { + id: String, + origin: FinalizationOrigin, + project: Arc, + result: watch::Sender, +} + +pub(crate) struct FinalizationToken { + recordings: Arc>, + attempt: Arc, +} + +enum FinalizationRequest { + Started(FinalizationToken), + Existing(watch::Receiver), +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +enum ProjectObjectId { + #[cfg(unix)] + Unix { device: u64, inode: u64 }, + #[cfg(windows)] + Windows { volume: u64, file: [u8; 16] }, +} + +#[derive(Clone, Copy, PartialEq, Eq)] +enum FinalizationAccess { + Observe, + Write, +} + +pub(crate) struct FinalizationProject { + display_path: PathBuf, + work_path: PathBuf, + identity: ProjectObjectId, + access: FinalizationAccess, + _directory: std::fs::File, +} + +fn open_finalization_directory(path: &Path) -> std::io::Result { + fn validate(metadata: &std::fs::Metadata) -> std::io::Result<()> { + #[cfg(windows)] + { + use std::os::windows::fs::MetadataExt; + if metadata.file_attributes() & 0x400 != 0 { + return Err(std::io::Error::other( + "Recording directory is a reparse point", + )); + } + } + if metadata.file_type().is_symlink() || !metadata.is_dir() { + return Err(std::io::Error::other( + "Recording path is not an ordinary directory", + )); + } + Ok(()) + } + + validate(&path.symlink_metadata()?)?; + let mut options = std::fs::OpenOptions::new(); + options.read(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + options.custom_flags(nix::libc::O_DIRECTORY | nix::libc::O_NOFOLLOW | nix::libc::O_CLOEXEC); + } + #[cfg(windows)] + { + use std::os::windows::fs::OpenOptionsExt; + options + .custom_flags(0x0200_0000 | 0x0020_0000) + .share_mode(0x1 | 0x2 | 0x4); + } + let directory = options.open(path)?; + validate(&directory.metadata()?)?; + Ok(directory) +} + +fn finalization_directory_identity(directory: &std::fs::File) -> std::io::Result { + #[cfg(unix)] + { + use std::os::unix::fs::MetadataExt; + let metadata = directory.metadata()?; + Ok(ProjectObjectId::Unix { + device: metadata.dev(), + inode: metadata.ino(), + }) + } + #[cfg(windows)] + { + use ::windows::Win32::{ + Foundation::HANDLE, + Storage::FileSystem::{FILE_ID_INFO, FileIdInfo, GetFileInformationByHandleEx}, + }; + use std::os::windows::io::AsRawHandle; + let mut information = FILE_ID_INFO::default(); + unsafe { + GetFileInformationByHandleEx( + HANDLE(directory.as_raw_handle()), + FileIdInfo, + (&mut information as *mut FILE_ID_INFO).cast(), + std::mem::size_of::() as u32, + ) + } + .map_err(|error| std::io::Error::other(error.to_string()))?; + Ok(ProjectObjectId::Windows { + volume: information.VolumeSerialNumber, + file: information.FileId.Identifier, + }) + } +} + +impl FinalizationProject { + fn capture(display_path: PathBuf, access: FinalizationAccess) -> Result, String> { + let capture = || -> std::io::Result { + let directory = match access { + FinalizationAccess::Observe => { + open_finalization_directory(&display_path.canonicalize()?)? + } + FinalizationAccess::Write => open_finalization_directory(&display_path)?, + }; + let identity = finalization_directory_identity(&directory)?; + let work_path = display_path.canonicalize()?; + let project = Self { + display_path: display_path.clone(), + work_path, + identity, + access, + _directory: directory, + }; + project.validate_identity()?; + Ok(project) + }; + capture() + .map(Arc::new) + .map_err(|error| Self::identity_error(&display_path, error)) + } + + pub(crate) async fn admit(display_path: PathBuf) -> Result, String> { + Self::capture_async(display_path, FinalizationAccess::Write).await + } + + pub(crate) async fn observe(display_path: PathBuf) -> Result, String> { + Self::capture_async(display_path, FinalizationAccess::Observe).await + } + + async fn capture_async( + display_path: PathBuf, + access: FinalizationAccess, + ) -> Result, String> { + let error_path = display_path.clone(); + tokio::task::spawn_blocking(move || Self::capture(display_path, access)) + .await + .map_err(|error| Self::identity_error(&error_path, error))? + } + + fn identity_error(path: &Path, error: impl std::fmt::Display) -> String { + recoverable_finalization_error( + path, + format!("Could not verify the recording directory: {error}"), + ) + } + + fn validate_identity(&self) -> std::io::Result<()> { + let observed_path = match self.access { + FinalizationAccess::Observe => self.display_path.canonicalize()?, + FinalizationAccess::Write => self.display_path.clone(), + }; + for path in [&observed_path, &self.work_path] { + let directory = open_finalization_directory(path)?; + if finalization_directory_identity(&directory)? != self.identity { + return Err(std::io::Error::other(format!( + "Recording directory changed at {}", + path.display() + ))); + } + } + Ok(()) + } + + pub(crate) fn validate(&self) -> Result<(), String> { + self.validate_identity() + .map_err(|error| Self::identity_error(&self.display_path, error)) + } + + pub(crate) async fn validate_async(self: &Arc) -> Result<(), String> { + let project = self.clone(); + tokio::task::spawn_blocking(move || project.validate()) + .await + .map_err(|error| Self::identity_error(&self.display_path, error))? + } + + pub(crate) fn work_path(&self) -> &Path { + &self.work_path + } + + pub(crate) fn display_path(&self) -> &Path { + &self.display_path + } +} const EDITOR_PREVIEW_FPS: u32 = 60; const EDITOR_OUTPUT_SIZE: XY = XY::new(1920, 1080); @@ -353,7 +566,7 @@ mod tests { #[derive(Default)] pub struct FinalizingRecordings { - recordings: std::sync::Mutex, + recordings: Arc>, } pub struct CameraWindowCloseGate(AtomicBool); @@ -752,34 +965,233 @@ impl CameraWindowPositionGuard { pub type CameraWindowOperationLock = Mutex<()>; impl FinalizingRecordings { - pub fn start_finalizing(&self, path: PathBuf) -> watch::Receiver { + fn request( + &self, + project: Arc, + retry_failed: bool, + origin: FinalizationOrigin, + ) -> Result { + if project.access != FinalizationAccess::Write { + return Err("Recording directory was not admitted for recovery.".into()); + } let mut recordings = self .recordings .lock() .expect("FinalizingRecordings mutex poisoned"); - let (tx, rx) = watch::channel(false); - recordings.insert(path, (tx, rx.clone())); - rx + if let Some(attempt) = recordings.attempts.get(&project.identity) + && attempt + .result + .borrow() + .as_ref() + .is_none_or(|result| !retry_failed && result.is_err()) + { + return Ok(FinalizationRequest::Existing(attempt.result.subscribe())); + } + let attempt = Arc::new(FinalizationAttempt { + id: uuid::Uuid::new_v4().to_string(), + origin, + project, + result: watch::channel(None).0, + }); + recordings + .settled + .retain(|(identity, _)| *identity != attempt.project.identity); + let _ = recordings + .attempts + .insert(attempt.project.identity, attempt.clone()); + Ok(FinalizationRequest::Started(FinalizationToken { + recordings: self.recordings.clone(), + attempt, + })) + } + + fn start_with_origin( + &self, + project: Arc, + origin: FinalizationOrigin, + ) -> Result { + match self.request(project, true, origin)? { + FinalizationRequest::Started(token) => Ok(token), + FinalizationRequest::Existing(_) => Err( + "This recording is already being prepared. Please wait for it to finish.".into(), + ), + } } - pub fn finish_finalizing(&self, path: &Path) { - let mut recordings = self - .recordings - .lock() - .expect("FinalizingRecordings mutex poisoned"); - if let Some((tx, _)) = recordings.remove(path) - && tx.send(true).is_err() + pub(crate) fn start_finalizing( + &self, + project: Arc, + ) -> Result { + self.start_with_origin(project, FinalizationOrigin::Recording) + } + + pub(crate) fn start_recovering( + &self, + project: Arc, + ) -> Result { + self.start_with_origin(project, FinalizationOrigin::Recovery) + } + + pub(crate) async fn recovery_success(&self, path: &Path) -> Result, String> { + let project = FinalizationProject::observe(path.to_path_buf()).await?; + self.recovery_success_for_project(&project).await + } + + pub(crate) async fn recovery_success_for_project( + &self, + project: &Arc, + ) -> Result, String> { + project.validate_async().await?; + let attempt = { + let recordings = self.recordings.lock().unwrap(); + let Some(attempt) = recordings.attempts.get(&project.identity) else { + return Ok(None); + }; + if attempt.origin != FinalizationOrigin::Recovery { + return Ok(None); + } + attempt.clone() + }; + if await_finalization_result(attempt.result.subscribe()) + .await + .is_err() { - debug!("Finalizing receiver dropped for path: {:?}", path); + return Ok(None); } + project.validate_async().await?; + let recordings = self.recordings.lock().unwrap(); + Ok(recordings + .attempts + .get(&project.identity) + .filter(|current| Arc::ptr_eq(current, &attempt)) + .map(|_| attempt.id.clone())) } - pub fn is_finalizing(&self, path: &Path) -> Option> { + fn is_finalizing( + &self, + project: &FinalizationProject, + ) -> Option> { let recordings = self.recordings.lock().unwrap(); - recordings.get(path).map(|(_, rx)| rx.clone()) + recordings + .attempts + .get(&project.identity) + .filter(|attempt| !matches!(attempt.result.borrow().as_ref(), Some(Ok(())))) + .map(|attempt| attempt.result.subscribe()) } } +fn recoverable_finalization_error(path: &Path, error: String) -> String { + if error.starts_with("Not enough space to finish this recording.") + || error + .to_ascii_lowercase() + .contains("may need to be recovered") + { + return error; + } + format!( + "This recording may need to be recovered. {error} Your recording files have been kept at {}. Try recovery again.", + path.display() + ) +} + +fn has_pending_finalizations(recordings: &FinalizingRecordingsMap) -> bool { + recordings + .attempts + .values() + .any(|attempt| attempt.result.borrow().is_none()) +} + +impl FinalizationToken { + fn publish(&self, result: Result<(), String>) -> bool { + let mut recordings = self.recordings.lock().unwrap(); + if recordings + .attempts + .get(&self.attempt.project.identity) + .is_none_or(|current| !Arc::ptr_eq(current, &self.attempt)) + { + return false; + } + let result = result.map_err(|error| { + recoverable_finalization_error(self.attempt.project.display_path(), error) + }); + let succeeded = result.is_ok(); + let published = self.attempt.result.send_if_modified(|current| { + if current.is_some() { + return false; + } + *current = Some(result); + true + }); + if published { + if succeeded && self.attempt.origin == FinalizationOrigin::Recording { + let _ = recordings.attempts.remove(&self.attempt.project.identity); + } else { + recordings + .settled + .push_back((self.attempt.project.identity, self.attempt.id.clone())); + while recordings.settled.len() > MAX_SETTLED_FINALIZATIONS { + if let Some((identity, id)) = recordings.settled.pop_front() + && recordings.attempts.get(&identity).is_some_and(|attempt| { + attempt.id == id && attempt.result.borrow().is_some() + }) + { + let _ = recordings.attempts.remove(&identity); + } + } + } + } + published + } + + pub(crate) fn finish(self, result: Result<(), String>) { + self.publish(result); + } +} + +impl Drop for FinalizationToken { + fn drop(&mut self) { + self.publish(Err("Preparing this recording was interrupted.".into())); + } +} + +async fn await_finalization_result( + mut result: watch::Receiver, +) -> Result<(), String> { + loop { + if let Some(result) = result.borrow_and_update().clone() { + return result; + } + result + .changed() + .await + .map_err(|_| "Recording finalization ended without a result.".to_string())?; + } +} + +pub(crate) async fn run_finalization_worker( + token: FinalizationToken, + work: impl FnOnce(&FinalizationProject) -> Result + Send + 'static, +) -> Result { + let path = token.attempt.project.display_path().to_path_buf(); + tokio::task::spawn_blocking(move || { + let project = &token.attempt.project; + let result = project + .validate() + .and_then(|()| work(project)) + .and_then(|value| project.validate().map(|()| value)) + .map_err(|error| recoverable_finalization_error(project.display_path(), error)); + token.finish(result.as_ref().map(|_| ()).map_err(Clone::clone)); + result + }) + .await + .map_err(|error| { + recoverable_finalization_error( + &path, + format!("Recording finalization task failed: {error}"), + ) + })? +} + #[allow(clippy::large_enum_variant)] pub enum RecordingState { None, @@ -1249,7 +1661,9 @@ impl App { .await .map_err(|e| e.to_string())?; - if let Some(label) = self.selected_mic_label.clone() { + if let Some(label) = self.selected_mic_label.clone() + && permissions::check_microphone_access().is_ok() + { let settings = self.microphone_settings_for_label(&label); match mic_feed.ask(microphone::SetInput { label, settings }).await { Ok(ready) => { @@ -1350,38 +1764,72 @@ impl App { } async fn handle_input_restored(&mut self, kind: RecordingInputKind) -> Result<(), String> { - if !self.disconnected_inputs.remove(&kind) { + let app_handle = self.handle.clone(); + let requested = app_handle.state::(); + let camera_snapshot = requested.snapshot().camera; + if matches!(kind, RecordingInputKind::Camera) + && (self.selected_camera_id.is_none() + || camera_snapshot.pending + || camera_snapshot.value != self.selected_camera_id) + { + return Ok(()); + } + let camera_revision = camera_snapshot.revision; + let pending = match kind { + RecordingInputKind::Microphone => self.disconnected_inputs.remove(&kind), + RecordingInputKind::Camera => self.disconnected_inputs.contains(&kind), + }; + if !pending { return Ok(()); } match kind { RecordingInputKind::Microphone => { - self.ensure_selected_mic_ready().await.ok(); + if let Err(error) = self.ensure_selected_mic_ready().await { + warn!(%error, "Failed to restore microphone; will retry when access and the device are available"); + self.disconnected_inputs + .insert(RecordingInputKind::Microphone); + return Ok(()); + } } RecordingInputKind::Camera => match self.ensure_selected_camera_ready().await { Ok(()) => { - info!("Camera reconnected and reinitialized successfully"); - let _ = NewNotification { - title: "Camera reconnected".to_string(), - body: "Camera overlay has been restored.".to_string(), - is_error: false, + if !requested.publish_camera_if_current(camera_revision, || { + self.disconnected_inputs.remove(&RecordingInputKind::Camera); + info!("Camera reconnected and reinitialized successfully"); + let _ = NewNotification { + title: "Camera reconnected".to_string(), + body: "Camera overlay has been restored.".to_string(), + is_error: false, + } + .emit(&self.handle); + }) { + return Ok(()); } - .emit(&self.handle); } Err(e) => { warn!(error = %e, "Failed to reinitialize camera after reconnect, will retry on next poll"); - self.disconnected_inputs.insert(RecordingInputKind::Camera); return Ok(()); } }, } - let _ = RecordingEvent::InputRestored { input: kind }.emit(&self.handle); + if matches!(kind, RecordingInputKind::Camera) { + requested.publish_camera_if_current(camera_revision, || { + let _ = RecordingEvent::InputRestored { input: kind }.emit(&self.handle); + }); + } else { + let _ = RecordingEvent::InputRestored { input: kind }.emit(&self.handle); + } Ok(()) } async fn ensure_selected_mic_ready(&mut self) -> Result<(), String> { + check_requested_microphone_permission( + self.selected_mic_label.as_deref(), + permissions::check_microphone_access, + )?; self.applied_mic_input.invalidate(); self.ensure_mic_feed_alive().await?; @@ -1400,6 +1848,16 @@ impl App { } async fn ensure_selected_camera_ready(&mut self) -> Result<(), String> { + let app_handle = self.handle.clone(); + let requested = app_handle.state::(); + let snapshot = requested.snapshot(); + if snapshot.camera.pending || snapshot.camera.value != self.selected_camera_id { + return Err("Camera selection was superseded by a newer request".into()); + } + check_requested_camera_permission( + self.selected_camera_id.as_ref(), + permissions::check_camera_access, + )?; if let Some(id) = self.selected_camera_id.clone() { let settings = self.camera_settings_for_id(&id); let ready = self @@ -1411,7 +1869,10 @@ impl App { .await .map_err(|e| e.to_string())?; - ready.await.map_err(|e| e.to_string())?; + await_current_camera_request(async { ready.await.map_err(|e| e.to_string()) }, || { + requested.camera_is_current(snapshot.camera.revision) + }) + .await??; } Ok(()) @@ -1433,11 +1894,18 @@ async fn set_mic_input( .unwrap() .microphone .begin(label.clone()); - let _operation = requested.operation.lock().await; - if !requested.mic_is_current(revision) { - return Err("Microphone selection was superseded by a newer request".into()); + let result = async { + check_requested_microphone_permission( + label.as_deref(), + permissions::check_microphone_access, + )?; + let _operation = requested.operation.lock().await; + if !requested.mic_is_current(revision) { + return Err("Microphone selection was superseded by a newer request".into()); + } + apply_mic_input(&app_handle, state, label, revision).await } - let result = apply_mic_input(&app_handle, state, label, revision).await; + .await; requested .inner .lock() @@ -1447,6 +1915,16 @@ async fn set_mic_input( result } +fn check_requested_microphone_permission( + label: Option<&str>, + check: impl FnOnce() -> Result<(), String>, +) -> Result<(), String> { + if label.is_some() { + check()?; + } + Ok(()) +} + async fn finish_microphone_input_change( change: impl std::future::Future>, is_current: impl FnOnce() -> bool, @@ -1461,7 +1939,19 @@ async fn finish_microphone_input_change( const MICROPHONE_UNLOCK_TIMEOUT: Duration = Duration::from_millis(500); const MICROPHONE_UNLOCK_RETRY: Duration = Duration::from_millis(50); -const MICROPHONE_CHANGE_TIMEOUT: Duration = Duration::from_millis(1500); +const MICROPHONE_CHANGE_TIMEOUT: Duration = + microphone::SETUP_TIMEOUT.saturating_add(Duration::from_secs(1)); + +async fn wait_for_microphone_setup( + setup: impl std::future::Future>, +) -> Result<(), String> { + tokio::time::timeout(MICROPHONE_CHANGE_TIMEOUT, setup) + .await + .map_err(|_| { + "Timed out configuring the requested microphone. Select it again before recording." + .to_string() + })? +} enum MicrophoneRemovalError { Locked, @@ -1563,6 +2053,10 @@ async fn apply_mic_input( revision: u64, ) -> Result<(), String> { let requested = app_handle.state::(); + check_requested_microphone_permission( + desired_label.as_deref(), + permissions::check_microphone_access, + )?; let (mic_feed, studio_handle, app_handle, applied_generation) = { let mut app = state.write().await; @@ -1639,7 +2133,7 @@ async fn apply_mic_input( &app_handle, label, ); - tokio::time::timeout(MICROPHONE_CHANGE_TIMEOUT, async { + wait_for_microphone_setup(async { mic_feed .ask(feeds::microphone::SetInput { label: label.clone(), @@ -1648,10 +2142,10 @@ async fn apply_mic_input( .await .map_err(|error| error.to_string())? .await + .map(drop) .map_err(|error| error.to_string()) }) - .await - .map_err(|_| "Timed out configuring the requested microphone. Select it again before recording.".to_string())??; + .await?; } } @@ -1766,11 +2260,15 @@ async fn set_camera_input( ) -> Result<(), String> { let requested = app_handle.state::(); let revision = requested.inner.lock().unwrap().camera.begin(id.clone()); - let _operation = requested.operation.lock().await; - if !requested.camera_is_current(revision) { - return Err("Camera selection was superseded by a newer request".into()); + let result = async { + check_requested_camera_permission(id.as_ref(), permissions::check_camera_access)?; + let _operation = requested.operation.lock().await; + if !requested.camera_is_current(revision) { + return Err("Camera selection was superseded by a newer request".into()); + } + apply_camera_input(&app_handle, state, id, skip_camera_window, revision).await } - let result = apply_camera_input(&app_handle, state, id, skip_camera_window, revision).await; + .await; requested .inner .lock() @@ -1780,6 +2278,27 @@ async fn set_camera_input( result } +fn check_requested_camera_permission( + id: Option<&DeviceOrModelID>, + check: impl FnOnce() -> Result<(), String>, +) -> Result<(), String> { + if id.is_some() { + check()?; + } + Ok(()) +} + +async fn await_current_camera_request( + request: impl std::future::Future, + is_current: impl FnOnce() -> bool, +) -> Result { + let result = request.await; + if !is_current() { + return Err("Camera selection was superseded by a newer request".into()); + } + Ok(result) +} + async fn apply_camera_input( app_handle: &AppHandle, state: MutableState<'_, App>, @@ -1787,6 +2306,7 @@ async fn apply_camera_input( skip_camera_window: Option, revision: u64, ) -> Result<(), String> { + check_requested_camera_permission(id.as_ref(), permissions::check_camera_access)?; let requested = app_handle.state::(); let operation_lock = app_handle.state::(); let _operation_guard = operation_lock.lock().await; @@ -1803,6 +2323,9 @@ async fn apply_camera_input( let camera_in_use = app.camera_in_use; let recording_active = matches!(app.recording_state, RecordingState::Active(_)); drop(app); + if !requested.camera_is_current(revision) { + return Err("Camera selection was superseded by a newer request".into()); + } let skip_camera_window = skip_camera_window.unwrap_or(false); let camera_window_is_visible = CapWindowId::Camera @@ -1844,14 +2367,19 @@ async fn apply_camera_input( None => { let shutdown_rx = { let app = &mut *state.write().await; - app.camera_in_use = false; - app.camera_cleanup_done = true; - if skip_camera_window { - app.camera_preview.begin_shutdown() - } else { - app.camera_preview.pause(); - None + let mut shutdown_rx = None; + if !requested.publish_camera_if_current(revision, || { + app.camera_in_use = false; + app.camera_cleanup_done = true; + if skip_camera_window { + shutdown_rx = app.camera_preview.begin_shutdown(); + } else { + app.camera_preview.pause(); + } + }) { + return Err("Camera selection was superseded by a newer request".into()); } + shutdown_rx }; camera_feed @@ -1868,7 +2396,11 @@ async fn apply_camera_input( } } Some(id) => { - emit_camera_preview_clear(app_handle); + if !requested.publish_camera_if_current(revision, || { + emit_camera_preview_clear(app_handle); + }) { + return Err("Camera selection was superseded by a newer request".into()); + } let settings = recording_settings::RecordingSettingsStore::camera_settings_for(app_handle, id); let (camera_ws_sender, camera_preview_sender, use_ws_preview) = { @@ -1876,8 +2408,12 @@ async fn apply_camera_input( let use_ws_preview = !(camera_window_is_visible && app.camera_preview.is_initialized() && !app.camera_preview.is_paused()); - app.camera_in_use = true; - app.camera_cleanup_done = false; + if !requested.publish_camera_if_current(revision, || { + app.camera_in_use = true; + app.camera_cleanup_done = false; + }) { + return Err("Camera selection was superseded by a newer request".into()); + } #[allow(deprecated)] ( app.camera_ws_sender.clone(), @@ -1897,6 +2433,9 @@ async fn apply_camera_input( let mut showed_camera_window = skip_camera_window; let mut attempts = 0; let init_result: Result<(), String> = loop { + if !requested.camera_is_current(revision) { + return Err("Camera selection was superseded by a newer request".into()); + } attempts += 1; let request = camera_feed @@ -1918,19 +2457,37 @@ async fn apply_camera_input( .ok(); } - let result = match request { - Ok(future) => future.await.map_err(|e| e.to_string()), - Err(e) => Err(e), - }; + let result = await_current_camera_request( + async { + match request { + Ok(future) => future.await.map_err(|e| e.to_string()), + Err(e) => Err(e), + } + }, + || requested.camera_is_current(revision), + ) + .await?; match result { Ok(_) => { - emit_camera_preview_clear(app_handle); + if !requested.publish_camera_if_current(revision, || { + emit_camera_preview_clear(app_handle); + }) { + return Err("Camera selection was superseded by a newer request".into()); + } break Ok(()); } Err(e) => { - if attempts == 1 && !skip_camera_window { - emit_camera_preview_error(app_handle, camera_preview_error_message(&e)); + if attempts == 1 + && !skip_camera_window + && !requested.publish_camera_if_current(revision, || { + emit_camera_preview_error( + app_handle, + camera_preview_error_message(&e), + ); + }) + { + return Err("Camera selection was superseded by a newer request".into()); } if attempts >= 3 { break Err(format!( @@ -1941,7 +2498,11 @@ async fn apply_camera_input( "Failed to set camera input (attempt {}): {}. Retrying...", attempts, e ); - tokio::time::sleep(Duration::from_millis(500)).await; + await_current_camera_request( + tokio::time::sleep(Duration::from_millis(500)), + || requested.camera_is_current(revision), + ) + .await?; } } }; @@ -1952,24 +2513,25 @@ async fn apply_camera_input( if let Err(e) = init_result { let message = camera_preview_error_message(&e); let _ = camera_feed.ask(feeds::camera::RemoveInput).await; - let emit_input_lost = { - let app = &mut *state.write().await; + let app = &mut *state.write().await; + if !requested.publish_camera_if_current(revision, || { app.camera_in_use = false; - app.disconnected_inputs.insert(RecordingInputKind::Camera) - }; - if emit_input_lost { - let _ = RecordingEvent::InputLost { - input: RecordingInputKind::Camera, + if app.disconnected_inputs.insert(RecordingInputKind::Camera) { + let _ = RecordingEvent::InputLost { + input: RecordingInputKind::Camera, + } + .emit(app_handle); + } + emit_camera_preview_error(app_handle, message.clone()); + let _ = NewNotification { + title: "Camera unavailable".to_string(), + body: message, + is_error: true, } .emit(app_handle); + }) { + return Err("Camera selection was superseded by a newer request".into()); } - emit_camera_preview_error(app_handle, message.clone()); - let _ = NewNotification { - title: "Camera unavailable".to_string(), - body: message, - is_error: true, - } - .emit(app_handle); return Err(e); } } @@ -2035,6 +2597,14 @@ pub(crate) async fn restore_requested_inputs(app_handle: &AppHandle) { if snapshot.microphone.pending || snapshot.camera.pending { return; } + let microphone_permission = check_requested_microphone_permission( + snapshot.microphone.value.as_deref(), + permissions::check_microphone_access, + ); + let camera_permission = check_requested_camera_permission( + snapshot.camera.value.as_ref(), + permissions::check_camera_access, + ); let _operation = requested.operation.lock().await; let state = app_handle.state::>(); if !requested.is_current(&snapshot) || state.read().await.is_recording_active_or_pending() { @@ -2049,13 +2619,18 @@ pub(crate) async fn restore_requested_inputs(app_handle: &AppHandle) { { return; } - let mic_result = apply_mic_input( - app_handle, - state.clone(), - snapshot.microphone.value.clone(), - snapshot.microphone.revision, - ) - .await; + let mic_result = match microphone_permission { + Ok(()) => { + apply_mic_input( + app_handle, + state.clone(), + snapshot.microphone.value.clone(), + snapshot.microphone.revision, + ) + .await + } + Err(error) => Err(error), + }; requested .inner .lock() @@ -2086,14 +2661,19 @@ pub(crate) async fn restore_requested_inputs(app_handle: &AppHandle) { { return; } - let camera_result = apply_camera_input( - app_handle, - state, - snapshot.camera.value, - Some(false), - snapshot.camera.revision, - ) - .await; + let camera_result = match camera_permission { + Ok(()) => { + apply_camera_input( + app_handle, + state, + snapshot.camera.value, + Some(false), + snapshot.camera.revision, + ) + .await + } + Err(error) => Err(error), + }; requested .inner .lock() @@ -2845,7 +3425,7 @@ fn with_idle_app_for_title_flush( .recordings .try_lock() .map_err(|_| ExitBlocked::StateUnavailable)?; - if !recordings.is_empty() { + if has_pending_finalizations(&recordings) { return Err(ExitBlocked::FinalizationActive); } if include_exports @@ -3049,6 +3629,8 @@ pub async fn request_app_exit(app: AppHandle) { } pub(crate) async fn complete_admitted_app_exit(app: AppHandle) { + #[cfg(target_os = "macos")] + cancel_macos_startup_opens(&app); spawn_exit_watchdog(); export::cancel_all_exports(); @@ -4710,26 +5292,24 @@ async fn upload_rendered_screenshot( #[tauri::command] #[specta::specta] -#[instrument(skip(app))] +#[instrument(skip(window))] async fn save_file_dialog( - app: AppHandle, + window: tauri::Window, file_name: String, file_type: String, ) -> Result, String> { run_command_safely( "save_file_dialog", - save_file_dialog_inner(app, file_name, file_type), + save_file_dialog_inner(window, file_name, file_type), ) .await } async fn save_file_dialog_inner( - app: AppHandle, + window: tauri::Window, file_name: String, file_type: String, ) -> Result, String> { - use tauri_plugin_dialog::DialogExt; - info!(file_name, file_type, "Save file dialog requested"); let file_name = file_name @@ -4750,38 +5330,67 @@ async fn save_file_dialog_inner( info!(file_name, name, extension, "Showing save file dialog"); - // Use `tokio::sync::oneshot` so the async runtime worker yields while the native dialog - // is open instead of being parked by a synchronous `std::sync::mpsc` receive. The - // previous version blocked a runtime worker for the lifetime of the dialog which, in - // release builds with fewer/active workers, could starve other tasks and let an unrelated - // exit event slip through before the export session guard incremented. - let (tx, rx) = tokio::sync::oneshot::channel(); - - app.dialog() - .file() - .set_title("Save File") - .set_file_name(file_name) - .add_filter(name, &[extension]) - .save_file(move |path| { - let _ = tx.send( - path.as_ref() - .and_then(|p| p.as_path()) - .map(|p| p.to_string_lossy().to_string()), - ); - }); - - match rx.await { - Ok(result) => { - info!(path = ?result, "Save file dialog completed"); - Ok(result) + #[cfg(target_os = "linux")] + { + use tauri_plugin_fs::FsExt; + + let path = export::show_linux_save_dialog( + window.clone(), + tokio_util::sync::CancellationToken::new(), + file_name, + name, + extension, + ) + .await?; + if let Some(path) = &path { + if let Some(scope) = window.try_fs_scope() { + scope.allow_file(path).map_err(|error| error.to_string())?; + } + window + .state::() + .allow_file(path) + .map_err(|error| error.to_string())?; } - Err(e) => { - warn!(error = %e, "Save file dialog failed"); - notifications::send_notification( - &app, - notifications::NotificationType::VideoSaveFailed, - ); - Err(e.to_string()) + Ok(path.map(|path| path.to_string_lossy().into_owned())) + } + #[cfg(not(target_os = "linux"))] + { + use tauri_plugin_dialog::DialogExt; + + let app = window.app_handle().clone(); + // Use `tokio::sync::oneshot` so the async runtime worker yields while the native dialog + // is open instead of being parked by a synchronous `std::sync::mpsc` receive. The + // previous version blocked a runtime worker for the lifetime of the dialog which, in + // release builds with fewer/active workers, could starve other tasks and let an unrelated + // exit event slip through before the export session guard incremented. + let (tx, rx) = tokio::sync::oneshot::channel(); + + app.dialog() + .file() + .set_title("Save File") + .set_file_name(file_name) + .add_filter(name, &[extension]) + .save_file(move |path| { + let _ = tx.send( + path.as_ref() + .and_then(|p| p.as_path()) + .map(|p| p.to_string_lossy().to_string()), + ); + }); + + match rx.await { + Ok(result) => { + info!(path = ?result, "Save file dialog completed"); + Ok(result) + } + Err(e) => { + warn!(error = %e, "Save file dialog failed"); + notifications::send_notification( + &app, + notifications::NotificationType::VideoSaveFailed, + ); + Err(e.to_string()) + } } } } @@ -6177,6 +6786,7 @@ pub async fn run(recording_logging_handle: LoggingHandle, logs_dir: PathBuf) { #[cfg(target_os = "macos")] let builder = builder + .manage(StartupOpenGate::default()) .menu(build_macos_app_menu) .on_menu_event(|app, event| { if event.id() == APP_MENU_QUIT_ID { @@ -6237,9 +6847,23 @@ pub async fn run(recording_logging_handle: LoggingHandle, logs_dir: PathBuf) { }) .build(), ) - .invoke_handler(specta_builder.invoke_handler()) + .invoke_handler({ + let public_commands = specta_builder.invoke_handler(); + let recovery_commands: fn(tauri::ipc::Invoke) -> bool = tauri::generate_handler![recovery::get_recording_recovery_success]; + move |invoke| { + if invoke.message.command() == "get_recording_recovery_success" { + recovery_commands(invoke) + } else { + public_commands(invoke) + } + } + }) .setup(move |app| { let app = app.handle().clone(); + #[cfg(target_os = "macos")] + let _startup_open_guard = app + .try_state::() + .map(|state| StartupOpenGuard((*state).clone())); if let Err(err) = update_project_names::migrate_if_needed(&app) { tracing::error!("Failed to migrate project file names: {}", err); @@ -6263,6 +6887,7 @@ pub async fn run(recording_logging_handle: LoggingHandle, logs_dir: PathBuf) { if app.try_state::().is_none() { app.manage(gpui_app::StartupRedirectState::default()); } + finish_macos_startup_opens(&app, StartupOpenDestination::Gpui); gpui_app::retire_foreground_parent_for_handoff(&app); let app = app.clone(); tokio::spawn(async move { @@ -6603,6 +7228,9 @@ pub async fn run(recording_logging_handle: LoggingHandle, logs_dir: PathBuf) { deeplink_actions::handle(&app_handle, event.urls()); }); + #[cfg(target_os = "macos")] + finish_macos_startup_opens(&app, StartupOpenDestination::Desktop); + Ok(()) }) .on_window_event(|window, event| { @@ -6723,22 +7351,17 @@ pub async fn run(recording_logging_handle: LoggingHandle, logs_dir: PathBuf) { } WindowEvent::Destroyed => { fake_window::cancel_fake_window_listener(app, label); + let window_id = CapWindowId::from_str(label).ok(); + if let Some(window_id) = &window_id { + retire_project_window(window, window_id); + } if app_is_exiting(app) { return; } - let window_id = CapWindowId::from_str(label).ok(); - let is_editor_window = matches!( - window_id, - Some(CapWindowId::Editor { .. }) - | Some(CapWindowId::ScreenshotEditor { .. }) - ); - if is_editor_window { - export::cancel_exports_for_window(label); - } if export::export_session_active() { warn!( window = label, - "Skipping Destroyed cleanup during active export" + "Skipping window restoration during active export" ); return; } @@ -6796,41 +7419,7 @@ pub async fn run(recording_logging_handle: LoggingHandle, logs_dir: PathBuf) { } }); } - CapWindowId::Editor { id } => { - let window_ids = EditorWindowIds::get(window.app_handle()); - match window_ids.ids.lock() { - Ok(mut ids) => ids.retain(|(_, _id)| *_id != id), - Err(err) => warn!(error = %err, "Editor window ids lock poisoned"), - } - - let label = window.label().to_string(); - let pending = editor_window::PendingEditorInstances::get(app); - spawn_on_runtime(async move { - pending.cancel_prewarm(&label).await; - }); - - spawn_on_runtime(EditorInstances::remove(window.clone())); - - restore_main_windows_if_no_editors(app); - } - CapWindowId::ScreenshotEditor { id } => { - let window_ids = - ScreenshotEditorWindowIds::get(window.app_handle()); - match window_ids.ids.lock() { - Ok(mut ids) => ids.retain(|(_, _id)| *_id != id), - Err(err) => { - warn!(error = %err, "Screenshot editor window ids lock poisoned"); - } - } - - let label = window.label().to_string(); - let pending = PendingScreenshotEditorInstances::get(app); - spawn_on_runtime(async move { - pending.cancel_prewarm(&label).await; - }); - - spawn_on_runtime(ScreenshotEditorInstances::remove(window.clone())); - + CapWindowId::Editor { .. } | CapWindowId::ScreenshotEditor { .. } => { restore_main_windows_if_no_editors(app); } CapWindowId::Settings => { @@ -7152,19 +7741,258 @@ where Err(format!("{command_name} failed unexpectedly")) } } -} +} + +fn emit_app_event_safely(app: &AppHandle, event: E) +where + E: Event + Serialize + Clone, +{ + let event_name = std::any::type_name::(); + match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| event.emit(app))) { + Ok(Ok(())) => {} + Ok(Err(error)) => warn!(event = event_name, %error, "Failed to emit app event"), + Err(panic) => { + let message = panic_payload_message(&panic); + error!(event = event_name, panic = %message, "Suppressed panic while emitting app event"); + } + } +} + +#[cfg(any(target_os = "macos", test))] +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum StartupOpenDestination { + Desktop, + Gpui, +} + +#[cfg(any(target_os = "macos", test))] +struct StartupOpenDispatch { + destination: StartupOpenDestination, + urls: Vec, +} + +#[cfg(any(target_os = "macos", test))] +#[derive(Default)] +struct StartupOpenQueue { + destination: Option, + cancelled: bool, + urls: Vec, + gpui_forwarding: bool, + gpui_dispatched: Vec, +} + +#[cfg(any(target_os = "macos", test))] +impl StartupOpenQueue { + fn request(&mut self, urls: Vec) -> Result, String> { + if self.cancelled { + return Err("Cap startup stopped before the project could be opened".into()); + } + if self.destination == Some(StartupOpenDestination::Desktop) { + return Ok(Some(StartupOpenDispatch { + destination: StartupOpenDestination::Desktop, + urls, + })); + } + let mut additions = Vec::new(); + for url in urls { + if self.urls.contains(&url) + || self.gpui_dispatched.contains(&url) + || additions.contains(&url) + { + continue; + } + if self.urls.len() + self.gpui_dispatched.len() + additions.len() >= 64 { + return Err("Too many projects were requested while Cap was starting".into()); + } + additions.push(url); + } + self.urls.extend(additions); + if self.destination == Some(StartupOpenDestination::Gpui) && !self.gpui_forwarding { + return Ok(self.take_queued()); + } + Ok(None) + } + + fn finish(&mut self, destination: StartupOpenDestination) -> Option { + if self.cancelled || self.destination.is_some() { + return None; + } + self.destination = Some(destination); + self.take_queued() + } + + fn take_queued(&mut self) -> Option { + let destination = self.destination?; + if self.urls.is_empty() { + return None; + } + let urls = std::mem::take(&mut self.urls); + if destination == StartupOpenDestination::Gpui { + self.gpui_forwarding = true; + self.gpui_dispatched.extend(urls.iter().cloned()); + } + Some(StartupOpenDispatch { destination, urls }) + } + + fn next_gpui_batch(&mut self) -> Option { + if self.cancelled + || self.destination != Some(StartupOpenDestination::Gpui) + || !self.gpui_forwarding + { + return None; + } + if let Some(dispatch) = self.take_queued() { + return Some(dispatch); + } + self.cancel(); + None + } + + fn cancel(&mut self) { + self.cancelled = true; + self.urls.clear(); + self.gpui_dispatched.clear(); + self.gpui_forwarding = false; + } +} + +#[cfg(any(target_os = "macos", test))] +#[derive(Clone, Default)] +struct StartupOpenGate(Arc>); + +#[cfg(any(target_os = "macos", test))] +struct StartupOpenGuard(StartupOpenGate); + +#[cfg(any(target_os = "macos", test))] +impl Drop for StartupOpenGuard { + fn drop(&mut self) { + if let Ok(mut queue) = self.0.0.lock() + && queue.destination.is_none() + { + queue.cancel(); + } + } +} + +#[cfg(target_os = "macos")] +fn queue_macos_startup_urls(app: &AppHandle, urls: Vec) -> Result<(), String> { + let gate = app + .try_state::() + .ok_or_else(|| "Cap startup is not ready to receive projects".to_string())?; + let dispatch = gate + .0 + .lock() + .map_err(|_| "Cap startup file-open state is unavailable".to_string())? + .request(urls)?; + if let Some(dispatch) = dispatch { + dispatch_macos_startup_urls(app, dispatch); + } + Ok(()) +} + +#[cfg(target_os = "macos")] +fn finish_macos_startup_opens(app: &AppHandle, destination: StartupOpenDestination) { + let Some(gate) = app.try_state::() else { + return; + }; + let dispatch = match gate.0.lock() { + Ok(mut queue) => queue.finish(destination), + Err(error) => { + warn!(%error, "Could not release startup project requests"); + return; + } + }; + if let Some(dispatch) = dispatch { + dispatch_macos_startup_urls(app, dispatch); + } +} + +#[cfg(target_os = "macos")] +fn cancel_macos_startup_opens(app: &AppHandle) { + if let Some(gate) = app.try_state::() + && let Ok(mut queue) = gate.0.lock() + { + queue.cancel(); + } +} + +#[cfg(target_os = "macos")] +fn dispatch_macos_startup_urls(app: &AppHandle, dispatch: StartupOpenDispatch) { + let urls = dispatch.urls; + let arguments = urls + .iter() + .map(|url| url.as_str().to_string()) + .collect::>(); + + if dispatch.destination == StartupOpenDestination::Gpui { + let Some(redirect) = app.try_state::() else { + warn!("Cap GPUI startup forwarding state is unavailable"); + return; + }; + if redirect.begin_forwarding() { + let app = app.clone(); + tokio::spawn(async move { + let mut arguments = arguments; + let mut forwarded_pid = None; + loop { + let forwarded = tokio::task::spawn_blocking(move || { + gpui_app::forward_deep_links_to_gpui_when_ready(&arguments) + }) + .await + .ok() + .flatten(); + if let Some(pid) = forwarded { + forwarded_pid = Some(pid); + } else { + warn!("Could not forward the requested project batch to Cap GPUI"); + } + let next = app.try_state::().and_then(|gate| { + gate.0 + .lock() + .ok() + .and_then(|mut queue| queue.next_gpui_batch()) + }); + let Some(next) = next else { + break; + }; + arguments = next + .urls + .iter() + .map(|url| url.as_str().to_string()) + .collect(); + } + + if let Some(pid) = forwarded_pid + && let Err(error) = app.run_on_main_thread(move || { + gpui_app::activate_instance(pid); + }) + { + warn!(%error, "Could not activate Cap GPUI after forwarding a project"); + } + if app + .try_state::() + .is_some_and(|state| state.exit_after_forwarding()) + { + app.exit(0); + } + }); + } else { + cancel_macos_startup_opens(app); + warn!("Cap GPUI handoff already finished before the project could be forwarded"); + } + return; + } -fn emit_app_event_safely(app: &AppHandle, event: E) -where - E: Event + Serialize + Clone, -{ - let event_name = std::any::type_name::(); - match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| event.emit(app))) { - Ok(Ok(())) => {} - Ok(Err(error)) => warn!(event = event_name, %error, "Failed to emit app event"), - Err(panic) => { - let message = panic_payload_message(&panic); - error!(event = event_name, panic = %message, "Suppressed panic while emitting app event"); + if gpui_app::forward_deep_links_to_active_gpui(app, &arguments) { + return; + } + + for url in urls { + if url.scheme() == "file" + && let Ok(path) = url.to_file_path() + && let Err(error) = open_project_from_path(&path, app.clone()) + { + warn!(path = %path.display(), %error, "Could not open the requested project"); } } } @@ -7173,53 +8001,8 @@ fn handle_run_event(_handle: &AppHandle, event: tauri::RunEvent) { match event { #[cfg(target_os = "macos")] tauri::RunEvent::Opened { urls } => { - let arguments = urls - .iter() - .map(|url| url.as_str().to_string()) - .collect::>(); - - if let Some(redirect) = _handle.try_state::() { - if redirect.begin_forwarding() { - let app = _handle.clone(); - tokio::spawn(async move { - let forwarded = tokio::task::spawn_blocking(move || { - gpui_app::forward_deep_links_to_gpui_when_ready(&arguments) - }) - .await - .ok() - .flatten(); - - if let Some(pid) = forwarded { - if let Err(error) = app.run_on_main_thread(move || { - gpui_app::activate_instance(pid); - }) { - warn!(%error, "Could not activate Cap GPUI after forwarding a project"); - } - } else { - warn!("Could not forward the requested project to Cap GPUI"); - } - if app - .try_state::() - .is_some_and(|state| state.exit_after_forwarding()) - { - app.exit(0); - } - }); - } - return; - } - - if gpui_app::forward_deep_links_to_active_gpui(_handle, &arguments) { - return; - } - - for url in urls { - if url.scheme() == "file" - && let Ok(path) = url.to_file_path() - && let Err(error) = open_project_from_path(&path, _handle.clone()) - { - warn!(path = %path.display(), %error, "Could not open the requested project"); - } + if let Err(error) = queue_macos_startup_urls(_handle, urls) { + warn!(%error, "Could not receive the requested startup project"); } } #[cfg(target_os = "macos")] @@ -7312,6 +8095,8 @@ fn handle_run_event(_handle: &AppHandle, event: tauri::RunEvent) { } } tauri::RunEvent::Exit => { + #[cfg(target_os = "macos")] + cancel_macos_startup_opens(_handle); #[cfg(target_os = "macos")] { // This arm runs on the AppKit main thread, so reverse the Liquid Glass @@ -7374,6 +8159,41 @@ where } } +fn retire_project_window(window: &Window, window_id: &CapWindowId) { + let app = window.app_handle(); + match window_id { + CapWindowId::Editor { id } => { + let window_ids = EditorWindowIds::get(app); + match window_ids.ids.lock() { + Ok(mut ids) => ids.retain(|(_, current_id)| current_id != id), + Err(err) => warn!(error = %err, "Editor window ids lock poisoned"), + } + export::cancel_exports_for_window(window.label()); + let label = window.label().to_string(); + let pending = PendingEditorInstances::get(app); + spawn_on_runtime(async move { + pending.cancel_prewarm(&label).await; + }); + spawn_on_runtime(EditorInstances::remove(window.clone())); + } + CapWindowId::ScreenshotEditor { id } => { + let window_ids = ScreenshotEditorWindowIds::get(app); + match window_ids.ids.lock() { + Ok(mut ids) => ids.retain(|(_, current_id)| current_id != id), + Err(err) => warn!(error = %err, "Screenshot editor window ids lock poisoned"), + } + export::cancel_exports_for_window(window.label()); + let label = window.label().to_string(); + let pending = PendingScreenshotEditorInstances::get(app); + spawn_on_runtime(async move { + pending.cancel_prewarm(&label).await; + }); + spawn_on_runtime(ScreenshotEditorInstances::remove(window.clone())); + } + _ => {} + } +} + #[cfg(target_os = "windows")] fn has_open_editor_window(app: &AppHandle) -> bool { app.webview_windows() @@ -7669,13 +8489,14 @@ async fn create_editor_instance_impl( } pub(crate) async fn wait_for_recording_ready(app: &AppHandle, path: &Path) -> Result<(), String> { + let project = FinalizationProject::observe(path.to_path_buf()).await?; + let path = project.work_path(); let finalizing_state = app.state::(); - if let Some(mut rx) = finalizing_state.is_finalizing(path) { + if let Some(result) = finalizing_state.is_finalizing(&project) { info!("Recording is being finalized, waiting for completion..."); - rx.wait_for(|&ready| ready) - .await - .map_err(|_| "Finalization was cancelled".to_string())?; + await_finalization_result(result).await?; + project.validate_async().await?; info!("Recording finalization completed"); let meta = RecordingMeta::load_for_project(path) .map_err(|e| format!("Failed to reload recording meta: {e}"))?; @@ -7707,6 +8528,12 @@ pub(crate) async fn wait_for_recording_ready(app: &AppHandle, path: &Path) -> Re tokio::time::sleep(POLL_INTERVAL).await; + project.validate_async().await?; + if let Some(result) = finalizing_state.is_finalizing(&project) { + await_finalization_result(result).await?; + break; + } + let current_meta = match RecordingMeta::load_for_project(path) { Ok(m) => m, Err(_) => continue, @@ -7728,6 +8555,12 @@ pub(crate) async fn wait_for_recording_ready(app: &AppHandle, path: &Path) -> Re } } + project.validate_async().await?; + if let Some(result) = finalizing_state.is_finalizing(&project) { + await_finalization_result(result).await?; + project.validate_async().await?; + } + let meta = RecordingMeta::load_for_project(path) .map_err(|e| format!("Failed to reload recording meta: {e}"))?; if let Some(studio_meta) = meta.studio_meta() { @@ -7738,10 +8571,20 @@ pub(crate) async fn wait_for_recording_ready(app: &AppHandle, path: &Path) -> Re && recording::needs_fragment_remux(path, studio_meta) { info!("Recording needs remux (crash recovery), starting remux..."); - let path = path.to_path_buf(); - tokio::task::spawn_blocking(move || recording::remux_fragmented_recording(&path)) - .await - .map_err(|e| format!("Remux task panicked: {e}"))??; + let work_project = FinalizationProject::admit(project.display_path().to_path_buf()).await?; + if work_project.identity != project.identity { + return Err(FinalizationProject::identity_error( + project.display_path(), + "Recording directory changed before recovery", + )); + } + match finalizing_state.request(work_project, false, FinalizationOrigin::Recording)? { + FinalizationRequest::Started(token) => { + run_finalization_worker(token, recording::remux_fragmented_recording).await?; + } + FinalizationRequest::Existing(result) => await_finalization_result(result).await?, + } + project.validate_async().await?; info!("Crash recovery remux completed"); } @@ -7763,6 +8606,7 @@ pub(crate) async fn wait_for_recording_ready(app: &AppHandle, path: &Path) -> Re } } + project.validate_async().await?; Ok(()) } @@ -7917,6 +8761,32 @@ fn open_importable_from_path(path: &Path, app: AppHandle) -> Result<(), String> } fn open_project_from_path(path: &Path, app: AppHandle) -> Result<(), String> { + #[cfg(target_os = "macos")] + { + let gate = app + .try_state::() + .ok_or_else(|| "Cap startup is not ready to receive projects".to_string())?; + let ready = { + let queue = gate + .0 + .lock() + .map_err(|_| "Cap startup file-open state is unavailable".to_string())?; + !queue.cancelled && queue.destination == Some(StartupOpenDestination::Desktop) + }; + if !ready { + let path = if path.is_absolute() { + path.to_path_buf() + } else { + std::env::current_dir() + .map_err(|error| error.to_string())? + .join(path) + }; + let url = tauri::Url::from_file_path(path) + .map_err(|_| "The requested project path is invalid".to_string())?; + return queue_macos_startup_urls(&app, vec![url]); + } + } + let meta = RecordingMeta::load_for_project(path).map_err(|v| v.to_string())?; match &meta.inner { @@ -7929,7 +8799,11 @@ fn open_project_from_path(path: &Path, app: AppHandle) -> Result<(), String> { } let project_path = path.to_path_buf(); - tokio::spawn(async move { ShowCapWindow::Editor { project_path }.show(&app).await }); + tokio::spawn(async move { + if let Err(error) = (ShowCapWindow::Editor { project_path }).show(&app).await { + warn!(%error, "Could not show the requested project editor"); + } + }); } RecordingMetaInner::Instant(_) => { let mp4_path = path.join("content/output.mp4"); @@ -7946,6 +8820,148 @@ fn open_project_from_path(path: &Path, app: AppHandle) -> Result<(), String> { Ok(()) } +#[cfg(test)] +mod startup_project_open_tests { + use super::{StartupOpenDestination, StartupOpenGate, StartupOpenGuard, StartupOpenQueue}; + + fn project(name: &str) -> tauri::Url { + tauri::Url::parse(&format!("file:///recordings/{name}.cap")).unwrap() + } + + #[test] + fn early_file_opens_wait_for_full_desktop_startup() { + let mut queue = StartupOpenQueue::default(); + let urls = vec![project("first"), project("second")]; + assert!(queue.request(urls.clone()).unwrap().is_none()); + let dispatch = queue.finish(StartupOpenDestination::Desktop).unwrap(); + assert_eq!(dispatch.destination, StartupOpenDestination::Desktop); + assert_eq!(dispatch.urls, urls); + assert!(queue.urls.is_empty()); + assert!(queue.finish(StartupOpenDestination::Desktop).is_none()); + } + + #[test] + fn queued_files_follow_gpui_handoff_without_opening_classic_editors() { + let mut queue = StartupOpenQueue::default(); + let url = project("handoff"); + assert!(queue.request(vec![url.clone()]).unwrap().is_none()); + let dispatch = queue.finish(StartupOpenDestination::Gpui).unwrap(); + assert_eq!(dispatch.destination, StartupOpenDestination::Gpui); + assert_eq!(dispatch.urls, [url]); + assert!(queue.request(vec![project("later")]).unwrap().is_none()); + let dispatch = queue.next_gpui_batch().unwrap(); + assert_eq!(dispatch.destination, StartupOpenDestination::Gpui); + assert_eq!(dispatch.urls, [project("later")]); + } + + #[test] + fn normal_post_ready_file_opens_dispatch_immediately() { + let mut queue = StartupOpenQueue::default(); + assert!(queue.finish(StartupOpenDestination::Desktop).is_none()); + let url = project("ready"); + let dispatch = queue.request(vec![url.clone()]).unwrap().unwrap(); + assert_eq!(dispatch.destination, StartupOpenDestination::Desktop); + assert_eq!(dispatch.urls, [url]); + } + + #[test] + fn failed_or_cancelled_setup_discards_pending_opens() { + let gate = StartupOpenGate::default(); + let guard = StartupOpenGuard(gate.clone()); + assert!( + gate.0 + .lock() + .unwrap() + .request(vec![project("pending")]) + .unwrap() + .is_none() + ); + drop(guard); + let mut queue = gate.0.lock().unwrap(); + assert!(queue.urls.is_empty()); + assert!(queue.request(vec![project("later")]).is_err()); + assert!(queue.finish(StartupOpenDestination::Desktop).is_none()); + assert!(queue.finish(StartupOpenDestination::Gpui).is_none()); + } + + #[test] + fn completed_setup_guard_preserves_selected_destination() { + for destination in [ + StartupOpenDestination::Desktop, + StartupOpenDestination::Gpui, + ] { + let gate = StartupOpenGate::default(); + let guard = StartupOpenGuard(gate.clone()); + assert!(gate.0.lock().unwrap().finish(destination).is_none()); + drop(guard); + let dispatch = gate + .0 + .lock() + .unwrap() + .request(vec![project("ready")]) + .unwrap() + .unwrap(); + assert_eq!(dispatch.destination, destination); + } + } + + #[test] + fn pending_file_open_queue_is_bounded_and_deduplicates() { + let mut queue = StartupOpenQueue::default(); + for _ in 0..100 { + assert!(queue.request(vec![project("same")]).unwrap().is_none()); + } + assert_eq!(queue.urls.len(), 1); + let urls = (0..100).map(|index| project(&index.to_string())).collect(); + assert!(queue.request(urls).is_err()); + assert_eq!(queue.urls, [project("same")]); + let urls = (0..63).map(|index| project(&index.to_string())).collect(); + assert!(queue.request(urls).unwrap().is_none()); + assert_eq!(queue.urls.len(), 64); + assert!(queue.request(vec![project("overflow")]).is_err()); + } + + #[test] + fn gpui_handoff_drains_later_batches_without_duplicate_dispatches() { + let mut queue = StartupOpenQueue::default(); + assert!(queue.finish(StartupOpenDestination::Gpui).is_none()); + let first = queue.request(vec![project("first")]).unwrap().unwrap(); + assert_eq!(first.urls, [project("first")]); + assert!( + queue + .request(vec![project("first"), project("second")]) + .unwrap() + .is_none() + ); + assert!(queue.request(vec![project("second")]).unwrap().is_none()); + let second = queue.next_gpui_batch().unwrap(); + assert_eq!(second.urls, [project("second")]); + assert_eq!(second.destination, StartupOpenDestination::Gpui); + assert!(queue.request(vec![project("third")]).unwrap().is_none()); + assert_eq!(queue.next_gpui_batch().unwrap().urls, [project("third")]); + assert!(queue.next_gpui_batch().is_none()); + assert!(queue.request(vec![project("after-exit")]).is_err()); + } + + #[test] + fn cancellation_stops_desktop_dispatch_and_gpui_pending_batches() { + for destination in [ + StartupOpenDestination::Desktop, + StartupOpenDestination::Gpui, + ] { + let mut queue = StartupOpenQueue::default(); + assert!(queue.finish(destination).is_none()); + assert!(queue.request(vec![project("first")]).unwrap().is_some()); + let _ = queue.request(vec![project("pending")]).unwrap(); + queue.cancel(); + assert!(queue.request(vec![project("after-exit")]).is_err()); + assert!(queue.next_gpui_batch().is_none()); + assert!(queue.urls.is_empty()); + assert!(queue.gpui_dispatched.is_empty()); + } + } +} + #[cfg(test)] mod editor_title_save_tests { use super::{AppExitState, EditorTitleSaveResponse, wait_for_editor_title_saves}; @@ -8388,10 +9404,29 @@ mod studio_microphone_ownership_tests { #[cfg(test)] mod applied_microphone_tests { - use super::{AppliedMicrophoneInput, RequestedInputsState, finish_microphone_input_change}; + use super::{ + AppliedMicrophoneInput, RequestedInputsState, finish_microphone_input_change, + wait_for_microphone_setup, + }; use std::sync::Mutex; use tokio::sync::oneshot; + #[tokio::test] + async fn microphone_configuration_accepts_native_startup_longer_than_legacy_deadline() { + let result = wait_for_microphone_setup(async { + tokio::time::sleep(std::time::Duration::from_millis(1600)).await; + Ok(()) + }) + .await; + assert_eq!(result, Ok(())); + } + + #[tokio::test] + async fn microphone_configuration_preserves_backend_failure() { + let result = wait_for_microphone_setup(async { Err("device disconnected".into()) }).await; + assert_eq!(result, Err("device disconnected".into())); + } + #[derive(Default)] struct Feed { applied: AppliedMicrophoneInput, @@ -8650,6 +9685,85 @@ mod applied_microphone_tests { } } +#[cfg(test)] +mod microphone_permission_tests { + use super::{RequestedInput, check_requested_microphone_permission}; + + #[test] + fn disabled_microphone_bypasses_permission() { + check_requested_microphone_permission(None, || { + panic!("turning the microphone off must not require permission") + }) + .unwrap(); + } + + #[test] + fn saved_microphone_permission_failure_retains_intent_and_blocks_start() { + let mut input = RequestedInput::new(Some("Saved microphone".to_string())); + let revision = input.revision; + let result = check_requested_microphone_permission(input.value.as_deref(), || { + Err("Allow microphone access in System Settings".into()) + }); + assert!(input.prepare_restore(revision)); + input.finish(revision, &result); + assert_eq!(input.value.as_deref(), Some("Saved microphone")); + assert!(!input.pending); + assert!( + input + .validate("microphone") + .unwrap_err() + .contains("System Settings") + ); + } + + #[test] + fn revoked_access_blocks_current_selection_until_reselected_after_grant() { + let mut input = RequestedInput::new(Some("Saved microphone".to_string())); + let denied = input.begin(Some("Saved microphone".into())); + let result = check_requested_microphone_permission(input.value.as_deref(), || { + Err("Microphone access is blocked".into()) + }); + input.finish(denied, &result); + assert!(input.validate("microphone").is_err()); + let granted = input.begin(Some("Saved microphone".into())); + let result = check_requested_microphone_permission(input.value.as_deref(), || Ok(())); + input.finish(granted, &result); + assert!(input.validate("microphone").is_ok()); + assert_eq!(input.value.as_deref(), Some("Saved microphone")); + } + + #[test] + fn stale_permission_failure_cannot_replace_a_newer_selection() { + let mut input = RequestedInput::::new(None); + let old = input.begin(Some("Old microphone".into())); + let current = input.begin(Some("New microphone".into())); + let result = check_requested_microphone_permission(Some("Old microphone"), || { + Err("Microphone access is blocked".into()) + }); + input.finish(old, &result); + assert!(input.pending); + assert!(input.error.is_none()); + input.finish(current, &Ok(())); + assert_eq!(input.value.as_deref(), Some("New microphone")); + assert!(input.validate("microphone").is_ok()); + } + + #[test] + fn explicit_off_clears_permission_failure_and_ignores_old_completion() { + let mut input = RequestedInput::new(Some("Saved microphone".to_string())); + let old = input.begin(Some("Saved microphone".into())); + input.finish(old, &Err("Microphone access is blocked".into())); + let off = input.begin(None); + let result = check_requested_microphone_permission(input.value.as_deref(), || { + panic!("turning the microphone off must not require permission") + }); + input.finish(off, &result); + input.finish(old, &Err("Old permission failure".into())); + assert!(input.value.is_none()); + assert!(input.validate("microphone").is_ok()); + } +} + #[cfg(test)] mod requested_inputs_tests { use super::{RequestedInput, RequestedInputsState}; @@ -9089,3 +10203,208 @@ mod instant_resume_safety_tests { std::fs::remove_dir_all(path).unwrap(); } } + +#[cfg(test)] +mod camera_permission_tests { + use super::{ + DeviceOrModelID, RequestedInput, RequestedInputsState, await_current_camera_request, + check_requested_camera_permission, + }; + + fn camera(name: &str) -> DeviceOrModelID { + DeviceOrModelID::DeviceID(name.into()) + } + + #[test] + fn disabled_camera_bypasses_permission() { + check_requested_camera_permission(None, || { + panic!("turning the camera off must not require permission") + }) + .unwrap(); + } + + #[test] + fn saved_camera_permission_failure_preserves_intent_and_retry() { + let mut input = RequestedInput::new(Some(camera("Saved camera"))); + let revision = input.revision; + assert!(input.prepare_restore(revision)); + let result = check_requested_camera_permission(input.value.as_ref(), || { + Err("Allow camera access in System Settings".into()) + }); + input.finish(revision, &result); + assert_eq!(input.value, Some(camera("Saved camera"))); + assert!(!input.pending); + assert!( + input + .validate("camera") + .unwrap_err() + .contains("System Settings") + ); + assert!(input.prepare_restore(revision)); + let result = check_requested_camera_permission(input.value.as_ref(), || Ok(())); + input.finish(revision, &result); + assert!(input.validate("camera").is_ok()); + } + + #[test] + fn revoked_camera_access_requires_successful_reselection() { + let mut input = RequestedInput::new(Some(camera("Saved camera"))); + let denied = input.begin(Some(camera("Saved camera"))); + let result = check_requested_camera_permission(input.value.as_ref(), || { + Err("Camera access is blocked".into()) + }); + input.finish(denied, &result); + assert!(input.validate("camera").is_err()); + let granted = input.begin(Some(camera("Saved camera"))); + let result = check_requested_camera_permission(input.value.as_ref(), || Ok(())); + input.finish(granted, &result); + input.finish(denied, &Err("Stale denial".into())); + assert!(input.validate("camera").is_ok()); + assert_eq!(input.value, Some(camera("Saved camera"))); + } + + #[test] + fn camera_off_clears_permission_failure_without_requesting_access() { + let mut input = RequestedInput::new(Some(camera("Saved camera"))); + let old = input.begin(Some(camera("Saved camera"))); + input.finish(old, &Err("Camera access is blocked".into())); + let off = input.begin(None); + let result = check_requested_camera_permission(input.value.as_ref(), || { + panic!("camera off must not request permission") + }); + input.finish(off, &result); + input.finish(old, &Err("Old failure".into())); + assert!(input.value.is_none()); + assert!(input.validate("camera").is_ok()); + } + + #[tokio::test] + async fn current_camera_setup_keeps_success_and_device_failure() { + assert_eq!( + await_current_camera_request(async { Ok::<_, String>(()) }, || true).await, + Ok(Ok(())) + ); + assert_eq!( + await_current_camera_request( + async { Err::<(), _>("CameraTimeout".to_string()) }, + || true + ) + .await, + Ok(Err("CameraTimeout".into())) + ); + } + + #[tokio::test] + async fn stale_camera_ready_failure_does_not_publish_or_retry() { + let requested = RequestedInputsState::new(None, Some(camera("Saved camera"))); + let revision = requested + .inner + .lock() + .unwrap() + .camera + .begin(Some(camera("Saved camera"))); + let result = await_current_camera_request( + async { + let mut current = requested.inner.lock().unwrap(); + let latest = current.camera.begin(Some(camera("New camera"))); + current.camera.finish(latest, &Ok(())); + Err::<(), _>("CameraTimeout".to_string()) + }, + || requested.camera_is_current(revision), + ) + .await; + assert!(result.unwrap_err().contains("superseded")); + let mut published = false; + assert!(!requested.publish_camera_if_current(revision, || published = true)); + assert!(!published); + assert!(requested.snapshot().camera.validate("camera").is_ok()); + assert_eq!( + requested.snapshot().camera.value, + Some(camera("New camera")) + ); + } + + #[tokio::test] + async fn stale_camera_ready_success_cannot_clear_newer_failure() { + let requested = RequestedInputsState::new(None, Some(camera("Saved camera"))); + let revision = requested + .inner + .lock() + .unwrap() + .camera + .begin(Some(camera("Saved camera"))); + let result = await_current_camera_request( + async { + let mut current = requested.inner.lock().unwrap(); + let latest = current.camera.begin(Some(camera("Saved camera"))); + current + .camera + .finish(latest, &Err("New selection failed".into())); + Ok::<(), String>(()) + }, + || requested.camera_is_current(revision), + ) + .await; + assert!(result.unwrap_err().contains("superseded")); + assert_eq!( + requested.snapshot().camera.error.as_deref(), + Some("New selection failed") + ); + } + + #[tokio::test] + async fn superseded_camera_retry_wait_cannot_start_another_attempt() { + let requested = RequestedInputsState::new(None, Some(camera("Saved camera"))); + let revision = requested + .inner + .lock() + .unwrap() + .camera + .begin(Some(camera("Saved camera"))); + let mut attempts = 1; + let retry = await_current_camera_request( + async { + requested.inner.lock().unwrap().camera.begin(None); + }, + || requested.camera_is_current(revision), + ) + .await; + if retry.is_ok() { + attempts += 1; + } + assert!(retry.unwrap_err().contains("superseded")); + assert_eq!(attempts, 1); + assert!(requested.snapshot().camera.value.is_none()); + } + + #[test] + fn superseded_camera_cleanup_cannot_mark_a_newer_camera_disconnected() { + let requested = RequestedInputsState::new(None, Some(camera("Old camera"))); + let old = requested + .inner + .lock() + .unwrap() + .camera + .begin(Some(camera("Old camera"))); + let latest = requested + .inner + .lock() + .unwrap() + .camera + .begin(Some(camera("New camera"))); + requested + .inner + .lock() + .unwrap() + .camera + .finish(latest, &Ok(())); + let mut connected = true; + let mut notification = None; + assert!(!requested.publish_camera_if_current(old, || { + connected = false; + notification = Some("Camera unavailable"); + })); + assert!(connected); + assert!(notification.is_none()); + } +} diff --git a/apps/desktop/src-tauri/src/main.rs b/apps/desktop/src-tauri/src/main.rs index ac7a169f67f..4b9e9b0daba 100644 --- a/apps/desktop/src-tauri/src/main.rs +++ b/apps/desktop/src-tauri/src/main.rs @@ -103,16 +103,16 @@ fn main() { path }; - // Ensure logs directory exists - std::fs::create_dir_all(&logs_dir).unwrap_or_else(|e| { - eprintln!("Failed to create logs directory: {e}"); - }); - - let info_file_appender = tracing_appender::rolling::daily(&logs_dir, "cap-desktop.log"); - let (info_file_writer, _info_logger_guard) = tracing_appender::non_blocking(info_file_appender); + let (info_file_writer, _info_logger_guard) = + match create_log_appender(&logs_dir, "cap-desktop.log") { + Some(appender) => { + let (writer, guard) = tracing_appender::non_blocking(appender); + (Some(writer), Some(guard)) + } + None => (None, None), + }; - let errors_file_appender = - tracing_appender::rolling::daily(&logs_dir, "cap-desktop-errors.log"); + let errors_file_appender = create_log_appender(&logs_dir, "cap-desktop-errors.log"); let (otel_layer, _tracer) = if cfg!(debug_assertions) { use opentelemetry::trace::TracerProvider; @@ -161,19 +161,19 @@ fn main() { .with_ansi(true) .with_target(true), ) - .with( + .with(info_file_writer.map(|writer| { tracing_subscriber::fmt::layer() .with_ansi(false) .with_target(true) - .with_writer(info_file_writer), - ) - .with( + .with_writer(writer) + })) + .with(errors_file_appender.map(|appender| { tracing_subscriber::fmt::layer() .with_ansi(false) .with_target(true) - .with_writer(errors_file_appender) - .with_filter(tracing_subscriber::filter::LevelFilter::WARN), - ) + .with_writer(appender) + .with_filter(tracing_subscriber::filter::LevelFilter::WARN) + })) .init(); install_panic_hook(logs_dir.clone()); @@ -194,6 +194,29 @@ fn main() { .block_on(cap_desktop_lib::run(handle, logs_dir)); } +fn create_log_appender( + directory: &std::path::Path, + prefix: &str, +) -> Option { + use std::io::Write; + + match tracing_appender::rolling::RollingFileAppender::builder() + .rotation(tracing_appender::rolling::Rotation::DAILY) + .filename_prefix(prefix) + .build(directory) + { + Ok(appender) => Some(appender), + Err(error) => { + let _ = writeln!( + std::io::stderr(), + "Could not open {prefix} in {}: {error}; console logging remains enabled", + directory.display() + ); + None + } + } +} + fn install_panic_hook(logs_dir: std::path::PathBuf) { let prev = std::panic::take_hook(); let panics_log = logs_dir.join("panics.log"); @@ -262,3 +285,73 @@ fn write_panic_record( ); let _ = file.flush(); } + +#[cfg(test)] +mod logging_tests { + use super::create_log_appender; + use std::{io::Write, path::PathBuf}; + + struct LogDirectory(PathBuf); + + impl LogDirectory { + fn new() -> Self { + let nonce = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos(); + let directory = std::env::temp_dir().join(format!( + "cap-desktop-logging-{}-{nonce}", + std::process::id() + )); + std::fs::create_dir_all(&directory).unwrap(); + Self(directory) + } + } + + impl Drop for LogDirectory { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } + } + + #[test] + fn healthy_log_destination_preserves_existing_records() { + let directory = LogDirectory::new(); + let destination = directory.0.join("nested"); + for record in ["first\n", "second\n"] { + let mut appender = create_log_appender(&destination, "cap.log").unwrap(); + appender.write_all(record.as_bytes()).unwrap(); + appender.flush().unwrap(); + } + let records: String = std::fs::read_dir(destination) + .unwrap() + .map(|entry| std::fs::read_to_string(entry.unwrap().path()).unwrap()) + .collect(); + assert!(records.contains("first\n")); + assert!(records.contains("second\n")); + } + + #[test] + fn unavailable_log_directory_disables_only_file_logging() { + let directory = LogDirectory::new(); + let destination = directory.0.join("blocked"); + std::fs::write(&destination, "existing file").unwrap(); + assert!(create_log_appender(&destination, "cap.log").is_none()); + assert_eq!( + std::fs::read_to_string(destination).unwrap(), + "existing file" + ); + } + + #[test] + fn unavailable_daily_log_file_disables_only_file_logging() { + let directory = LogDirectory::new(); + let today = chrono::Utc::now().date_naive(); + for days in [-1, 0, 1] { + let date = today + chrono::Duration::days(days); + std::fs::create_dir(directory.0.join(format!("cap.log.{date}"))).unwrap(); + } + assert!(create_log_appender(&directory.0, "cap.log").is_none()); + assert!(create_log_appender(&directory.0, "other.log").is_some()); + } +} diff --git a/apps/desktop/src-tauri/src/permissions.rs b/apps/desktop/src-tauri/src/permissions.rs index 4acd9cf9fca..6c37e8e9c5b 100644 --- a/apps/desktop/src-tauri/src/permissions.rs +++ b/apps/desktop/src-tauri/src/permissions.rs @@ -148,7 +148,7 @@ fn macos_focus_permission_window(app: &tauri::AppHandle) { } #[cfg(target_os = "macos")] -fn macos_activate_permission_request(app: &tauri::AppHandle) { +fn macos_prepare_permission_request(app: &tauri::AppHandle, focus_app: bool) { if crate::app_is_exiting(app) { return; } @@ -161,6 +161,10 @@ fn macos_activate_permission_request(app: &tauri::AppHandle) { tracing::warn!("Failed to set activation policy to Regular: {err}"); } + if !focus_app { + return; + } + macos_focus_permission_window(app); if let Some(current_app) = unsafe { @@ -607,7 +611,7 @@ pub enum OSPermission { pub fn open_permission_settings(_app: tauri::AppHandle, _permission: OSPermission) { #[cfg(target_os = "macos")] { - macos_activate_permission_request(&_app); + macos_prepare_permission_request(&_app, false); macos_open_permission_settings(&_app, &_permission); } } @@ -618,7 +622,7 @@ pub fn open_permission_settings(_app: tauri::AppHandle, _permission: OSPermissio pub async fn request_permission(_app: tauri::AppHandle, _permission: OSPermission) { #[cfg(target_os = "macos")] { - macos_activate_permission_request(&_app); + macos_prepare_permission_request(&_app, true); let permission = _permission; let app = _app.clone(); @@ -640,6 +644,52 @@ pub async fn request_permission(_app: tauri::AppHandle, _permission: OSPermissio crate::tray::refresh_tray_menu_for_app(&_app); } +pub(crate) fn check_camera_access() -> Result<(), String> { + #[cfg(target_os = "macos")] + let status = + objc2::rc::autoreleasepool(|_| macos_permission_status(&OSPermission::Camera, false)); + #[cfg(not(target_os = "macos"))] + let status = OSPermissionStatus::NotNeeded; + camera_access_result(status) +} + +fn camera_access_result(status: OSPermissionStatus) -> Result<(), String> { + match status { + OSPermissionStatus::Granted | OSPermissionStatus::NotNeeded => Ok(()), + OSPermissionStatus::Empty => Err( + "Camera access is required. Click the camera control to allow access, then select your camera again." + .into(), + ), + OSPermissionStatus::Denied => Err( + "Camera access is blocked. Allow Cap in System Settings > Privacy & Security > Camera, then select your camera again. If access is restricted, contact your administrator." + .into(), + ), + } +} + +pub(crate) fn check_microphone_access() -> Result<(), String> { + #[cfg(target_os = "macos")] + let status = + objc2::rc::autoreleasepool(|_| macos_permission_status(&OSPermission::Microphone, false)); + #[cfg(not(target_os = "macos"))] + let status = OSPermissionStatus::NotNeeded; + microphone_access_result(status) +} + +fn microphone_access_result(status: OSPermissionStatus) -> Result<(), String> { + match status { + OSPermissionStatus::Granted | OSPermissionStatus::NotNeeded => Ok(()), + OSPermissionStatus::Empty => Err( + "Microphone access is required. Click the microphone control to allow access, then select your microphone again." + .into(), + ), + OSPermissionStatus::Denied => Err( + "Microphone access is blocked. Allow Cap in System Settings > Privacy & Security > Microphone, then select your microphone again." + .into(), + ), + } +} + #[derive(Serialize, Deserialize, Debug, specta::Type, Clone)] #[serde(rename_all = "camelCase")] pub enum OSPermissionStatus { @@ -708,6 +758,38 @@ pub fn do_permissions_check(_initial_check: bool) -> OSPermissionsCheck { mod tests { use super::*; + #[test] + fn camera_setup_requires_granted_access() { + for status in [OSPermissionStatus::Granted, OSPermissionStatus::NotNeeded] { + assert!(camera_access_result(status).is_ok()); + } + assert!( + camera_access_result(OSPermissionStatus::Empty) + .unwrap_err() + .contains("camera control") + ); + let blocked = camera_access_result(OSPermissionStatus::Denied).unwrap_err(); + assert!(blocked.contains("System Settings > Privacy & Security > Camera")); + assert!(blocked.contains("restricted")); + } + + #[test] + fn microphone_setup_requires_granted_access() { + for status in [OSPermissionStatus::Granted, OSPermissionStatus::NotNeeded] { + assert!(microphone_access_result(status).is_ok()); + } + assert!( + microphone_access_result(OSPermissionStatus::Empty) + .unwrap_err() + .contains("microphone control") + ); + assert!( + microphone_access_result(OSPermissionStatus::Denied) + .unwrap_err() + .contains("System Settings > Privacy & Security > Microphone") + ); + } + #[test] fn permission_status_permitted_matches_granted_states() { assert!(OSPermissionStatus::Granted.permitted()); diff --git a/apps/desktop/src-tauri/src/platform/macos/delegates.rs b/apps/desktop/src-tauri/src/platform/macos/delegates.rs index 5b6536b7f73..bf0bfa1b7a2 100644 --- a/apps/desktop/src-tauri/src/platform/macos/delegates.rs +++ b/apps/desktop/src-tauri/src/platform/macos/delegates.rs @@ -139,6 +139,7 @@ pub fn setup(window: Window, controls_inset: LogicalPosition }) } extern "C" fn on_window_will_close(this: &Object, _cmd: Sel, notification: id) { + let window = unsafe { objc::rc::StrongPtr::retain(*this.get_ivar("window")) }; let super_del: id = unsafe { *this.get_ivar("super_delegate") }; // Forward to the previous delegate first, but don't let a panic there @@ -148,6 +149,9 @@ pub fn setup(window: Window, controls_inset: LogicalPosition }); suppress_delegate_panic("windowWillClose:cleanup", (), || unsafe { + // Tao clears the delegate before Destroyed; preserve that even if forwarding panics. + let _: () = msg_send![*window, setDelegate: cocoa::base::nil]; + // Drop the boxed `WindowState` (and the `Window` handle it holds) // that was leaked via `Box::into_raw` when this delegate was created. let app_box: *mut c_void = *this.get_ivar("app_box"); @@ -157,11 +161,6 @@ pub fn setup(window: Window, controls_inset: LogicalPosition drop(Box::from_raw(app_box as *mut WindowState)); } - // Restore the previous delegate before releasing this one, so any - // further delegate callbacks during teardown don't hit a freed object. - let window: id = *this.get_ivar("window"); - let _: () = msg_send![window, setDelegate: super_del]; - // NSWindow does not retain its delegate, so the reference taken when // this delegate was created (`new`) is the only owning one. Release // it now that the window is closing. diff --git a/apps/desktop/src-tauri/src/platform/win.rs b/apps/desktop/src-tauri/src/platform/win.rs index 866ceb35842..e5c47140d7f 100644 --- a/apps/desktop/src-tauri/src/platform/win.rs +++ b/apps/desktop/src-tauri/src/platform/win.rs @@ -1,36 +1,10 @@ -//! Detection of desktops that are only viewable through a capture-based -//! stream (cloud PCs like Shadow, RDP sessions, VMs, virtual display -//! adapters). -//! -//! On these systems `WDA_EXCLUDEFROMCAPTURE` does not just hide a window from -//! recordings — the streamer itself sees the desktop through the capture -//! APIs, so an excluded window becomes invisible to the user and DRM -//! detectors flag it as protected content (e.g. Shadow error S:102). - use winreg::RegKey; use winreg::enums::HKEY_LOCAL_MACHINE; -/// Environment override (case-insensitive): `off`/`never`/`0` forces -/// exclusion off, `on`/`always`/`1` forces it on (skips detection), -/// anything else = auto. const ENV_OVERRIDE: &str = "CAP_WINDOW_CAPTURE_EXCLUSION"; -const SMBIOS_MARKERS: &[&str] = &[ - "qemu", - "kvm", - "vmware", - "virtualbox", - "innotek", - "xen", - "bochs", - "parallels", - "virtual machine", - "hvm domu", - "amazon ec2", - "google compute engine", - "openstack", - "shadow", -]; +// Shadow reports S:102 for protected windows; EC2/DCV still displays excluded windows. +const SMBIOS_MARKERS: &[&str] = &["shadow"]; const VIRTUAL_DISPLAY_MARKERS: &[&str] = &[ "parsec", @@ -42,38 +16,41 @@ const VIRTUAL_DISPLAY_MARKERS: &[&str] = &[ "shadow", ]; -/// Returns `Some(reason)` when this desktop is being viewed through a -/// capture-based stream and window capture exclusion would hide Cap's -/// windows from the user themselves. pub fn capture_streamed_display_reason() -> Option { - let override_value = std::env::var(ENV_OVERRIDE) - .ok() - .map(|value| value.trim().to_ascii_lowercase()); - match override_value.as_deref() { - Some("on" | "always" | "1") => return None, - Some("off" | "never" | "0") => { - return Some(format!("{ENV_OVERRIDE} env override")); - } - _ => {} - } + streamed_display_reason_with( + std::env::var(ENV_OVERRIDE).ok().as_deref(), + remote_session_active, + streamed_computer_marker, + virtual_display_adapter, + ) +} - if remote_session_active() { - return Some("remote desktop session (SM_REMOTESESSION)".to_string()); +fn exclusion_override(value: &str) -> Option { + match value.trim().to_ascii_lowercase().as_str() { + "on" | "always" | "1" => Some(true), + "off" | "never" | "0" => Some(false), + _ => None, } +} - if let Some(vendor) = hypervisor_guest() { - return Some(format!("hypervisor guest ({vendor})")); +fn streamed_display_reason_with( + override_value: Option<&str>, + remote_session: impl FnOnce() -> bool, + streamed_computer: impl FnOnce() -> Option, + virtual_display: impl FnOnce() -> Option, +) -> Option { + match override_value.and_then(exclusion_override) { + Some(true) => return None, + Some(false) => return Some(format!("{ENV_OVERRIDE} env override")), + None => {} } - - if let Some(marker) = smbios_virtual_machine_marker() { - return Some(format!("virtual machine SMBIOS ({marker})")); + if remote_session() { + return Some("remote desktop session (SM_REMOTESESSION)".to_string()); } - - if let Some(device) = virtual_display_adapter() { - return Some(format!("virtual display adapter ({device})")); + if let Some(marker) = streamed_computer() { + return Some(format!("streamed computer SMBIOS ({marker})")); } - - None + virtual_display().map(|device| format!("virtual display adapter ({device})")) } fn remote_session_active() -> bool { @@ -81,46 +58,7 @@ fn remote_session_active() -> bool { unsafe { GetSystemMetrics(SM_REMOTESESSION) != 0 } } -#[cfg(target_arch = "x86_64")] -fn hypervisor_guest() -> Option { - use std::arch::x86_64::__cpuid; - - if unsafe { __cpuid(1) }.ecx & (1 << 31) == 0 { - return None; - } - - let hv = unsafe { __cpuid(0x4000_0000) }; - let mut vendor = [0u8; 12]; - vendor[0..4].copy_from_slice(&hv.ebx.to_le_bytes()); - vendor[4..8].copy_from_slice(&hv.ecx.to_le_bytes()); - vendor[8..12].copy_from_slice(&hv.edx.to_le_bytes()); - - // Hyper-V hosts the desktop OS itself when VBS / WSL2 / Hyper-V is - // enabled. The root partition (CreatePartitions privilege, leaf - // 0x40000003 EBX bit 0) is the physical machine, not a guest. - if &vendor == b"Microsoft Hv" - && hv.eax >= 0x4000_0003 - && unsafe { __cpuid(0x4000_0003) }.ebx & 1 != 0 - { - return None; - } - - let vendor = String::from_utf8_lossy(&vendor) - .trim_matches([char::from(0), ' ']) - .to_string(); - Some(if vendor.is_empty() { - "unknown hypervisor".to_string() - } else { - vendor - }) -} - -#[cfg(not(target_arch = "x86_64"))] -fn hypervisor_guest() -> Option { - None -} - -fn smbios_virtual_machine_marker() -> Option { +fn streamed_computer_marker() -> Option { let key = RegKey::predef(HKEY_LOCAL_MACHINE) .open_subkey("HARDWARE\\DESCRIPTION\\System\\BIOS") .ok()?; @@ -184,24 +122,121 @@ mod tests { use super::*; #[test] - fn markers_match_known_environments() { - assert_eq!( - find_marker("QEMU Standard PC (Q35 + ICH9, 2009)", SMBIOS_MARKERS), - Some("qemu") - ); - assert_eq!( - find_marker("Virtual Machine", SMBIOS_MARKERS), - Some("virtual machine") - ); + fn overrides_preserve_auto_and_explicit_choices() { + for value in ["on", " ALWAYS ", "1"] { + assert_eq!(exclusion_override(value), Some(true)); + } + for value in ["off", " NEVER ", "0"] { + assert_eq!(exclusion_override(value), Some(false)); + } + for value in ["", "auto", "unknown"] { + assert_eq!(exclusion_override(value), None); + } + } + + #[test] + fn explicit_override_does_not_probe_the_environment() { + for (value, expected) in [ + ("on", None), + ("off", Some(format!("{ENV_OVERRIDE} env override"))), + ] { + assert_eq!( + streamed_display_reason_with( + Some(value), + || panic!("override must skip remote-session detection"), + || panic!("override must skip SMBIOS detection"), + || panic!("override must skip display detection"), + ), + expected + ); + } + } + + #[test] + fn remote_session_keeps_its_compatibility_exception() { assert_eq!( - find_marker("Parsec Virtual Display Adapter", VIRTUAL_DISPLAY_MARKERS), - Some("parsec") + streamed_display_reason_with( + None, + || true, + || panic!("remote session must skip SMBIOS detection"), + || panic!("remote session must skip display detection"), + ), + Some("remote desktop session (SM_REMOTESESSION)".to_string()) ); } #[test] - fn markers_ignore_physical_hardware() { - for text in [ + fn shadow_keeps_its_compatibility_exception() { + for computer in ["Shadow", "SHADOW COMPUTER"] { + assert_eq!( + streamed_display_reason_with( + None, + || false, + || find_marker(computer, SMBIOS_MARKERS).map(str::to_string), + || panic!("Shadow must skip display detection"), + ), + Some("streamed computer SMBIOS (shadow)".to_string()) + ); + } + } + + #[test] + fn existing_streamed_adapters_keep_their_compatibility_exception() { + for adapter in [ + "Parsec Virtual Display Adapter", + "spacedesk", + "IddSampleDriver", + "Virtual Display", + "usbmmidd", + "Amyuni", + "Shadow", + ] { + assert!( + streamed_display_reason_with( + None, + || false, + || None, + || find_marker(adapter, VIRTUAL_DISPLAY_MARKERS).map(str::to_string), + ) + .is_some(), + "{adapter}" + ); + } + } + + #[test] + fn virtual_machine_hardware_keeps_capture_exclusion() { + for computer in [ + "Amazon EC2", + "QEMU Standard PC (Q35 + ICH9, 2009)", + "KVM", + "VMware", + "VirtualBox", + "innotek", + "Xen", + "Bochs", + "Parallels", + "Microsoft Corporation Virtual Machine", + "HVM domU", + "Google Compute Engine", + "OpenStack", + ] { + assert_eq!( + streamed_display_reason_with( + None, + || false, + || find_marker(computer, SMBIOS_MARKERS).map(str::to_string), + || None, + ), + None, + "{computer}" + ); + } + } + + #[test] + fn physical_hardware_keeps_capture_exclusion() { + for name in [ "Dell Inc.", "ASUSTeK COMPUTER INC.", "NVIDIA GeForce RTX 3080", @@ -210,8 +245,14 @@ mod tests { "LENOVO", "Micro-Star International Co., Ltd.", ] { - assert_eq!(find_marker(text, SMBIOS_MARKERS), None, "{text}"); - assert_eq!(find_marker(text, VIRTUAL_DISPLAY_MARKERS), None, "{text}"); + assert_eq!(find_marker(name, SMBIOS_MARKERS), None, "{name}"); + assert_eq!(find_marker(name, VIRTUAL_DISPLAY_MARKERS), None, "{name}"); + } + for override_value in [None, Some("auto"), Some("unknown")] { + assert_eq!( + streamed_display_reason_with(override_value, || false, || None, || None), + None + ); } } } diff --git a/apps/desktop/src-tauri/src/recording.rs b/apps/desktop/src-tauri/src/recording.rs index ed1bbf960ec..7b2db68f8ed 100644 --- a/apps/desktop/src-tauri/src/recording.rs +++ b/apps/desktop/src-tauri/src/recording.rs @@ -46,11 +46,14 @@ use std::{ sync::Arc, time::Duration, }; -use tauri::{AppHandle, Manager, path::BaseDirectory}; -use tauri_plugin_dialog::{DialogExt, MessageDialogBuilder}; +use tauri::{AppHandle, Listener, Manager, path::BaseDirectory}; +use tauri_plugin_dialog::{ + DialogExt, MessageDialogBuilder, MessageDialogButtons, MessageDialogKind, +}; use tauri_plugin_global_shortcut::GlobalShortcutExt; use tauri_plugin_store::StoreExt; use tauri_specta::Event; +use tokio_util::sync::CancellationToken; use tracing::*; use crate::camera::{CameraPreviewManager, CameraPreviewShape}; @@ -1228,6 +1231,347 @@ fn recording_start_mode_error(mode: RecordingMode, authenticated: bool) -> Optio } } +const RECORDING_START_CANCELLED: &str = "Recording cancelled before starting."; + +#[derive(Clone, Default)] +struct RecordingStoragePrompt(Arc>>>); + +impl RecordingStoragePrompt { + fn begin(&self) -> Option { + let mut slot = self.0.lock().unwrap(); + if slot.is_some() { + return None; + } + let cancelled = Arc::new(CancellationToken::new()); + *slot = Some(cancelled.clone()); + Some(RecordingStoragePromptLease { + slot: self.clone(), + cancelled, + }) + } + + fn cancel(&self) -> bool { + let slot = self.0.lock().unwrap(); + if let Some(cancelled) = slot.as_ref() { + cancelled.cancel(); + true + } else { + false + } + } +} + +fn recording_storage_prompt(app: &AppHandle) -> RecordingStoragePrompt { + if app.try_state::().is_none() { + app.manage(RecordingStoragePrompt::default()); + } + app.state::().inner().clone() +} + +struct RecordingStoragePromptLease { + slot: RecordingStoragePrompt, + cancelled: Arc, +} + +impl Drop for RecordingStoragePromptLease { + fn drop(&mut self) { + let mut slot = self.slot.0.lock().unwrap(); + if slot + .as_ref() + .is_some_and(|current| Arc::ptr_eq(current, &self.cancelled)) + { + *slot = None; + } + } +} + +struct RecordingStorageEvents { + app: AppHandle, + id: tauri::EventId, +} + +impl Drop for RecordingStorageEvents { + fn drop(&mut self) { + self.app.unlisten(self.id); + } +} + +async fn recording_storage_answer( + cancelled: &CancellationToken, + answer: tokio::sync::oneshot::Receiver, +) -> Result { + tokio::select! { + biased; + _ = cancelled.cancelled() => Err(RECORDING_START_CANCELLED.to_string()), + answer = answer => Ok(answer.unwrap_or(false)), + } +} + +async fn check_recording_start_storage( + directory: &Path, + mut sample: impl FnMut(&Path) -> std::io::Result, + confirm: impl FnOnce(u64) -> F, +) -> Result<(), String> +where + F: std::future::Future>, +{ + use cap_utils::disk_space::{DiskSpaceStatus, RecordingStorage}; + + let mut read = || { + sample(directory).map_err(|error| { + format!( + "Could not check available disk space at {}: {error}", + directory.display() + ) + }) + }; + let status = |available_bytes| { + RecordingStorage { + available_bytes, + recording_bytes: 0, + } + .status() + }; + let exhausted = |bytes| { + format!( + "Not enough disk space to start recording ({:.2} GiB free). Free up space so more than {} MiB is available at {} and try again.", + bytes as f64 / 1_073_741_824.0, + cap_utils::disk_space::RECORDING_DISK_RESERVE_BYTES / (1024 * 1024), + directory.display(), + ) + }; + let bytes = read()?; + match status(bytes) { + DiskSpaceStatus::Ok => Ok(()), + DiskSpaceStatus::Exhausted => Err(exhausted(bytes)), + DiskSpaceStatus::Low => { + if !confirm(bytes).await? { + return Err(RECORDING_START_CANCELLED.to_string()); + } + let bytes = read()?; + if status(bytes) == DiskSpaceStatus::Exhausted { + Err(exhausted(bytes)) + } else { + Ok(()) + } + } + } +} + +async fn cancel_recording_storage_prompt(app: &AppHandle, state: &MutableState<'_, App>) -> bool { + let state = state.read().await; + matches!(state.recording_state, RecordingState::Pending { .. }) + && app + .try_state::() + .is_some_and(|prompt| prompt.cancel()) +} + +#[cfg(any(target_os = "linux", test))] +fn storage_preflight_control_result( + result: Result<(), String>, + has_capture: bool, +) -> Result<(), String> { + match result { + Err(error) if error == RECORDING_START_CANCELLED && !has_capture => Ok(()), + result => result, + } +} + +#[cfg(test)] +mod recording_storage_preflight_tests { + use super::*; + use cap_utils::disk_space::{RECORDING_DISK_RESERVE_BYTES, RECORDING_DISK_WARN_BYTES}; + use std::cell::Cell; + + #[tokio::test] + async fn thresholds_match_recording_storage_policy() { + for (bytes, asks, starts) in [ + (RECORDING_DISK_WARN_BYTES + 1, false, true), + (RECORDING_DISK_WARN_BYTES, true, true), + (RECORDING_DISK_RESERVE_BYTES + 1, true, true), + (RECORDING_DISK_RESERVE_BYTES, false, false), + (0, false, false), + ] { + let prompted = Cell::new(false); + let result = check_recording_start_storage( + Path::new("recordings"), + |_| Ok(bytes), + |_| { + prompted.set(true); + async { Ok(true) } + }, + ) + .await; + assert_eq!(prompted.get(), asks); + assert_eq!(result.is_ok(), starts); + } + } + + #[tokio::test] + async fn confirmation_rechecks_same_recordings_drive() { + let directory = Path::new("external-drive/custom-recordings"); + let reads = Cell::new(0); + check_recording_start_storage( + directory, + |path| { + assert_eq!(path, directory); + reads.set(reads.get() + 1); + Ok(RECORDING_DISK_WARN_BYTES) + }, + |_| async { Ok(true) }, + ) + .await + .unwrap(); + assert_eq!(reads.get(), 2); + } + + #[tokio::test] + async fn confirmation_cannot_override_new_reserve_exhaustion() { + let mut bytes = [RECORDING_DISK_WARN_BYTES, RECORDING_DISK_RESERVE_BYTES].into_iter(); + let result = check_recording_start_storage( + Path::new("recordings"), + |_| Ok(bytes.next().unwrap()), + |_| async { Ok(true) }, + ) + .await; + assert!(result.unwrap_err().contains("more than 512 MiB")); + } + + #[tokio::test] + async fn go_back_skips_second_probe() { + let reads = Cell::new(0); + let result = check_recording_start_storage( + Path::new("recordings"), + |_| { + reads.set(reads.get() + 1); + Ok(RECORDING_DISK_WARN_BYTES) + }, + |_| async { Ok(false) }, + ) + .await; + assert_eq!(result.unwrap_err(), RECORDING_START_CANCELLED); + assert_eq!(reads.get(), 1); + } + + #[tokio::test] + async fn unknown_storage_never_admits_capture() { + for fail_at in [1, 2] { + let mut reads = 0; + let result = check_recording_start_storage( + Path::new("recordings"), + |_| { + reads += 1; + if reads == fail_at { + Err(std::io::Error::from_raw_os_error(5)) + } else { + Ok(RECORDING_DISK_WARN_BYTES) + } + }, + |_| async { Ok(true) }, + ) + .await; + assert!( + result + .unwrap_err() + .starts_with("Could not check available disk space") + ); + } + } + + #[tokio::test] + async fn closed_native_callback_declines() { + let (sender, receiver) = tokio::sync::oneshot::channel(); + drop(sender); + assert!( + !recording_storage_answer(&CancellationToken::new(), receiver) + .await + .unwrap() + ); + } + + #[tokio::test] + async fn cancellation_wins_simultaneous_affirmative() { + let token = CancellationToken::new(); + let (sender, receiver) = tokio::sync::oneshot::channel(); + sender.send(true).unwrap(); + token.cancel(); + assert_eq!( + recording_storage_answer(&token, receiver) + .await + .unwrap_err(), + RECORDING_START_CANCELLED + ); + } + + #[tokio::test] + async fn stop_unblocks_wait_and_late_answer_is_inert() { + let slot = RecordingStoragePrompt::default(); + let lease = slot.begin().unwrap(); + let (sender, receiver) = tokio::sync::oneshot::channel(); + let answer = recording_storage_answer(&lease.cancelled, receiver); + tokio::pin!(answer); + tokio::select! { + biased; + _ = &mut answer => panic!("Prompt resolved without an answer"), + _ = std::future::ready(()) => {}, + } + assert!(slot.cancel()); + assert_eq!(answer.await.unwrap_err(), RECORDING_START_CANCELLED); + assert!(sender.send(true).is_err()); + } + + #[test] + fn repeated_requests_do_not_replace_active_prompt() { + let slot = RecordingStoragePrompt::default(); + let first = slot.begin().unwrap(); + assert!(slot.begin().is_none()); + assert!(slot.cancel()); + assert!(first.cancelled.is_cancelled()); + assert!(slot.begin().is_none()); + drop(first); + let second = slot.begin().unwrap(); + assert!(!second.cancelled.is_cancelled()); + } + + #[test] + fn retired_prompt_cannot_cancel_admitted_recording() { + let slot = RecordingStoragePrompt::default(); + let lease = slot.begin().unwrap(); + let token = lease.cancelled.clone(); + drop(lease); + assert!(!slot.cancel()); + assert!(!token.is_cancelled()); + } + + #[test] + fn stale_drop_does_not_remove_replacement_prompt() { + let slot = RecordingStoragePrompt::default(); + let first = slot.begin().unwrap(); + *slot.0.lock().unwrap() = None; + let second = slot.begin().unwrap(); + drop(first); + assert!(slot.cancel()); + assert!(second.cancelled.is_cancelled()); + } + + #[test] + fn instant_control_normalizes_only_confirmed_pre_capture_cancellation() { + assert!( + storage_preflight_control_result(Err(RECORDING_START_CANCELLED.into()), false).is_ok() + ); + assert_eq!( + storage_preflight_control_result(Err(RECORDING_START_CANCELLED.into()), true) + .unwrap_err(), + RECORDING_START_CANCELLED + ); + assert_eq!( + storage_preflight_control_result(Err("cleanup unconfirmed".into()), false).unwrap_err(), + "cleanup unconfirmed" + ); + assert!(storage_preflight_control_result(Ok(()), true).is_ok()); + } +} + #[derive(Serialize, Type)] pub enum RecordingAction { Started, @@ -1405,6 +1749,8 @@ async fn lock_selected_camera( return Ok(None); }; + crate::permissions::check_camera_access().map_err(anyhow::Error::msg)?; + let existing_lock = match camera_feed.ask(camera::Lock).await { Ok(lock) if camera_lock_matches_id(&lock, &id) => Some(lock), Ok(lock) => { @@ -1650,10 +1996,13 @@ async fn start_recording_inner( .await; if !matches!(&result, Ok(RecordingAction::Started)) && let Some(generation) = clean_generation - && crate::clean_capture::is_current(&app, generation) { - state_mtx.write().await.clear_pending_recording(); - crate::clean_capture::release(&app, generation, false); + let mut state = state_mtx.write().await; + if crate::clean_capture::is_current(&app, generation) { + state.clear_pending_recording(); + drop(state); + crate::clean_capture::release(&app, generation, false); + } } result } @@ -1712,6 +2061,10 @@ async fn start_recording_prepared( } } + let storage_generation = crate::clean_capture::generation(&app); + #[cfg(target_os = "linux")] + let storage_instant = linux_instant::current(&app); + if cfg!(target_os = "linux") && inputs.mode == RecordingMode::Instant { drop(_input_operation.take()); } @@ -1778,36 +2131,120 @@ async fn start_recording_prepared( "Failed to create recordings directory: {e}" )); - match cap_utils::disk_space::free_bytes_for_path(&recordings_base_dir) { - Ok(bytes) => { - if bytes <= cap_utils::disk_space::LOW_DISK_STOP_BYTES { - let gb = bytes as f64 / 1_073_741_824.0; - let error = format!( - "Not enough disk space to start recording ({:.2} GB free). Free up at least {} MB and try again.", - gb, - (cap_utils::disk_space::LOW_DISK_STOP_BYTES / (1024 * 1024)) - ); - error!( - bytes_remaining = bytes, - "Refusing to start recording: disk full" - ); - state_mtx.write().await.clear_pending_recording(); - notify_recording_start_failed(&app, &error); - return Err(error); + let storage_prompt = recording_storage_prompt(&app) + .begin() + .ok_or(RECORDING_START_CANCELLED)?; + let event_app = app.clone(); + let cancelled = storage_prompt.cancelled.clone(); + let storage_events = RecordingStorageEvents { + app: app.clone(), + id: app.listen(CurrentRecordingChanged::NAME, move |_| { + if crate::clean_capture::generation(&event_app) != storage_generation + || clean_generation.is_some_and(|generation| { + crate::clean_capture::stop_requested(&event_app, generation) + }) + { + cancelled.cancel(); } - if bytes <= cap_utils::disk_space::LOW_DISK_WARN_BYTES { - let gb = bytes as f64 / 1_073_741_824.0; - warn!( - bytes_remaining = bytes, - available_gb = gb, - "Starting recording with low disk space" - ); + }), + }; + if crate::clean_capture::generation(&app) != storage_generation + || clean_generation + .is_some_and(|generation| crate::clean_capture::stop_requested(&app, generation)) + { + storage_prompt.cancelled.cancel(); + } + let prompt_app = &app; + let prompt_cancelled = &storage_prompt.cancelled; + let storage_work = check_recording_start_storage( + &recordings_base_dir, + cap_utils::disk_space::free_bytes_for_path, + |bytes| async move { + if prompt_cancelled.is_cancelled() { + return Err(RECORDING_START_CANCELLED.to_string()); } - } - Err(e) => { - warn!(error = %e, "Failed to check disk space before starting recording"); + let (sender, receiver) = tokio::sync::oneshot::channel(); + prompt_app.dialog() + .message(format!( + "Only {:.2} GiB is available on your recordings drive. The recording may stop early if space runs out. Free up space, or record anyway.", + bytes as f64 / 1_073_741_824.0, + )) + .title("Low storage space") + .kind(MessageDialogKind::Warning) + .buttons(MessageDialogButtons::OkCancelCustom( + "Record anyway".to_string(), + "Go back".to_string(), + )) + .show(move |confirmed| { + let _ = sender.send(confirmed); + }); + recording_storage_answer(prompt_cancelled, receiver).await + }, + ); + #[cfg(target_os = "linux")] + let storage_result = if let Some(attempt) = &storage_instant { + attempt.while_active(storage_work).await.map_err(|error| { + if attempt.cancelled() { + RECORDING_START_CANCELLED.to_string() + } else { + error + } + }) + } else { + storage_work.await + }; + #[cfg(not(target_os = "linux"))] + let storage_result = storage_work.await; + { + let mut app_state = state_mtx.write().await; + let owns_pending = matches!(app_state.recording_state, RecordingState::Pending { .. }) + && crate::clean_capture::generation(&app) == storage_generation + && clean_generation + .is_none_or(|generation| crate::clean_capture::is_current(&app, generation)); + #[cfg(target_os = "linux")] + let owns_pending = owns_pending + && match (&storage_instant, linux_instant::current(&app)) { + (Some(expected), Some(current)) => expected.same(¤t), + (None, None) => true, + _ => false, + }; + #[cfg(target_os = "linux")] + let instant_cancelled = storage_instant + .as_ref() + .is_some_and(|attempt| attempt.cancelled()); + #[cfg(not(target_os = "linux"))] + let instant_cancelled = false; + let storage_result = if !owns_pending + || storage_prompt.cancelled.is_cancelled() + || instant_cancelled + || clean_generation + .is_some_and(|generation| crate::clean_capture::stop_requested(&app, generation)) + { + Err(RECORDING_START_CANCELLED.to_string()) + } else if !requested_state.is_current(&requested_inputs) { + Err( + "Input selection changed before recording could start. Try recording again." + .to_string(), + ) + } else { + storage_result.and_then(|()| requested_state.ready_snapshot().map(|_| ())) + }; + // Stop checks Pending under the same App lock before cancelling this lease. + // Retire it here so a successful Stop cannot race admission to capture setup. + drop(storage_prompt); + if let Err(error) = storage_result { + if owns_pending { + app_state.clear_pending_recording(); + } + drop(app_state); + drop(storage_events); + if owns_pending { + notify_recording_start_failed(&app, &error); + } + return Err(error); } } + drop(storage_events); let project_file_path = recordings_base_dir.join(&pending_try!( cap_utils::ensure_unique_filename(&filename, &recordings_base_dir,), @@ -2064,6 +2501,11 @@ async fn start_recording_prepared( ) }; + crate::check_requested_camera_permission( + selected_camera_id.as_ref(), + crate::permissions::check_camera_access, + ) + .map_err(anyhow::Error::msg)?; validate_selected_camera_for_start( selected_camera_id.as_ref(), crate::is_camera_available, @@ -2148,7 +2590,7 @@ async fn start_recording_prepared( let mut mic_restart_attempts = 0; - let (done_fut, health_rx) = loop { + let (done_fut, health_rx, automatic_stop) = loop { let actor_result: Result = async { if !app_handle .state::() @@ -2432,7 +2874,23 @@ async fn start_recording_prepared( "Input selection changed during recording startup. Try recording again." )); } - break (done_fut, health_rx); + #[cfg(any(target_os = "linux", target_os = "macos", windows))] + let automatic_stop = match state.current_recording() { + Some(InProgressRecording::Studio { handle, common, .. }) => { + Some(enroll_studio_stop( + &app_handle, + &state_mtx, + handle, + &common.recording_dir, + crate::clean_capture::owner(&app_handle, &common.recording_dir), + StudioStopOrigin::Automatic, + )) + } + _ => None, + }; + #[cfg(not(any(target_os = "linux", target_os = "macos", windows)))] + let automatic_stop = (); + break (done_fut, health_rx, automatic_stop); } #[cfg(target_os = "macos")] Err(err) if is_shareable_content_error(&err) => { @@ -2474,13 +2932,13 @@ async fn start_recording_prepared( } }; - Ok::<_, anyhow::Error>((done_fut, health_rx)) + Ok::<_, anyhow::Error>((done_fut, health_rx, automatic_stop)) } }; let actor_task_res = AssertUnwindSafe(actor_task).catch_unwind().await; - let (actor_done_fut, health_rx) = match actor_task_res { + let (actor_done_fut, health_rx, automatic_stop) = match actor_task_res { Ok(Ok(v)) => v, Ok(Err(err)) => { let message = format!("{err:#}"); @@ -2507,33 +2965,7 @@ async fn start_recording_prepared( } }; - drop(_input_operation); - if clean_generation - .is_some_and(|generation| crate::clean_capture::stop_requested(&app, generation)) - { - #[cfg(target_os = "linux")] - if inputs.mode == RecordingMode::Instant { - if let Some(attempt) = linux_instant::current(&app) { - attempt.cancel(); - } - return Err("Instant startup cancelled".into()); - } - Box::pin(stop_recording(app.clone(), state_mtx.clone())).await?; - return Ok(RecordingAction::Started); - } - - if matches!(inputs.mode, RecordingMode::Studio) { - spawn_current_desktop_background_snapshot( - project_file_path.clone(), - inputs.capture_target.clone(), - ); - } - - let _ = RecordingEvent::Started.emit(&app); - let _ = RecordingStarted.emit(&app); - - emit_recording_started_telemetry(&app, &state_mtx).await; - + let (watcher_started_tx, watcher_started_rx) = tokio::sync::oneshot::channel::<()>(); spawn_actor({ let app = app.clone(); let state_mtx = Arc::clone(&state_mtx); @@ -2541,6 +2973,7 @@ async fn start_recording_prepared( #[cfg(any(target_os = "linux", target_os = "macos", windows))] let instant_watch = inputs.mode == RecordingMode::Instant; async move { + let _ = watcher_started_rx.await; fail!("recording::wait_actor_done"); let disposition = { let res = actor_done_fut.await; @@ -2554,27 +2987,71 @@ async fn start_recording_prepared( } return; } - #[cfg(target_os = "linux")] + #[cfg(any(target_os = "linux", target_os = "macos", windows))] if !instant_watch { - let state = state_mtx.read().await; - if let Some(InProgressRecording::Studio { handle, common, .. }) = - state.current_recording() - && common.recording_dir == project_file_path - && handle.lifecycle().terminal_started() - { + let terminal_started = { + let state = state_mtx.read().await; + let Some(InProgressRecording::Studio { handle, common, .. }) = + state.current_recording() + else { + return; + }; + if common.recording_dir != project_file_path + || automatic_stop.as_ref().is_none_or(|participant| { + !participant.cohort.identity.matches( + handle, + &common.recording_dir, + crate::clean_capture::owner(&app, &common.recording_dir), + ) + }) + { + return; + } + #[cfg(target_os = "linux")] + let started = handle.lifecycle().terminal_started(); + #[cfg(any(target_os = "macos", windows))] + let started = handle.terminal_started(); + started + }; + if clean_generation.is_some_and(|generation| { + crate::clean_capture::owner(&app, &project_file_path) != Some(generation) + }) { return; } - } - #[cfg(any(target_os = "macos", windows))] - if !instant_watch { - let state = state_mtx.read().await; - if let Some(InProgressRecording::Studio { handle, common, .. }) = - state.current_recording() - && common.recording_dir == project_file_path - && handle.terminal_started() - { + let follow_stop = automatic_stop.as_ref().is_some_and(|participant| { + participant.cohort.flight.lock().unwrap().explicit_seen + }) || matches!( + crate::clean_capture::phase(&app), + Some( + crate::clean_capture::Phase::Stopping + | crate::clean_capture::Phase::Restoring + ) + ); + if terminal_started && !follow_stop { return; } + let failure = if follow_stop { + res.err().map(|error| error.to_string()) + } else { + match classify_actor_done_result(res, true) { + ActorDoneDisposition::UnexpectedStop { error } + | ActorDoneDisposition::Failed { error } => Some(error), + ActorDoneDisposition::UserInitiatedStop => None, + } + }; + if let Some(completion) = control_studio_recording( + &app, + &state_mtx, + Some(&project_file_path), + StudioTerminalAction::Stop, + failure, + automatic_stop, + ) + .await + { + completion.present_automatic(); + } + return; } if let Some(generation) = clean_generation && (crate::clean_capture::owner(&app, &project_file_path) != Some(generation) @@ -2602,18 +3079,6 @@ async fn start_recording_prepared( } ActorDoneDisposition::UnexpectedStop { error } | ActorDoneDisposition::Failed { error } => { - #[cfg(any(target_os = "linux", target_os = "macos", windows))] - if !instant_watch { - let _ = control_studio_recording( - &app, - &state_mtx, - Some(&project_file_path), - StudioTerminalAction::Stop, - Some(error), - ) - .await; - return; - } let mut state = state_mtx.write().await; if let Some(generation) = clean_generation && (crate::clean_capture::owner(&app, &project_file_path) @@ -2660,6 +3125,49 @@ async fn start_recording_prepared( } }); + drop(_input_operation); + if clean_generation + .is_some_and(|generation| crate::clean_capture::stop_requested(&app, generation)) + { + #[cfg(target_os = "linux")] + if inputs.mode == RecordingMode::Instant { + if let Some(attempt) = linux_instant::current(&app) { + attempt.cancel(); + } + return Err("Instant startup cancelled".into()); + } + if inputs.mode == RecordingMode::Studio { + if let Some(generation) = clean_generation + && let Some(identity) = + studio_stop_registry(&app).active_identity(&project_file_path, generation) + { + Box::pin(stop_clean_studio_recording( + app.clone(), + identity.handle, + generation, + project_file_path.clone(), + )) + .await?; + } + } else { + Box::pin(stop_recording(app.clone(), state_mtx.clone())).await?; + } + return Ok(RecordingAction::Started); + } + + if matches!(inputs.mode, RecordingMode::Studio) { + spawn_current_desktop_background_snapshot( + project_file_path.clone(), + inputs.capture_target.clone(), + ); + } + + let _ = RecordingEvent::Started.emit(&app); + let _ = RecordingStarted.emit(&app); + let _ = watcher_started_tx.send(()); + + emit_recording_started_telemetry(&app, &state_mtx).await; + if let Some(mut health_rx) = health_rx { let accumulator_mode = { let state = state_mtx.read().await; @@ -3063,6 +3571,8 @@ async fn lock_selected_microphone( return Ok(None); }; + permissions::check_microphone_access().map_err(anyhow::Error::msg)?; + let existing_lock = match mic_feed.ask(microphone::Lock).await { Ok(lock) if lock.device_name() == label => Some(lock), Ok(lock) => { @@ -3248,21 +3758,581 @@ where F: std::future::Future>, { let report = stop.await; + if report.quiescence != studio_recording::StudioQuiescence::Joined { + return Err(format!( + "Studio cleanup is unconfirmed; recording and Stop control retained: {}", + report + .result + .err() + .unwrap_or_else(|| "terminal acknowledgement missing".into()) + )); + } if !report.accepted_intent { return Err("Another Studio terminal action owns cleanup".into()); } - if report.quiescence != studio_recording::StudioQuiescence::Joined { - return Err("Studio cleanup is unconfirmed; recording and Stop control retained".into()); + finish(report.result).await +} + +#[cfg(any(target_os = "linux", target_os = "macos", windows))] +#[derive(Clone, Copy, PartialEq, Eq)] +enum StudioTerminalAction { + Stop, + Discard, + Restart, +} + +#[cfg(any(target_os = "linux", target_os = "macos", windows))] +#[derive(Clone, Copy, PartialEq, Eq)] +enum StudioStopOrigin { + Automatic, + Explicit, +} + +#[cfg(any(target_os = "linux", target_os = "macos", windows))] +#[derive(Clone)] +struct StudioStopIdentity { + handle: studio_recording::ActorHandle, + directory: PathBuf, + generation: Option, +} + +#[cfg(any(target_os = "linux", target_os = "macos", windows))] +impl StudioStopIdentity { + fn matches( + &self, + handle: &studio_recording::ActorHandle, + directory: &Path, + generation: Option, + ) -> bool { + #[cfg(target_os = "linux")] + let same = self.handle.lifecycle().same_attempt(&handle.lifecycle()); + #[cfg(any(target_os = "macos", windows))] + let same = self.handle.same_attempt(handle); + same && self.directory == directory && self.generation == generation + } +} + +#[cfg(any(target_os = "linux", target_os = "macos", windows))] +#[derive(Default)] +struct StudioStopFlight { + participants: usize, + explicit: usize, + explicit_seen: bool, + presentation_claimed: bool, + automatic_error: Option, + cleanup_completed: bool, +} + +#[cfg(any(target_os = "linux", target_os = "macos", windows))] +struct StudioStopCohort { + identity: StudioStopIdentity, + notice: crate::clean_capture::StopNoticeTicket, + flight: std::sync::Mutex, +} + +#[cfg(any(target_os = "linux", target_os = "macos", windows))] +#[derive(Clone, Default)] +struct StudioStopRegistry(Arc>); + +#[cfg(any(target_os = "linux", target_os = "macos", windows))] +#[derive(Default)] +struct StudioStopRegistryState { + cohorts: Vec>, + active: Option<(StudioStopIdentity, crate::clean_capture::StopNoticeOwner)>, +} + +#[cfg(any(target_os = "linux", target_os = "macos", windows))] +#[derive(Clone)] +struct StudioStopError { + kind: crate::clean_capture::StopNoticeKind, + message: String, +} + +#[cfg(any(target_os = "linux", target_os = "macos", windows))] +type StudioStopPresenter = + Arc; + +#[cfg(any(target_os = "linux", target_os = "macos", windows))] +impl StudioStopRegistry { + fn retire(&self, owner: &crate::clean_capture::StopNoticeOwner) { + let mut entries = self.0.lock().unwrap(); + if entries + .active + .as_ref() + .is_some_and(|(_, active)| active.same_attempt(owner)) + { + entries.active = None; + } + } + + fn active_identity(&self, directory: &Path, generation: u32) -> Option { + self.0 + .lock() + .unwrap() + .active + .as_ref() + .and_then(|(identity, _)| { + (identity.directory == directory && identity.generation == Some(generation)) + .then(|| identity.clone()) + }) + } + + fn retire_identity( + &self, + handle: &studio_recording::ActorHandle, + directory: &Path, + generation: Option, + ) -> Option { + let mut entries = self.0.lock().unwrap(); + if entries + .active + .as_ref() + .is_some_and(|(identity, _)| identity.matches(handle, directory, generation)) + { + entries.active.take().map(|(_, owner)| owner) + } else { + None + } + } + + fn enroll( + &self, + identity: StudioStopIdentity, + origin: StudioStopOrigin, + present: StudioStopPresenter, + new_owner: impl FnOnce(&StudioStopIdentity) -> crate::clean_capture::StopNoticeOwner, + new_ticket: impl FnOnce( + crate::clean_capture::StopNoticeOwner, + ) -> crate::clean_capture::StopNoticeTicket, + ) -> StudioStopParticipant { + let mut entries = self.0.lock().unwrap(); + let cohort = match entries.cohorts.iter().find(|entry| { + entry + .identity + .matches(&identity.handle, &identity.directory, identity.generation) + }) { + Some(entry) => entry.clone(), + None => { + let owner = match &entries.active { + Some((active, owner)) + if active.matches( + &identity.handle, + &identity.directory, + identity.generation, + ) => + { + owner.clone() + } + _ => { + let owner = new_owner(&identity); + entries.active = Some((identity.clone(), owner.clone())); + owner + } + }; + let entry = Arc::new(StudioStopCohort { + identity, + notice: new_ticket(owner), + flight: std::sync::Mutex::new(StudioStopFlight::default()), + }); + entries.cohorts.push(entry.clone()); + entry + } + }; + { + let mut flight = cohort.flight.lock().unwrap(); + flight.participants += 1; + if origin == StudioStopOrigin::Explicit { + flight.explicit += 1; + flight.explicit_seen = true; + } + } + StudioStopParticipant { + registry: self.clone(), + cohort, + origin, + present, + } + } +} + +#[cfg(any(target_os = "linux", target_os = "macos", windows))] +fn studio_stop_registry(app: &AppHandle) -> StudioStopRegistry { + if app.try_state::().is_none() { + app.manage(StudioStopRegistry::default()); + } + app.state::().inner().clone() +} + +#[cfg(any(target_os = "linux", target_os = "macos", windows))] +fn enroll_studio_stop( + app: &AppHandle, + _state: &Arc>, + handle: &studio_recording::ActorHandle, + directory: &Path, + generation: Option, + origin: StudioStopOrigin, +) -> StudioStopParticipant { + let retained_app = app.clone(); + let registry = studio_stop_registry(app); + registry.enroll( + StudioStopIdentity { + handle: handle.clone(), + directory: directory.to_owned(), + generation, + }, + origin, + Arc::new(move |ticket, error| { + crate::clean_capture::retain_stop_notice( + &retained_app, + ticket, + error.kind, + error.message, + ); + }), + |identity| { + crate::clean_capture::reserve_stop_notice_owner( + app, + identity.directory.clone(), + identity.generation, + ) + }, + |owner| crate::clean_capture::reserve_stop_notice_ticket(app, owner), + ) +} + +#[cfg(any(target_os = "linux", target_os = "macos", windows))] +struct StudioStopParticipant { + registry: StudioStopRegistry, + cohort: Arc, + origin: StudioStopOrigin, + present: StudioStopPresenter, +} + +#[cfg(any(target_os = "linux", target_os = "macos", windows))] +impl StudioStopParticipant { + fn claim_presentation(&self) { + let mut flight = self.cohort.flight.lock().unwrap(); + flight.presentation_claimed = true; + flight.automatic_error = None; + } + + fn hand_off_error(&self) { + if self.origin == StudioStopOrigin::Explicit { + self.claim_presentation(); + } + } + + fn defer_error(&self, error: StudioStopError) { + let mut flight = self.cohort.flight.lock().unwrap(); + if !flight.presentation_claimed && flight.automatic_error.is_none() { + flight.automatic_error = Some(error.clone()); + } + drop(flight); + (self.present)(&self.cohort.notice, error); + } + + fn stale_completion(self) -> StudioTerminalCompletion { + let outcome = stale_studio_completion( + Some(&self.cohort), + self.origin == StudioStopOrigin::Automatic, + ); + StudioTerminalCompletion { + outcome, + participant: Some(self), + } + } +} + +#[cfg(any(target_os = "linux", target_os = "macos", windows))] +impl Drop for StudioStopParticipant { + fn drop(&mut self) { + let error = { + let mut entries = self.registry.0.lock().unwrap(); + let mut flight = self.cohort.flight.lock().unwrap(); + flight.participants -= 1; + if self.origin == StudioStopOrigin::Explicit { + flight.explicit -= 1; + } + let error = if flight.explicit == 0 && !flight.presentation_claimed { + let error = flight.automatic_error.take(); + if error.is_some() { + flight.presentation_claimed = true; + } + error + } else { + None + }; + if flight.participants == 0 { + entries + .cohorts + .retain(|entry| !Arc::ptr_eq(entry, &self.cohort)); + } + error + }; + if let Some(error) = error { + (self.present)(&self.cohort.notice, error); + } + } +} + +#[cfg(any(target_os = "linux", target_os = "macos", windows))] +enum StudioControlFailure { + Unconfirmed(String), + TaskFailed(String), + RejectedConfirmed(String), + Other(String), +} + +#[cfg(any(target_os = "linux", target_os = "macos", windows))] +enum StudioTerminalOutcome { + AppliedCompletion(Result<(), String>), + SupersededConfirmedStop, + ControlFailure(StudioControlFailure), +} + +#[cfg(any(target_os = "linux", target_os = "macos", windows))] +fn stale_studio_completion( + cohort: Option<&Arc>, + automatic: bool, +) -> StudioTerminalOutcome { + if cohort.is_some_and(|cohort| { + cohort.flight.lock().unwrap().cleanup_completed + || automatic && cohort.notice.owner.is_confirmed() + }) { + StudioTerminalOutcome::SupersededConfirmedStop + } else { + StudioTerminalOutcome::ControlFailure(StudioControlFailure::Other( + "Studio terminal completion is stale".into(), + )) + } +} + +#[cfg(any(target_os = "linux", target_os = "macos", windows))] +fn complete_studio_cleanup( + app: &AppHandle, + cohort: Option<&Arc>, + state: &App, + result: Result<(), String>, +) -> StudioTerminalOutcome { + if let Some(cohort) = cohort { + if let Some(InProgressRecording::Studio { handle, common, .. }) = state.current_recording() + && cohort + .identity + .matches(handle, &common.recording_dir, cohort.identity.generation) + { + return StudioTerminalOutcome::ControlFailure(StudioControlFailure::Other( + result + .err() + .unwrap_or_else(|| "Studio cleanup did not retire the recording".into()), + )); + } + cohort.flight.lock().unwrap().cleanup_completed = true; + studio_stop_registry(app).retire(&cohort.notice.owner); + crate::clean_capture::confirm_stop_notice(app, &cohort.notice.owner); + } + StudioTerminalOutcome::AppliedCompletion(result) +} + +#[cfg(any(target_os = "linux", target_os = "macos", windows))] +struct StudioTerminalCompletion { + outcome: StudioTerminalOutcome, + participant: Option, +} + +#[cfg(any(target_os = "linux", target_os = "macos", windows))] +impl StudioTerminalCompletion { + #[cfg(any(target_os = "macos", windows))] + fn error(&self) -> Option<&String> { + match &self.outcome { + StudioTerminalOutcome::ControlFailure(StudioControlFailure::RejectedConfirmed(_)) + if self.participant.as_ref().is_some_and(|participant| { + participant.origin == StudioStopOrigin::Automatic + }) => + { + None + } + StudioTerminalOutcome::AppliedCompletion(Err(error)) + | StudioTerminalOutcome::ControlFailure(StudioControlFailure::Unconfirmed(error)) + | StudioTerminalOutcome::ControlFailure(StudioControlFailure::TaskFailed(error)) + | StudioTerminalOutcome::ControlFailure(StudioControlFailure::Other(error)) + | StudioTerminalOutcome::ControlFailure(StudioControlFailure::RejectedConfirmed( + error, + )) => Some(error), + _ => None, + } + } + + fn from_report( + result: Result, + accepted: bool, + confirmed: bool, + participant: Option, + ) -> Self { + let outcome = match result { + Ok(outcome) => outcome, + Err(error) => StudioTerminalOutcome::ControlFailure(if !confirmed { + StudioControlFailure::Unconfirmed(error) + } else if !accepted && confirmed { + StudioControlFailure::RejectedConfirmed(error) + } else { + StudioControlFailure::Other(error) + }), + }; + Self { + outcome, + participant: None, + } + .with_participant(participant) + } + + fn with_participant(mut self, participant: Option) -> Self { + let error = match &self.outcome { + StudioTerminalOutcome::AppliedCompletion(Err(message)) => Some(StudioStopError { + kind: crate::clean_capture::StopNoticeKind::ConfirmedFailure, + message: message.clone(), + }), + StudioTerminalOutcome::ControlFailure(StudioControlFailure::Unconfirmed(message)) => { + Some(StudioStopError { + kind: crate::clean_capture::StopNoticeKind::Unconfirmed, + message: message.clone(), + }) + } + StudioTerminalOutcome::ControlFailure( + StudioControlFailure::TaskFailed(message) | StudioControlFailure::Other(message), + ) => Some(StudioStopError { + kind: crate::clean_capture::StopNoticeKind::ControlFailure, + message: message.clone(), + }), + StudioTerminalOutcome::ControlFailure(StudioControlFailure::RejectedConfirmed( + message, + )) if participant + .as_ref() + .is_some_and(|participant| participant.origin == StudioStopOrigin::Explicit) => + { + Some(StudioStopError { + kind: crate::clean_capture::StopNoticeKind::ControlFailure, + message: message.clone(), + }) + } + _ => None, + }; + if let (Some(error), Some(participant)) = (error, &participant) { + participant.defer_error(error); + } + self.participant = participant; + self + } + + fn into_result(self) -> Result<(), String> { + let Self { + outcome, + participant, + } = self; + let completion = Self { + outcome, + participant: None, + } + .with_participant(participant); + match completion.outcome { + StudioTerminalOutcome::AppliedCompletion(result) => { + if result.is_err() + && let Some(participant) = &completion.participant + { + participant.hand_off_error(); + } + result + } + StudioTerminalOutcome::SupersededConfirmedStop => Ok(()), + StudioTerminalOutcome::ControlFailure(StudioControlFailure::Unconfirmed(error)) + | StudioTerminalOutcome::ControlFailure(StudioControlFailure::TaskFailed(error)) => { + if let Some(participant) = &completion.participant { + participant.hand_off_error(); + } + Err(error) + } + StudioTerminalOutcome::ControlFailure(StudioControlFailure::Other(error)) + | StudioTerminalOutcome::ControlFailure(StudioControlFailure::RejectedConfirmed( + error, + )) => Err(error), + } + } + + fn present_automatic(self) { + let participant = self.participant; + drop( + Self { + outcome: self.outcome, + participant: None, + } + .with_participant(participant), + ); + } +} + +#[cfg(any(target_os = "linux", target_os = "macos", windows))] +async fn run_owned_studio_stop( + work: impl std::future::Future> + Send + 'static, + participant: Option, +) -> Option { + let failed_task_presenter = participant + .as_ref() + .map(|participant| (participant.cohort.clone(), participant.present.clone())); + let owned = async move { + let completion = match AssertUnwindSafe(work).catch_unwind().await { + Ok(completion) => completion, + Err(panic) => Some(StudioTerminalCompletion { + outcome: StudioTerminalOutcome::ControlFailure(StudioControlFailure::TaskFailed( + format!( + "Studio Stop task failed; cleanup may be incomplete: {}", + panic_message(panic) + ), + )), + participant: None, + }), + }; + completion.map(|completion| completion.with_participant(participant)) + }; + match tauri::async_runtime::spawn(owned).await { + Ok(completion) => completion, + Err(error) => { + let error = format!("Studio Stop task failed; cleanup may be incomplete: {error}"); + if let Some((cohort, present)) = failed_task_presenter { + present( + &cohort.notice, + StudioStopError { + kind: crate::clean_capture::StopNoticeKind::ControlFailure, + message: error.clone(), + }, + ); + } + Some(StudioTerminalCompletion { + outcome: StudioTerminalOutcome::ControlFailure(StudioControlFailure::TaskFailed( + error, + )), + participant: None, + }) + } } - finish(report.result).await } #[cfg(any(target_os = "linux", target_os = "macos", windows))] -#[derive(Clone, Copy, PartialEq, Eq)] -enum StudioTerminalAction { - Stop, - Discard, - Restart, +fn stopped_studio_error(error: &str, directory: &Path, action: StudioTerminalAction) -> String { + if action != StudioTerminalAction::Stop { + return error.to_owned(); + } + let lower = error.to_ascii_lowercase(); + let disk_full = lower.contains("disk full:") + || lower.contains("no space left on device") + || !cfg!(windows) && lower.contains("(os error 28)") + || cfg!(windows) && (lower.contains("(os error 112)") || lower.contains("(os error 39)")); + if disk_full { + format!( + "Recording stopped because your disk is full. Your recording files have been kept at {}. Free up space before recording again.", + directory.display() + ) + } else { + error.to_owned() + } } #[cfg(target_os = "linux")] @@ -3272,35 +4342,91 @@ async fn control_studio_recording( expected_directory: Option<&Path>, action: StudioTerminalAction, failure: Option, -) -> Option> { - let (handle, directory, target_name, capture_target, generation) = { - let state = state.read().await; - let InProgressRecording::Studio { handle, common, .. } = state.current_recording()? else { - return None; + automatic: Option, +) -> Option { + let (handle, directory, target_name, capture_target, generation, participant) = { + let current_state = state.read().await; + let Some(InProgressRecording::Studio { handle, common, .. }) = + current_state.current_recording() + else { + return automatic.map(StudioStopParticipant::stale_completion); }; if expected_directory.is_some_and(|expected| expected != common.recording_dir) { - return Some(Err( - "Studio terminal operation belongs to an older recording".into(), - )); + return Some(match automatic { + Some(participant) => participant.stale_completion(), + None => StudioTerminalCompletion { + outcome: StudioTerminalOutcome::ControlFailure(StudioControlFailure::Other( + "Studio terminal operation belongs to an older recording".into(), + )), + participant: None, + }, + }); } + let generation = crate::clean_capture::owner(app, &common.recording_dir); + let participant = if action == StudioTerminalAction::Stop { + match automatic { + Some(participant) => { + if !participant.cohort.identity.matches( + handle, + &common.recording_dir, + generation, + ) || participant.origin == StudioStopOrigin::Explicit + && !studio_stop_retry_is_current(app, &common.recording_dir, generation) + { + return Some(participant.stale_completion()); + } + Some(participant) + } + None => Some(enroll_studio_stop( + app, + state, + handle, + &common.recording_dir, + generation, + StudioStopOrigin::Explicit, + )), + } + } else { + None + }; ( handle.clone(), common.recording_dir.clone(), common.target_name.clone(), common.inputs.capture_target.clone(), - crate::clean_capture::owner(app, &common.recording_dir), + generation, + participant, ) }; - let discard = action != StudioTerminalAction::Stop; - let intent = if discard { - studio_recording::StudioStopIntent::Discard - } else { - studio_recording::StudioStopIntent::Preserve - }; - let stopping = handle.clone(); - Some( - after_studio_join( - async move { stopping.stop_with_intent(intent).await }, + + let automatic = participant + .as_ref() + .is_some_and(|participant| participant.origin == StudioStopOrigin::Automatic); + let cohort = participant + .as_ref() + .map(|participant| participant.cohort.clone()); + let app = app.clone(); + let state = state.clone(); + #[cfg(any(target_os = "macos", windows))] + let expected_directory = expected_directory.map(Path::to_owned); + let work = async move { + let app = &app; + let state = &state; + #[cfg(any(target_os = "macos", windows))] + let expected_directory = expected_directory.as_deref(); + let discard = action != StudioTerminalAction::Stop; + let intent = if discard { + studio_recording::StudioStopIntent::Discard + } else { + studio_recording::StudioStopIntent::Preserve + }; + + let stopping = handle.clone(); + let report = stopping.stop_with_intent(intent).await; + let accepted = report.accepted_intent; + let confirmed = report.quiescence == studio_recording::StudioQuiescence::Joined; + let result = after_studio_join( + async move { report }, |result| async move { let outcome = match failure { Some(error) => Err(error), @@ -3309,6 +4435,11 @@ async fn control_studio_recording( if discard && let Err(error) = &outcome { return Err(error.clone()); } + let finalization_project = if !discard && outcome.is_ok() { + Some(crate::FinalizationProject::admit(directory.clone()).await) + } else { + None + }; let mut state = state.write().await; let current = match state.current_recording() { Some(InProgressRecording::Studio { @@ -3323,7 +4454,7 @@ async fn control_studio_recording( _ => false, }; if !current { - return Err("Studio terminal completion is stale".into()); + return Ok(stale_studio_completion(cohort.as_ref(), automatic)); } if discard && let Err(error) = remove_recording_dir(&directory).await { return Err(error); @@ -3338,7 +4469,11 @@ async fn control_studio_recording( capture_target, }) }; - if let Some(error) = &error { + let display_error = error.as_deref().map(|error| { + tracing::error!(error, directory = %directory.display(), "Studio stopped with a recording failure"); + stopped_studio_error(error, &directory, action) + }); + if let Some(error) = &display_error { let _ = RecordingEvent::Failed { error: error.clone(), } @@ -3350,16 +4485,28 @@ async fn control_studio_recording( &mut state, directory, action == StudioTerminalAction::Restart, + finalization_project, ) .await; - match error { + let result = match display_error { Some(error) => Err(error), None => cleanup, - } + }; + let outcome = complete_studio_cleanup(app, cohort.as_ref(), &state, result); + drop(state); + Ok(outcome) }, ) - .await, - ) + .await; + Some(StudioTerminalCompletion::from_report( + result, accepted, confirmed, None, + )) + }; + if action == StudioTerminalAction::Stop { + run_owned_studio_stop(work, participant).await + } else { + work.await + } } #[cfg(any(target_os = "macos", windows))] @@ -3371,9 +4518,6 @@ where F: std::future::Future>, { let report = stop.await; - if !report.accepted_intent { - return Err("Another Studio terminal action owns cleanup".into()); - } if !report.stop_acknowledged { return Err(format!( "Studio cleanup is unconfirmed; recording and Stop control retained: {}", @@ -3383,6 +4527,9 @@ where .unwrap_or_else(|| "terminal acknowledgement missing".into()) )); } + if !report.accepted_intent { + return Err("Another Studio terminal action owns cleanup".into()); + } finish(report.result).await } @@ -3393,36 +4540,102 @@ async fn control_studio_recording( expected_directory: Option<&Path>, action: StudioTerminalAction, failure: Option, -) -> Option> { - let (handle, directory, target_name, capture_target, generation) = { - let state = state.read().await; - let InProgressRecording::Studio { handle, common, .. } = state.current_recording()? else { - return None; + automatic: Option, +) -> Option { + let (handle, directory, target_name, capture_target, generation, participant) = { + let current_state = state.read().await; + let Some(InProgressRecording::Studio { handle, common, .. }) = + current_state.current_recording() + else { + return automatic.map(StudioStopParticipant::stale_completion); }; if expected_directory.is_some_and(|expected| expected != common.recording_dir) { - return Some(Err( - "Studio terminal operation belongs to an older recording".into(), - )); + return Some(match automatic { + Some(participant) => participant.stale_completion(), + None => StudioTerminalCompletion { + outcome: StudioTerminalOutcome::ControlFailure(StudioControlFailure::Other( + "Studio terminal operation belongs to an older recording".into(), + )), + participant: None, + }, + }); } + let generation = crate::clean_capture::owner(app, &common.recording_dir); + let participant = if action == StudioTerminalAction::Stop { + match automatic { + Some(participant) => { + if !participant.cohort.identity.matches( + handle, + &common.recording_dir, + generation, + ) || participant.origin == StudioStopOrigin::Explicit + && !studio_stop_retry_is_current(app, &common.recording_dir, generation) + { + return Some(participant.stale_completion()); + } + Some(participant) + } + None => Some(enroll_studio_stop( + app, + state, + handle, + &common.recording_dir, + generation, + StudioStopOrigin::Explicit, + )), + } + } else { + None + }; ( handle.clone(), common.recording_dir.clone(), common.target_name.clone(), common.inputs.capture_target.clone(), - crate::clean_capture::owner(app, &common.recording_dir), + generation, + participant, ) }; - let discard = action != StudioTerminalAction::Stop; - let intent = if discard { - studio_recording::StudioStopIntent::Discard - } else { - studio_recording::StudioStopIntent::Preserve - }; - let stopping = handle.clone(); - let finishing = handle.clone(); - let result = after_studio_capture_stop( - async move { stopping.stop_with_intent(intent).await }, + let automatic = participant + .as_ref() + .is_some_and(|participant| participant.origin == StudioStopOrigin::Automatic); + let cohort = participant + .as_ref() + .map(|participant| participant.cohort.clone()); + let app = app.clone(); + let state = state.clone(); + #[cfg(any(target_os = "macos", windows))] + let expected_directory = expected_directory.map(Path::to_owned); + let work = async move { + let app = &app; + let state = &state; + #[cfg(any(target_os = "macos", windows))] + let expected_directory = expected_directory.as_deref(); + let discard = action != StudioTerminalAction::Stop; + let intent = if discard { + studio_recording::StudioStopIntent::Discard + } else { + studio_recording::StudioStopIntent::Preserve + }; + + let stopping = handle.clone(); + let finishing = handle.clone(); + let report = stopping.stop_with_intent(intent).await; + let accepted = report.accepted_intent; + let confirmed = report.stop_acknowledged; + let result = after_studio_capture_stop( + async move { report }, |result| async move { + let outcome = match (failure, result) { + (Some(failure), Err(error)) => Err(format!("{failure}; {error}")), + (Some(error), _) => Err(error), + (None, result) => result, + }; + let finalization_project = if !discard && outcome.is_ok() { + Some(crate::FinalizationProject::admit(directory.clone()).await) + } else { + None + }; let mut state = state.write().await; let current = match state.current_recording() { Some(InProgressRecording::Studio { @@ -3437,13 +4650,8 @@ async fn control_studio_recording( _ => false, }; if !current { - return Err("Studio terminal completion is stale".into()); + return Ok(stale_studio_completion(cohort.as_ref(), automatic)); } - let outcome = match (failure, result) { - (Some(failure), Err(error)) => Err(format!("{failure}; {error}")), - (Some(error), _) => Err(error), - (None, result) => result, - }; let mut error = outcome.as_ref().err().cloned(); if discard && error.is_none() { error = remove_recording_dir(&directory).await.err(); @@ -3451,8 +4659,11 @@ async fn control_studio_recording( #[cfg(target_os = "macos")] if action == StudioTerminalAction::Restart && error.is_none() { drop(state.clear_current_recording()); + if let Some(owner) = studio_stop_registry(app).retire_identity(&finishing, &directory, generation) { + crate::clean_capture::confirm_stop_notice(app, &owner); + } CurrentRecordingChanged.emit(app).ok(); - return Ok(()); + return Ok(StudioTerminalOutcome::AppliedCompletion(Ok(()))); } let completed = if let Some(error) = &error { Err(error.clone()) @@ -3465,7 +4676,11 @@ async fn control_studio_recording( capture_target, }) }; - if let Some(error) = &error { + let display_error = error.as_deref().map(|error| { + tracing::error!(error, directory = %directory.display(), "Studio stopped with a recording failure"); + stopped_studio_error(error, &directory, action) + }); + if let Some(error) = &display_error { let _ = RecordingEvent::Failed { error: error.clone(), } @@ -3477,50 +4692,211 @@ async fn control_studio_recording( &mut state, directory, action == StudioTerminalAction::Restart && error.is_none(), + finalization_project, ) .await; - match error { + let result = match display_error { Some(error) => Err(error), None => cleanup, - } + }; + let outcome = complete_studio_cleanup(app, cohort.as_ref(), &state, result); + drop(state); + Ok(outcome) }, ) .await; - if let Err(error) = &result { - let state = state.read().await; - if let Some(InProgressRecording::Studio { - handle: current, - common, - .. - }) = state.current_recording() - && current.same_attempt(&handle) - && expected_directory.is_none_or(|expected| expected == common.recording_dir) + let completion = StudioTerminalCompletion::from_report(result, accepted, confirmed, None); + if action != StudioTerminalAction::Stop + && !(automatic + && matches!( + &completion.outcome, + StudioTerminalOutcome::ControlFailure(StudioControlFailure::RejectedConfirmed( + _ + )) + )) + && let Some(error) = completion.error() { - let _ = RecordingEvent::Failed { - error: error.clone(), + let state = state.read().await; + if let Some(InProgressRecording::Studio { + handle: current, + common, + .. + }) = state.current_recording() + && current.same_attempt(&handle) + && expected_directory.is_none_or(|expected| expected == common.recording_dir) + { + let _ = RecordingEvent::Failed { + error: error.clone(), + } + .emit(app); } - .emit(app); } + Some(completion) + }; + if action == StudioTerminalAction::Stop { + run_owned_studio_stop(work, participant).await + } else { + work.await } - Some(result) +} + +#[cfg(any(target_os = "linux", target_os = "macos", windows))] +fn studio_stop_retry_is_current( + app: &AppHandle, + directory: &Path, + generation: Option, +) -> bool { + let Some(generation) = generation else { + return false; + }; + let snapshot = crate::clean_capture::get_clean_capture_state(app.clone()); + snapshot.generation == generation + && matches!(snapshot.phase, Some(crate::clean_capture::Phase::Stopping)) + && matches!(snapshot.mode, Some(RecordingMode::Studio)) + && crate::clean_capture::owner(app, directory) == Some(generation) +} + +#[cfg(any(target_os = "linux", target_os = "macos", windows))] +pub(crate) fn queue_clean_studio_stop(app: &AppHandle, generation: u32, directory: PathBuf) { + let Some(identity) = studio_stop_registry(app).active_identity(&directory, generation) else { + return; + }; + let app = app.clone(); + drop(tauri::async_runtime::spawn(async move { + if let Err(error) = + stop_clean_studio_recording(app, identity.handle, generation, directory).await + { + tracing::error!(%error, "Clean capture Stop did not complete"); + } + })); +} + +#[cfg(any(target_os = "linux", target_os = "macos", windows))] +pub(crate) async fn stop_clean_studio_recording( + app: AppHandle, + handle: studio_recording::ActorHandle, + generation: u32, + directory: PathBuf, +) -> Result<(), String> { + let state = app.state::>>().inner().clone(); + let participant = { + let current = state.read().await; + let Some(InProgressRecording::Studio { + handle: current_handle, + common, + .. + }) = current.current_recording() + else { + return Ok(()); + }; + let identity = StudioStopIdentity { + handle, + directory, + generation: Some(generation), + }; + if !identity.matches( + current_handle, + &common.recording_dir, + crate::clean_capture::owner(&app, &common.recording_dir), + ) || !crate::clean_capture::queue_owned_studio_stop( + &app, + generation, + &common.recording_dir, + ) { + return Ok(()); + } + enroll_studio_stop( + &app, + &state, + current_handle, + &common.recording_dir, + Some(generation), + StudioStopOrigin::Explicit, + ) + }; + control_studio_recording( + &app, + &state, + None, + StudioTerminalAction::Stop, + None, + Some(participant), + ) + .await + .map_or(Ok(()), StudioTerminalCompletion::into_result) +} + +#[cfg(any(target_os = "linux", target_os = "macos", windows))] +async fn queue_studio_stop( + app: &AppHandle, + state: &Arc>, +) -> (bool, Option) { + let current = state.read().await; + let generation = match current.current_recording() { + Some(InProgressRecording::Studio { common, .. }) => { + crate::clean_capture::owner(app, &common.recording_dir) + } + _ => None, + }; + let deferred = crate::clean_capture::queue_stop(app); + let retry = if deferred { + match current.current_recording() { + Some(InProgressRecording::Studio { handle, common, .. }) + if studio_stop_retry_is_current(app, &common.recording_dir, generation) => + { + Some(enroll_studio_stop( + app, + state, + handle, + &common.recording_dir, + generation, + StudioStopOrigin::Explicit, + )) + } + _ => None, + } + } else { + None + }; + (deferred, retry) } #[tauri::command] #[specta::specta] #[instrument(skip(app, state))] pub async fn stop_recording(app: AppHandle, state: MutableState<'_, App>) -> Result<(), String> { + if cancel_recording_storage_prompt(&app, &state).await { + return Ok(()); + } #[cfg(target_os = "linux")] if let Some(attempt) = linux_instant::current(&app) { return linux_instant::control(app, attempt, false).await; } - if crate::clean_capture::queue_stop(&app) { + #[cfg(any(target_os = "linux", target_os = "macos", windows))] + let (deferred, retry) = queue_studio_stop(&app, &state).await; + #[cfg(not(any(target_os = "linux", target_os = "macos", windows)))] + let deferred = crate::clean_capture::queue_stop(&app); + if deferred { + #[cfg(any(target_os = "linux", target_os = "macos", windows))] + if let Some(retry) = retry { + return control_studio_recording( + &app, + &state, + None, + StudioTerminalAction::Stop, + None, + Some(retry), + ) + .await + .map_or(Ok(()), StudioTerminalCompletion::into_result); + } return Ok(()); } #[cfg(any(target_os = "linux", target_os = "macos", windows))] if let Some(result) = - control_studio_recording(&app, &state, None, StudioTerminalAction::Stop, None).await + control_studio_recording(&app, &state, None, StudioTerminalAction::Stop, None, None).await { - return result; + return result.into_result(); } let mut state = state.write().await; let recording_pending = matches!(&state.recording_state, RecordingState::Pending { .. }); @@ -3619,9 +4995,11 @@ pub async fn restart_recording( Some(&directory), StudioTerminalAction::Restart, None, + None, ) .await - .ok_or("Studio recording changed before restart")??; + .ok_or("Studio recording changed before restart")? + .into_result()?; Ok(()) }, async { @@ -3729,8 +5107,12 @@ pub async fn restart_recording( } .await; if !matches!(&result, Ok(RecordingAction::Started)) { - state.write().await.clear_pending_recording(); - crate::clean_capture::release(&app, generation, false); + let mut app_state = state.write().await; + if crate::clean_capture::is_current(&app, generation) { + app_state.clear_pending_recording(); + drop(app_state); + crate::clean_capture::release(&app, generation, false); + } } return result; } @@ -3815,15 +5197,25 @@ fn take_editor_target_after_recording( #[specta::specta] #[instrument(skip(app, state))] pub async fn delete_recording(app: AppHandle, state: MutableState<'_, App>) -> Result<(), String> { + if cancel_recording_storage_prompt(&app, &state).await { + return Ok(()); + } #[cfg(target_os = "linux")] if let Some(attempt) = linux_instant::current(&app) { return linux_instant::control(app, attempt, true).await; } #[cfg(any(target_os = "linux", target_os = "macos", windows))] - if let Some(result) = - control_studio_recording(&app, &state, None, StudioTerminalAction::Discard, None).await + if let Some(result) = control_studio_recording( + &app, + &state, + None, + StudioTerminalAction::Discard, + None, + None, + ) + .await { - return result; + return result.into_result(); } if crate::clean_capture::phase(&app) == Some(crate::clean_capture::Phase::Recording) { crate::clean_capture::control(&app, false).await?; @@ -4106,7 +5498,7 @@ async fn handle_recording_end( app: &mut App, recording_dir: PathBuf, ) -> Result<(), String> { - handle_recording_end_inner(handle, recording, app, recording_dir, false).await + handle_recording_end_inner(handle, recording, app, recording_dir, false, None).await } async fn handle_recording_end_inner( @@ -4115,6 +5507,7 @@ async fn handle_recording_end_inner( app: &mut App, recording_dir: PathBuf, preserve_editor_target: bool, + finalization_project: Option, String>>, ) -> Result<(), String> { #[cfg(target_os = "linux")] if let Some(InProgressRecording::Studio { @@ -4157,6 +5550,21 @@ async fn handle_recording_end_inner( return Ok(()); } let cleared = app.clear_recording_state(); + #[cfg(any(target_os = "linux", target_os = "macos", windows))] + if let Some(InProgressRecording::Studio { + handle: studio, + common, + .. + }) = &cleared + && let Some(owner) = studio_stop_registry(&handle).retire_identity( + studio, + &common.recording_dir, + clean_generation, + ) + { + crate::clean_capture::confirm_stop_notice(&handle, &owner); + } + #[cfg(not(target_os = "linux"))] let mut cleared = cleared; @@ -4264,7 +5672,9 @@ async fn handle_recording_end_inner( } let res = match recording { // we delay reporting errors here so that everything else happens first - Ok(recording) => Some(handle_recording_finish(&handle, recording).await), + Ok(recording) => { + Some(handle_recording_finish(&handle, recording, finalization_project).await) + } Err(error) => { if let Ok(mut project_meta) = RecordingMeta::load_for_project(&recording_dir).map_err(|err| { @@ -4470,6 +5880,7 @@ async fn apply_post_studio_editor_behaviour( async fn handle_recording_finish( app: &AppHandle, completed_recording: CompletedRecording, + finalization_project: Option, String>>, ) -> Result { let recording_dir = completed_recording.project_path().clone(); @@ -4500,8 +5911,14 @@ async fn handle_recording_finish( "Recording has fragments queued for finalization - opening editor immediately" ); + let project = finalization_project.ok_or_else(|| { + crate::recoverable_finalization_error( + &recording_dir, + "Recording directory was not admitted for finalization.".into(), + ) + })??; let finalizing_state = app.state::(); - finalizing_state.start_finalizing(recording_dir.clone()); + let finalization = finalizing_state.start_finalizing(project.clone())?; let duration = compute_studio_duration_secs(&recording_dir); let editor_took_foreground = @@ -4511,7 +5928,6 @@ async fn handle_recording_finish( let app = app.clone(); let recording_dir_for_finalize = recording_dir.clone(); - let screenshots_dir = screenshots_dir.clone(); let default_preset = PresetsStore::get_default_preset(&app) .ok() .flatten() @@ -4520,15 +5936,14 @@ async fn handle_recording_finish( tokio::spawn(async move { let result = finalize_studio_recording( &app, - recording_dir_for_finalize.clone(), - screenshots_dir, + project, recording, default_preset, Some(capture_target), ) .await; - match result { + match &result { Ok(()) => { let duration = compute_studio_duration_secs(&recording_dir_for_finalize); @@ -4541,8 +5956,7 @@ async fn handle_recording_finish( Err(e) => error!("Failed to finalize recording: {e}"), } - app.state::() - .finish_finalizing(&recording_dir_for_finalize); + finalization.finish(result); }); return Ok(editor_took_foreground); @@ -4753,19 +6167,22 @@ async fn handle_recording_finish( async fn finalize_studio_recording( app: &AppHandle, - recording_dir: PathBuf, - screenshots_dir: PathBuf, + project: Arc, recording: cap_recording::studio_recording::CompletedRecording, default_preset: Option, capture_target: Option, ) -> Result<(), String> { info!("Starting background finalization for recording"); - + project.validate_async().await?; + let recording_dir = project.work_path().to_path_buf(); + let screenshots_dir = recording_dir.join("screenshots"); + let display_path = project.display_path().to_path_buf(); let recording_dir_for_remux = recording_dir.clone(); let app_for_remux = app.clone(); let remux_result = tokio::task::spawn_blocking(move || { remux_fragmented_recording_with_trigger( &recording_dir_for_remux, + &display_path, "recording_stop", Some(&app_for_remux), ) @@ -4775,7 +6192,7 @@ async fn finalize_studio_recording( if let Err(e) = remux_result { error!("Failed to finalize fragmented recording: {e}"); - return Err(format!("Failed to finalize fragmented recording: {e}")); + return Err(e); } let updated_meta = RecordingMeta::load_for_project(&recording_dir) @@ -4821,6 +6238,7 @@ async fn finalize_studio_recording( .write(&recording_dir) .map_err(|e| format!("Failed to write project config: {e}"))?; + project.validate_async().await?; info!("Background finalization completed for recording"); Ok(()) @@ -5072,6 +6490,8 @@ fn project_config_from_recording( transitions: Vec::new(), zoom_segments, scene_segments: Vec::new(), + style_segments: Vec::new(), + image_segments: Vec::new(), mask_segments: Vec::new(), text_segments: Vec::new(), caption_segments: Vec::new(), @@ -5214,15 +6634,24 @@ fn mark_fragmented_recording_for_ffmpeg_export(recording_dir: &Path) -> Result<( .map_err(|e| format!("Failed to mark recording for FFmpeg export: {e}")) } -pub fn remux_fragmented_recording(recording_dir: &Path) -> Result<(), String> { - remux_fragmented_recording_with_trigger(recording_dir, "manual_remux", None) +pub(crate) fn remux_fragmented_recording( + project: &crate::FinalizationProject, +) -> Result<(), String> { + remux_fragmented_recording_with_trigger( + project.work_path(), + project.display_path(), + "manual_remux", + None, + ) } pub fn remux_fragmented_recording_with_trigger( recording_dir: &Path, + display_path: &Path, trigger: &'static str, app: Option<&AppHandle>, ) -> Result<(), String> { + crate::recovery::ensure_finalization_storage(recording_dir, display_path)?; let incomplete_recording = RecoveryManager::inspect_recording(recording_dir); if let Some(recording) = incomplete_recording { @@ -5237,7 +6666,9 @@ pub fn remux_fragmented_recording_with_trigger( match outcome { Ok(_) => { - mark_fragmented_recording_for_ffmpeg_export(recording_dir)?; + if let Err(error) = mark_fragmented_recording_for_ffmpeg_export(recording_dir) { + warn!(project_path = %recording_dir.display(), error = %error, "Failed to write fragmented recording export marker"); + } if normal_stop { info!("Successfully finalized fragmented recording"); } else { @@ -5291,6 +6722,7 @@ pub fn remux_fragmented_recording_with_trigger( Ok(()) } Err(e) => { + let storage_full = crate::recovery::is_storage_full_recovery_error(&e); let reason = format!("{e}"); if let Some(app_handle) = app { crate::telemetry::async_capture_event( @@ -5301,6 +6733,9 @@ pub fn remux_fragmented_recording_with_trigger( }, ); } + if storage_full { + return Err(crate::recovery::finalization_storage_error(display_path)); + } let action = if normal_stop { "finalize" } else { "recover" }; Err(format!("Failed to {action} recording: {reason}")) } @@ -6447,7 +7882,8 @@ pub(crate) mod linux_instant { owned_reply( attempt, async move { - let result = execute(app.clone(), worker, discard).await; + let result = execute(app.clone(), worker.clone(), discard).await; + let result = storage_preflight_control_result(result, worker.has_capture()); if let Err(error) = &result { let _ = RecordingEvent::Failed { error: error.clone(), @@ -8119,6 +9555,71 @@ mod studio_joined_completion_tests { } } +#[cfg(all(test, any(target_os = "linux", target_os = "macos", windows)))] +mod studio_failure_presentation_tests { + use super::*; + + #[test] + fn confirmed_disk_full_message_keeps_exact_recording_path() { + let directory = Path::new("/recordings/Unfinished capture.cap"); + for error in [ + "out-of-process media finalization failed: disk full: encoder exited with code 60", + "Could not save cursor events: No space left on device (os error 28)", + "Failed to write keyboard events file: No space left on device (os error 28)", + ] { + let message = stopped_studio_error(error, directory, StudioTerminalAction::Stop); + assert!(message.starts_with("Recording stopped because your disk is full.")); + assert!(message.contains(&directory.display().to_string())); + assert!(message.ends_with("Free up space before recording again.")); + assert!(!message.contains("playable")); + } + } + + #[test] + fn discard_and_restart_errors_do_not_claim_files_were_kept() { + let error = "partial directory removal failed: No space left on device (os error 28)"; + for action in [StudioTerminalAction::Discard, StudioTerminalAction::Restart] { + assert_eq!( + stopped_studio_error(error, Path::new("recording.cap"), action), + error + ); + } + } + + #[test] + fn unrelated_recording_failures_keep_their_details() { + for error in [ + "camera disconnected", + "Permission denied (os error 13)", + "could not open /recordings/disk full recording.cap", + ] { + assert_eq!( + stopped_studio_error( + error, + Path::new("recording.cap"), + StudioTerminalAction::Stop + ), + error + ); + } + } + + #[cfg(windows)] + #[test] + fn windows_storage_full_codes_are_recognized() { + for error in ["write failed (os error 112)", "write failed (os error 39)"] { + assert!( + stopped_studio_error( + error, + Path::new("recording.cap"), + StudioTerminalAction::Stop + ) + .contains("your disk is full") + ); + } + } +} + #[cfg(all(test, any(target_os = "macos", windows)))] mod studio_capture_control_tests { use super::*; @@ -8194,4 +9695,40 @@ mod studio_capture_control_tests { assert!(stop.await.is_err()); assert_eq!(effects.load(std::sync::atomic::Ordering::SeqCst), 1); } + + #[tokio::test] + async fn disk_full_presentation_requires_confirmed_stop_ownership() { + for (accepted_intent, stop_acknowledged) in [(true, true), (true, false), (false, true)] { + let entered = std::sync::atomic::AtomicBool::new(false); + let result = after_studio_capture_stop( + async { + studio_recording::WindowsStudioStopReport { + accepted_intent, + stop_acknowledged, + result: Err("disk full: encoder exited with code 60".into()), + } + }, + |result| async { + entered.store(true, std::sync::atomic::Ordering::SeqCst); + result.map_err(|error| { + stopped_studio_error( + &error, + Path::new("retained.cap"), + StudioTerminalAction::Stop, + ) + }) + }, + ) + .await; + let confirmed = accepted_intent && stop_acknowledged; + assert_eq!(entered.load(std::sync::atomic::Ordering::SeqCst), confirmed); + assert_eq!( + result + .err() + .unwrap() + .starts_with("Recording stopped because your disk is full."), + confirmed + ); + } + } } diff --git a/apps/desktop/src-tauri/src/recovery.rs b/apps/desktop/src-tauri/src/recovery.rs index 20d00e0df62..2538b2c4e03 100644 --- a/apps/desktop/src-tauri/src/recovery.rs +++ b/apps/desktop/src-tauri/src/recovery.rs @@ -1,16 +1,76 @@ use cap_project::StudioRecordingMeta; -use cap_recording::recovery::RecoveryManager; +use cap_recording::recovery::{RecoveryError, RecoveryManager}; use chrono::NaiveDate; use serde::{Deserialize, Serialize}; use specta::Type; -use std::path::PathBuf; -use tauri::AppHandle; +use std::path::{Path, PathBuf}; +use tauri::{AppHandle, Emitter, Manager}; use tracing::info; use crate::create_screenshot; const RECOVERY_CUTOFF_DATE: (i32, u32, u32) = (2025, 12, 31); +pub(crate) fn finalization_storage_error(path: &Path) -> String { + format!( + "Not enough space to finish this recording. Your recording files have been kept at {}. Free up space, then click Recover Recording.", + path.display() + ) +} + +fn ensure_finalization_storage_with( + path: &Path, + inspect: impl FnOnce(&Path) -> std::io::Result, +) -> Result<(), String> { + let storage = inspect(path) + .map_err(|error| format!("Could not check available space for this recording: {error}"))?; + if !storage.can_finalize() { + return Err(finalization_storage_error(path)); + } + Ok(()) +} + +pub(crate) fn ensure_finalization_storage( + work_path: &Path, + display_path: &Path, +) -> Result<(), String> { + ensure_finalization_storage_with(display_path, |_| { + cap_utils::disk_space::recording_storage(work_path) + }) +} + +fn is_storage_full_remux_error(error: &cap_enc_ffmpeg::remux::RemuxError) -> bool { + match error { + cap_enc_ffmpeg::remux::RemuxError::Io(error) => { + error.kind() == std::io::ErrorKind::StorageFull + } + cap_enc_ffmpeg::remux::RemuxError::Ffmpeg(ffmpeg::Error::Other { errno }) => { + *errno == ffmpeg::error::ENOSPC + } + _ => false, + } +} + +pub(crate) fn is_storage_full_recovery_error(error: &RecoveryError) -> bool { + match error { + RecoveryError::Io(error) => error.kind() == std::io::ErrorKind::StorageFull, + RecoveryError::VideoConcat(error) + | RecoveryError::AudioConcat(error) + | RecoveryError::MediaMerge(error) => is_storage_full_remux_error(error), + _ => false, + } +} + +fn recovery_error_message(path: &Path, error: RecoveryError) -> String { + let storage_full = is_storage_full_recovery_error(&error); + tracing::error!(project_path = %path.display(), error = %error, "Recording recovery failed"); + if storage_full { + finalization_storage_error(path) + } else { + error.to_string() + } +} + fn parse_recording_date(pretty_name: &str) -> Option { let date_part = pretty_name.strip_prefix("Cap ")?; let date_str = date_part.split(" at ").next()?; @@ -69,79 +129,100 @@ pub async fn find_incomplete_recordings( } #[tauri::command] -#[specta::specta] -pub async fn recover_recording(app: AppHandle, project_path: String) -> Result { - let path = PathBuf::from(&project_path); - - let recording = tokio::task::spawn_blocking(move || RecoveryManager::inspect_recording(&path)) +pub async fn get_recording_recovery_success( + app: AppHandle, + project_path: String, +) -> Result, String> { + app.state::() + .recovery_success(Path::new(&project_path)) .await - .map_err(|e| format!("Recovery scan task failed: {e}"))? - .ok_or_else(|| "No recoverable segments found".to_string())?; - - if recording.recoverable_segments.is_empty() { - return Err("No recoverable segments found".to_string()); - } +} - let estimated_duration_secs = recording.estimated_duration.as_secs(); +#[tauri::command] +#[specta::specta] +pub async fn recover_recording(app: AppHandle, project_path: String) -> Result { + let project = crate::FinalizationProject::admit(PathBuf::from(&project_path)).await?; + let token = app + .state::() + .start_recovering(project)?; let recover_start = std::time::Instant::now(); - let recovered = match RecoveryManager::recover(&recording) { - Ok(r) => r, - Err(e) => { - let reason = format!("{e}"); - crate::telemetry::async_capture_event( - &app, - crate::telemetry::AnalyticsEvent::RecordingRecoveryFailed { - trigger: "app_startup", - reason: reason.clone(), - }, - ); - return Err(reason); + let app_for_recovery = app.clone(); + let result = crate::run_finalization_worker(token, move |project| { + let path = project.work_path(); + ensure_finalization_storage(path, project.display_path())?; + let recording = RecoveryManager::inspect_recording(path) + .ok_or_else(|| "No recoverable segments found".to_string())?; + if recording.recoverable_segments.is_empty() { + return Err("No recoverable segments found".to_string()); } - }; - let validation_took_ms = recover_start.elapsed().as_millis() as u64; - - let segment_count = match &recovered.meta { - StudioRecordingMeta::SingleSegment { .. } => 1, - StudioRecordingMeta::MultipleSegments { inner } => inner.segments.len(), - }; - - info!( - "Recovered recording with {} segments: {}", - segment_count, project_path - ); - - crate::telemetry::async_capture_event( - &app, - crate::telemetry::AnalyticsEvent::RecordingRecovered { - trigger: "app_startup", - recovered_duration_secs: estimated_duration_secs, - segments_recovered: segment_count as u32, - validation_took_ms, - }, - ); - - let display_output_path = match &recovered.meta { - StudioRecordingMeta::SingleSegment { segment } => { - segment.display.path.to_path(&recovered.project_path) + let estimated_duration_secs = recording.estimated_duration.as_secs(); + let recovered = RecoveryManager::recover(&recording) + .map_err(|error| recovery_error_message(project.display_path(), error))?; + project.validate()?; + let validation_took_ms = recover_start.elapsed().as_millis() as u64; + + let segment_count = match &recovered.meta { + StudioRecordingMeta::SingleSegment { .. } => 1, + StudioRecordingMeta::MultipleSegments { inner } => inner.segments.len(), + }; + + info!( + "Recovered recording with {} segments: {}", + segment_count, project_path + ); + + crate::telemetry::async_capture_event( + &app_for_recovery, + crate::telemetry::AnalyticsEvent::RecordingRecovered { + trigger: "app_startup", + recovered_duration_secs: estimated_duration_secs, + segments_recovered: segment_count as u32, + validation_took_ms, + }, + ); + + let display_output_path = match &recovered.meta { + StudioRecordingMeta::SingleSegment { segment } => { + segment.display.path.to_path(&recovered.project_path) + } + StudioRecordingMeta::MultipleSegments { inner, .. } => inner.segments[0] + .display + .path + .to_path(&recovered.project_path), + }; + + let screenshots_dir = recovered.project_path.join("screenshots"); + match std::fs::create_dir_all(&screenshots_dir) { + Ok(()) => { + let display_screenshot = screenshots_dir.join("display.jpg"); + tokio::spawn(async move { + if let Err(e) = create_screenshot(display_output_path, display_screenshot, None).await { + tracing::error!("Failed to create screenshot during recovery: {}", e); + } + }); + } + Err(error) => { + tracing::warn!(project_path = %project_path, error = %error, "Failed to create recovery screenshots directory"); + } } - StudioRecordingMeta::MultipleSegments { inner, .. } => inner.segments[0] - .display - .path - .to_path(&recovered.project_path), - }; - - let screenshots_dir = recovered.project_path.join("screenshots"); - std::fs::create_dir_all(&screenshots_dir) - .map_err(|e| format!("Failed to create screenshots directory: {e}"))?; - let display_screenshot = screenshots_dir.join("display.jpg"); - tokio::spawn(async move { - if let Err(e) = create_screenshot(display_output_path, display_screenshot, None).await { - tracing::error!("Failed to create screenshot during recovery: {}", e); + if let Err(error) = app_for_recovery.emit("recording-recovery-completed", &project_path) { + tracing::warn!(project_path = %project_path, error = %error, "Failed to notify editors of completed recording recovery"); } - }); - Ok(project_path) + Ok(project_path) + }) + .await; + if let Err(reason) = &result { + crate::telemetry::async_capture_event( + &app, + crate::telemetry::AnalyticsEvent::RecordingRecoveryFailed { + trigger: "app_startup", + reason: reason.clone(), + }, + ); + } + result } #[tauri::command] diff --git a/apps/desktop/src-tauri/src/screenshot_editor.rs b/apps/desktop/src-tauri/src/screenshot_editor.rs index c2760a8fb89..b8344f82e09 100644 --- a/apps/desktop/src-tauri/src/screenshot_editor.rs +++ b/apps/desktop/src-tauri/src/screenshot_editor.rs @@ -18,6 +18,7 @@ use serde::{Deserialize, Serialize}; use specta::Type; use std::io::Cursor; use std::str::FromStr; +use std::sync::atomic::{AtomicU8, Ordering}; use std::time::Instant; use std::{collections::HashMap, ops::Deref, path::PathBuf, sync::Arc}; use tauri::{ @@ -29,9 +30,77 @@ use tokio_util::sync::CancellationToken; const MAX_DIMENSION: u32 = 16_384; -type PendingResult = Result, String>; +type PendingResult = Result, String>; type PendingReceiver = watch::Receiver>; +pub(crate) struct ScreenshotEditorInstanceDelivery { + instance: Arc, + cleanup_runtime: tokio::runtime::Handle, + state: AtomicU8, +} + +impl ScreenshotEditorInstanceDelivery { + const PENDING: u8 = 0; + const ADOPTED: u8 = 1; + const RETIRED: u8 = 2; + + fn new( + instance: Arc, + cleanup_runtime: tokio::runtime::Handle, + ) -> Arc { + Arc::new(Self { + instance, + cleanup_runtime, + state: AtomicU8::new(Self::PENDING), + }) + } + + fn adopt_into( + &self, + instances: &mut HashMap>, + window_label: &str, + ) -> Result, String> { + self.state + .compare_exchange( + Self::PENDING, + Self::ADOPTED, + Ordering::AcqRel, + Ordering::Acquire, + ) + .map_err(|_| "Editor instance delivery is no longer pending".to_string())?; + let instance = self.instance.clone(); + let _ = instances.insert(window_label.to_string(), instance.clone()); + Ok(instance) + } + + fn retire(&self) -> Option> { + self.state + .compare_exchange( + Self::PENDING, + Self::RETIRED, + Ordering::AcqRel, + Ordering::Acquire, + ) + .ok()?; + let instance = self.instance.clone(); + Some(self.cleanup_runtime.spawn(async move { + instance.dispose().await; + })) + } + + async fn dispose(&self) { + if let Some(cleanup) = self.retire() { + let _ = cleanup.await; + } + } +} + +impl Drop for ScreenshotEditorInstanceDelivery { + fn drop(&mut self) { + drop(self.retire()); + } +} + #[derive(Clone)] pub struct ScreenshotConfigUpdate { pub revision: u32, @@ -242,6 +311,7 @@ impl ScreenshotEditorInstances { let (frame_tx, frame_rx) = watch::channel(None); let (ws_port, ws_shutdown_token) = create_watch_frame_ws(frame_rx, Default::default()).await; + let ws_guard = ws_shutdown_token.clone().drop_guard(); if ws_port == 0 { return Err("Failed to start screenshot editor frame websocket".to_string()); } @@ -380,6 +450,7 @@ impl ScreenshotEditorInstances { image_height: height, source_rgba: source_rgba.clone(), }); + ws_guard.disarm(); let decoded_frame = DecodedFrame::new(source_rgba.as_ref().clone(), width, height); @@ -510,48 +581,69 @@ impl ScreenshotEditorInstances { window: &Window, path: PathBuf, ) -> Result, String> { + let CapWindowId::ScreenshotEditor { id } = + CapWindowId::from_str(window.label()).map_err(|error| error.to_string())? + else { + return Err("Invalid screenshot editor window".to_string()); + }; + let window_ids = ScreenshotEditorWindowIds::get(window.app_handle()); + with_registered_screenshot_editor(&window_ids, id, || ())?; let instances = match window.try_state::() { Some(s) => (*s).clone(), None => { - let instances = Self(Arc::new(RwLock::new(HashMap::new()))); - window.manage(instances.clone()); - instances + window.manage(Self(Arc::new(RwLock::new(HashMap::new())))); + (*window.state::()).clone() } }; - let mut instances = instances.0.write().await; + if let Some(instance) = with_registered_screenshot_editor(&window_ids, id, || { + instances.get(window.label()).map(|instance| { + let instance = instance.clone(); + let config = instance.config_tx.borrow().clone(); + let _ = instance.config_tx.send(config); + instance + }) + })? { + return Ok(instance); + } - use std::collections::hash_map::Entry; - - match instances.entry(window.label().to_string()) { - Entry::Vacant(entry) => { - let pending = PendingScreenshotEditorInstances::get(window.app_handle()); - - if let Some(mut prewarmed_rx) = pending.take_prewarmed(window.label()).await { - loop { - if let Some(result) = prewarmed_rx.borrow_and_update().clone() { - let instance = result?; - entry.insert(instance.clone()); - return Ok(instance); - } - if prewarmed_rx.changed().await.is_err() { - break; - } - } + let pending = PendingScreenshotEditorInstances::get(window.app_handle()); + let mut prewarmed = None; + if let Some(mut prewarmed_rx) = pending.take_prewarmed(window.label()).await { + loop { + let result = prewarmed_rx.borrow_and_update().clone(); + if let Some(result) = result { + prewarmed = Some(result?); + break; } - + if prewarmed_rx.changed().await.is_err() { + break; + } + } + } + let instance = match prewarmed { + Some(instance) => instance, + None => { + with_registered_screenshot_editor(&window_ids, id, || ())?; + let cleanup_runtime = tokio::runtime::Handle::current(); let instance = - Self::create_standalone_instance(window.app_handle(), path, true).await?; - entry.insert(instance.clone()); - Ok(instance) + Self::create_standalone_instance(window.app_handle(), path.clone(), true) + .await?; + ScreenshotEditorInstanceDelivery::new(instance, cleanup_runtime) } - Entry::Occupied(entry) => { - let instance = entry.get().clone(); - let config = instance.config_tx.borrow().clone(); - let _ = instance.config_tx.send(config); - Ok(instance) + }; + let published = with_registered_screenshot_editor(&window_ids, id, || { + instance.adopt_into(&mut instances, window.label()) + }); + drop(instances); + let instance = match published { + Ok(Ok(instance)) => instance, + Ok(Err(error)) | Err(error) => { + instance.dispose().await; + return Err(error); } - } + }; + Ok(instance) } pub async fn remove(window: Window) { @@ -590,39 +682,65 @@ impl ScreenshotEditorInstances { } } +fn with_registered_screenshot_editor( + window_ids: &ScreenshotEditorWindowIds, + id: u32, + action: impl FnOnce() -> T, +) -> Result { + let ids = window_ids.ids.lock().map_err(|error| error.to_string())?; + if !ids.iter().any(|(_, registered_id)| *registered_id == id) { + return Err("Screenshot editor window is no longer registered".to_string()); + } + Ok(action()) +} + impl PendingScreenshotEditorInstances { pub fn get(app: &AppHandle) -> Self { match app.try_state::() { Some(s) => (*s).clone(), None => { let pending = Self::default(); - app.manage(pending.clone()); - pending + app.manage(pending); + (*app.state::()).clone() } } } pub async fn start_prewarm(app: &AppHandle, window_label: String, path: PathBuf) { + let Ok(CapWindowId::ScreenshotEditor { id }) = CapWindowId::from_str(&window_label) else { + return; + }; + let window_ids = ScreenshotEditorWindowIds::get(app); let pending = Self::get(app); let app = app.clone(); - - { - let instances = pending.0.read().await; - if instances.contains_key(&window_label) { - return; - } - } - - let (tx, rx) = watch::channel(None); - - { + let tx = { let mut instances = pending.0.write().await; - instances.insert(window_label.clone(), rx); - } + let admitted = with_registered_screenshot_editor(&window_ids, id, || { + use std::collections::hash_map::Entry; + match instances.entry(window_label) { + Entry::Vacant(entry) => { + let (tx, rx) = watch::channel(None); + entry.insert(rx); + Some(tx) + } + Entry::Occupied(_) => None, + } + }); + match admitted { + Ok(Some(tx)) => tx, + Ok(None) => return, + Err(error) => { + tracing::debug!(%error, "Skipping prewarm for a retired screenshot editor"); + return; + } + } + }; + let cleanup_runtime = tokio::runtime::Handle::current(); tokio::spawn(async move { - let result = - ScreenshotEditorInstances::create_standalone_instance(&app, path, true).await; + let result = ScreenshotEditorInstances::create_standalone_instance(&app, path, true) + .await + .map(|instance| ScreenshotEditorInstanceDelivery::new(instance, cleanup_runtime)); tx.send(Some(result)).ok(); }); } diff --git a/apps/desktop/src-tauri/src/tray.rs b/apps/desktop/src-tauri/src/tray.rs index e7c16babc50..7ad9835b939 100644 --- a/apps/desktop/src-tauri/src/tray.rs +++ b/apps/desktop/src-tauri/src/tray.rs @@ -971,8 +971,11 @@ pub fn create_tray(app: &AppHandle) -> tauri::Result<()> { let is_recording = Arc::clone(&is_recording); let app_handle = app.clone(); move |tray, event| { - if let tauri::tray::TrayIconEvent::Click { .. } = event { + if let tauri::tray::TrayIconEvent::Click { button_state, .. } = event { if is_recording.load(Ordering::Relaxed) { + if button_state != tauri::tray::MouseButtonState::Down { + return; + } let app = app_handle.clone(); tokio::spawn(async move { let _ = recording::stop_recording(app.clone(), app.state()).await; diff --git a/apps/desktop/src-tauri/src/window_exclusion.rs b/apps/desktop/src-tauri/src/window_exclusion.rs index 23ef1e373f5..8bdf228afc6 100644 --- a/apps/desktop/src-tauri/src/window_exclusion.rs +++ b/apps/desktop/src-tauri/src/window_exclusion.rs @@ -80,6 +80,24 @@ fn matches_window_title(exclusions: &[WindowExclusion], title: &str) -> bool { .any(|entry| entry.matches(None, None, Some(title))) } +#[cfg(any(target_os = "macos", test))] +fn excludes_own_window( + exclusions: &[WindowExclusion], + window: &crate::windows::CapWindowId, +) -> bool { + matches!(window, crate::windows::CapWindowId::RecordingControls) + || matches_window_title(exclusions, &window.title()) +} + +#[cfg(target_os = "macos")] +fn append_native_window_id(ids: &mut Vec, native_id: &WindowId) -> bool { + if ids.contains(native_id) { + return false; + } + ids.push(native_id.clone()); + true +} + #[cfg(target_os = "macos")] pub fn resolve_window_ids(exclusions: &[WindowExclusion]) -> Vec { if exclusions.is_empty() { @@ -132,7 +150,7 @@ pub fn append_matching_webview_window_ids( continue; }; let title = window_id.title(); - if !matches_window_title(exclusions, &title) { + if !excludes_own_window(exclusions, &window_id) { continue; } let Some(native_id) = webview_window_id(&window) else { @@ -143,25 +161,7 @@ pub fn append_matching_webview_window_ids( ); continue; }; - if Window::from_id(&native_id).is_none() { - if window.is_visible().unwrap_or(false) { - warn!( - window_id = %native_id, - label = %label, - title = %title, - "Excluded Tauri webview window id is not visible to CGWindowList" - ); - } else { - debug!( - window_id = %native_id, - label = %label, - title = %title, - "Skipping hidden excluded Tauri webview window" - ); - } - continue; - } - if ids.contains(&native_id) { + if !append_native_window_id(ids, &native_id) { debug!( window_id = %native_id, label = %label, @@ -176,14 +176,14 @@ pub fn append_matching_webview_window_ids( title = %title, "Resolved excluded Tauri webview window" ); - ids.push(native_id); } } #[cfg(target_os = "macos")] fn webview_window_id(window: &tauri::WebviewWindow) -> Option { let ns_window = window.ns_window().ok()? as *const objc2_app_kit::NSWindow; - let number = unsafe { (*ns_window).windowNumber() }; + let ns_window = unsafe { ns_window.as_ref() }?; + let number = unsafe { ns_window.windowNumber() }; if number <= 0 { return None; @@ -335,6 +335,47 @@ mod tests { assert!(!matches_window_title(&exclusions, "Cap Recording Controls")); } + #[test] + fn own_controls_are_excluded_with_empty_or_custom_rules() { + use crate::windows::CapWindowId; + + assert!(excludes_own_window(&[], &CapWindowId::RecordingControls)); + assert!(excludes_own_window( + &[title_exclusion("Unrelated window")], + &CapWindowId::RecordingControls, + )); + assert!(!excludes_own_window(&[], &CapWindowId::Camera)); + } + + #[test] + fn own_window_exclusions_preserve_default_and_instant_camera_rules() { + use crate::windows::CapWindowId; + + let defaults = crate::general_settings::default_excluded_windows(); + assert!(excludes_own_window( + &defaults, + &CapWindowId::RecordingControls + )); + assert!(excludes_own_window(&defaults, &CapWindowId::Camera)); + let instant = filter_for_instant_mode(defaults, &CapWindowId::Camera.title()); + assert!(!excludes_own_window(&instant, &CapWindowId::Camera)); + assert!(excludes_own_window( + &instant, + &CapWindowId::RecordingControls + )); + } + + #[cfg(target_os = "macos")] + #[test] + fn native_exclusion_ids_do_not_require_a_visible_cg_window() { + let native_id: WindowId = "4294967294".parse().unwrap(); + let mut ids = Vec::new(); + assert!(append_native_window_id(&mut ids, &native_id)); + assert_eq!(ids, vec![native_id.clone()]); + assert!(!append_native_window_id(&mut ids, &native_id)); + assert_eq!(ids.len(), 1); + } + #[test] fn default_exclusions_contain_camera() { let defaults = crate::general_settings::default_excluded_windows(); diff --git a/apps/desktop/src-tauri/src/windows.rs b/apps/desktop/src-tauri/src/windows.rs index dfe3ad5f483..27ba686cc54 100644 --- a/apps/desktop/src-tauri/src/windows.rs +++ b/apps/desktop/src-tauri/src/windows.rs @@ -7,11 +7,12 @@ use scap_targets::{Display, DisplayId}; use serde::Deserialize; use specta::Type; use std::{ + collections::HashMap, ops::Deref, - path::PathBuf, + path::{Path, PathBuf}, str::FromStr, sync::{ - Arc, Mutex, + Arc, Mutex, Weak, atomic::{AtomicU32, AtomicU64, Ordering}, }, time::Duration, @@ -30,13 +31,13 @@ use crate::panel_manager::{PanelManager, PanelState, PanelWindowType, is_window_ use crate::{ App, ArcLock, CameraWindowCloseGate, CameraWindowPositionGuard, MainWindowReadyState, NewNotification, RequestSetTargetMode, camera_preview_error_message, - editor_window::PendingEditorInstances, + editor_window::{EditorInstances, PendingEditorInstances}, emit_camera_preview_clear, emit_camera_preview_error, fake_window, general_settings::{self, AppTheme, GeneralSettingsStore}, permissions, recording::{RecordingEvent, RecordingInputKind}, recording_settings::RecordingTargetMode, - screenshot_editor::PendingScreenshotEditorInstances, + screenshot_editor::{PendingScreenshotEditorInstances, ScreenshotEditorInstances}, target_select_overlay::WindowFocusManager, window_exclusion::WindowExclusion, }; @@ -46,10 +47,7 @@ use cap_recording::{feeds, sources::screen_capture::ScreenCaptureTarget}; const DEFAULT_TRAFFIC_LIGHTS_INSET: LogicalPosition = LogicalPosition::new(12.0, 12.0); #[cfg(target_os = "macos")] -const MAIN_PANEL_LEVEL: i32 = 100; - -#[cfg(target_os = "macos")] -const TELEPROMPTER_PANEL_LEVEL: objc2_app_kit::NSWindowLevel = MAIN_PANEL_LEVEL as isize + 1; +const TELEPROMPTER_PANEL_LEVEL: objc2_app_kit::NSWindowLevel = 101; const DEFAULT_FALLBACK_DISPLAY_WIDTH: f64 = 1920.0; const DEFAULT_FALLBACK_DISPLAY_HEIGHT: f64 = 1080.0; @@ -341,6 +339,10 @@ pub(crate) async fn ensure_camera_input_active(app_state: &mut App) { if let Some(id) = app_state.selected_camera_id.clone() && !app_state.camera_in_use { + if let Err(error) = crate::permissions::check_camera_access() { + warn!(%error, "Camera preview requires permission before restoring input"); + return; + } let settings = crate::recording_settings::RecordingSettingsStore::camera_settings_for( &app_state.handle, &id, @@ -771,15 +773,341 @@ fn recenter_window_if_offscreen(window: &WebviewWindow) { let _ = window.set_position(monitor.position(pos_x, pos_y)); } -fn ensure_settings_window_bounds(window: &WebviewWindow) { - const MIN_W: f64 = 780.0; - const MIN_H: f64 = 560.0; - let _ = window.set_min_size(Some(LogicalSize::new(MIN_W, MIN_H))); - if let (Ok(physical), Ok(scale)) = (window.inner_size(), window.scale_factor()) { - let width = physical.width as f64 / scale; - let height = physical.height as f64 / scale; - if width < MIN_W || height < MIN_H { - let _ = window.set_size(LogicalSize::new(width.max(MIN_W), height.max(MIN_H))); +#[derive(Clone, Copy, Debug, PartialEq)] +struct ContentWindowRect { + x: f64, + y: f64, + width: f64, + height: f64, +} + +#[derive(Debug, PartialEq)] +struct ContentWindowFit { + frame: ContentWindowRect, + inner: (f64, f64), + minimum: (f64, f64), +} + +fn fit_content_window( + work_area: ContentWindowRect, + frame: ContentWindowRect, + inner: (f64, f64), + minimum: (f64, f64), + preferred: Option<(f64, f64)>, +) -> Option { + let requested = preferred.unwrap_or(inner); + if ![work_area.x, work_area.y, frame.x, frame.y] + .into_iter() + .all(f64::is_finite) + || ![ + work_area.width, + work_area.height, + frame.width, + frame.height, + inner.0, + inner.1, + minimum.0, + minimum.1, + requested.0, + requested.1, + ] + .into_iter() + .all(|value| value.is_finite() && value > 0.0) + { + return None; + } + + let decoration = ( + (frame.width - inner.0).max(0.0), + (frame.height - inner.1).max(0.0), + ); + let available = ( + work_area.width - 32.0 - decoration.0, + work_area.height - 32.0 - decoration.1, + ); + if available.0 <= 0.0 || available.1 <= 0.0 { + return None; + } + let minimum = (minimum.0.min(available.0), minimum.1.min(available.1)); + let inner = ( + requested.0.clamp(minimum.0, available.0), + requested.1.clamp(minimum.1, available.1), + ); + let width = inner.0 + decoration.0; + let height = inner.1 + decoration.1; + let left = work_area.x + 16.0; + let bottom = work_area.y + 16.0; + let right = (work_area.x + work_area.width - width - 16.0).max(left); + let top = (work_area.y + work_area.height - height - 16.0).max(bottom); + let (x, y) = if preferred.is_some() { + ( + work_area.x + (work_area.width - width) / 2.0, + work_area.y + (work_area.height - height) / 2.0, + ) + } else { + (frame.x.clamp(left, right), frame.y.clamp(bottom, top)) + }; + Some(ContentWindowFit { + frame: ContentWindowRect { + x, + y, + width, + height, + }, + inner, + minimum, + }) +} + +fn monitor_work_area(monitor: &Monitor) -> Option<(ContentWindowRect, f64)> { + let scale = monitor.scale_factor(); + let area = monitor.work_area(); + Some((logical_work_area(area.position, area.size, scale)?, scale)) +} + +fn logical_work_area( + position: PhysicalPosition, + size: PhysicalSize, + scale: f64, +) -> Option { + if !scale.is_finite() || scale <= 0.0 { + return None; + } + Some(ContentWindowRect { + x: position.x as f64 / scale, + y: position.y as f64 / scale, + width: size.width as f64 / scale, + height: size.height as f64 / scale, + }) +} + +fn initial_content_window_fit( + app: &AppHandle, + id: &CapWindowId, +) -> Option<(ContentWindowFit, f64)> { + let preferred = id.preferred_content_size()?; + let monitor = app + .cursor_position() + .ok() + .and_then(|position| { + app.monitor_from_point(position.x, position.y) + .ok() + .flatten() + }) + .or_else(|| app.primary_monitor().ok().flatten())?; + let (area, scale) = monitor_work_area(&monitor)?; + let frame = ContentWindowRect { + width: preferred.0, + height: preferred.1, + ..area + }; + Some(( + fit_content_window(area, frame, preferred, id.min_size()?, Some(preferred))?, + scale, + )) +} + +async fn fit_content_window_bounds(window: &WebviewWindow, id: &CapWindowId, initial: bool) { + let Some(preferred) = id.preferred_content_size() else { + return; + }; + let Some(minimum) = id.min_size() else { + return; + }; + if window.is_maximized().unwrap_or(false) || window.is_fullscreen().unwrap_or(false) { + return; + } + #[cfg(target_os = "macos")] + { + let (tx, rx) = tokio::sync::oneshot::channel(); + let result = window.run_on_main_thread({ + let window = window.clone(); + move || { + if tx.is_closed() { + return; + } + let result = + fit_macos_content_window(&window, minimum, initial.then_some(preferred)); + let _ = tx.send(result); + } + }); + if let Err(error) = result { + warn!(%error, "Failed to schedule content window bounds update"); + } else if let Err(error) = + await_window_operation(rx, "Content window bounds update", Duration::from_secs(5)).await + { + warn!(%error, "Failed to fit content window bounds"); + } + } + #[cfg(not(target_os = "macos"))] + { + let initial_monitor = initial + .then(|| { + window + .app_handle() + .cursor_position() + .ok() + .and_then(|position| { + window + .monitor_from_point(position.x, position.y) + .ok() + .flatten() + }) + }) + .flatten(); + let Some((area, scale)) = initial_monitor + .or_else(|| window.current_monitor().ok().flatten()) + .or_else(|| window.primary_monitor().ok().flatten()) + .as_ref() + .and_then(monitor_work_area) + else { + return; + }; + let (Ok(position), Ok(outer), Ok(inner), Ok(current_scale)) = ( + window.outer_position(), + window.outer_size(), + window.inner_size(), + window.scale_factor(), + ) else { + return; + }; + if !current_scale.is_finite() || current_scale <= 0.0 { + return; + } + let frame = ContentWindowRect { + x: position.x as f64 / current_scale, + y: position.y as f64 / current_scale, + width: outer.width as f64 / current_scale, + height: outer.height as f64 / current_scale, + }; + let Some(fit) = fit_content_window( + area, + frame, + ( + inner.width as f64 / current_scale, + inner.height as f64 / current_scale, + ), + minimum, + initial.then_some(preferred), + ) else { + return; + }; + let _ = window.set_min_size(Some(LogicalSize::new(fit.minimum.0, fit.minimum.1))); + #[cfg(windows)] + { + let _ = window.set_position(PhysicalPosition::new( + (fit.frame.x * scale).round() as i32, + (fit.frame.y * scale).round() as i32, + )); + let _ = window.set_size(PhysicalSize::new( + (fit.inner.0 * scale).round() as u32, + (fit.inner.1 * scale).round() as u32, + )); + } + #[cfg(target_os = "linux")] + { + let _ = scale; + let _ = window.set_size(LogicalSize::new(fit.inner.0, fit.inner.1)); + let _ = window.set_position(LogicalPosition::new(fit.frame.x, fit.frame.y)); + } + } +} + +#[cfg(target_os = "macos")] +fn fit_macos_content_window( + window: &WebviewWindow, + minimum: (f64, f64), + preferred: Option<(f64, f64)>, +) -> Result<(), String> { + use objc2::{MainThreadMarker, runtime::NSObjectProtocol, sel}; + use objc2_app_kit::{NSEvent, NSScreen, NSWindow}; + use objc2_foundation::{NSPoint, NSRect, NSSize}; + + let main_thread = MainThreadMarker::new().ok_or("Window bounds require the main thread")?; + let native = window.ns_window().map_err(|error| error.to_string())? as *const NSWindow; + let native = unsafe { native.as_ref() }.ok_or("Content window is unavailable")?; + let cursor_screen = preferred.and_then(|_| { + let cursor = unsafe { NSEvent::mouseLocation() }; + NSScreen::screens(main_thread).iter().find(|screen| { + let frame = screen.frame(); + cursor.x >= frame.origin.x + && cursor.x < frame.origin.x + frame.size.width + && cursor.y >= frame.origin.y + && cursor.y < frame.origin.y + frame.size.height + }) + }); + let screen = cursor_screen + .or_else(|| native.screen()) + .or_else(|| NSScreen::mainScreen(main_thread)) + .ok_or("Content window screen is unavailable")?; + let visible = screen.visibleFrame(); + let screen_frame = screen.frame(); + let safe_top = if screen.respondsToSelector(sel!(safeAreaInsets)) { + unsafe { screen.safeAreaInsets().top } + } else { + 0.0 + }; + let top = (visible.origin.y + visible.size.height) + .min(screen_frame.origin.y + screen_frame.size.height - safe_top); + let area = ContentWindowRect { + x: visible.origin.x, + y: visible.origin.y, + width: visible.size.width, + height: top - visible.origin.y, + }; + let frame = native.frame(); + let content = native + .contentView() + .ok_or("Content view is unavailable")? + .frame(); + let Some(fit) = fit_content_window( + area, + ContentWindowRect { + x: frame.origin.x, + y: frame.origin.y, + width: frame.size.width, + height: frame.size.height, + }, + (content.size.width, content.size.height), + minimum, + preferred, + ) else { + return Ok(()); + }; + native.setMinSize(NSSize::new( + fit.minimum.0 + fit.frame.width - fit.inner.0, + fit.minimum.1 + fit.frame.height - fit.inner.1, + )); + native.setFrame_display( + NSRect::new( + NSPoint::new(fit.frame.x, fit.frame.y), + NSSize::new(fit.frame.width, fit.frame.height), + ), + true, + ); + Ok(()) +} + +#[cfg(any(target_os = "macos", test))] +async fn await_window_operation( + receiver: tokio::sync::oneshot::Receiver>, + operation: &str, + timeout: Duration, +) -> Result<(), String> { + tokio::time::timeout(timeout, receiver) + .await + .map_err(|_| format!("{operation} timed out"))? + .map_err(|_| format!("{operation} was cancelled"))? +} + +#[cfg(target_os = "macos")] +struct PendingControlsWindow(Option); + +#[cfg(target_os = "macos")] +impl Drop for PendingControlsWindow { + fn drop(&mut self) { + if let Some(window) = self.0.take() { + let _ = window.destroy(); } } } @@ -876,6 +1204,14 @@ impl std::fmt::Display for CapWindowId { } impl CapWindowId { + fn preferred_content_size(&self) -> Option<(f64, f64)> { + match self { + Self::Settings => Some((782.0, 775.0)), + Self::Editor { .. } => Some((1275.0, 800.0)), + Self::ScreenshotEditor { .. } => Some((1240.0, 800.0)), + _ => None, + } + } pub fn label(&self) -> String { self.to_string() } @@ -1030,37 +1366,41 @@ impl ShowCapWindow { #[cfg(target_os = "linux")] crate::clean_capture::admit_wayland_window_creation(app) .map_err(|error| tauri::Error::Io(std::io::Error::other(error)))?; - if let Self::Editor { project_path } = &self { - let state = app.state::(); - let window_id = { - let mut s = state.ids.lock().unwrap(); - if !s.iter().any(|(path, _)| path == project_path) { - let id = state - .counter - .fetch_add(1, std::sync::atomic::Ordering::SeqCst); - s.push((project_path.clone(), id)); - id - } else { - s.iter().find(|(path, _)| path == project_path).unwrap().1 - } - }; - - let window_label = CapWindowId::Editor { id: window_id }.label(); - PendingEditorInstances::start_prewarm(app, window_label, project_path.clone()).await; - } - - if let Self::ScreenshotEditor { path } = &self { - let state = app.state::(); - { - let mut s = state.ids.lock().unwrap(); - if !s.iter().any(|(p, _)| p == path) { - let id = state - .counter - .fetch_add(1, std::sync::atomic::Ordering::SeqCst); - s.push((path.clone(), id)); - } + let mut project_opening = match self { + Self::Editor { project_path } => { + let state = app.state::(); + Some( + ProjectWindowOpening::acquire( + app, + project_path, + state.ids.clone(), + &state.counter, + &state.open_gates, + |id| CapWindowId::Editor { id }, + ) + .await?, + ) } - } + Self::ScreenshotEditor { path } => { + let state = app.state::(); + Some( + ProjectWindowOpening::acquire( + app, + path, + state.ids.clone(), + &state.counter, + &state.open_gates, + |id| CapWindowId::ScreenshotEditor { id }, + ) + .await?, + ) + } + _ => None, + }; + let window_id = project_opening + .as_ref() + .map(|opening| opening.id.clone()) + .unwrap_or_else(|| self.id(app)); let camera_window_label = if matches!(self, Self::Camera { .. }) { Some(camera_window_label_for_session(bump_camera_window_session( @@ -1306,17 +1646,33 @@ impl ShowCapWindow { let _ = window.set_position(tauri::LogicalPosition::new(pos_x, pos_y)); let label = window.label().to_string(); + let (show_tx, show_rx) = tokio::sync::oneshot::channel(); app.run_on_main_thread({ let app = app.clone(); move || { - use tauri_nspanel::ManagerExt; - if let Ok(panel) = app.get_webview_panel(&label) { - panel.order_front_regardless(); - panel.show(); + if show_tx.is_closed() { + return; } + use tauri_nspanel::ManagerExt; + let result = app + .get_webview_panel(&label) + .map(|panel| { + panel.order_front_regardless(); + panel.show(); + }) + .map_err(|error| { + format!("Recording controls panel is unavailable: {error:?}") + }); + let _ = show_tx.send(result); } - }) - .ok(); + })?; + await_window_operation( + show_rx, + "Showing recording controls", + Duration::from_secs(5), + ) + .await + .map_err(|error| tauri::Error::Anyhow(anyhow!(error)))?; fake_window::spawn_fake_window_listener(app.clone(), window.clone()); return Ok(window); } else { @@ -1365,8 +1721,15 @@ impl ShowCapWindow { return Ok(window); } + let existing_window = match project_opening.as_ref() { + Some(opening) => opening.existing_window.clone(), + None if !matches!(self, Self::Camera { .. } | Self::InProgressRecording { .. }) => { + window_id.get(app) + } + None => None, + }; if !matches!(self, Self::Camera { .. } | Self::InProgressRecording { .. }) - && let Some(window) = self.id(app).get(app) + && let Some(window) = existing_window { if matches!(self, Self::Main { .. }) && crate::should_show_onboarding(app) { return Box::pin(Self::Onboarding.show(app)).await; @@ -1409,17 +1772,14 @@ impl ShowCapWindow { let _ = window.set_ignore_cursor_events(false); } - if matches!(self, Self::Main { .. } | Self::Settings { .. }) { + if matches!(self, Self::Main { .. }) { recenter_window_if_offscreen(&window); } + fit_content_window_bounds(&window, &window_id, false).await; crate::clean_capture::guarded_show(window.clone(), reveal_generation, true, true) .await?; - if let Self::Settings { .. } = self { - ensure_settings_window_bounds(&window); - } - if let Self::Main { init_target_mode } = self { emit_app_event( app, @@ -1439,14 +1799,14 @@ impl ShowCapWindow { } #[cfg(target_os = "macos")] - if self.id(app).activates_dock() { + if window_id.activates_dock() { crate::permissions::sync_macos_dock_visibility(app); } return Ok(window); } - let _id = self.id(app); + let _id = window_id; let cursor_monitor = CursorMonitorInfo::get(); let window = match self { @@ -1527,8 +1887,6 @@ impl ShowCapWindow { | NSWindowCollectionBehavior::NSWindowCollectionBehaviorFullScreenPrimary, ); - panel.set_level(MAIN_PANEL_LEVEL); - let resized_window = window.clone(); window.on_window_event(move |event| { if matches!(event, tauri::WindowEvent::Resized(_) | tauri::WindowEvent::ScaleFactorChanged { .. }) @@ -1647,7 +2005,14 @@ impl ShowCapWindow { } #[cfg(target_os = "linux")] - { + if cap_recording::screenshot::uses_wayland_portal() { + let Some(bounds) = display.raw_handle().logical_bounds() else { + return Err(tauri::Error::WindowNotFound); + }; + window_builder = window_builder + .inner_size(bounds.size().width(), bounds.size().height()) + .position(bounds.position().x(), bounds.position().y()); + } else { let position = display.raw_handle().physical_position().unwrap(); let size = display.physical_size().unwrap(); window_builder = window_builder @@ -1659,7 +2024,20 @@ impl ShowCapWindow { lock_window_text_scale(&window); #[cfg(target_os = "linux")] - { + if cap_recording::screenshot::uses_wayland_portal() { + use tauri::{LogicalPosition, LogicalSize}; + let Some(bounds) = display.raw_handle().logical_bounds() else { + return Err(tauri::Error::WindowNotFound); + }; + let _ = window.set_position(LogicalPosition::new( + bounds.position().x(), + bounds.position().y(), + )); + let _ = window.set_size(LogicalSize::new( + bounds.size().width(), + bounds.size().height(), + )); + } else { use tauri::{LogicalSize, PhysicalPosition}; let position = display.raw_handle().physical_position().unwrap(); let size = display.physical_size().unwrap(); @@ -1786,8 +2164,6 @@ impl ShowCapWindow { app, format!("/settings/{}", page.clone().unwrap_or_default()), ) - .inner_size(782.0, 775.0) - .min_inner_size(780.0, 560.0) .resizable(true) .maximized(false) .focused(true); @@ -1800,62 +2176,28 @@ impl ShowCapWindow { let window = builder.build()?; lock_window_text_scale(&window); - let (pos_x, pos_y) = cursor_monitor.center_position(782.0, 775.0); - let _ = window.set_position(cursor_monitor.position(pos_x, pos_y)); - - #[cfg(windows)] - { - if let Err(e) = window.set_size(LogicalSize::new(782.0, 775.0)) { - warn!("Failed to set Settings window size on Windows: {}", e); - } - if let Err(e) = window.set_position(cursor_monitor.position(pos_x, pos_y)) { - warn!("Failed to position Settings window on Windows: {}", e); - } - } - - ensure_settings_window_bounds(&window); + fit_content_window_bounds(&window, &_id, true).await; window } - Self::Editor { .. } => { + Self::Editor { project_path } => { let open_started = std::time::Instant::now(); hide_recording_windows(app, false); release_camera_preview_if_idle(app); - let window = match self - .window_builder(app, "/editor") + PendingEditorInstances::start_prewarm(app, _id.label(), project_path.clone()).await; + + let window = self + .window_builder_with_id(app, "/editor", &_id, _id.label()) .maximizable(true) - .inner_size(1275.0, 800.0) - .min_inner_size(1275.0, 800.0) .focused(true) - .build() - { - Ok(window) => window, - Err(error) => { - // Don't leave the prewarmed instance (decoders, frame - // websocket) orphaned if the window failed to appear. - let window_label = self.id(app).label(); - PendingEditorInstances::get(app) - .cancel_prewarm(&window_label) - .await; - return Err(error); - } - }; + .build()?; + if let Some(opening) = project_opening.as_mut() { + opening.own_window(&window); + } lock_window_text_scale(&window); - let (pos_x, pos_y) = cursor_monitor.center_position(1275.0, 800.0); - let _ = window.set_position(cursor_monitor.position(pos_x, pos_y)); - - #[cfg(windows)] - { - use tauri::LogicalSize; - if let Err(e) = window.set_size(LogicalSize::new(1275.0, 800.0)) { - warn!("Failed to set Editor window size on Windows: {}", e); - } - if let Err(e) = window.set_position(cursor_monitor.position(pos_x, pos_y)) { - warn!("Failed to position Editor window on Windows: {}", e); - } - } + fit_content_window_bounds(&window, &_id, true).await; // Show immediately: the native background color is already // themed, so the window can appear before the webview loads and @@ -1885,50 +2227,20 @@ impl ShowCapWindow { hide_recording_windows(app, false); release_camera_preview_if_idle(app); - let window_label = self.id(app).label(); - let pending = PendingScreenshotEditorInstances::get(app); - PendingScreenshotEditorInstances::start_prewarm( - app, - window_label.clone(), - path.clone(), - ) - .await; + PendingScreenshotEditorInstances::start_prewarm(app, _id.label(), path.clone()) + .await; - let window = match self - .window_builder(app, "/screenshot-editor") + let window = self + .window_builder_with_id(app, "/screenshot-editor", &_id, _id.label()) .maximizable(true) - .inner_size(1240.0, 800.0) - .min_inner_size(800.0, 600.0) .focused(true) - .build() - { - Ok(window) => window, - Err(error) => { - pending.cancel_prewarm(&window_label).await; - return Err(error); - } - }; + .build()?; + if let Some(opening) = project_opening.as_mut() { + opening.own_window(&window); + } lock_window_text_scale(&window); - let (pos_x, pos_y) = cursor_monitor.center_position(1240.0, 800.0); - let _ = window.set_position(cursor_monitor.position(pos_x, pos_y)); - - #[cfg(windows)] - { - use tauri::LogicalSize; - if let Err(e) = window.set_size(LogicalSize::new(1240.0, 800.0)) { - warn!( - "Failed to set ScreenshotEditor window size on Windows: {}", - e - ); - } - if let Err(e) = window.set_position(cursor_monitor.position(pos_x, pos_y)) { - warn!( - "Failed to position ScreenshotEditor window on Windows: {}", - e - ); - } - } + fit_content_window_bounds(&window, &_id, true).await; window.show().ok(); window.set_focus().ok(); @@ -2398,7 +2710,14 @@ impl ShowCapWindow { } #[cfg(target_os = "linux")] - { + if cap_recording::screenshot::uses_wayland_portal() { + let Some(bounds) = display.raw_handle().logical_bounds() else { + return Err(tauri::Error::WindowNotFound); + }; + window_builder = window_builder + .inner_size(bounds.size().width(), bounds.size().height()) + .position(bounds.position().x(), bounds.position().y()); + } else { let position = display.raw_handle().physical_position().unwrap(); let Some(size) = display.physical_size() else { warn!(screen_id = %screen_id, "Missing display size for window capture occluder"); @@ -2413,7 +2732,20 @@ impl ShowCapWindow { lock_window_text_scale(&window); #[cfg(target_os = "linux")] - { + if cap_recording::screenshot::uses_wayland_portal() { + use tauri::{LogicalPosition, LogicalSize}; + let Some(bounds) = display.raw_handle().logical_bounds() else { + return Err(tauri::Error::WindowNotFound); + }; + let _ = window.set_position(LogicalPosition::new( + bounds.position().x(), + bounds.position().y(), + )); + let _ = window.set_size(LogicalSize::new( + bounds.size().width(), + bounds.size().height(), + )); + } else { use tauri::{LogicalSize, PhysicalPosition}; let position = display.raw_handle().physical_position().unwrap(); if let Some(size) = display.physical_size() { @@ -2519,7 +2851,14 @@ impl ShowCapWindow { } #[cfg(target_os = "linux")] - if let Some(bounds) = display.raw_handle().physical_bounds() { + if cap_recording::screenshot::uses_wayland_portal() { + let Some(bounds) = display.raw_handle().logical_bounds() else { + return Err(tauri::Error::WindowNotFound); + }; + window_builder = window_builder + .inner_size(bounds.size().width(), bounds.size().height()) + .position(bounds.position().x(), bounds.position().y()); + } else if let Some(bounds) = display.raw_handle().physical_bounds() { window_builder = window_builder .inner_size(bounds.size().width(), bounds.size().height()) .position(bounds.position().x(), bounds.position().y()); @@ -2572,7 +2911,20 @@ impl ShowCapWindow { } #[cfg(target_os = "linux")] - if let Some(bounds) = display.raw_handle().physical_bounds() { + if cap_recording::screenshot::uses_wayland_portal() { + use tauri::{LogicalPosition, LogicalSize}; + let Some(bounds) = display.raw_handle().logical_bounds() else { + return Err(tauri::Error::WindowNotFound); + }; + let _ = window.set_position(LogicalPosition::new( + bounds.position().x(), + bounds.position().y(), + )); + let _ = window.set_size(LogicalSize::new( + bounds.size().width(), + bounds.size().height(), + )); + } else if let Some(bounds) = display.raw_handle().physical_bounds() { use tauri::{LogicalSize, PhysicalPosition}; let _ = window.set_position(PhysicalPosition::new( bounds.position().x(), @@ -2704,12 +3056,18 @@ impl ShowCapWindow { #[cfg(target_os = "macos")] { - app.run_on_main_thread({ + let mut pending_window = PendingControlsWindow(Some(window.clone())); + let (show_tx, show_rx) = tokio::sync::oneshot::channel(); + let scheduled = app.run_on_main_thread({ let window = window.clone(); let app = app.clone(); let panel_activation_guard = panel_activation_guard; move || { let _panel_activation_guard = panel_activation_guard; + if show_tx.is_closed() { + crate::permissions::sync_macos_dock_visibility(&app); + return; + } use tauri_nspanel::cocoa::appkit::NSWindowCollectionBehavior; use tauri_nspanel::panel_delegate; use tauri_nspanel::WebviewWindowExt as NSPanelWebviewWindowExt; @@ -2734,6 +3092,7 @@ impl ShowCapWindow { Err(e) => { tracing::error!("Failed to convert recording controls to panel: {:?}", e); crate::permissions::sync_macos_dock_visibility(&app); + let _ = show_tx.send(Err(format!("Failed to prepare recording controls: {e:?}"))); return; } }; @@ -2752,9 +3111,21 @@ impl ShowCapWindow { panel.show(); crate::permissions::schedule_macos_dock_visibility_sync(&app); + let _ = show_tx.send(Ok(())); } - }) - .ok(); + }); + let shown = match scheduled { + Ok(()) => await_window_operation( + show_rx, + "Showing recording controls", + Duration::from_secs(5), + ) + .await + .map_err(|error| tauri::Error::Anyhow(anyhow!(error))), + Err(error) => Err(error), + }; + shown?; + pending_window.0 = None; fake_window::spawn_fake_window_listener(app.clone(), window.clone()); } @@ -2878,6 +3249,9 @@ impl ShowCapWindow { crate::permissions::sync_macos_dock_visibility(app); } + if let Some(opening) = project_opening.as_mut() { + opening.commit(); + } Ok(window) } @@ -2887,7 +3261,7 @@ impl ShowCapWindow { url: impl Into, ) -> WebviewWindowBuilder<'a, Wry, AppHandle> { let id = self.id(app); - self.window_builder_with_label(app, url, id.label()) + self.window_builder_with_id(app, url, &id, id.label()) } fn window_builder_with_label<'a>( @@ -2898,6 +3272,16 @@ impl ShowCapWindow { ) -> WebviewWindowBuilder<'a, Wry, AppHandle> { let id = self.id(app); + self.window_builder_with_id(app, url, &id, label) + } + + fn window_builder_with_id<'a>( + &'a self, + app: &'a AppHandle, + url: impl Into, + id: &CapWindowId, + label: impl Into, + ) -> WebviewWindowBuilder<'a, Wry, AppHandle> { let settings = GeneralSettingsStore::get(app).ok().flatten(); let window_transparency_enabled = settings .as_ref() @@ -2948,9 +3332,13 @@ impl ShowCapWindow { } if let Some(min) = id.min_size() { + let preferred = id.preferred_content_size().unwrap_or(min); + let (inner, minimum) = initial_content_window_fit(app, id) + .map(|(fit, _)| (fit.inner, fit.minimum)) + .unwrap_or((preferred, min)); builder = builder - .inner_size(min.0, min.1) - .min_inner_size(min.0, min.1); + .inner_size(inner.0, inner.1) + .min_inner_size(minimum.0, minimum.1); } #[cfg(target_os = "macos")] @@ -2994,8 +3382,8 @@ impl ShowCapWindow { ShowCapWindow::Settings { .. } => CapWindowId::Settings, ShowCapWindow::Editor { project_path } => { let state = app.state::(); - let s = state.ids.lock().unwrap(); - let id = s.iter().find(|(path, _)| path == project_path).unwrap().1; + let id = project_window_id_for_key(&state.ids, &project_window_key(project_path)) + .expect("editor window is not reserved"); CapWindowId::Editor { id } } ShowCapWindow::RecordingsOverlay => CapWindowId::RecordingsOverlay, @@ -3017,8 +3405,8 @@ impl ShowCapWindow { ShowCapWindow::Onboarding => CapWindowId::Onboarding, ShowCapWindow::ScreenshotEditor { path } => { let state = app.state::(); - let s = state.ids.lock().unwrap(); - let id = s.iter().find(|(p, _)| p == path).unwrap().1; + let id = project_window_id_for_key(&state.ids, &project_window_key(path)) + .expect("screenshot editor window is not reserved"); CapWindowId::ScreenshotEditor { id } } } @@ -3207,8 +3595,7 @@ fn position_traffic_lights_impl( // Cap's own windows while a recording is actually active, which is the only time the // exclusion is meaningful. // -// On desktops that are themselves delivered through a capture-based stream (Shadow -// and other cloud PCs, RDP, VMs), even recording-gated exclusion hides the recording +// On known capture-based remote displays (Shadow and RDP), recording-gated exclusion hides the recording // controls from the user and trips DRM detectors (Shadow error S:102), so exclusion // is skipped entirely there — Cap's windows then appear in recordings, which is the // lesser evil. Overridable via the CAP_WINDOW_CAPTURE_EXCLUSION env var. @@ -3272,9 +3659,14 @@ fn window_capture_excluded(app: &AppHandle, window_title: &str) -> bool { fn should_protect_window(app: &AppHandle, window_title: &str) -> bool { content_protection_enabled(app) && !capture_exclusion_hides_ui() + && native_content_protection_allowed(window_title) && window_capture_excluded(app, window_title) } +fn native_content_protection_allowed(window_title: &str) -> bool { + !cfg!(target_os = "macos") || window_title != CapWindowId::RecordingControls.title() +} + pub fn apply_content_protection(app: &AppHandle, enabled: bool) { let enabled = enabled && !capture_exclusion_hides_ui(); @@ -3294,7 +3686,9 @@ pub fn apply_content_protection(app: &AppHandle, enabled: bool) { } let title = id.title(); - let should_protect = enabled && window_capture_excluded(app, &title); + let should_protect = enabled + && native_content_protection_allowed(&title) + && window_capture_excluded(app, &title); let _ = window.set_content_protected(should_protect); #[cfg(target_os = "windows")] @@ -3548,10 +3942,165 @@ pub fn set_window_transparent(_window: tauri::Window, _value: bool) { } } +fn project_window_key(path: &Path) -> PathBuf { + std::fs::canonicalize(path) + .or_else(|_| std::path::absolute(path)) + .unwrap_or_else(|_| path.to_path_buf()) +} + +fn project_window_id_for_key(ids: &Mutex>, key: &Path) -> Option { + let entries = ids.lock().unwrap().clone(); + entries + .into_iter() + .find_map(|(path, id)| (project_window_key(&path) == key).then_some(id)) +} + +fn next_project_window_id(counter: &AtomicU32) -> tauri::Result { + counter + .fetch_update(Ordering::SeqCst, Ordering::SeqCst, |value| { + value.checked_add(1) + }) + .map_err(|_| tauri::Error::Io(std::io::Error::other("project window IDs exhausted"))) +} + +#[derive(Default, Clone)] +struct ProjectWindowOpenGates { + paths: Arc>, +} + +type ProjectWindowGateMap = HashMap>>; + +impl ProjectWindowOpenGates { + fn for_path(&self, key: PathBuf) -> Arc> { + let mut paths = self.paths.lock().unwrap(); + paths.retain(|_, gate| gate.strong_count() > 0); + if let Some(gate) = paths.get(&key).and_then(Weak::upgrade) { + return gate; + } + let gate = Arc::new(tokio::sync::Mutex::new(())); + paths.insert(key, Arc::downgrade(&gate)); + gate + } +} + +struct ProjectWindowOpening { + app: AppHandle, + ids: Arc>>, + id: CapWindowId, + existing_window: Option, + owned_window: Option, + committed: bool, + _gate: tokio::sync::OwnedMutexGuard<()>, +} + +impl ProjectWindowOpening { + async fn acquire( + app: &AppHandle, + path: &Path, + ids: Arc>>, + counter: &AtomicU32, + gates: &ProjectWindowOpenGates, + make_id: fn(u32) -> CapWindowId, + ) -> tauri::Result { + let key = project_window_key(path); + let gate = gates.for_path(key.clone()).lock_owned().await; + let previous_id = project_window_id_for_key(&ids, &key); + if let Some(previous_id) = previous_id { + let id = make_id(previous_id); + if let Some(window) = id.get(app) { + return Ok(Self { + app: app.clone(), + ids, + id, + existing_window: Some(window), + owned_window: None, + committed: true, + _gate: gate, + }); + } + } + + let numeric_id = next_project_window_id(counter)?; + let id = make_id(numeric_id); + { + let mut entries = ids.lock().unwrap(); + entries.retain(|(_, id)| Some(*id) != previous_id); + entries.push((path.to_path_buf(), numeric_id)); + } + Ok(Self { + app: app.clone(), + ids, + id, + existing_window: None, + owned_window: None, + committed: false, + _gate: gate, + }) + } + + fn own_window(&mut self, window: &WebviewWindow) { + self.owned_window = Some(window.clone()); + } + + fn commit(&mut self) { + self.committed = true; + } +} + +impl Drop for ProjectWindowOpening { + fn drop(&mut self) { + if self.committed { + return; + } + let numeric_id = match self.id { + CapWindowId::Editor { id } | CapWindowId::ScreenshotEditor { id } => id, + _ => return, + }; + self.ids + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .retain(|(_, id)| *id != numeric_id); + if let Some(window) = self.owned_window.take() { + if let Err(error) = window.destroy() { + warn!(label = %self.id.label(), %error, "Failed to destroy unfinished project window"); + } + let native_window = window.as_ref().window().clone(); + match self.id { + CapWindowId::Editor { .. } => { + tauri::async_runtime::spawn(EditorInstances::remove(native_window)); + } + CapWindowId::ScreenshotEditor { .. } => { + tauri::async_runtime::spawn(ScreenshotEditorInstances::remove(native_window)); + } + _ => {} + } + } + let app = self.app.clone(); + let id = self.id.clone(); + tauri::async_runtime::spawn(async move { + let label = id.label(); + match id { + CapWindowId::Editor { .. } => { + PendingEditorInstances::get(&app) + .cancel_prewarm(&label) + .await; + } + CapWindowId::ScreenshotEditor { .. } => { + PendingScreenshotEditorInstances::get(&app) + .cancel_prewarm(&label) + .await; + } + _ => {} + } + }); + } +} + #[derive(Default, Clone)] pub struct EditorWindowIds { pub ids: Arc>>, pub counter: Arc, + open_gates: ProjectWindowOpenGates, } impl EditorWindowIds { @@ -3564,6 +4113,7 @@ impl EditorWindowIds { pub struct ScreenshotEditorWindowIds { pub ids: Arc>>, pub counter: Arc, + open_gates: ProjectWindowOpenGates, } impl ScreenshotEditorWindowIds { @@ -3595,9 +4145,212 @@ impl EditorRecordingTarget { pub fn editor_window_for_path(app: &AppHandle, path: &std::path::Path) -> Option { let ids = EditorWindowIds::get(app); - let id = { - let guard = ids.ids.lock().unwrap(); - guard.iter().find(|(p, _)| p == path).map(|(_, id)| *id)? - }; + let id = project_window_id_for_key(&ids.ids, &project_window_key(path))?; CapWindowId::Editor { id }.get(app) } + +#[cfg(test)] +mod content_window_tests { + use super::*; + + fn rect(x: f64, y: f64, width: f64, height: f64) -> ContentWindowRect { + ContentWindowRect { + x, + y, + width, + height, + } + } + + fn assert_inside(frame: ContentWindowRect, area: ContentWindowRect) { + assert!(frame.x >= area.x + 16.0); + assert!(frame.y >= area.y + 16.0); + assert!(frame.x + frame.width <= area.x + area.width - 16.0); + assert!(frame.y + frame.height <= area.y + area.height - 16.0); + } + + #[test] + fn small_display_fits_all_content_windows_and_lowers_oversized_minimums() { + let area = rect(0.0, 25.0, 1024.0, 703.0); + for id in [ + CapWindowId::Settings, + CapWindowId::Editor { id: 0 }, + CapWindowId::ScreenshotEditor { id: 0 }, + ] { + let preferred = id.preferred_content_size().unwrap(); + let fit = fit_content_window( + area, + rect(0.0, 0.0, preferred.0, preferred.1), + preferred, + id.min_size().unwrap(), + Some(preferred), + ) + .unwrap(); + assert_inside(fit.frame, area); + assert!(fit.minimum.0 <= fit.inner.0); + assert!(fit.minimum.1 <= fit.inner.1); + } + } + + #[test] + fn roomy_display_keeps_preferred_size_and_minimum() { + let area = rect(0.0, 24.0, 2560.0, 1376.0); + let preferred = (1275.0, 800.0); + let fit = fit_content_window( + area, + rect(0.0, 0.0, 1275.0, 800.0), + preferred, + preferred, + Some(preferred), + ) + .unwrap(); + assert_eq!(fit.inner, preferred); + assert_eq!(fit.minimum, preferred); + assert_inside(fit.frame, area); + } + + #[test] + fn decorated_window_fits_outer_frame_on_negative_origin_display() { + let area = rect(-1536.0, 24.0, 1536.0, 800.0); + let fit = fit_content_window( + area, + rect(0.0, 0.0, 1291.0, 839.0), + (1275.0, 800.0), + (1275.0, 800.0), + Some((1275.0, 800.0)), + ) + .unwrap(); + assert_eq!(fit.inner, (1275.0, 729.0)); + assert_eq!(fit.minimum, (1275.0, 729.0)); + assert_inside(fit.frame, area); + } + + #[test] + fn work_area_uses_the_target_monitors_scale() { + for scale in [1.0, 1.25, 2.0] { + let position = PhysicalPosition::new(-1920, 30); + let size = PhysicalSize::new(1920, 1000); + let area = logical_work_area(position, size, scale).unwrap(); + let fit = fit_content_window( + area, + rect(area.x, area.y, 1275.0, 800.0), + (1275.0, 800.0), + (1275.0, 800.0), + Some((1275.0, 800.0)), + ) + .unwrap(); + assert_inside(fit.frame, area); + assert!(fit.frame.x * scale >= position.x as f64); + assert!( + (fit.frame.x + fit.frame.width) * scale <= (position.x + size.width as i32) as f64 + ); + } + assert!( + logical_work_area( + PhysicalPosition::new(0, 0), + PhysicalSize::new(1024, 768), + 0.0 + ) + .is_none() + ); + } + + #[test] + fn reopening_shrinks_and_recovers_an_oversized_offscreen_window() { + let area = rect(1920.0, 0.0, 1024.0, 728.0); + let fit = fit_content_window( + area, + rect(-2000.0, 3000.0, 1800.0, 1200.0), + (1800.0, 1200.0), + (1275.0, 800.0), + None, + ) + .unwrap(); + assert_eq!(fit.inner, (992.0, 696.0)); + assert_inside(fit.frame, area); + } + + #[test] + fn reopening_preserves_a_valid_user_size_and_position() { + let area = rect(0.0, 24.0, 1920.0, 1016.0); + let frame = rect(43.0, 71.0, 900.0, 650.0); + let fit = fit_content_window(area, frame, (900.0, 650.0), (780.0, 560.0), None).unwrap(); + assert_eq!(fit.frame, frame); + } + + #[test] + fn invalid_or_unusable_geometry_is_ignored() { + for area in [ + rect(0.0, 0.0, 0.0, 768.0), + rect(0.0, 0.0, 20.0, 20.0), + rect(f64::NAN, 0.0, 1024.0, 768.0), + ] { + assert!( + fit_content_window( + area, + rect(0.0, 0.0, 1275.0, 800.0), + (1275.0, 800.0), + (1275.0, 800.0), + None + ) + .is_none() + ); + } + } + + #[test] + fn only_macos_controls_bypass_native_content_protection() { + assert_eq!( + native_content_protection_allowed(&CapWindowId::RecordingControls.title()), + !cfg!(target_os = "macos") + ); + assert!(native_content_protection_allowed( + &CapWindowId::Camera.title() + )); + assert!(native_content_protection_allowed( + &CapWindowId::Settings.title() + )); + assert!(native_content_protection_allowed( + &CapWindowId::Teleprompter.title() + )); + } + + #[tokio::test] + async fn panel_show_waits_for_acknowledgment_and_propagates_failure() { + let (tx, rx) = tokio::sync::oneshot::channel(); + let wait = await_window_operation(rx, "Test panel", Duration::from_secs(1)); + tokio::pin!(wait); + assert!(futures::poll!(&mut wait).is_pending()); + tx.send(Err("Native panel conversion failed".to_string())) + .unwrap(); + assert_eq!( + wait.await, + Err("Native panel conversion failed".to_string()) + ); + } + + #[tokio::test] + async fn timed_out_panel_show_cancels_queued_native_work() { + let (tx, rx) = tokio::sync::oneshot::channel(); + let result = await_window_operation(rx, "Test panel", Duration::ZERO).await; + assert_eq!(result, Err("Test panel timed out".to_string())); + assert!(tx.is_closed()); + } + + #[tokio::test] + async fn panel_show_success_and_cancel_are_distinct() { + let (tx, rx) = tokio::sync::oneshot::channel(); + tx.send(Ok(())).unwrap(); + assert!( + await_window_operation(rx, "Test panel", Duration::from_secs(1)) + .await + .is_ok() + ); + let (tx, rx) = tokio::sync::oneshot::channel(); + drop(tx); + assert_eq!( + await_window_operation(rx, "Test panel", Duration::from_secs(1)).await, + Err("Test panel was cancelled".to_string()) + ); + } +} diff --git a/apps/desktop/src-tauri/tauri.conf.json b/apps/desktop/src-tauri/tauri.conf.json index f31590d46a3..08ca9826847 100644 --- a/apps/desktop/src-tauri/tauri.conf.json +++ b/apps/desktop/src-tauri/tauri.conf.json @@ -67,6 +67,7 @@ "../src/assets/music/*.mp3": "assets/music/" }, "macOS": { + "minimumSystemVersion": "12.3", "dmg": { "background": "assets/dmg-background.png", "appPosition": { diff --git a/apps/desktop/src/routes/(window-chrome)/new-main/TargetCard.tsx b/apps/desktop/src/routes/(window-chrome)/new-main/TargetCard.tsx index c01e68f7593..e42bb469006 100644 --- a/apps/desktop/src/routes/(window-chrome)/new-main/TargetCard.tsx +++ b/apps/desktop/src/routes/(window-chrome)/new-main/TargetCard.tsx @@ -297,9 +297,9 @@ export default function TargetCard(props: TargetCardProps) { e.stopPropagation(); const recording = recordingTarget(); if (!recording) return; - commands.showWindow({ - Editor: { project_path: recording.path }, - }); + if (e.currentTarget instanceof HTMLElement) { + e.currentTarget.closest("button")?.click(); + } }; const handleOpenRecordingLink = (e: MouseEvent) => { diff --git a/apps/desktop/src/routes/(window-chrome)/new-main/index.tsx b/apps/desktop/src/routes/(window-chrome)/new-main/index.tsx index 92e44288a1e..0a0019c9c7d 100644 --- a/apps/desktop/src/routes/(window-chrome)/new-main/index.tsx +++ b/apps/desktop/src/routes/(window-chrome)/new-main/index.tsx @@ -74,6 +74,11 @@ import { listWindowsWithThumbnails, revealRecordingWindow, } from "~/utils/queries"; +import { + isRecordingStartCancelled, + recordingMetaNeedsRecovery, + recordingOpenErrorMessage, +} from "~/utils/recording"; import { type CaptureDisplay, type CaptureDisplayWithThumbnail, @@ -138,6 +143,17 @@ const CAPTURE_LIST_GC_TIME = 60_000; const CAPTURE_THUMBNAIL_STALE_TIME = 10_000; const CAPTURE_THUMBNAIL_GC_TIME = 60_000; +async function restoreCameraWhenPermitted( + check: () => Promise, + isCurrent: () => boolean, + restore: () => Promise, +) { + const { camera } = await check(); + if (isCurrent() && (camera === "granted" || camera === "notNeeded")) { + await restore(); + } +} + const findCamera = (cameras: CameraWithDetails[], id: DeviceOrModelID) => { return cameras.find((c) => { if (!id) return false; @@ -1848,9 +1864,60 @@ function MainWindowHelpButton() { function Page() { const queryClient = useQueryClient(); - const { rawOptions, setOptions } = useRecordingOptions(); + const { rawOptions, setOptions, getCameraRevision } = useRecordingOptions(); const currentRecording = createCurrentRecordingQuery(); const cleanCapture = createCleanCaptureQuery(); + const [stopRequested, setStopRequested] = createSignal(false); + const [stopError, setStopError] = createSignal(null); + const recordingErrors = createMemo(() => { + const errors: string[] = []; + const nativeError = cleanCapture.data?.error; + if (nativeError) errors.push(nativeError); + const localError = stopError(); + if (localError) errors.push(localError); + return [...new Set(errors)]; + }); + let stopRequest: { cleanCaptureGeneration: number | undefined } | undefined; + let stopErrorRequest: typeof stopRequest; + const resetStopRequest = () => { + stopRequest = undefined; + setStopRequested(false); + }; + const resetStopNotice = () => { + resetStopRequest(); + stopErrorRequest = undefined; + setStopError(null); + }; + onCleanup(() => { + stopRequest = undefined; + stopErrorRequest = undefined; + }); + createEffect(() => { + const snapshot = cleanCapture.data; + const status = currentRecording.data?.status; + if (!snapshot) return; + for (const request of [stopRequest, stopErrorRequest]) { + if (!request) continue; + if ( + ((snapshot.phase === "awaitingShortcut" && status === "pending") || + ((snapshot.phase === "recording" || snapshot.phase === "paused") && + status === "recording")) && + request.cleanCaptureGeneration !== undefined && + snapshot.generation > request.cleanCaptureGeneration + ) { + if (stopRequest === request) { + stopRequest = undefined; + setStopRequested(false); + } + if (stopErrorRequest === request) { + stopErrorRequest = undefined; + setStopError(null); + } + } else if (request.cleanCaptureGeneration === undefined) { + request.cleanCaptureGeneration = snapshot.generation; + } + } + }); const [isExpanded, setIsExpanded] = createSignal(false); const [isWindowFocused, setIsWindowFocused] = createSignal(false); const [isWindowResizing, setIsWindowResizing] = createSignal(false); @@ -2170,9 +2237,11 @@ function Page() { // picker flow also hides this window, so reveal it first or the toast lands // in a hidden webview and the failure is invisible again. createTauriEventListener(events.recordingEvent, (payload) => { + if (payload.variant === "Started" || payload.variant === "Countdown") + resetStopNotice(); if (payload.variant === "StartFailed") { void revealRecordingWindow(); - toast.error(payload.error); + if (!isRecordingStartCancelled(payload.error)) toast.error(payload.error); } }); @@ -2464,6 +2533,10 @@ function Page() { const setMicInput = createMicrophoneMutation(); const setCamera = createCameraMutation(); + let cameraRestoreDisposed = false; + onCleanup(() => { + cameraRestoreDisposed = true; + }); createUpdateCheck(); createUpdateReadyToast(); @@ -2515,9 +2588,19 @@ function Page() { } if (rawOptions.cameraID) { - setCamera - .mutateAsync({ model: rawOptions.cameraID }) - .catch((error) => console.error("Failed to set camera input:", error)); + const model = rawOptions.cameraID; + const cameraKey = JSON.stringify(model); + const restoreRevision = getCameraRevision(); + void restoreCameraWhenPermitted( + () => commands.doPermissionsCheck(false), + () => + !cameraRestoreDisposed && + getCameraRevision() === restoreRevision && + JSON.stringify(rawOptions.cameraID) === cameraKey, + () => setCamera.mutateAsync({ model }), + ).catch((error) => + console.error("Failed to restore camera input:", error), + ); } const unlistenFocus = currentWindow.onFocusChanged( @@ -2818,15 +2901,21 @@ function Page() { const signIn = createSignInMutation(); const stopRecording = createMutation(() => ({ mutationFn: async () => { + if (stopRequested()) return; + const request = { + cleanCaptureGeneration: cleanCapture.data?.generation, + }; + stopRequest = request; + setStopRequested(true); try { await commands.stopRecording(); } catch (error) { - await dialog.message( - `Failed to stop recording: ${ - error instanceof Error ? error.message : String(error) - }`, - { title: "Stop Recording", kind: "error" }, - ); + if (stopRequest === request) { + stopErrorRequest = request; + setStopError(error instanceof Error ? error.message : String(error)); + } + } finally { + if (stopRequest === request) resetStopRequest(); } }, })); @@ -2870,16 +2959,25 @@ function Page() { const openRecording = async (recording: RecordingWithPath) => { if (recording.mode === "studio") { let projectPath = recording.path; - const needsRecovery = - recording.status.status === "InProgress" || - recording.status.status === "NeedsRemux"; - - if (needsRecovery) { - try { + try { + const meta = await commands.getRecordingMetaByPath(projectPath); + if (recordingMetaNeedsRecovery(meta)) { projectPath = await commands.recoverRecording(projectPath); - } catch (error) { - console.error("Failed to recover recording:", error); } + } catch (error) { + console.error("Failed to recover recording:", error); + await dialog + .message(recordingOpenErrorMessage(error, projectPath), { + title: "Recover Recording", + kind: "error", + }) + .catch((dialogError: unknown) => { + console.error( + "Failed to show recording recovery error", + dialogError, + ); + }); + return; } await commands.showWindow({ @@ -3186,7 +3284,7 @@ function Page() { - - - {/** Dashed divider */} -
- + +
+ + {(item) => ( + + )} + +
+
+ + {(item) => ( + + )} + +
+ +
- - - Use the wallpaper from your desktop - - } - > - {importingDesktopBackground() - ? "Importing..." - : "Import desktop background"} - -
+ when={ + animatedGradientCatalog.isError || + animatedGradientLibrary.isError } > - {(photo) => ( -
- +
+ + {/** Dashed divider */} +
+ + + + + Use the wallpaper from your desktop - -
{importingDesktopBackground() ? "Importing..." - : "Re-import"} + : "Import desktop background"}
-
- )} - - - - {/** Background Tabs */} - - 0 ? "24px" : "0" - }, black calc(100% - ${ - reachedEndOfScroll() ? "0px" : "24px" - }), transparent)`, - - "mask-image": `linear-gradient(to right, transparent, black ${ - scrollX() > 0 ? "24px" : "0" - }, black calc(100% - ${ - reachedEndOfScroll() ? "0px" : "24px" - }), transparent);`, - }} + } > - - {([key, value]) => ( - <> - - setBackgroundTab( - key as keyof typeof BACKGROUND_THEMES, - ) + {(photo) => ( +
+ +
+ } > - {value} - - - )} - - - - {/** End of Background Tabs */} - { - try { - const wallpaper = wallpaperOptions().find( - (w) => w.url === photoUrl, - ); - if (!wallpaper) return; - - // Get the raw path without any URL prefixes - - setWallpaperSource(wallpaper.rawPath); - - ensureBackgroundPresentation(); - } catch (_err) { - toast.error("Failed to set wallpaper"); - } - }} - class="grid grid-cols-7 gap-2 h-auto" - > - -
-
- Loading wallpapers... + {importingDesktopBackground() + ? "Importing..." + : "Re-import"} +
- } - > - - {(photo) => ( - - - - Wallpaper option - - - )} - - - -
- - {(photo) => ( - - - - Wallpaper option - - - )} - -
-
-
+ )}
-
- - - fileInput.click()} - class="p-6 bg-gray-2 text-[13px] w-full rounded-lg border border-gray-5 border-dashed flex flex-col items-center justify-center gap-2 hover:bg-gray-3 transition-colors duration-100" + + + {/** Background Tabs */} + + 0 ? "24px" : "0" + }, black calc(100% - ${ + reachedEndOfScroll() ? "0px" : "24px" + }), transparent)`, + + "mask-image": `linear-gradient(to right, transparent, black ${ + scrollX() > 0 ? "24px" : "0" + }, black calc(100% - ${ + reachedEndOfScroll() ? "0px" : "24px" + }), transparent);`, + }} > - - - Click to select or drag and drop image - - - } - > - {(source) => ( -
- Selected background -
- -
-
- )} - - { - const file = e.currentTarget.files?.[0]; - if (!file) return; - - const extension = getValidBackgroundImageExtension(file); - if (!extension) { - toast.error("Invalid image file type"); - return; + + {([key, value]) => ( + <> + + setBackgroundTab( + key as keyof typeof BACKGROUND_THEMES, + ) + } + value={key} + class="flex relative z-10 flex-1 justify-center items-center px-4 py-2 bg-transparent rounded-lg border transition-colors duration-200 text-gray-11 not-data-selected:hover:border-gray-7 data-selected:bg-gray-3 data-selected:border-gray-3 group data-selected:text-gray-12 disabled:opacity-50 focus:outline-hidden" + > + {value} + + + )} + +
+
+ {/** End of Background Tabs */} + { + try { + const wallpaper = wallpaperOptions().find( + (w) => w.url === photoUrl, + ); + if (!wallpaper) return; - try { - const fileName = `bg-${Date.now()}-${file.name}`; - const arrayBuffer = await file.arrayBuffer(); - const uint8Array = new Uint8Array(arrayBuffer); + // Get the raw path without any URL prefixes - const fullPath = `${await appDataDir()}/${fileName}`; + setWallpaperSource(wallpaper.rawPath); - await writeFile(fileName, uint8Array, { - baseDir: BaseDirectory.AppData, - }); + ensureBackgroundPresentation(); + } catch (_err) { + toast.error("Failed to set wallpaper"); + } + }} + class="grid grid-cols-7 gap-2 h-auto" + > + +
+
+ Loading wallpapers... +
+
+ } + > + + {(photo) => ( + + + + Wallpaper option + + + )} + + + +
+ + {(photo) => ( + + + + Wallpaper option + + + )} + +
+
+
+
+
+
+ + fileInput.click()} + class="p-6 bg-gray-2 text-[13px] w-full rounded-lg border border-gray-5 border-dashed flex flex-col items-center justify-center gap-2 hover:bg-gray-3 transition-colors duration-100" + > + + + Click to select or drag and drop image + + + } + > + {(source) => ( +
+ Selected background +
+ +
+
+ )} +
+ { + const file = e.currentTarget.files?.[0]; + if (!file) return; + + const extension = getValidBackgroundImageExtension(file); + if (!extension) { + toast.error("Invalid image file type"); + return; + } - setProject("background", "source", { - type: "image", - path: fullPath, - }); - } catch (_err) { - toast.error("Failed to save image"); + try { + const fileName = `bg-${Date.now()}-${file.name}`; + const arrayBuffer = await file.arrayBuffer(); + const uint8Array = new Uint8Array(arrayBuffer); + + const fullPath = `${await appDataDir()}/${fileName}`; + + await writeFile(fileName, uint8Array, { + baseDir: BaseDirectory.AppData, + }); + + setProject("background", "source", { + type: "image", + path: fullPath, + }); + } catch (_err) { + toast.error("Failed to save image"); + } + }} + /> +
+ + - - - -
-
- { - setProject("background", "source", { - type: "color", - value, - }); - }} - /> - -
+ > +
+
+ { + setProject("background", "source", { + type: "color", + value, + }); + }} + /> + +
-
- - {(color) => ( -
+ + + + + + + + + + - }> - setProject("background", "blur", v[0])} - minValue={0} - maxValue={100} - step={0.1} - formatTooltip="%" - /> - - {/** Dashed divider */} -
- }> - setBackgroundDimension("padding", v[0])} - minValue={0} - maxValue={40} - step={0.1} - formatTooltip="%" - /> - -
- - Custom screen position (dragged on canvas) - - setProject("background", "displayPosition", null)} - > - Reset - -
-
-
- }> -
+ }> setBackgroundDimension("rounding", v[0])} + value={[project.background.blur]} + onChange={(v) => setProject("background", "blur", v[0])} minValue={0} maxValue={100} step={0.1} formatTooltip="%" /> - - setProject("background", "roundingType", value) - } + + {/** Dashed divider */} +
+ }> + setBackgroundDimension("padding", v[0])} + minValue={0} + maxValue={40} + step={0.1} + formatTooltip="%" /> -
- - }> - { - const value = v[0] ?? 0; - batch(() => { - setProject("cursor", "motionBlur", value); - setProject("screenMotionBlur", value); - }); - }} - minValue={0} - maxValue={1} - step={0.01} - formatTooltip={(value) => `${Math.round(value * 100)}%`} - /> - - } - value={ - { - const prev = project.background.border ?? { - enabled: false, - width: 5.0, - color: [0, 0, 0], - opacity: 50.0, - }; - - if (props.scrollRef && enabled) { - setTimeout( - () => - props.scrollRef.scrollTo({ - top: props.scrollRef.scrollHeight, - behavior: "smooth", - }), - 100, - ); + +
+ + Custom screen position (dragged on canvas) + + + setProject("background", "displayPosition", null) + } + > + Reset + +
+
+
+ }> +
+ setBackgroundDimension("rounding", v[0])} + minValue={0} + maxValue={100} + step={0.1} + formatTooltip="%" + /> + + setProject("background", "roundingType", value) } + /> +
+
+ + }> + { + const value = v[0] ?? 0; + batch(() => { + setProject("cursor", "motionBlur", value); + setProject("screenMotionBlur", value); + }); + }} + minValue={0} + maxValue={1} + step={0.01} + formatTooltip={(value) => `${Math.round(value * 100)}%`} + /> + + + } + value={ + { + const prev = project.background.border ?? { + enabled: false, + width: 5.0, + color: [0, 0, 0], + opacity: 50.0, + }; - setProject("background", "border", { - ...prev, - enabled, - }); - }} - /> - } - /> - - -
- }> - - setProject("background", "border", { - ...(project.background.border ?? { - enabled: true, - width: 5.0, - color: [0, 0, 0], - opacity: 50.0, - }), - width: v[0], - }) + if (props.scrollRef && enabled) { + setTimeout( + () => + props.scrollRef.scrollTo({ + top: props.scrollRef.scrollHeight, + behavior: "smooth", + }), + 100, + ); } - minValue={1} - maxValue={20} - step={0.1} - formatTooltip="px" - /> - - }> -
- + + setProject("background", "border", { + ...prev, + enabled, + }); + }} + /> + } + /> + + +
+ } + > + setProject("background", "border", { ...(project.background.border ?? { enabled: true, @@ -2878,194 +2936,217 @@ function BackgroundConfig(props: { color: [0, 0, 0], opacity: 50.0, }), - color, + width: v[0], }) } + minValue={1} + maxValue={20} + step={0.1} + formatTooltip="px" /> - + }> +
+ + setProject("background", "border", { + ...(project.background.border ?? { + enabled: true, + width: 5.0, + color: [0, 0, 0], + opacity: 50.0, + }), + color, + }) + } + /> + +
+
+ } + > + + setProject("background", "border", { + ...(project.background.border ?? { + enabled: true, + width: 5.0, + color: [0, 0, 0], + opacity: 50.0, + }), + opacity: v[0], + }) + } + minValue={0} + maxValue={100} + step={0.1} + formatTooltip="%" /> -
- - } - > - - setProject("background", "border", { - ...(project.background.border ?? { - enabled: true, - width: 5.0, - color: [0, 0, 0], - opacity: 50.0, - }), - opacity: v[0], - }) - } - minValue={0} - maxValue={100} - step={0.1} - formatTooltip="%" - /> - -
- - - } - value={ - - setProject("background", "notch", { - ...(project.background.notch ?? UNPLACED_NOTCH), - enabled, - }) - } - /> - } - /> - - -
-

- Draws a MacBook notch over the recording. Recordings made on a Mac - with a notch use their own measurements; otherwise start from the - size below and adjust to match. -

- +
+
+
+ } + value={ + + setProject("background", "notch", { + ...(project.background.notch ?? UNPLACED_NOTCH), + enabled, + }) } - > - {(field) => ( - } - > - { - const base = editorInstance.notchBase; - const prev = project.background.notch ?? UNPLACED_NOTCH; - const next: NotchConfiguration = { - ...prev, - enabled: true, - }; - if (field.key === "x") { - next.x = Math.min(v[0], notchXMax()); - } else { - next[field.key] = v[0]; - } + /> + } + /> + + +
+

+ Draws a MacBook notch over the recording. Recordings made on a + Mac with a notch use their own measurements; otherwise start + from the size below and adjust to match. +

+ + {(field) => ( + } + > + { + const base = editorInstance.notchBase; + const prev = project.background.notch ?? UNPLACED_NOTCH; + const next: NotchConfiguration = { + ...prev, + enabled: true, + }; + if (field.key === "x") { + next.x = Math.min(v[0], notchXMax()); + } else { + next[field.key] = v[0]; + } - if (field.key === "width") { - // Resize about the centre rather than dragging the - // left edge along with the width. - const centre = - (prev.x ?? base.x) + (prev.width ?? base.width) / 2; - next.x = Math.min( - Math.max(centre - v[0] / 2, 0), - 1 - v[0], - ); - } + if (field.key === "width") { + // Resize about the centre rather than dragging the + // left edge along with the width. + const centre = + (prev.x ?? base.x) + (prev.width ?? base.width) / 2; + next.x = Math.min( + Math.max(centre - v[0] / 2, 0), + 1 - v[0], + ); + } - setProject("background", "notch", next); - }} - minValue={0} - maxValue={field.key === "x" ? notchXMax() : field.max} - step={0.001} - formatTooltip={(value) => `${(value * 100).toFixed(1)}%`} - /> - - )} - -
-
-
- }> - { - batch(() => { - setProject("background", "shadow", v[0]); - // Initialize advanced shadow settings if they don't exist and shadow is enabled - if (v[0] > 0 && !project.background.advancedShadow) { + setProject("background", "notch", next); + }} + minValue={0} + maxValue={field.key === "x" ? notchXMax() : field.max} + step={0.001} + formatTooltip={(value) => `${(value * 100).toFixed(1)}%`} + /> + + )} + +
+
+
+ }> + { + batch(() => { + setProject("background", "shadow", v[0]); + // Initialize advanced shadow settings if they don't exist and shadow is enabled + if (v[0] > 0 && !project.background.advancedShadow) { + setProject("background", "advancedShadow", { + size: 50, + opacity: 18, + blur: 50, + }); + } + }); + }} + minValue={0} + maxValue={100} + step={0.1} + formatTooltip="%" + /> + + { setProject("background", "advancedShadow", { - size: 50, - opacity: 18, - blur: 50, + ...(project.background.advancedShadow ?? { + size: 50, + opacity: 18, + blur: 50, + }), + size: v[0], }); - } - }); - }} - minValue={0} - maxValue={100} - step={0.1} - formatTooltip="%" - /> - - { - setProject("background", "advancedShadow", { - ...(project.background.advancedShadow ?? { - size: 50, - opacity: 18, - blur: 50, - }), - size: v[0], - }); - }, - }} - opacity={{ - value: [project.background.advancedShadow?.opacity ?? 18], - onChange: (v) => { - setProject("background", "advancedShadow", { - ...(project.background.advancedShadow ?? { - size: 50, - opacity: 18, - blur: 50, - }), - opacity: v[0], - }); - }, - }} - blur={{ - value: [project.background.advancedShadow?.blur ?? 50], - onChange: (v) => { - setProject("background", "advancedShadow", { - ...(project.background.advancedShadow ?? { - size: 50, - opacity: 18, - blur: 50, - }), - blur: v[0], - }); - }, - }} - /> - - - {/* + }, + }} + opacity={{ + value: [project.background.advancedShadow?.opacity ?? 18], + onChange: (v) => { + setProject("background", "advancedShadow", { + ...(project.background.advancedShadow ?? { + size: 50, + opacity: 18, + blur: 50, + }), + opacity: v[0], + }); + }, + }} + blur={{ + value: [project.background.advancedShadow?.blur ?? 50], + onChange: (v) => { + setProject("background", "advancedShadow", { + ...(project.background.advancedShadow ?? { + size: 50, + opacity: 18, + blur: 50, + }), + blur: v[0], + }); + }, + }} + /> +
+ + + + {/* }> */} + ); } function CameraConfig(props: { scrollRef: HTMLDivElement }) { - const { project, setProject } = useEditorContext(); + const { project, setProject, selectedStyle } = useEditorContext(); // A camera dragged on the preview canvas has a manual position; none of // the preset dots match until it is reset. const cameraPositionValue = createMemo(() => @@ -3095,330 +3177,337 @@ function CameraConfig(props: { scrollRef: HTMLDivElement }) { value={TAB_IDS.camera} class="flex flex-col flex-1 gap-6 p-4 min-h-0" > - } name="Camera"> -
-
- - { - const [x, y] = v.split(":"); - const xPosition = CAMERA_X_POSITIONS.find( - (position) => position === x, - ); - const yPosition = CAMERA_Y_POSITIONS.find( - (position) => position === y, - ); - if (!xPosition || !yPosition) return; - batch(() => { - setProject("camera", "position", { - x: xPosition, - y: yPosition, - }); - setProject("camera", "manualPosition", null); - }); - }} - class="mt-3 rounded-lg border border-gray-3 bg-gray-2 w-full h-30 relative" - > - - {(item) => { - const itemValue = `${item.x}:${item.y}`; - const selected = () => cameraPositionValue() === itemValue; - return ( - - - -
- - + + + } name="Camera"> +
+
+ + { + const [x, y] = v.split(":"); + const xPosition = CAMERA_X_POSITIONS.find( + (position) => position === x, ); - }} - - - -
- - Custom position (dragged on canvas) - - setProject("camera", "manualPosition", null)} - > - Reset - -
-
-
- - setProject("camera", "hide", hide)} - /> - - - setProject("camera", "mirror", mirror)} - /> - - - - options={[ - { name: "Off", value: "off" }, - { name: "Light Blur", value: "light" }, - { name: "Heavy Blur", value: "heavy" }, - ]} - optionValue="value" - optionTextValue="name" - value={ - ( - [ - { name: "Off", value: "off" }, - { name: "Light Blur", value: "light" }, - { name: "Heavy Blur", value: "heavy" }, - ] as const - ).find( - (v) => - v.value === (project.camera.backgroundBlur?.mode ?? "off"), - ) ?? { name: "Off", value: "off" } - } - onChange={(v) => { - if (v) - setProject("camera", "backgroundBlur", { - mode: v.value, + const yPosition = CAMERA_Y_POSITIONS.find( + (position) => position === y, + ); + if (!xPosition || !yPosition) return; + batch(() => { + setProject("camera", "position", { + x: xPosition, + y: yPosition, + }); + setProject("camera", "manualPosition", null); }); - }} - disallowEmptySelection - itemComponent={(props) => ( - - as={KSelect.Item} - item={props.item} - > - - {props.item.rawValue.name} - - - )} - > - - class="flex-1 text-sm text-left truncate text-(--gray-500) font-normal"> - {(state) => {state.selectedOption().name}} - - - as={(iconProps) => ( - - )} - /> - - - - as={KSelect.Content} - class={cx(topSlideAnimateClasses, "z-50")} + }} + class="mt-3 rounded-lg border border-gray-3 bg-gray-2 w-full h-30 relative" + > + - - class="overflow-y-auto max-h-32" - as={KSelect.Listbox} + {(item) => { + const itemValue = `${item.x}:${item.y}`; + const selected = () => cameraPositionValue() === itemValue; + return ( + + + +
+ + + ); + }} + + + +
+ + Custom position (dragged on canvas) + + setProject("camera", "manualPosition", null)} + > + Reset + +
+
+
+ + setProject("camera", "hide", hide)} + /> + + + setProject("camera", "mirror", mirror)} + /> + + + + options={[ + { name: "Off", value: "off" }, + { name: "Light Blur", value: "light" }, + { name: "Heavy Blur", value: "heavy" }, + ]} + optionValue="value" + optionTextValue="name" + value={ + ( + [ + { name: "Off", value: "off" }, + { name: "Light Blur", value: "light" }, + { name: "Heavy Blur", value: "heavy" }, + ] as const + ).find( + (v) => + v.value === + (project.camera.backgroundBlur?.mode ?? "off"), + ) ?? { name: "Off", value: "off" } + } + onChange={(v) => { + if (v) + setProject("camera", "backgroundBlur", { + mode: v.value, + }); + }} + disallowEmptySelection + itemComponent={(props) => ( + + as={KSelect.Item} + item={props.item} + > + + {props.item.rawValue.name} + + + )} + > + + class="flex-1 text-sm text-left truncate text-(--gray-500) font-normal"> + {(state) => {state.selectedOption().name}} + + + as={(iconProps) => ( + + )} /> - -
- -
- - - options={CAMERA_SHAPES} - optionValue="value" - optionTextValue="name" - value={CAMERA_SHAPES.find( - (v) => v.value === project.camera.shape, - )} - onChange={(v) => { - if (v) setProject("camera", "shape", v.value); - }} - disallowEmptySelection - itemComponent={(props) => ( - - as={KSelect.Item} - item={props.item} - > - - {props.item.rawValue.name} - - - )} - > - - class="flex-1 text-sm text-left truncate text-(--gray-500) font-normal"> - {(state) => {state.selectedOption().name}} - - - as={(props) => ( - + + + as={KSelect.Content} + class={cx(topSlideAnimateClasses, "z-50")} + > + + class="overflow-y-auto max-h-32" + as={KSelect.Listbox} /> - )} - /> - - - - as={KSelect.Content} - class={cx(topSlideAnimateClasses, "z-50")} - > - - class="overflow-y-auto max-h-32" - as={KSelect.Listbox} + + + + + + + options={CAMERA_SHAPES} + optionValue="value" + optionTextValue="name" + value={CAMERA_SHAPES.find( + (v) => v.value === project.camera.shape, + )} + onChange={(v) => { + if (v) setProject("camera", "shape", v.value); + }} + disallowEmptySelection + itemComponent={(props) => ( + + as={KSelect.Item} + item={props.item} + > + + {props.item.rawValue.name} + + + )} + > + + class="flex-1 text-sm text-left truncate text-(--gray-500) font-normal"> + {(state) => {state.selectedOption().name}} + + + as={(props) => ( + + )} /> - - - - + + + + as={KSelect.Content} + class={cx(topSlideAnimateClasses, "z-50")} + > + + class="overflow-y-auto max-h-32" + as={KSelect.Listbox} + /> + + + + - {/* + {/* setProject("camera", "use_camera_aspect", v)} /> */} -
-
- {/** Dashed divider */} -
- }> - setProject("camera", "size", v[0])} - minValue={20} - maxValue={80} - step={0.1} - formatTooltip="%" - /> - - }> - setProject("camera", "zoomSize", v[0])} - minValue={10} - maxValue={60} - step={0.1} - formatTooltip="%" - /> - - - = 1 - } - onChange={(keep) => - setProject( - "camera", - "scaleDuringZoom", - keep ? 1 : DEFAULT_CAMERA_SCALE_DURING_ZOOM, - ) - } - /> - - }> -
+
+
+ {/** Dashed divider */} +
+ }> setProject("camera", "rounding", v[0])} - minValue={0} - maxValue={100} + value={[project.camera.size]} + onChange={(v) => setProject("camera", "size", v[0])} + minValue={20} + maxValue={80} step={0.1} formatTooltip="%" /> - setProject("camera", "roundingType", value)} - /> -
- - }> -
+ + }> setProject("camera", "shadow", v[0])} - minValue={0} - maxValue={100} + value={[project.camera.zoomSize ?? 60]} + onChange={(v) => setProject("camera", "zoomSize", v[0])} + minValue={10} + maxValue={60} step={0.1} formatTooltip="%" /> - { - setProject("camera", "advancedShadow", { - ...(project.camera.advancedShadow ?? { - size: 50, - opacity: 18, - blur: 50, - }), - size: v[0], - }); - }, - }} - opacity={{ - value: [project.camera.advancedShadow?.opacity ?? 18], - onChange: (v) => { - setProject("camera", "advancedShadow", { - ...(project.camera.advancedShadow ?? { - size: 50, - opacity: 18, - blur: 50, - }), - opacity: v[0], - }); - }, - }} - blur={{ - value: [project.camera.advancedShadow?.blur ?? 50], - onChange: (v) => { - setProject("camera", "advancedShadow", { - ...(project.camera.advancedShadow ?? { - size: 50, - opacity: 18, - blur: 50, - }), - blur: v[0], - }); - }, - }} + + + = 1 + } + onChange={(keep) => + setProject( + "camera", + "scaleDuringZoom", + keep ? 1 : DEFAULT_CAMERA_SCALE_DURING_ZOOM, + ) + } /> -
-
- - {/* + + }> +
+ setProject("camera", "rounding", v[0])} + minValue={0} + maxValue={100} + step={0.1} + formatTooltip="%" + /> + setProject("camera", "roundingType", value)} + /> +
+
+ }> +
+ setProject("camera", "shadow", v[0])} + minValue={0} + maxValue={100} + step={0.1} + formatTooltip="%" + /> + { + setProject("camera", "advancedShadow", { + ...(project.camera.advancedShadow ?? { + size: 50, + opacity: 18, + blur: 50, + }), + size: v[0], + }); + }, + }} + opacity={{ + value: [project.camera.advancedShadow?.opacity ?? 18], + onChange: (v) => { + setProject("camera", "advancedShadow", { + ...(project.camera.advancedShadow ?? { + size: 50, + opacity: 18, + blur: 50, + }), + opacity: v[0], + }); + }, + }} + blur={{ + value: [project.camera.advancedShadow?.blur ?? 50], + onChange: (v) => { + setProject("camera", "advancedShadow", { + ...(project.camera.advancedShadow ?? { + size: 50, + opacity: 18, + blur: 50, + }), + blur: v[0], + }); + }, + }} + /> +
+
+ + + + {/* }> */} + ); } diff --git a/apps/desktop/src/routes/editor/Editor.tsx b/apps/desktop/src/routes/editor/Editor.tsx index 23a88edbe0d..6898f858924 100644 --- a/apps/desktop/src/routes/editor/Editor.tsx +++ b/apps/desktop/src/routes/editor/Editor.tsx @@ -79,6 +79,7 @@ const TranscriptPanel = lazy(() => const DEFAULT_TIMELINE_HEIGHT = 260; const MIN_PLAYER_CONTENT_HEIGHT = 320; const MIN_TIMELINE_HEIGHT = 240; +const MIN_COMPACT_TIMELINE_HEIGHT = 144; const RESIZE_HANDLE_HEIGHT = 16; const MIN_PLAYER_HEIGHT = MIN_PLAYER_CONTENT_HEIGHT + RESIZE_HANDLE_HEIGHT; const TIMELINE_RESIZE_GRIP_MARKS = [0, 1, 2] as const; @@ -386,6 +387,7 @@ function Inner(props: { }) { const { project, + flushProjectConfig, editorInstance, editorState, setEditorState, @@ -394,6 +396,27 @@ function Inner(props: { exportState, } = useEditorContext(); + const registerEditorSave = ( + registration: TitleSaveRegistration | undefined, + ) => { + props.registerTitleSave( + registration + ? { + ...registration, + flush: async () => { + await registration.flush(); + try { + await flushProjectConfig(); + } catch (error) { + toast.error(getEditorErrorMessage(error)); + throw error; + } + }, + } + : undefined, + ); + }; + createTauriEventListener(events.editorRecordingAdded, (payload) => { const normalize = (p: string) => p.replace(/[\\/]+$/, ""); if (normalize(payload.editor_path) !== normalize(editorInstance.path)) @@ -408,7 +431,7 @@ function Inner(props: { await commands.stopPlayback(); setEditorState("playing", false); } - await commands.setProjectConfig(serializeProjectConfiguration(project)); + await flushProjectConfig(); await commands.addExistingRecordingToEditor(recordingPath); await commands.deleteRecordingDirectory(recordingPath).catch(() => {}); toast.success("Clip added", { id: toastId }); @@ -511,16 +534,40 @@ function Inner(props: { visibleTrackCount: number; } | null>(null); + const layoutLimits = createMemo(() => { + const fullHeight = MIN_PLAYER_HEIGHT + MIN_TIMELINE_HEIGHT; + const available = Math.max(layoutBounds.height ?? fullHeight, 0); + const minPlayerHeight = + MIN_PLAYER_HEIGHT * Math.min(1, available / fullHeight); + const maxTimelineHeight = Math.floor( + Math.max(0, available - minPlayerHeight), + ); + + return { + minPlayerHeight, + maxTimelineHeight, + minTimelineHeight: Math.min( + maxTimelineHeight, + MIN_TIMELINE_HEIGHT, + Math.max(MIN_COMPACT_TIMELINE_HEIGHT, available - MIN_PLAYER_HEIGHT), + ), + compactness: Math.min( + 1, + Math.max( + 0, + (fullHeight - available) / + (MIN_TIMELINE_HEIGHT - MIN_COMPACT_TIMELINE_HEIGHT), + ), + ), + }; + }); + const clampTimelineHeight = (value: number) => { - const available = layoutBounds.height ?? 0; - const maxHeight = - available > 0 - ? Math.max(MIN_TIMELINE_HEIGHT, available - MIN_PLAYER_HEIGHT) - : Number.POSITIVE_INFINITY; - const upperBound = Number.isFinite(maxHeight) - ? maxHeight - : Math.max(value, MIN_TIMELINE_HEIGHT); - return Math.min(Math.max(value, MIN_TIMELINE_HEIGHT), upperBound); + const limits = layoutLimits(); + return Math.min( + Math.max(value, limits.minTimelineHeight), + limits.maxTimelineHeight, + ); }; const timelineHeight = createMemo(() => @@ -549,12 +596,6 @@ function Inner(props: { window.addEventListener("mouseup", handleUp); }; - createEffect(() => { - const available = layoutBounds.height; - if (!available) return; - setStoredTimelineHeight((height) => clampTimelineHeight(height)); - }); - createEffect( on(timelineViewportOverflow, (next, prev) => { if ( @@ -563,9 +604,13 @@ function Inner(props: { next.visibleTrackCount > prev.visibleTrackCount && next.overflow > 0 ) { - setStoredTimelineHeight((height) => - clampTimelineHeight(height + next.overflow), - ); + const height = timelineHeight(); + const expandedHeight = clampTimelineHeight(height + next.overflow); + if (expandedHeight > height) { + setStoredTimelineHeight((preferredHeight) => + Math.max(preferredHeight, expandedHeight), + ); + } } return next; @@ -764,7 +809,7 @@ function Inner(props: { } >
-
+
- +
{ const { setProject: setState, + styleScopeToken, editorInstance, editorState, canvasControls, @@ -1054,6 +1100,16 @@ function Dialogs() { previewResolutionBase, } = useEditorContext(); const display = editorInstance.recordings.segments[0].display; + const cropTarget = dialog().styleTarget ?? null; + const cropToken = dialog().scopeToken; + const cropStyle = + cropTarget === null + ? null + : project.timeline?.styleSegments[cropTarget]; + const cropTargetValid = () => + (!cropToken || cropToken === styleScopeToken()) && + (cropTarget === null || + project.timeline?.styleSegments[cropTarget] === cropStyle); let cropperRef: CropperRef | undefined; let previewCanvas: HTMLCanvasElement | undefined; @@ -1172,13 +1228,37 @@ function Dialogs() { const queueConfig = (bounds: CropBounds | null) => { const config = getPreviewProjectConfig(project, editorState); if (bounds) { - config.background = { - ...config.background, - crop: { - position: { x: bounds.x, y: bounds.y }, - size: { x: bounds.width, y: bounds.height }, - }, + if (!cropTargetValid()) return; + const nextCrop = { + position: { x: bounds.x, y: bounds.y }, + size: { x: bounds.width, y: bounds.height }, }; + if (cropTarget === null) + config.background = { + ...config.background, + crop: nextCrop, + }; + else if (config.timeline) { + config.timeline = { + ...config.timeline, + styleSegments: config.timeline.styleSegments.map( + (segment, index) => + index === cropTarget + ? { + ...segment, + overrides: { + ...segment.overrides, + background: { + ...(segment.overrides.background ?? + config.background), + crop: nextCrop, + }, + }, + } + : segment, + ), + }; + } } pendingConfig = { config, @@ -1508,10 +1588,22 @@ function Dialogs() {
+ + +

- {needsRecovery() - ? "Recording Needs Recovery" - : "Unable to Open Recording"} + {storageShortage() + ? "More space needed" + : needsRecovery() + ? "Recording Needs Recovery" + : "Unable to Open Recording"}

{props.error}

@@ -67,16 +199,24 @@ export function EditorErrorScreen(props: {

- Automatic Recovery + {storageShortage() + ? "Finish saving your recording" + : "Automatic Recovery"}

- Cap can attempt to recover your recording automatically. This - will reconstruct the recording from available segment data. + {storageShortage() + ? "Free up space on the recording drive, then click Recover Recording. Your recording files have been kept." + : "Cap can attempt to recover your recording automatically. This will reconstruct the recording from available segment data."}

- {settings.resolution.width}×{settings.resolution.height} + {outputDimensions().width}×{outputDimensions().height} {(est) => { diff --git a/apps/desktop/src/routes/editor/FrameButton.tsx b/apps/desktop/src/routes/editor/FrameButton.tsx index c680c28690f..acf0ab2d518 100644 --- a/apps/desktop/src/routes/editor/FrameButton.tsx +++ b/apps/desktop/src/routes/editor/FrameButton.tsx @@ -11,7 +11,8 @@ import IconLucideAppWindowMac from "~icons/lucide/app-window-mac"; import IconLucideBan from "~icons/lucide/ban"; import IconLucideGlobe from "~icons/lucide/globe"; import IconLucideLaptop from "~icons/lucide/laptop"; -import { useEditorContext } from "./context"; +import { EditorStyleContext, useEditorContext } from "./context"; +import { StyleGroupToggle } from "./StyleSegmentConfig"; import { EditorButton, Input } from "./ui"; const DEFAULT_FRAME_CONFIG: FrameConfiguration = { @@ -72,7 +73,7 @@ function SettingRow(props: { name: string; children: JSX.Element }) { } function FrameSettings() { - const { project, setProject } = useEditorContext(); + const { project, setProject, selectedStyle } = useEditorContext(); const style = () => project.background.frame?.style ?? "none"; const updateFrame = (patch: Partial) => @@ -89,98 +90,131 @@ function FrameSettings() { Wrap your recording in a window or device frame.
-
- - {(option) => { - const selected = () => style() === option.value; - return ( - - ); - }} - -
- - {(frame) => ( -
- - - updateFrame({ theme: v as FrameConfiguration["theme"] }) - } - > - - - Light - - - Dark - - -
- - - - - - -
- updateFrame({ url: e.currentTarget.value })} - /> -
-
-
- - -
- - updateFrame({ title: e.currentTarget.value }) - } - /> -
+ + + + + ); + }} + +
+ + {(frame) => ( +
+ + + updateFrame({ theme: v as FrameConfiguration["theme"] }) + } + > + + + Light + + + Dark + + +
+ + + - -
- )} + + +
+ + updateFrame({ url: e.currentTarget.value }) + } + /> +
+
+
+ + +
+ + updateFrame({ title: e.currentTarget.value }) + } + /> +
+
+
+
+ )} +
); } export function FrameButton() { + const context = useEditorContext(); + return ( + + {(_scope) => ( + + + + )} + + ); +} + +function ScopedFrameButton() { const { project } = useEditorContext(); const activeStyle = () => diff --git a/apps/desktop/src/routes/editor/Header.tsx b/apps/desktop/src/routes/editor/Header.tsx index 36b3c7224fc..c10c6b537c8 100644 --- a/apps/desktop/src/routes/editor/Header.tsx +++ b/apps/desktop/src/routes/editor/Header.tsx @@ -92,14 +92,14 @@ export function Header(props: { registerTitleSave: RegisterTitleSave }) { return (
- {ostype() === "macos" &&
} - {ostype() === "linux" && } + {ostype() === "macos" &&
} + {ostype() === "linux" && } { clearTimelineSelection(); @@ -123,21 +123,24 @@ export function Header(props: { registerTitleSave: RegisterTitleSave }) { leftIcon={} /> -
+
- .cap + .cap
@@ -146,7 +149,7 @@ export function Header(props: { registerTitleSave: RegisterTitleSave }) {
@@ -180,7 +183,9 @@ export function Header(props: { registerTitleSave: RegisterTitleSave }) { - {ostype() === "windows" && }
+ {ostype() === "windows" && ( + + )}
); } @@ -327,8 +338,8 @@ function NameEditor(props: { ); return ( - -
+ +
editorState.previewTime ?? editorState.playbackTime; + const visible = () => + (project.timeline?.imageSegments ?? []) + .map((segment, index) => ({ segment, index })) + .filter( + ({ segment }) => + segment.enabled && time() >= segment.start && time() < segment.end, + ) + .sort((a, b) => a.segment.track - b.segment.track || a.index - b.index); + const selected = (index: number) => + editorState.timeline.selection?.type === "image" && + editorState.timeline.selection.indices.includes(index); + let endDrag: (() => void) | undefined; + onCleanup(() => endDrag?.()); + + function drag( + event: MouseEvent, + index: number, + mode: "move" | "rotate" | { x: number; y: number }, + ) { + if (event.button !== 0 || editorState.playing) return; + event.preventDefault(); + event.stopPropagation(); + endDrag?.(); + const source = project.timeline?.imageSegments[index]; + if (!source) return; + setEditorState("timeline", "selection", { + type: "image", + indices: [index], + }); + const initial = structuredClone(unwrap(source)); + const canvas = { ...props.size }; + const rect = (event.currentTarget as HTMLElement) + .closest("[data-image-overlay]") + ?.getBoundingClientRect(); + const center = rect + ? { x: rect.left + rect.width / 2, y: rect.top + rect.height / 2 } + : { x: event.clientX, y: event.clientY }; + const startAngle = Math.atan2( + event.clientY - center.y, + event.clientX - center.x, + ); + const targets = snapTargets({ image: index }); + const resume = projectHistory.pause(); + let moved = false; + const move = (next: MouseEvent) => { + if (project.timeline?.imageSegments[index] !== source) return; + const delta = { + x: next.clientX - event.clientX, + y: next.clientY - event.clientY, + }; + if (!moved && Math.hypot(delta.x, delta.y) < 2) return; + moved = true; + if (mode === "rotate") { + let rotation = + initial.rotation + + ((Math.atan2(next.clientY - center.y, next.clientX - center.x) - + startAngle) * + 180) / + Math.PI; + if (next.shiftKey) rotation = Math.round(rotation / 15) * 15; + setProject( + "timeline", + "imageSegments", + index, + "rotation", + ((((rotation + 180) % 360) + 360) % 360) - 180, + ); + } else if (mode === "move") { + const radians = (initial.rotation * Math.PI) / 180; + const width = initial.size.x * canvas.width; + const height = initial.size.y * canvas.height; + const w = + (Math.abs(width * Math.cos(radians)) + + Math.abs(height * Math.sin(radians))) / + canvas.width; + const h = + (Math.abs(width * Math.sin(radians)) + + Math.abs(height * Math.cos(radians))) / + canvas.height; + const raw = { + x: initial.center.x + delta.x / canvas.width - w / 2, + y: initial.center.y + delta.y / canvas.height - h / 2, + w, + h, + }; + const snap = next.shiftKey + ? { dx: 0, dy: 0, guides: [] } + : snapMovingRect( + raw, + targets, + SNAP_PX / canvas.width, + SNAP_PX / canvas.height, + ); + setSnapGuides(snap.guides); + setProject("timeline", "imageSegments", index, "center", { + x: Math.max(0, Math.min(1, raw.x + w / 2 + snap.dx)), + y: Math.max(0, Math.min(1, raw.y + h / 2 + snap.dy)), + }); + } else + setProject( + "timeline", + "imageSegments", + index, + resizeImage(initial, delta, mode, canvas), + ); + }; + const finish = (next?: MouseEvent) => { + if (!endDrag) return; + if (next) move(next); + window.removeEventListener("mousemove", move); + window.removeEventListener("mouseup", finish); + window.removeEventListener("blur", cancel); + endDrag = undefined; + setSnapGuides([]); + resume(); + }; + const cancel = () => finish(); + endDrag = cancel; + window.addEventListener("mousemove", move); + window.addEventListener("mouseup", finish); + window.addEventListener("blur", cancel); + } + + createEventListener(window, "keydown", (event) => { + if ( + editorState.playing || + (event.target instanceof HTMLElement && + (event.target.isContentEditable || + ["INPUT", "TEXTAREA", "SELECT"].includes(event.target.tagName))) + ) + return; + const direction = { + ArrowLeft: [-1, 0], + ArrowRight: [1, 0], + ArrowUp: [0, -1], + ArrowDown: [0, 1], + }[event.key]; + if (!direction) return; + const items = visible().filter(({ index }) => selected(index)); + if (!items.length) return; + event.preventDefault(); + const resume = projectHistory.pause(); + for (const { segment, index } of items) { + const step = event.shiftKey ? 10 : 1; + setProject("timeline", "imageSegments", index, "center", { + x: Math.max( + 0, + Math.min( + 1, + segment.center.x + (direction[0] * step) / props.size.width, + ), + ), + y: Math.max( + 0, + Math.min( + 1, + segment.center.y + (direction[1] * step) / props.size.height, + ), + ), + }); + } + resume(); + }); + + return ( +
+ + + {({ segment, index }) => ( +
drag(event, index, "move")} + > +
+ + + {segment.name} + +
+ )} + + +
+ ); +} diff --git a/apps/desktop/src/routes/editor/ImageSegmentConfig.tsx b/apps/desktop/src/routes/editor/ImageSegmentConfig.tsx new file mode 100644 index 00000000000..9325f5038c4 --- /dev/null +++ b/apps/desktop/src/routes/editor/ImageSegmentConfig.tsx @@ -0,0 +1,283 @@ +import { convertFileSrc } from "@tauri-apps/api/core"; +import { createEffect, createSignal, For, Show } from "solid-js"; +import { Toggle } from "~/components/Toggle"; +import { useEditorContext } from "./context"; +import { imageAssetPath } from "./images"; +import { EditorButton, Field, Slider } from "./ui"; + +export function ImageSegmentConfig(props: { index: number }) { + const { project, setProject, editorInstance, projectActions, editorState } = + useEditorContext(); + const segment = () => project.timeline?.imageSegments[props.index]; + const path = () => imageAssetPath(editorInstance.path, segment()?.path ?? ""); + const [failed, setFailed] = createSignal(false); + createEffect(() => { + path(); + setFailed(false); + }); + return ( + + {(image) => ( +
+
+ + setProject( + "timeline", + "imageSegments", + props.index, + "name", + event.currentTarget.value.trim() || "Image", + ) + } + /> + + setProject( + "timeline", + "imageSegments", + props.index, + "enabled", + value, + ) + } + /> +
+ + Image unavailable. Replace it to restore this layer. +

+ } + > + {image().name} setFailed(true)} + /> +
+
+ + void projectActions.importImageSegment( + image().track, + image().start, + props.index, + ) + } + > + {editorState.importingImage ? "Importing…" : "Replace image"} + + + projectActions.deleteOverlaySegments("image", [props.index]) + } + > + Delete + +
+ +
+ + {(axis) => ( + + )} + +
+
+ +
+ + {(axis) => ( + + )} + +
+
+ + + setProject( + "timeline", + "imageSegments", + props.index, + "opacity", + value[0] / 100, + ) + } + /> + + + `${value}°`} + onChange={(value) => + setProject( + "timeline", + "imageSegments", + props.index, + "rotation", + value[0], + ) + } + /> + + + + setProject( + "timeline", + "imageSegments", + props.index, + "rounding", + value[0], + ) + } + /> + + + setProject( + "timeline", + "imageSegments", + props.index, + "lockAspect", + value, + ) + } + /> + } + /> + + setProject( + "timeline", + "imageSegments", + props.index, + "flipX", + value, + ) + } + /> + } + /> + + setProject( + "timeline", + "imageSegments", + props.index, + "flipY", + value, + ) + } + /> + } + /> + + setProject("timeline", "imageSegments", props.index, { + center: { x: 0.5, y: 0.5 }, + rotation: 0, + flipX: false, + flipY: false, + }) + } + > + Center and reset rotation + +

+ Drag the image to move it. Drag a corner to resize. Shift + temporarily disables snapping. +

+
+ )} +
+ ); +} diff --git a/apps/desktop/src/routes/editor/OrganizationDropdown.tsx b/apps/desktop/src/routes/editor/OrganizationDropdown.tsx index d01a121d01b..23fe73063e0 100644 --- a/apps/desktop/src/routes/editor/OrganizationDropdown.tsx +++ b/apps/desktop/src/routes/editor/OrganizationDropdown.tsx @@ -401,8 +401,12 @@ export function OrganizationDropdown() { as={KDropdownMenu.Trigger} leftIcon={} rightIcon={} + title={triggerLabel()} + aria-label={`Organization: ${triggerLabel()}`} > - {triggerLabel()} + + {triggerLabel()} + diff --git a/apps/desktop/src/routes/editor/Player.tsx b/apps/desktop/src/routes/editor/Player.tsx index 1da2c7e40f4..c857c97d34d 100644 --- a/apps/desktop/src/routes/editor/Player.tsx +++ b/apps/desktop/src/routes/editor/Player.tsx @@ -6,7 +6,6 @@ import { Menu } from "@tauri-apps/api/menu"; import { type as ostype } from "@tauri-apps/plugin-os"; import { cx } from "cva"; import { createEffect, createSignal, onMount, Show } from "solid-js"; - import Tooltip from "~/components/Tooltip"; import { captionsStore } from "~/store/captions"; import { commands } from "~/utils/tauri"; @@ -18,13 +17,9 @@ import { import { CaptionOverlay } from "./CaptionOverlay"; import { CaptionsRegenerateBadge } from "./CaptionsRegenerateBadge"; import { createCaptionTrackSegments } from "./captions"; -import { - type EditorPreviewQuality, - FPS, - serializeProjectConfiguration, - useEditorContext, -} from "./context"; +import { type EditorPreviewQuality, FPS, useEditorContext } from "./context"; import { FrameButton } from "./FrameButton"; +import { ImageOverlay } from "./ImageOverlay"; import { MaskOverlay } from "./MaskOverlay"; import { PerformanceOverlay } from "./PerformanceOverlay"; import { SplitScreenOverlay } from "./SplitScreenOverlay"; @@ -40,9 +35,14 @@ import { import { useEditorShortcuts } from "./useEditorShortcuts"; import { formatTime } from "./utils"; -export function PlayerContent() { +export function PlayerContent(props: { compactness?: number }) { const { + previewStyle, + selectedStyle, + toggleStyleGroup, + styleScopeToken, project, + flushProjectConfig, editorInstance, setDialog, totalDuration, @@ -108,6 +108,8 @@ export function PlayerContent() { sceneSegments: [], maskSegments: [], textSegments: [], + styleSegments: [], + imageSegments: [], camera3dSegments: [], transitions: [], }), @@ -131,9 +133,7 @@ export function PlayerContent() { if (projectDidChange) { setProject(updatedProject); - await commands.setProjectConfig( - serializeProjectConfiguration(updatedProject), - ); + await flushProjectConfig(); } } } @@ -159,15 +159,24 @@ export function PlayerContent() { }; const cropDialogHandler = async () => { + const background = selectedStyle() + ? (selectedStyle()?.overrides.background ?? previewStyle().background) + : project.background; + if (selectedStyle() && !selectedStyle()?.overrides.background) + toggleStyleGroup("background", true); + const styleTarget = editorState.styleEditIndex; + const scopeToken = styleScopeToken(); const display = editorInstance.recordings.segments[0].display; setDialog({ open: true, type: "crop", + styleTarget, + scopeToken, position: { - ...(project.background.crop?.position ?? { x: 0, y: 0 }), + ...(background.crop?.position ?? { x: 0, y: 0 }), }, size: { - ...(project.background.crop?.size ?? { + ...(background.crop?.size ?? { x: display.width, y: display.height, }), @@ -287,9 +296,14 @@ export function PlayerContent() { return (
-
+
- + + +
-
+