diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 85e9df74..8a7ea53f 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -82,6 +82,47 @@ jobs: working-directory: packages/server run: npx vitest run + # ────────────────────────────────────────────────────────────────── + # Worker: ffmpeg segment pipeline. Proves the clip-compile contract — + # every capture unit (JPEG still, VP8 webm clip, H.264 mp4 clip, any + # resolution) normalizes to exactly one second of 30fps video with + # pinned parameters, and the segments stream-copy concatenate into a + # decodable MP4. Runs real ffmpeg on synthetic inputs; no DB/R2. + # ────────────────────────────────────────────────────────────────── + worker: + name: Worker tests + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Install ffmpeg + run: | + sudo apt-get update + sudo apt-get install -y ffmpeg + + - name: Setup Node.js + uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0 + with: + node-version: lts/* + cache: npm + + - name: Install dependencies + run: npm ci + + - name: Build shared package + run: npm run build -w packages/shared + + - name: Type-check worker + working-directory: packages/worker + run: npx tsc --noEmit + + - name: Vitest (segment pipeline) + run: npm test -w packages/worker + + # Shared package: pure units — cut math, clock-offset estimation. + - name: Vitest (shared) + run: npm test -w packages/shared + # ────────────────────────────────────────────────────────────────── # Desktop client: Rust unit + serde compat tests. # The 4-way matrix (legacy/new struct × legacy/new JSON) here is the @@ -111,6 +152,25 @@ jobs: libgstreamer-plugins-base1.0-dev \ libgbm-dev + # RUNTIME GStreamer plugins, not just the dev headers above. The Linux + # clip encoder builds `appsrc ! videoconvert ! x264enc ! h264parse ! + # mp4mux ! filesink`, and those elements live in four different packages: + # videoconvert in -base, mp4mux in -good, h264parse in -bad, x264enc in + # -ugly. Without them ClipRecorder::new fails with "no H.264 encoder + # element available" and every clip test that touches a real encoder dies + # — which is exactly how this job has been failing. + # + # This is also the only place the Linux encoder path is executed at all + # (macOS runs it locally, Windows is check-only), so installing them is + # what makes that path tested rather than merely compiled. + - name: Install GStreamer runtime plugins + run: | + sudo apt-get install -y \ + gstreamer1.0-plugins-base \ + gstreamer1.0-plugins-good \ + gstreamer1.0-plugins-bad \ + gstreamer1.0-plugins-ugly + - name: Install Rust stable uses: dtolnay/rust-toolchain@631a55b12751854ce901bb631d5902ceb48146f7 # stable @@ -126,9 +186,42 @@ jobs: workspaces: "./clients/desktop/src-tauri -> target" shared-key: rust-ubuntu-24.04-native - - name: cargo test --lib + # Release, not debug. The clip encoders are unsafe FFI against + # VideoToolbox / Media Foundation / GStreamer, and that is exactly the + # code whose behaviour differs under optimisation — the kind of bug this + # project has already been bitten by once (a datatype-misalignment crash + # that only showed up in a release build). Testing debug binaries proves + # the wrong artifact. + - name: cargo test --lib --release + working-directory: clients/desktop/src-tauri + run: cargo test --lib --release + + # ────────────────────────────────────────────────────────────────── + # Windows type-check: the Media Foundation clip encoder is Windows-only + # cfg code that no other job compiles. A check (not full test run) + # catches API drift in the `windows` crate bindings. + # ────────────────────────────────────────────────────────────────── + desktop-rust-windows: + name: Desktop Rust check (Windows) + runs-on: windows-latest + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Install Rust stable + uses: dtolnay/rust-toolchain@631a55b12751854ce901bb631d5902ceb48146f7 # stable + + - name: Rust cache + uses: swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2 + with: + workspaces: "./clients/desktop/src-tauri -> target" + shared-key: rust-windows-check + + # Release profile, matching what actually ships (see the Linux job). + # This is the only job that compiles the Windows-only Media Foundation + # encoder at all, so it should compile it the way users get it. + - name: cargo check --release working-directory: clients/desktop/src-tauri - run: cargo test --lib + run: cargo check --release # ────────────────────────────────────────────────────────────────── # Type-check the rest of the workspace (React/web clients). Catches diff --git a/.gitignore b/.gitignore index 70353d22..dc7e5eb2 100644 --- a/.gitignore +++ b/.gitignore @@ -12,6 +12,15 @@ dist/ # Rust / Tauri clients/desktop/src-tauri/target/ clients/desktop/src-tauri/gen/ +# SwiftPM build output for the native tray. Its index database is a 64MB file +# that was committed once already; keep it out for good. +clients/desktop/src-tauri/swift/*/.build/ + +# Env files, including the editor/tooling backups that are easy to commit by +# accident (.env itself is covered above). +.env.bak +.env.*.bak +*.env.bak # Drizzle generated migrations are committed, but the meta folder is not needed # (optional — remove this line if you want to commit migration metadata) diff --git a/clients/desktop/package.json b/clients/desktop/package.json index 14c54ac4..01230855 100644 --- a/clients/desktop/package.json +++ b/clients/desktop/package.json @@ -1,6 +1,6 @@ { "name": "@lookout/desktop", - "version": "0.3.5", + "version": "0.3.7", "license": "AGPL-3.0-or-later", "private": true, "type": "module", @@ -12,7 +12,8 @@ "dependencies": { "@lookout/react": "*", "@lookout/shared": "*", - "@number-flow/react": "^0.6.0", + "@number-flow/react": "^0.6.2", + "@phosphor-icons/react": "^2.1.10", "@sentry/react": "^10.45.0", "@tauri-apps/api": "^2.0.0", "@tauri-apps/plugin-deep-link": "^2.4.7", diff --git a/clients/desktop/src-tauri/Cargo.lock b/clients/desktop/src-tauri/Cargo.lock index c5ca9fe0..5f405532 100644 --- a/clients/desktop/src-tauri/Cargo.lock +++ b/clients/desktop/src-tauri/Cargo.lock @@ -1529,6 +1529,18 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "fast_image_resize" +version = "5.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fbc7fe45cf92b43817ff62a3723e862b85bd1d06288f63007f7645d1d2f7a060" +dependencies = [ + "cfg-if", + "document-features", + "num-traits", + "thiserror 2.0.18", +] + [[package]] name = "fastrand" version = "2.3.0" @@ -2546,7 +2558,7 @@ dependencies = [ "libc", "percent-encoding", "pin-project-lite", - "socket2 0.5.10", + "socket2 0.6.3", "system-configuration", "tokio", "tower-service", @@ -3152,22 +3164,31 @@ checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" [[package]] name = "lookout-desktop" -version = "0.3.5" +version = "0.3.7" dependencies = [ "ashpd", "base64 0.22.1", + "block2", + "bytes", + "fast_image_resize", "gstreamer", "gstreamer-app", "gstreamer-video", "image", + "objc2", + "objc2-app-kit", + "objc2-av-foundation", "objc2-core-foundation", "objc2-core-graphics", + "objc2-core-media", + "objc2-core-video", "objc2-foundation", "os_info", "reqwest 0.12.28", "sentry", "serde", "serde_json", + "swift-rs", "tauri", "tauri-build", "tauri-plugin-deep-link", @@ -3185,6 +3206,7 @@ dependencies = [ "url", "webview2-com", "window-vibrancy 0.7.1", + "windows 0.62.2", "xcap", ] @@ -3608,6 +3630,7 @@ dependencies = [ "objc2-core-foundation", "objc2-core-graphics", "objc2-core-image", + "objc2-core-media", "objc2-core-video", "objc2-foundation", "objc2-image-io", @@ -4588,7 +4611,7 @@ dependencies = [ "quinn-udp", "rustc-hash", "rustls", - "socket2 0.5.10", + "socket2 0.6.3", "thiserror 2.0.18", "tokio", "tracing", @@ -4625,7 +4648,7 @@ dependencies = [ "cfg_aliases", "libc", "once_cell", - "socket2 0.5.10", + "socket2 0.6.3", "tracing", "windows-sys 0.60.2", ] diff --git a/clients/desktop/src-tauri/Cargo.toml b/clients/desktop/src-tauri/Cargo.toml index 3eb3e5d1..d40df3a8 100644 --- a/clients/desktop/src-tauri/Cargo.toml +++ b/clients/desktop/src-tauri/Cargo.toml @@ -12,7 +12,7 @@ debug = true [package] name = "lookout-desktop" -version = "0.3.5" +version = "0.3.7" edition = "2021" [lib] @@ -21,6 +21,9 @@ crate-type = ["staticlib", "cdylib", "rlib"] [build-dependencies] tauri-build = { version = "2", features = [] } +# Compiles swift/lookout-tray (SwiftUI menu-bar item with numericText digit +# animation) and links it into the macOS binary; no-op on other targets. +swift-rs = { version = "1.0.7", features = ["build"] } [dependencies] tauri = { version = "2", features = ["macos-private-api", "devtools", "tray-icon", "image-png"] } @@ -29,10 +32,17 @@ tauri-plugin-dialog = "2" serde = { version = "1", features = ["derive"] } serde_json = "1" xcap = "0.9" -image = { version = "0.25", default-features = false, features = ["jpeg"] } +image = { version = "0.25", default-features = false, features = ["jpeg", "png"] } +# SIMD resizing (SSE4/AVX2/NEON) for the capture downscale — ~4x faster than +# the `image` crate's scalar filters at screen sizes (29ms → 7ms, 5K→1080p). +fast_image_resize = "5" reqwest = { version = "0.12", default-features = false, features = ["json", "multipart", "rustls-tls"] } tokio = { version = "1", features = ["full"] } base64 = "0.22" +# Refcounted byte buffers for the upload pipeline — retrying an R2 PUT clones +# the request body, and `Bytes` makes that clone a refcount bump instead of a +# full JPEG copy. Already in the dependency tree via tokio/reqwest. +bytes = "1" tauri-plugin-deep-link = "2.4.7" tauri-plugin-http = "2.5.7" tauri-plugin-macos-permissions = "2.3.0" @@ -53,12 +63,30 @@ sentry = "0.38" tokio = { version = "1", features = ["test-util"] } [target.'cfg(target_os = "macos")'.dependencies] +objc2 = "0.6" objc2-core-foundation = "0.3.2" objc2-core-graphics = "0.3.2" -objc2-foundation = { version = "0.3.2", features = ["NSProcessInfo", "NSString"] } +objc2-foundation = { version = "0.3.2", features = ["NSProcessInfo", "NSString", "NSArray", "NSData", "NSDictionary", "NSEnumerator", "NSBundle", "NSURL"] } +objc2-app-kit = { version = "0.3.2", features = ["NSWorkspace", "NSRunningApplication", "NSImage", "NSImageRep", "NSBitmapImageRep", "NSGraphicsContext", "objc2-core-graphics"] } +# Clip encoding: hardware H.264 via AVAssetWriter (VideoToolbox under the +# hood) writing MP4 directly. All already in the tree transitively via xcap. +objc2-av-foundation = { version = "0.3.2", features = ["AVAssetWriter", "AVAssetWriterInput", "AVMediaFormat", "AVVideoSettings", "objc2-core-media", "objc2-core-video"] } +objc2-core-media = { version = "0.3.2", features = ["CMTime"] } +objc2-core-video = { version = "0.3.2", features = ["CVPixelBuffer", "CVBuffer", "CVReturn", "CVImageBuffer"] } +block2 = "0.6" [target.'cfg(target_os = "windows")'.dependencies] webview2-com = "0.38" +windows = { version = "0.62", features = [ + "Win32_Foundation", + "Win32_Graphics_Gdi", + # Clip encoding: hardware H.264 via the Media Foundation sink writer. + "Win32_Media_MediaFoundation", + "Win32_Storage_FileSystem", + "Win32_System_Com", + "Win32_UI_Shell", + "Win32_UI_WindowsAndMessaging", +] } [target."cfg(target_os = \"linux\")".dependencies] ashpd = { version = "0.9", default-features = false, features = ["tokio"] } diff --git a/clients/desktop/src-tauri/build.rs b/clients/desktop/src-tauri/build.rs index 105301eb..d18e12df 100644 --- a/clients/desktop/src-tauri/build.rs +++ b/clients/desktop/src-tauri/build.rs @@ -1,7 +1,14 @@ fn main() { // Link CoreGraphics on macOS for screen capture permission APIs #[cfg(target_os = "macos")] - println!("cargo:rustc-link-lib=framework=CoreGraphics"); + { + println!("cargo:rustc-link-lib=framework=CoreGraphics"); + // Native menu-bar item: compiles swift/lookout-tray (SwiftUI with the + // numericText digit-roll animation) and links it into the binary. + swift_rs::SwiftLinker::new("10.15") + .with_package("lookout-tray", "./swift/lookout-tray") + .link(); + } tauri_build::build() } diff --git a/clients/desktop/src-tauri/capabilities/default.json b/clients/desktop/src-tauri/capabilities/default.json index 55114185..569a7c8e 100644 --- a/clients/desktop/src-tauri/capabilities/default.json +++ b/clients/desktop/src-tauri/capabilities/default.json @@ -2,7 +2,11 @@ "$schema": "../gen/schemas/desktop-schema.json", "identifier": "default", "description": "Default capabilities for the Lookout desktop app", - "windows": ["main", "tray"], + "windows": [ + "main", + "tray", + "editor-*" + ], "permissions": [ "core:default", "liquid-glass:default", @@ -18,13 +22,22 @@ "macos-permissions:default", "updater:default", "notification:default", + "core:webview:allow-create-webview-window", + "core:window:allow-close", + "core:window:allow-set-focus", + "core:window:allow-get-all-windows", + "core:webview:allow-get-all-webviews", + "core:window:allow-destroy", { "identifier": "http:default", "allow": [ - { "url": "http://localhost:*" }, - { "url": "https://lookout.hackclub.com/*" }, - { "url": "https://*.r2.cloudflarestorage.com/*" } + { + "url": "http://localhost:*" + }, + { + "url": "https://**" + } ] } ] -} +} \ No newline at end of file diff --git a/clients/desktop/src-tauri/src/capture.rs b/clients/desktop/src-tauri/src/capture.rs index d52bce0f..142faf07 100644 --- a/clients/desktop/src-tauri/src/capture.rs +++ b/clients/desktop/src-tauri/src/capture.rs @@ -200,7 +200,9 @@ fn capture_to_dynamic_image_with_blacklist( if let CaptureSource::Monitor { id } = source { if !blacklisted_apps.is_empty() { if let Some(bounds) = get_monitor_screen_bounds(*id) { - let mut rgba = dynamic.to_rgba8(); + // into_rgba8 is a move (captures are always RGBA8) — avoids + // cloning a full-resolution frame just to draw on it. + let mut rgba = dynamic.into_rgba8(); let w = rgba.width(); let h = rgba.height(); redact_blacklisted_regions(&mut rgba, bounds, blacklisted_apps, w, h); @@ -235,31 +237,78 @@ pub struct RawCaptureResult { pub height: u32, } -pub fn take_screenshot_raw( - source: CaptureSource, - max_width: u32, - max_height: u32, +/// SIMD resize (fast_image_resize: SSE4/AVX2/NEON) — ~4x faster than the +/// `image` crate's scalar filters at screen sizes (measured 29ms → 7ms for +/// a 5K→1080p downscale). Box for downscales (area average: the correct +/// filter for heavy downscaling AND the cheapest), bilinear when upscaling +/// (box upscales look blocky). Falls back to the scalar Triangle path if +/// the SIMD buffers can't be built — unreachable in practice, but capture +/// must never die on a resize. +pub fn fast_resize_rgba(dynamic: DynamicImage, new_w: u32, new_h: u32) -> DynamicImage { + // Captures are always RGBA8 — into_rgba8 is a move, not a copy. + let rgba = dynamic.into_rgba8(); + match fast_resize_buffer(&rgba, new_w, new_h) { + Some(out) => DynamicImage::ImageRgba8(out), + // Unreachable in practice; the scalar path keeps capture alive. + None => DynamicImage::ImageRgba8(rgba).resize_exact( + new_w, + new_h, + image::imageops::FilterType::Triangle, + ), + } +} + +/// Borrowing core of {@link fast_resize_rgba} — resizes without consuming +/// the source, for callers that still need the original frame (e.g. the +/// preview downscale next to the clip encoder). +pub fn fast_resize_buffer( + rgba: &image::RgbaImage, + new_w: u32, + new_h: u32, +) -> Option { + use fast_image_resize as fir; + + let (w, h) = (rgba.width(), rgba.height()); + let alg = if new_w <= w && new_h <= h { + fir::ResizeAlg::Convolution(fir::FilterType::Box) + } else { + fir::ResizeAlg::Convolution(fir::FilterType::Bilinear) + }; + let src = fir::images::ImageRef::new(w, h, rgba.as_raw(), fir::PixelType::U8x4).ok()?; + let mut dst = fir::images::Image::new(new_w, new_h, fir::PixelType::U8x4); + let opts = fir::ResizeOptions::new().resize_alg(alg); + fir::Resizer::new().resize(&src, &mut dst, &opts).ok()?; + image::RgbaImage::from_raw(new_w, new_h, dst.into_vec()) +} + +/// Encode a captured (already redacted + scaled) frame as JPEG. +pub fn encode_frame_jpeg( + dynamic: &DynamicImage, jpeg_quality: u8, - pipewire_fds: &std::collections::HashMap, ) -> Result { - take_screenshot_raw_with_blacklist( - source, - max_width, - max_height, - jpeg_quality, - pipewire_fds, - &[], - ) + let rgb = dynamic.to_rgb8(); + let mut jpeg_buf = Cursor::new(Vec::new()); + let mut encoder = JpegEncoder::new_with_quality(&mut jpeg_buf, jpeg_quality); + encoder + .encode_image(&rgb) + .map_err(|e| format!("JPEG encoding failed: {e}"))?; + Ok(RawCaptureResult { + data: jpeg_buf.into_inner(), + width: dynamic.width(), + height: dynamic.height(), + }) } -pub fn take_screenshot_raw_with_blacklist( +/// Capture a single source, redact, and scale to fit — returning the raw +/// RGBA frame (no JPEG encode). Base primitive shared by the JPEG upload +/// path, the live preview, and the clip encoder. +pub fn take_screenshot_image_with_blacklist( source: CaptureSource, max_width: u32, max_height: u32, - jpeg_quality: u8, pipewire_fds: &std::collections::HashMap, blacklisted_apps: &[String], -) -> Result { +) -> Result { let mut dynamic = capture_to_dynamic_image_with_blacklist(&source, pipewire_fds, blacklisted_apps)?; @@ -273,51 +322,37 @@ pub fn take_screenshot_raw_with_blacklist( let scale = f64::min(max_width as f64 / w as f64, max_height as f64 / h as f64); let new_w = (w as f64 * scale).round() as u32; let new_h = (h as f64 * scale).round() as u32; - dynamic = dynamic.resize_exact(new_w, new_h, image::imageops::FilterType::Triangle); + dynamic = fast_resize_rgba(dynamic, new_w, new_h); } - let (final_w, final_h) = (dynamic.width(), dynamic.height()); - - // Encode as JPEG - let rgb = dynamic.to_rgb8(); - let mut jpeg_buf = Cursor::new(Vec::new()); - let mut encoder = JpegEncoder::new_with_quality(&mut jpeg_buf, jpeg_quality); - encoder - .encode_image(&rgb) - .map_err(|e| format!("JPEG encoding failed: {e}"))?; - - Ok(RawCaptureResult { - data: jpeg_buf.into_inner(), - width: final_w, - height: final_h, - }) + Ok(dynamic) } -/// Capture a specific source (monitor or window), scale to fit, encode as JPEG. -pub fn take_screenshot( +pub fn take_screenshot_raw_with_blacklist( source: CaptureSource, max_width: u32, max_height: u32, jpeg_quality: u8, pipewire_fds: &std::collections::HashMap, -) -> Result { - take_screenshot_with_blacklist( + blacklisted_apps: &[String], +) -> Result { + let dynamic = take_screenshot_image_with_blacklist( source, max_width, max_height, - jpeg_quality, pipewire_fds, - &[], - ) + blacklisted_apps, + )?; + encode_frame_jpeg(&dynamic, jpeg_quality) } -pub fn take_screenshot_with_blacklist( +/// Capture a specific source (monitor or window), scale to fit, encode as JPEG. +pub fn take_screenshot( source: CaptureSource, max_width: u32, max_height: u32, jpeg_quality: u8, pipewire_fds: &std::collections::HashMap, - blacklisted_apps: &[String], ) -> Result { let raw = take_screenshot_raw_with_blacklist( source, @@ -325,7 +360,7 @@ pub fn take_screenshot_with_blacklist( max_height, jpeg_quality, pipewire_fds, - blacklisted_apps, + &[], )?; let size_bytes = raw.data.len(); @@ -340,41 +375,25 @@ pub fn take_screenshot_with_blacklist( }) } -pub fn take_stitched_screenshots( +/// Capture one or more sources side-by-side, redact, and scale to fit — +/// returning the composed raw RGBA frame (no JPEG encode). Base primitive +/// shared by the JPEG upload path, the live preview, and the clip encoder. +pub fn take_stitched_screenshots_image_with_blacklist( sources: &[CaptureSource], max_width: u32, max_height: u32, - jpeg_quality: u8, - pipewire_fds: &std::collections::HashMap, -) -> Result { - take_stitched_screenshots_with_blacklist( - sources, - max_width, - max_height, - jpeg_quality, - pipewire_fds, - &[], - ) -} - -pub fn take_stitched_screenshots_with_blacklist( - sources: &[CaptureSource], - max_width: u32, - max_height: u32, - jpeg_quality: u8, pipewire_fds: &std::collections::HashMap, blacklisted_apps: &[String], -) -> Result { +) -> Result { if sources.is_empty() { return Err("No sources provided".to_string()); } if sources.len() == 1 { - return take_screenshot_with_blacklist( + return take_screenshot_image_with_blacklist( sources[0].clone(), max_width, max_height, - jpeg_quality, pipewire_fds, blacklisted_apps, ); @@ -409,7 +428,7 @@ pub fn take_stitched_screenshots_with_blacklist( if h != target_h && h > 0 { let scale = target_h as f64 / h as f64; let new_w = (w as f64 * scale).round() as u32; - let scaled = img.resize_exact(new_w, target_h, image::imageops::FilterType::Lanczos3); + let scaled = fast_resize_rgba(img, new_w, target_h); total_w += scaled.width(); scaled_images.push(scaled); } else { @@ -419,11 +438,13 @@ pub fn take_stitched_screenshots_with_blacklist( } let mut stitched = image::RgbaImage::new(total_w, target_h); - let mut current_x = 0; + let mut current_x: i64 = 0; for img in scaled_images { - let rgba = img.to_rgba8(); - image::imageops::overlay(&mut stitched, &rgba, current_x as i64, 0); - current_x += img.width() as i64; + let w = img.width() as i64; + // into_rgba8 is a move for RGBA8 frames — no full-frame clone. + let rgba = img.into_rgba8(); + image::imageops::overlay(&mut stitched, &rgba, current_x, 0); + current_x += w; } let mut dynamic = DynamicImage::ImageRgba8(stitched); @@ -441,31 +462,31 @@ pub fn take_stitched_screenshots_with_blacklist( ); let new_w = (w as f64 * scale).round() as u32; let new_h = (h as f64 * scale).round() as u32; - dynamic = dynamic.resize_exact(new_w, new_h, image::imageops::FilterType::Lanczos3); + dynamic = fast_resize_rgba(dynamic, new_w, new_h); } - let (final_w, final_h) = (dynamic.width(), dynamic.height()); - - // Encode as JPEG - let rgb = dynamic.to_rgb8(); - let mut jpeg_buf = Cursor::new(Vec::new()); - let mut encoder = JpegEncoder::new_with_quality(&mut jpeg_buf, jpeg_quality); - encoder - .encode_image(&rgb) - .map_err(|e| format!("JPEG encoding failed: {e}"))?; - - let jpeg_bytes = jpeg_buf.into_inner(); - let size_bytes = jpeg_bytes.len(); - - use base64::Engine; - let base64 = base64::engine::general_purpose::STANDARD.encode(&jpeg_bytes); + Ok(dynamic) +} - Ok(CaptureResult { - base64, - width: final_w, - height: final_h, - size_bytes, - }) +/// Capture one or more sources side-by-side and encode as a single JPEG. +/// Returns raw JPEG bytes — callers that need base64 (e.g. for the preview +/// event) encode it themselves, so the upload path never has to decode. +pub fn take_stitched_screenshots_raw_with_blacklist( + sources: &[CaptureSource], + max_width: u32, + max_height: u32, + jpeg_quality: u8, + pipewire_fds: &std::collections::HashMap, + blacklisted_apps: &[String], +) -> Result { + let dynamic = take_stitched_screenshots_image_with_blacklist( + sources, + max_width, + max_height, + pipewire_fds, + blacklisted_apps, + )?; + encode_frame_jpeg(&dynamic, jpeg_quality) } #[cfg(target_os = "macos")] diff --git a/clients/desktop/src-tauri/src/clips.rs b/clients/desktop/src-tauri/src/clips.rs new file mode 100644 index 00000000..032d5c04 --- /dev/null +++ b/clients/desktop/src-tauri/src/clips.rs @@ -0,0 +1,1239 @@ +//! Per-minute clip recording: hardware H.264 encoding of capture-loop +//! frames into MP4 clips — the `format=mp4` upload payload for sessions +//! with clips enabled. +//! +//! One `ClipRecorder` lives per upload interval: the capture loop pushes a +//! frame every `frameIntervalMs` (server-authoritative, 10s = 6/min), and +//! at the upload tick `finish()` produces the MP4 bytes. Encoding is done +//! by the OS hardware encoder on every platform — no bundled codecs: +//! +//! - macOS: AVAssetWriter (VideoToolbox underneath), muxes MP4 itself +//! - Windows: Media Foundation sink writer (hardware MFT when available) +//! - Linux: GStreamer (already a dependency for PipeWire capture) +//! +//! Every error is recoverable by design: the capture loop falls back to +//! the legacy one-JPEG-per-minute upload for that interval, so a broken +//! encoder degrades smoothness, never the recording. + +use image::DynamicImage; +use std::path::PathBuf; +use std::sync::atomic::{AtomicU64, Ordering}; + +/// Per-frame byte budget — the quality dial. Mirrors the shared +/// CLIP_FRAME_BYTE_BUDGET; keep the two in step. +/// +/// ~400 KB buys a JPEG-q85-class keyframe at 1080p, the bar the legacy +/// single-screenshot pipeline set. 133k/400k-per-second equivalents were +/// tried first and produced visibly soft H.264. +pub const CLIP_FRAME_BYTE_BUDGET: u64 = 400_000; + +/// Bitrate (bits/second of MEDIA time) that lands CLIP_FRAME_BYTE_BUDGET per +/// frame at the given cadence. Mirrors `nativeClipBitsPerSecond` in +/// @lookout/shared. +/// +/// This must scale with the cadence. These encoders get each frame's real +/// presentation timestamp, so their bitrate is denominated per second of +/// media time — the same number buys 2.5x the bytes per frame when frames +/// sit 10s apart instead of 4s. Left fixed, a slower cadence would silently +/// inflate every clip toward the server's 8 MB limit while a faster one +/// would starve it. (Browsers work differently and need a much larger +/// figure for the same quality — see CLIP_WEB_VIDEO_BITS_PER_SECOND. The +/// two are not comparable.) VBR ceiling, not a floor: static screens +/// undershoot heavily. +pub fn clip_bits_per_second(frame_interval_ms: u64) -> u32 { + let interval_ms = frame_interval_ms.max(1); + ((CLIP_FRAME_BYTE_BUDGET * 8 * 1000) / interval_ms).min(u32::MAX as u64) as u32 +} + +/// A finished clip ready for upload. +pub struct FinishedClip { + pub mp4: Vec, + pub frame_count: u32, + pub width: u32, + pub height: u32, +} + +/// Unique-enough temp path for an in-progress clip container. +fn clip_temp_path() -> PathBuf { + static COUNTER: AtomicU64 = AtomicU64::new(0); + let n = COUNTER.fetch_add(1, Ordering::Relaxed); + std::env::temp_dir().join(format!( + "lookout-clip-{}-{}.mp4", + std::process::id(), + n + )) +} + +/// Records one clip: accepts RGBA frames, hands back MP4 bytes. +pub struct ClipRecorder { + encoder: platform::Encoder, + path: PathBuf, + width: u32, + height: u32, + frame_count: u32, + frame_interval_ms: u64, +} + +impl ClipRecorder { + /// Start a new clip sized to the given frame dimensions (rounded down + /// to even — H.264 4:2:0 needs even dims). Later frames that arrive at + /// a different size (display change mid-clip) are scaled to fit. + pub fn new(width: u32, height: u32, frame_interval_ms: u64) -> Result { + let width = (width & !1).max(2); + let height = (height & !1).max(2); + let path = clip_temp_path(); + // A failed init can still have created the container — the GStreamer + // path opens `filesink` as soon as the pipeline goes Playing, and there + // is no ClipRecorder yet whose finish()/discard() would remove it. That + // matters more now the loop retries init: a machine with a broken + // encoder would drip an orphan into the temp dir per attempt. + let encoder = match platform::Encoder::new( + &path, + width, + height, + clip_bits_per_second(frame_interval_ms), + frame_interval_ms, + ) { + Ok(e) => e, + Err(e) => { + let _ = std::fs::remove_file(&path); + return Err(e); + } + }; + Ok(Self { + encoder, + path, + width, + height, + frame_count: 0, + frame_interval_ms, + }) + } + + pub fn frame_count(&self) -> u32 { + self.frame_count + } + + /// The in-progress container's path, so tests can assert it was cleaned up + /// without scanning the shared OS temp directory. + #[cfg(test)] + fn temp_path(&self) -> std::path::PathBuf { + self.path.clone() + } + + /// Append one captured frame. Presentation time advances by the clip + /// frame interval per frame, so the clip plays back in real time. + pub fn push_frame(&mut self, frame: &DynamicImage) -> Result<(), String> { + // Normalize to the encoder's fixed dimensions. Cheap no-op clone + // path when dimensions already match (the common case). + let bgra = frame_to_bgra(frame, self.width, self.height); + let pts_ms = self.frame_count as u64 * self.frame_interval_ms; + self.encoder + .append_bgra_frame(&bgra, self.width, self.height, pts_ms)?; + self.frame_count += 1; + Ok(()) + } + + /// Finalize the container and return its bytes. Consumes the recorder; + /// the temp file is always removed. + pub fn finish(self) -> Result { + // Hold the final duration so the last frame isn't zero-length. + let duration_ms = self.frame_count as u64 * self.frame_interval_ms; + let result = self.encoder.finish(duration_ms); + let bytes = result.and_then(|()| { + std::fs::read(&self.path).map_err(|e| format!("failed to read clip file: {e}")) + }); + let _ = std::fs::remove_file(&self.path); + let mp4 = bytes?; + if self.frame_count == 0 || mp4.is_empty() { + return Err("clip has no frames".into()); + } + Ok(FinishedClip { + mp4, + frame_count: self.frame_count, + width: self.width, + height: self.height, + }) + } + + /// Abort and clean up without producing a clip (pause/stop mid-minute). + pub fn discard(self) { + let _ = self.encoder.finish(0); + let _ = std::fs::remove_file(&self.path); + } +} + +/// Convert a frame to tightly-packed BGRA at exactly (width, height), +/// scaling (aspect-preserving pillarbox on black) when dimensions differ. +fn frame_to_bgra(frame: &DynamicImage, width: u32, height: u32) -> Vec { + // Common case: dimensions match and the frame is already RGBA8 + // (captures always are) — swizzle straight from the borrowed buffer + // into the output, one copy total. + if frame.width() == width && frame.height() == height { + if let Some(rgba) = frame.as_rgba8() { + let src = rgba.as_raw(); + let mut bgra = Vec::with_capacity(src.len()); + for px in src.chunks_exact(4) { + bgra.extend_from_slice(&[px[2], px[1], px[0], px[3]]); + } + return bgra; + } + } + + // Rare path: mid-clip display change — normalize to the clip's fixed + // dimensions with a pillarboxed canvas. + let rgba = if frame.width() == width && frame.height() == height { + frame.to_rgba8() + } else { + let scaled = frame.resize(width, height, image::imageops::FilterType::Triangle); + let mut canvas = image::RgbaImage::from_pixel(width, height, image::Rgba([0, 0, 0, 255])); + let x = (width - scaled.width()) / 2; + let y = (height - scaled.height()) / 2; + image::imageops::overlay(&mut canvas, &scaled.to_rgba8(), x as i64, y as i64); + canvas + }; + let mut bgra = rgba.into_raw(); + for px in bgra.chunks_exact_mut(4) { + px.swap(0, 2); + } + bgra +} + +#[cfg(test)] +mod tests { + use super::*; + + + /// Full round-trip through the real OS encoder: synthetic frames in, + /// container bytes out, then ffprobe (when installed) verifies the + /// frame count and that the stream decodes. + #[test] + fn encodes_frames_into_playable_mp4() { + let mut recorder = ClipRecorder::new(640, 360, 3000).expect("encoder init"); + for i in 0u32..5 { + let mut img = + image::RgbaImage::from_pixel(640, 360, image::Rgba([20, 20, 40, 255])); + // Moving block so inter frames aren't empty. + for x in 0..80 { + for y in 0..80 { + img.put_pixel(x + i * 60, y + 40, image::Rgba([220, 90, 40, 255])); + } + } + recorder + .push_frame(&DynamicImage::ImageRgba8(img)) + .expect("push frame"); + } + let clip = recorder.finish().expect("finish clip"); + + assert_eq!(clip.frame_count, 5); + assert_eq!(clip.width, 640); + assert_eq!(clip.height, 360); + assert!(clip.mp4.len() > 500, "suspiciously small mp4: {}B", clip.mp4.len()); + assert_eq!(&clip.mp4[4..8], b"ftyp", "not an MP4 container"); + + // Deep verification when ffprobe is on the machine (dev boxes, CI). + let probe = std::process::Command::new("ffprobe").arg("-version").output(); + if probe.is_ok() { + let path = clip_temp_path(); + std::fs::write(&path, &clip.mp4).unwrap(); + let out = std::process::Command::new("ffprobe") + .args([ + "-v", "error", + "-count_packets", + "-select_streams", "v:0", + "-show_entries", "stream=nb_read_packets,codec_name", + "-of", "csv=p=0", + ]) + .arg(&path) + .output() + .expect("ffprobe run"); + let _ = std::fs::remove_file(&path); + let stdout = String::from_utf8_lossy(&out.stdout); + assert!( + stdout.contains("h264"), + "expected h264 stream, got: {stdout}" + ); + assert!( + stdout.trim().ends_with(",5"), + "expected 5 packets, got: {stdout}" + ); + + // GOP shape: exactly ONE keyframe. Frames are seconds apart in + // media time, so a default max-keyframe-interval-duration makes + // the encoder emit ALL-keyframe clips — which rations the + // bitrate budget across 20 I-frames and produces uniformly soft + // output (~20KB/frame). One IDR + cheap P-frames is the shape + // that lets the keyframe stay crisp. + let path2 = clip_temp_path(); + std::fs::write(&path2, &clip.mp4).unwrap(); + let frames_out = std::process::Command::new("ffprobe") + .args([ + "-v", "error", + "-select_streams", "v:0", + "-show_entries", "frame=key_frame", + "-of", "csv=p=0", + ]) + .arg(&path2) + .output() + .expect("ffprobe frames run"); + let _ = std::fs::remove_file(&path2); + let keyframes = String::from_utf8_lossy(&frames_out.stdout) + .lines() + .filter(|l| l.trim_end_matches(',') == "1") + .count(); + assert_eq!( + keyframes, 1, + "expected exactly 1 keyframe in the clip, got {keyframes}" + ); + } else { + eprintln!("ffprobe not found — container-level checks only"); + } + } + + /// The bitrate must buy the same bytes per FRAME at any cadence — that + /// invariant is the whole reason it's derived instead of hardcoded. + #[test] + fn bitrate_holds_per_frame_quality_across_cadences() { + for interval_ms in [2_000u64, 4_000, 12_000, 30_000] { + let bytes_per_frame = + (clip_bits_per_second(interval_ms) as u64 * interval_ms) / (8 * 1000); + let drift = bytes_per_frame.abs_diff(CLIP_FRAME_BYTE_BUDGET); + assert!( + drift <= CLIP_FRAME_BYTE_BUDGET / 100, + "at {interval_ms}ms a frame gets {bytes_per_frame}B, want ~{CLIP_FRAME_BYTE_BUDGET}B" + ); + } + + // The 4s cadence is the one that was measured and tuned by hand at + // 800 kbps. Reproducing it exactly is what makes the formula + // trustworthy at every other cadence. + assert_eq!(clip_bits_per_second(4_000), 800_000); + + // And a whole clip has to stay clear of the server's 8 MB limit at + // the cadence actually shipping. + let frames_per_clip = 60_000 / 10_000; + assert!( + frames_per_clip * CLIP_FRAME_BYTE_BUDGET < 8 * 1024 * 1024, + "nominal clip exceeds MAX_CLIP_BYTES" + ); + } + + /// Resident-set size of this process, in KB, via `ps`. Crude on purpose — + /// good enough to tell a leak from steady state, and needs no dependency. + #[cfg(test)] + fn rss_kb() -> u64 { + let out = std::process::Command::new("ps") + .args(["-o", "rss=", "-p"]) + .arg(std::process::id().to_string()) + .output() + .expect("ps"); + String::from_utf8_lossy(&out.stdout).trim().parse().unwrap_or(0) + } + + /// Leak check for the encode cycle: many recorders, many frames each, all + /// finished properly. The capture loop runs one of these per minute for as + /// long as a session lasts (up to 12 hours = 720 cycles), so a per-cycle + /// leak in the CVPixelBuffer / AVAssetWriter handling would accumulate into + /// something a user notices. + /// + /// Ignored by default: it's a few seconds of real encoding and it shells + /// out to `ps`. Run with `cargo test --release -- --ignored leak`. + #[test] + #[ignore = "stress test — run explicitly"] + fn encode_cycle_does_not_leak() { + let frame = |i: u32| { + let mut img = image::RgbaImage::from_pixel(1280, 720, image::Rgba([30, 30, 40, 255])); + for x in 0..120u32 { + for y in 0..120u32 { + img.put_pixel((x + i * 37) % 1280, (y + i * 11) % 720, + image::Rgba([200, 80, 40, 255])); + } + } + DynamicImage::ImageRgba8(img) + }; + + // Warm up so one-time allocations (framework init, codec tables) don't + // read as growth. + for _ in 0..3 { + let mut r = ClipRecorder::new(1280, 720, 10_000).expect("init"); + for i in 0..7 { r.push_frame(&frame(i)).expect("push"); } + r.finish().expect("finish"); + } + + let before = rss_kb(); + let cycles: u32 = std::env::var("LEAK_CYCLES") + .ok() + .and_then(|v| v.parse().ok()) + .unwrap_or(40); + for c in 0..cycles { + let mut r = ClipRecorder::new(1280, 720, 10_000).expect("init"); + for i in 0..7 { r.push_frame(&frame(c * 7 + i)).expect("push"); } + let clip = r.finish().expect("finish"); + assert!(!clip.mp4.is_empty()); + } + let after = rss_kb(); + + let growth = after.saturating_sub(before); + eprintln!( + "RSS {before} -> {after} KB over {cycles} encode cycles ({} KB/cycle)", + growth / u64::from(cycles) + ); + // A genuine per-cycle leak of a 1280x720 BGRA buffer would be ~3.6MB + // each, i.e. ~144MB over this run. Allow generous headroom for + // allocator behaviour and VideoToolbox's own caches while still + // catching anything of that order. + // Scale the budget with the run so a deeper LEAK_CYCLES run stays a + // real assertion rather than a formality. + let budget = 20_000 + 500 * u64::from(cycles); + assert!( + growth < budget, + "RSS grew {growth} KB over {cycles} cycles (budget {budget}) — suspected leak" + ); + } + + /// Discarding a recorder mid-clip must release just as cleanly as + /// finishing one. This is the pause/stop path, and on Windows it is also + /// the path that has to balance MFStartup. + #[test] + #[ignore = "stress test — run explicitly"] + fn discard_path_does_not_leak() { + let img = || { + DynamicImage::ImageRgba8(image::RgbaImage::from_pixel( + 1280, 720, image::Rgba([10, 20, 30, 255]), + )) + }; + for _ in 0..3 { + let mut r = ClipRecorder::new(1280, 720, 10_000).expect("init"); + r.push_frame(&img()).expect("push"); + r.discard(); + } + let before = rss_kb(); + let cycles: u32 = std::env::var("LEAK_CYCLES") + .ok() + .and_then(|v| v.parse().ok()) + .unwrap_or(40); + for _ in 0..cycles { + let mut r = ClipRecorder::new(1280, 720, 10_000).expect("init"); + for _ in 0..4 { r.push_frame(&img()).expect("push"); } + r.discard(); + } + let growth = rss_kb().saturating_sub(before); + eprintln!("RSS growth over {cycles} discard cycles: {growth} KB"); + let budget = 20_000 + 500 * u64::from(cycles); + assert!( + growth < budget, + "RSS grew {growth} KB over {cycles} cycles (budget {budget}) — suspected leak on discard" + ); + } + + /// Encode cost at real capture resolution. The capture loop does this on a + /// tokio worker while the user works, so the number that matters is CPU per + /// captured frame — at 6 frames/min a millisecond here is nothing, but a + /// regression into hundreds would be felt on an old laptop. + /// + /// Run with `cargo test --release -- --ignored perf --nocapture`. + #[test] + #[ignore = "benchmark — run explicitly"] + fn encode_cost_at_1080p() { + let frame = |i: u32| { + // Dense detail so the encoder can't cheat: this is the worst case, + // a screen full of text and edges. + let mut img = image::RgbaImage::new(1920, 1080); + for (x, y, px) in img.enumerate_pixels_mut() { + let v = ((x * 7 + y * 13 + i * 29) % 256) as u8; + *px = image::Rgba([v, v.wrapping_mul(3), v.wrapping_add(90), 255]); + } + DynamicImage::ImageRgba8(img) + }; + let frames: Vec<_> = (0..7).map(frame).collect(); + + // Warm up the codec. + { + let mut r = ClipRecorder::new(1920, 1080, 10_000).expect("init"); + for f in &frames { r.push_frame(f).expect("push"); } + r.finish().expect("finish"); + } + + const CLIPS: u32 = 10; + let t0 = std::time::Instant::now(); + let mut bytes = 0usize; + for _ in 0..CLIPS { + let mut r = ClipRecorder::new(1920, 1080, 10_000).expect("init"); + for f in &frames { r.push_frame(f).expect("push"); } + bytes += r.finish().expect("finish").mp4.len(); + } + let per_clip = t0.elapsed().as_secs_f64() * 1000.0 / f64::from(CLIPS); + let per_frame = per_clip / frames.len() as f64; + eprintln!( + "1080p worst-case: {per_clip:.1} ms/clip, {per_frame:.1} ms/frame, \ +{} KB/clip avg", + bytes / CLIPS as usize / 1024 + ); + + // One clip a minute: even 2s/clip would be 3% of a core. This ceiling + // is loose on purpose — it exists to catch an order-of-magnitude + // regression, not to police jitter on a shared CI box. + assert!(per_clip < 2_000.0, "encode cost regressed: {per_clip:.0} ms/clip"); + } + + /// Temp files must not accumulate. Each clip writes a container to the OS + /// temp dir and must remove it on every exit path; a session leaking one a + /// minute would fill a small disk. + /// + /// Asserts on each recorder's OWN path rather than scanning the temp + /// directory: that directory is shared by every test in the process, so a + /// count-based check reports another test's in-flight file as a leak. (It + /// did exactly that in CI.) + #[test] + fn clip_temp_files_are_always_removed() { + let img = DynamicImage::ImageRgba8(image::RgbaImage::from_pixel( + 320, 240, image::Rgba([1, 2, 3, 255]), + )); + + // finish(): the happy path. + let mut r = ClipRecorder::new(320, 240, 10_000).expect("init"); + let finished = r.temp_path(); + r.push_frame(&img).expect("push"); + r.finish().expect("finish"); + assert!(!finished.exists(), "finish() left {finished:?}"); + + // discard(): pause/stop mid-clip. + let mut r = ClipRecorder::new(320, 240, 10_000).expect("init"); + let discarded = r.temp_path(); + r.push_frame(&img).expect("push"); + r.discard(); + assert!(!discarded.exists(), "discard() left {discarded:?}"); + + // finish() on a clip with no frames: returns Err, and must STILL clean + // up. This is the path a paused-immediately session takes. + let r = ClipRecorder::new(320, 240, 10_000).expect("init"); + let errored = r.temp_path(); + assert!(r.finish().is_err()); + assert!(!errored.exists(), "failed finish() left {errored:?}"); + } + + /// A recorder with zero frames must fail, not produce an empty clip. + #[test] + fn empty_clip_errors() { + let recorder = ClipRecorder::new(640, 360, 3000).expect("encoder init"); + assert!(recorder.finish().is_err()); + } + + /// Mid-clip resolution changes are normalized to the clip's dimensions. + #[test] + fn resized_frames_are_normalized() { + let mut recorder = ClipRecorder::new(640, 360, 3000).expect("encoder init"); + let small = image::RgbaImage::from_pixel(320, 200, image::Rgba([255, 0, 0, 255])); + recorder + .push_frame(&DynamicImage::ImageRgba8(small)) + .expect("push mismatched frame"); + let big = image::RgbaImage::from_pixel(1920, 1080, image::Rgba([0, 255, 0, 255])); + recorder + .push_frame(&DynamicImage::ImageRgba8(big)) + .expect("push mismatched frame"); + let clip = recorder.finish().expect("finish clip"); + assert_eq!(clip.frame_count, 2); + assert_eq!((clip.width, clip.height), (640, 360)); + } +} + +// ── macOS: AVAssetWriter (VideoToolbox) ───────────────────────────── + +#[cfg(target_os = "macos")] +mod platform { + use std::path::Path; + use std::ptr::NonNull; + + use block2::RcBlock; + use objc2::rc::{autoreleasepool, Retained}; + use objc2::runtime::{AnyObject, ProtocolObject}; + use objc2_av_foundation::{ + AVAssetWriter, AVAssetWriterInput, AVAssetWriterInputPixelBufferAdaptor, + AVAssetWriterStatus, AVFileTypeMPEG4, AVMediaTypeVideo, + AVVideoAllowFrameReorderingKey, AVVideoAverageBitRateKey, AVVideoCodecKey, + AVVideoCodecTypeH264, AVVideoCompressionPropertiesKey, + AVVideoExpectedSourceFrameRateKey, AVVideoHeightKey, + AVVideoMaxKeyFrameIntervalKey, AVVideoWidthKey, + }; + use objc2_core_media::CMTime; + use objc2_core_video::{ + kCVPixelFormatType_32BGRA, CVPixelBuffer, CVPixelBufferCreate, + CVPixelBufferGetBaseAddress, CVPixelBufferGetBytesPerRow, CVPixelBufferLockBaseAddress, + CVPixelBufferLockFlags, CVPixelBufferUnlockBaseAddress, + }; + use objc2_foundation::{NSMutableDictionary, NSNumber, NSString, NSURL}; + + fn ms_time(ms: u64) -> CMTime { + unsafe { CMTime::new(ms as i64, 1000) } + } + + pub struct Encoder { + writer: Retained, + input: Retained, + adaptor: Retained, + started: bool, + } + + // AVAssetWriter and friends are documented thread-safe for this usage + // pattern (single writer thread); the recorder is driven from one loop. + unsafe impl Send for Encoder {} + + impl Encoder { + pub fn new( + path: &Path, + width: u32, + height: u32, + bitrate: u32, + frame_interval_ms: u64, + ) -> Result { + // Every Cocoa object built below is autoreleased, and the capture + // loop calls this from a tokio worker thread — which, unlike the + // main run loop, never drains a pool. Without an explicit one the + // settings dictionaries and the writer itself accumulate for the + // life of the process: measured ~4.9 MB per clip, i.e. GBs over a + // long session. + autoreleasepool(|_| unsafe { + let url = NSURL::fileURLWithPath(&NSString::from_str( + path.to_str().ok_or("non-utf8 temp path")?, + )); + + // Weak-linked framework constants come through as Options. + let file_type = AVFileTypeMPEG4.ok_or("AVFileTypeMPEG4 unavailable")?; + let media_video = AVMediaTypeVideo.ok_or("AVMediaTypeVideo unavailable")?; + let codec_h264 = AVVideoCodecTypeH264.ok_or("AVVideoCodecTypeH264 unavailable")?; + let key_codec = AVVideoCodecKey.ok_or("AVVideoCodecKey unavailable")?; + let key_width = AVVideoWidthKey.ok_or("AVVideoWidthKey unavailable")?; + let key_height = AVVideoHeightKey.ok_or("AVVideoHeightKey unavailable")?; + let key_compression = + AVVideoCompressionPropertiesKey.ok_or("AVVideoCompressionPropertiesKey unavailable")?; + let key_bitrate = + AVVideoAverageBitRateKey.ok_or("AVVideoAverageBitRateKey unavailable")?; + let key_max_kf = + AVVideoMaxKeyFrameIntervalKey.ok_or("AVVideoMaxKeyFrameIntervalKey unavailable")?; + let key_reorder = AVVideoAllowFrameReorderingKey + .ok_or("AVVideoAllowFrameReorderingKey unavailable")?; + let key_expected_fps = AVVideoExpectedSourceFrameRateKey + .ok_or("AVVideoExpectedSourceFrameRateKey unavailable")?; + + let writer = AVAssetWriter::assetWriterWithURL_fileType_error(&url, file_type) + .map_err(|e| format!("AVAssetWriter init failed: {e}"))?; + + // {AVVideoCodecKey: h264, AVVideoWidthKey, AVVideoHeightKey, + // AVVideoCompressionPropertiesKey: {AVVideoAverageBitRateKey}} + let compression: Retained> = + NSMutableDictionary::new(); + compression.setObject_forKey( + NSNumber::new_u32(bitrate).as_ref(), + ProtocolObject::from_ref(key_bitrate), + ); + // One IDR per clip: frames sit seconds apart in media time, + // so any keyframe-interval default expressed in seconds + // would turn the whole clip into rationed I-frames — + // uniformly soft. One crisp keyframe + cheap P-frames is + // the intended shape. + compression.setObject_forKey( + NSNumber::new_u32(1200).as_ref(), + ProtocolObject::from_ref(key_max_kf), + ); + // No B-frames: pointless at this cadence and they add + // reorder latency/complexity. + compression.setObject_forKey( + NSNumber::new_bool(false).as_ref(), + ProtocolObject::from_ref(key_reorder), + ); + // Rate-control hint: the source is ~1 frame/interval, not + // 30fps — lets the encoder budget bits per frame correctly. + // The key is integer fps, so any interval at or above one + // second floors to 1; that's the honest answer and matches + // the measured behaviour (VideoToolbox budgets against the + // real presentation timestamps we hand it, which is why + // `bitrate` is derived from the cadence rather than fixed). + let expected_fps = (1000 / frame_interval_ms.max(1)).max(1) as u32; + compression.setObject_forKey( + NSNumber::new_u32(expected_fps).as_ref(), + ProtocolObject::from_ref(key_expected_fps), + ); + + let settings: Retained> = + NSMutableDictionary::new(); + settings.setObject_forKey( + codec_h264.as_ref(), + ProtocolObject::from_ref(key_codec), + ); + settings.setObject_forKey( + NSNumber::new_u32(width).as_ref(), + ProtocolObject::from_ref(key_width), + ); + settings.setObject_forKey( + NSNumber::new_u32(height).as_ref(), + ProtocolObject::from_ref(key_height), + ); + settings.setObject_forKey( + compression.as_ref(), + ProtocolObject::from_ref(key_compression), + ); + + let input = AVAssetWriterInput::assetWriterInputWithMediaType_outputSettings( + media_video, + Some(&*settings), + ); + // Live source: encode as frames arrive instead of buffering. + input.setExpectsMediaDataInRealTime(true); + + if !writer.canAddInput(&input) { + return Err("AVAssetWriter rejected video input".into()); + } + writer.addInput(&input); + + let adaptor = AVAssetWriterInputPixelBufferAdaptor::assetWriterInputPixelBufferAdaptorWithAssetWriterInput_sourcePixelBufferAttributes(&input, None); + + Ok(Self { + writer, + input, + adaptor, + started: false, + }) + }) + } + + pub fn append_bgra_frame( + &mut self, + bgra: &[u8], + width: u32, + height: u32, + pts_ms: u64, + ) -> Result<(), String> { + // Per-frame pool: this is the hottest of the three entry points, + // and CVPixelBufferCreate's buffer is only one of several objects + // the frameworks autorelease on the way through. + autoreleasepool(|_| unsafe { + if !self.started { + if !self.writer.startWriting() { + return Err(format!( + "AVAssetWriter startWriting failed: {:?}", + self.writer.error() + )); + } + self.writer.startSessionAtSourceTime(ms_time(0)); + self.started = true; + } + + // Wait (bounded) for the encoder to drain. With realtime + // input and 3s between frames this is virtually always + // immediate. + let mut waited_ms = 0u64; + while !self.input.isReadyForMoreMediaData() { + if waited_ms > 2_000 { + return Err("encoder not ready after 2s".into()); + } + std::thread::sleep(std::time::Duration::from_millis(10)); + waited_ms += 10; + } + + // BGRA CVPixelBuffer, row-by-row copy (CV row stride may + // exceed width*4). + let mut pb_out: *mut CVPixelBuffer = std::ptr::null_mut(); + let ret = CVPixelBufferCreate( + None, + width as usize, + height as usize, + kCVPixelFormatType_32BGRA, + None, + NonNull::from(&mut pb_out), + ); + if ret != 0 || pb_out.is_null() { + return Err(format!("CVPixelBufferCreate failed: {ret}")); + } + // Take ownership so the buffer is released on all paths. + let pb = Retained::from_raw(pb_out).ok_or("null pixel buffer")?; + + CVPixelBufferLockBaseAddress(&pb, CVPixelBufferLockFlags::empty()); + let base = CVPixelBufferGetBaseAddress(&pb) as *mut u8; + let dst_stride = CVPixelBufferGetBytesPerRow(&pb); + let src_stride = (width * 4) as usize; + for row in 0..height as usize { + std::ptr::copy_nonoverlapping( + bgra.as_ptr().add(row * src_stride), + base.add(row * dst_stride), + src_stride, + ); + } + CVPixelBufferUnlockBaseAddress(&pb, CVPixelBufferLockFlags::empty()); + + if !self + .adaptor + .appendPixelBuffer_withPresentationTime(&pb, ms_time(pts_ms)) + { + return Err(format!( + "appendPixelBuffer failed: {:?}", + self.writer.error() + )); + } + Ok(()) + }) + } + + pub fn finish(self, duration_ms: u64) -> Result<(), String> { + autoreleasepool(|_| unsafe { + if !self.started { + // Nothing was written; cancel to avoid a zero-byte file + // error from finishWriting. + self.writer.cancelWriting(); + return Err("no frames written".into()); + } + self.input.markAsFinished(); + self.writer.endSessionAtSourceTime(ms_time(duration_ms)); + + let (tx, rx) = std::sync::mpsc::channel::<()>(); + let block = RcBlock::new(move || { + let _ = tx.send(()); + }); + self.writer.finishWritingWithCompletionHandler(&block); + rx.recv_timeout(std::time::Duration::from_secs(15)) + .map_err(|_| "finishWriting timed out".to_string())?; + + if self.writer.status() != AVAssetWriterStatus::Completed { + return Err(format!( + "AVAssetWriter finished with status {:?}: {:?}", + self.writer.status(), + self.writer.error() + )); + } + Ok(()) + }) + } + } +} + +// ── Windows: Media Foundation sink writer ─────────────────────────── + +#[cfg(target_os = "windows")] +mod platform { + use std::path::Path; + + use windows::core::HSTRING; + use windows::Win32::Media::MediaFoundation::{ + IMFMediaType, IMFSample, IMFSinkWriter, MFCreateMediaType, MFCreateMemoryBuffer, + MFCreateSample, MFCreateSinkWriterFromURL, MFShutdown, MFStartup, MFSTARTUP_FULL, + MFVideoFormat_H264, MFVideoFormat_RGB32, MFVideoInterlace_Progressive, + MF_MT_AVG_BITRATE, MF_MT_FRAME_RATE, MF_MT_FRAME_SIZE, MF_MT_INTERLACE_MODE, + MF_MT_MAJOR_TYPE, MF_MT_MAX_KEYFRAME_SPACING, MF_MT_SUBTYPE, MF_VERSION, + MFMediaType_Video, + }; + use windows::Win32::System::Com::{CoInitializeEx, COINIT_MULTITHREADED}; + + /// Media Foundation needs COM initialized on the calling thread, and the + /// capture loop's calls land on tokio worker threads that never did so. + /// Refcounted and idempotent per thread; S_FALSE (already initialized) + /// and RPC_E_CHANGED_MODE (thread is STA) are both fine for the sink + /// writer, so the result is deliberately ignored. Called at the top of + /// every encoder entry point because consecutive async calls may run on + /// different pool threads. + fn ensure_com() { + unsafe { + let _ = CoInitializeEx(None, COINIT_MULTITHREADED); + } + } + + /// Pack two u32s into the u64 layout MF uses for SIZE/RATIO attributes. + fn pack_u64(hi: u32, lo: u32) -> u64 { + ((hi as u64) << 32) | lo as u64 + } + + /// Balances one `MFStartup` on drop. + /// + /// MFStartup/MFShutdown are refcounted, and encoder construction has a + /// dozen fallible steps after the startup call. Every one of those early + /// returns used to leak a refcount — invisible on a healthy machine + /// (init succeeds, finish() balances it), unbounded on one whose encoder + /// always fails, because the capture loop retries init on every frame + /// for the length of the session. `std::mem::forget` on the success path + /// hands the refcount to the encoder instead. + struct MfStartupGuard; + + impl Drop for MfStartupGuard { + fn drop(&mut self) { + unsafe { + let _ = MFShutdown(); + } + } + } + + pub struct Encoder { + writer: IMFSinkWriter, + stream_index: u32, + width: u32, + height: u32, + frame_interval_ms: u64, + } + + // Single-threaded use from the capture loop. + unsafe impl Send for Encoder {} + + impl Encoder { + /// Two attempts, in order of correctness: + /// + /// 1. Declare the REAL sub-1fps cadence as a ratio (1000 : + /// interval_ms) and hand over the per-media-second bitrate. Both + /// readings of `MF_MT_AVG_BITRATE` — bits per second of media + /// time, or bits per declared frame — then agree on the same + /// ~CLIP_FRAME_BYTE_BUDGET per frame. + /// 2. If the MFT refuses that media type (some hardware encoders + /// reject fractional frame rates outright), fall back to the + /// 1 fps hint this code shipped with, and scale the bitrate to + /// match so the per-frame budget is preserved rather than + /// silently divided by the interval. + /// + /// Only if BOTH fail does the caller fall back to a JPEG for the + /// interval. Windows per-frame output has not been measured on real + /// hardware the way the macOS path has; if clips come back soft or + /// oversize, this pair of attempts is where to look first. + pub fn new( + path: &Path, + width: u32, + height: u32, + bitrate: u32, + frame_interval_ms: u64, + ) -> Result { + let interval_ms = frame_interval_ms.max(1); + match Self::try_new(path, width, height, bitrate, interval_ms, 1000, interval_ms as u32) + { + Ok(enc) => Ok(enc), + Err(real_cadence_err) => { + let per_frame_bitrate = ((bitrate as u64 * interval_ms) / 1000) + .min(u32::MAX as u64) as u32; + eprintln!( + "[clips] Media Foundation rejected the {interval_ms}ms cadence \ + ({real_cadence_err}) — retrying at a 1fps hint" + ); + Self::try_new(path, width, height, per_frame_bitrate, interval_ms, 1, 1) + } + } + } + + fn try_new( + path: &Path, + width: u32, + height: u32, + bitrate: u32, + frame_interval_ms: u64, + frame_rate_num: u32, + frame_rate_den: u32, + ) -> Result { + ensure_com(); + unsafe { + // Idempotent per-process init (returns S_OK on repeat calls). + MFStartup(MF_VERSION, MFSTARTUP_FULL) + .map_err(|e| format!("MFStartup failed: {e}"))?; + + // From here on every early return must balance that startup. + // Without this the refcount leaked once per failed init — + // and a machine whose encoder always fails attempts one per + // frame, for the length of the session. + let guard = MfStartupGuard; + + let writer: IMFSinkWriter = MFCreateSinkWriterFromURL( + &HSTRING::from(path.to_string_lossy().as_ref()), + None, + None, + ) + .map_err(|e| format!("MFCreateSinkWriterFromURL failed: {e}"))?; + + // Output: H.264 at the clip bitrate. Frame timing is also + // carried per-sample; the rate attribute seeds the encoder's + // rate control, so it and `bitrate` have to agree about what + // a "second" means (see `new`). + let out_type: IMFMediaType = + MFCreateMediaType().map_err(|e| format!("MFCreateMediaType: {e}"))?; + out_type + .SetGUID(&MF_MT_MAJOR_TYPE, &MFMediaType_Video) + .map_err(|e| e.to_string())?; + out_type + .SetGUID(&MF_MT_SUBTYPE, &MFVideoFormat_H264) + .map_err(|e| e.to_string())?; + out_type + .SetUINT32(&MF_MT_AVG_BITRATE, bitrate) + .map_err(|e| e.to_string())?; + out_type + .SetUINT32(&MF_MT_INTERLACE_MODE, MFVideoInterlace_Progressive.0 as u32) + .map_err(|e| e.to_string())?; + out_type + .SetUINT64(&MF_MT_FRAME_SIZE, pack_u64(width, height)) + .map_err(|e| e.to_string())?; + out_type + .SetUINT64( + &MF_MT_FRAME_RATE, + pack_u64(frame_rate_num, frame_rate_den), + ) + .map_err(|e| e.to_string())?; + // One IDR per clip (see the macOS encoder for rationale). + out_type + .SetUINT32(&MF_MT_MAX_KEYFRAME_SPACING, 10_000) + .map_err(|e| e.to_string())?; + let stream_index = writer + .AddStream(&out_type) + .map_err(|e| format!("AddStream failed: {e}"))?; + + // Input: BGRA (MF calls it RGB32); the sink writer inserts + // the color converter to the encoder's NV12 automatically. + let in_type: IMFMediaType = + MFCreateMediaType().map_err(|e| format!("MFCreateMediaType: {e}"))?; + in_type + .SetGUID(&MF_MT_MAJOR_TYPE, &MFMediaType_Video) + .map_err(|e| e.to_string())?; + in_type + .SetGUID(&MF_MT_SUBTYPE, &MFVideoFormat_RGB32) + .map_err(|e| e.to_string())?; + in_type + .SetUINT32(&MF_MT_INTERLACE_MODE, MFVideoInterlace_Progressive.0 as u32) + .map_err(|e| e.to_string())?; + in_type + .SetUINT64(&MF_MT_FRAME_SIZE, pack_u64(width, height)) + .map_err(|e| e.to_string())?; + in_type + .SetUINT64( + &MF_MT_FRAME_RATE, + pack_u64(frame_rate_num, frame_rate_den), + ) + .map_err(|e| e.to_string())?; + writer + .SetInputMediaType(stream_index, &in_type, None) + .map_err(|e| format!("SetInputMediaType failed: {e}"))?; + + writer + .BeginWriting() + .map_err(|e| format!("BeginWriting failed: {e}"))?; + + // Success: ownership of the MFStartup refcount passes to the + // encoder, which balances it in finish(). + std::mem::forget(guard); + Ok(Self { + writer, + stream_index, + width, + height, + frame_interval_ms, + }) + } + } + + pub fn append_bgra_frame( + &mut self, + bgra: &[u8], + width: u32, + height: u32, + pts_ms: u64, + ) -> Result<(), String> { + debug_assert_eq!((width, height), (self.width, self.height)); + ensure_com(); + unsafe { + let byte_len = (width * height * 4) as u32; + let buffer = MFCreateMemoryBuffer(byte_len) + .map_err(|e| format!("MFCreateMemoryBuffer: {e}"))?; + + let mut data_ptr: *mut u8 = std::ptr::null_mut(); + buffer + .Lock(&mut data_ptr, None, None) + .map_err(|e| e.to_string())?; + // MF RGB32 with positive stride is bottom-up; flip rows so + // the frame is right side up. + let stride = (width * 4) as usize; + for row in 0..height as usize { + let src = bgra.as_ptr().add(row * stride); + let dst = data_ptr.add((height as usize - 1 - row) * stride); + std::ptr::copy_nonoverlapping(src, dst, stride); + } + buffer.Unlock().map_err(|e| e.to_string())?; + buffer + .SetCurrentLength(byte_len) + .map_err(|e| e.to_string())?; + + let sample: IMFSample = + MFCreateSample().map_err(|e| format!("MFCreateSample: {e}"))?; + sample.AddBuffer(&buffer).map_err(|e| e.to_string())?; + // MF time units: 100ns. + sample + .SetSampleTime((pts_ms * 10_000) as i64) + .map_err(|e| e.to_string())?; + // Duration must be the REAL frame interval. This was pinned + // at 3000ms — correct only for a cadence the app no longer + // uses — which left every sample claiming a span that + // disagreed with its own presentation timestamps. + sample + .SetSampleDuration((self.frame_interval_ms * 10_000) as i64) + .map_err(|e| e.to_string())?; + + self.writer + .WriteSample(self.stream_index, &sample) + .map_err(|e| format!("WriteSample failed: {e}")) + } + } + + pub fn finish(self, _duration_ms: u64) -> Result<(), String> { + ensure_com(); + unsafe { + let result = self + .writer + .Finalize() + .map_err(|e| format!("sink writer Finalize failed: {e}")); + // Balance MFStartup from new() on BOTH paths — an early + // return here would leak a startup refcount per failed clip. + let _ = MFShutdown(); + result + } + } + } +} + +// ── Linux: GStreamer (already a dependency for PipeWire capture) ──── + +#[cfg(target_os = "linux")] +mod platform { + use std::path::Path; + + use gstreamer as gst; + use gstreamer::prelude::*; + use gstreamer_app::AppSrc; + + /// Encoders in preference order: VA-API hardware first, then software + /// fallbacks. Availability differs per distro/GPU; first that exists + /// wins. + /// + /// PACKAGING: none of these are guaranteed present, and they live in + /// different packages from the ones the pipeline's other elements need. + /// The full Linux runtime set, declared in tauri.conf.json's + /// `bundle.linux`: + /// + /// pipewiresrc gstreamer1.0-pipewire (capture — see pipewire.rs) + /// videoconvert gstreamer1.0-plugins-base (capture + clips) + /// mp4mux gstreamer1.0-plugins-good + /// h264parse gstreamer1.0-plugins-bad + /// x264enc gstreamer1.0-plugins-ugly (Recommends, not Depends) + /// + /// The encoder is deliberately a soft dependency: -ugly carries GPL x264, + /// and gstreamer1-plugins-ugly isn't in Fedora proper at all, so a hard + /// dependency would either impose that licence or make the package + /// uninstallable. Missing it is survivable — `ClipRecorder::new` fails, + /// the interval falls back to a JPEG, and after MAX_CLIP_ENCODER_FAILURES + /// the loop stops trying. A user with no encoder gets the legacy + /// one-frame-per-minute recording rather than a broken app. + const ENCODER_CANDIDATES: &[&str] = &["vah264enc", "vaapih264enc", "x264enc", "openh264enc"]; + + pub struct Encoder { + pipeline: gst::Pipeline, + appsrc: AppSrc, + width: u32, + height: u32, + } + + unsafe impl Send for Encoder {} + + impl Encoder { + /// `frame_interval_ms` is unused here: the pipeline declares + /// `framerate=0/1` (variable) and carries timing per-buffer, and the + /// bitrate the caller passes is already scaled for the cadence. + pub fn new( + path: &Path, + width: u32, + height: u32, + bitrate: u32, + _frame_interval_ms: u64, + ) -> Result { + gst::init().map_err(|e| format!("gst init failed: {e}"))?; + + let encoder_name = ENCODER_CANDIDATES + .iter() + .find(|name| gst::ElementFactory::find(name).is_some()) + .ok_or("no H.264 encoder element available")?; + + // x264enc wants kbit/s; the VA encoders take kbps too; + // openh264enc uses bps. x264enc's default `medium` preset burns + // 5-10x the CPU this job needs — superfast + zerolatency keeps + // the software fallback cheap at screen-recording quality. + let encoder_props = match *encoder_name { + "openh264enc" => format!("bitrate={bitrate}"), + "x264enc" => format!( + "bitrate={} speed-preset=superfast tune=zerolatency", + (bitrate / 1000).max(1) + ), + _ => format!("bitrate={}", (bitrate / 1000).max(1)), + }; + + let desc = format!( + "appsrc name=src is-live=false format=time \ + caps=video/x-raw,format=BGRA,width={width},height={height},framerate=0/1 \ + ! videoconvert ! {encoder_name} {encoder_props} \ + ! h264parse ! mp4mux ! filesink location=\"{}\"", + path.to_string_lossy() + ); + let pipeline = gst::parse::launch(&desc) + .map_err(|e| format!("gst pipeline parse failed: {e}"))? + .downcast::() + .map_err(|_| "not a pipeline".to_string())?; + + let appsrc = pipeline + .by_name("src") + .ok_or("appsrc missing")? + .downcast::() + .map_err(|_| "appsrc cast failed".to_string())?; + + pipeline + .set_state(gst::State::Playing) + .map_err(|e| format!("gst set_state failed: {e}"))?; + + Ok(Self { + pipeline, + appsrc, + width, + height, + }) + } + + pub fn append_bgra_frame( + &mut self, + bgra: &[u8], + width: u32, + height: u32, + pts_ms: u64, + ) -> Result<(), String> { + debug_assert_eq!((width, height), (self.width, self.height)); + let mut buffer = gst::Buffer::with_size(bgra.len()) + .map_err(|e| format!("gst buffer alloc failed: {e}"))?; + { + let buffer_mut = buffer.get_mut().ok_or("buffer not writable")?; + buffer_mut.set_pts(gst::ClockTime::from_mseconds(pts_ms)); + let mut map = buffer_mut + .map_writable() + .map_err(|e| format!("gst buffer map failed: {e}"))?; + map.copy_from_slice(bgra); + } + self.appsrc + .push_buffer(buffer) + .map_err(|e| format!("gst push_buffer failed: {e}"))?; + Ok(()) + } + + pub fn finish(self, _duration_ms: u64) -> Result<(), String> { + self.appsrc + .end_of_stream() + .map_err(|e| format!("gst EOS failed: {e}"))?; + // Wait for the muxer to flush the moov atom. + let bus = self.pipeline.bus().ok_or("no gst bus")?; + let result = (|| { + for msg in bus.iter_timed(gst::ClockTime::from_seconds(15)) { + use gst::MessageView; + match msg.view() { + MessageView::Eos(_) => return Ok(()), + MessageView::Error(e) => { + return Err(format!("gst error: {}", e.error())); + } + _ => {} + } + } + Err("gst EOS timed out".to_string()) + })(); + let _ = self.pipeline.set_state(gst::State::Null); + result + } + } +} diff --git a/clients/desktop/src-tauri/src/lib.rs b/clients/desktop/src-tauri/src/lib.rs index 39d1986e..97e6909d 100644 --- a/clients/desktop/src-tauri/src/lib.rs +++ b/clients/desktop/src-tauri/src/lib.rs @@ -1,11 +1,65 @@ mod capture; +mod clips; mod crop; +mod native_menu; +#[cfg(target_os = "macos")] +mod native_tray; mod pipewire; mod screencast; mod tray; #[cfg(target_os = "windows")] mod windows_permissions; +/// Scoped App Nap / idle-system-sleep suppression (macOS). +/// +/// The assertion must be held while a session is recording (or paused +/// mid-session) so macOS never throttles the capture cadence or lets the +/// machine idle-sleep out from under an active recording. It must NOT be +/// held for the whole process lifetime — that kept the user's Mac from ever +/// idle-sleeping just because Lookout sat open on the gallery. +#[cfg(target_os = "macos")] +mod power { + use objc2::rc::Retained; + use objc2::runtime::{NSObjectProtocol, ProtocolObject}; + use objc2_foundation::{NSActivityOptions, NSProcessInfo, NSString}; + use std::sync::Mutex; + + struct ActivityToken(Retained>); + // SAFETY: the token is an opaque handle whose only use is being handed + // back to `NSProcessInfo::endActivity`, which is documented thread-safe. + unsafe impl Send for ActivityToken {} + + static ACTIVITY: Mutex> = Mutex::new(None); + + /// Begin the recording assertion. Idempotent — a second call while one + /// is already held is a no-op. + pub fn begin_recording_assertion() { + let mut guard = ACTIVITY.lock().unwrap_or_else(|e| e.into_inner()); + if guard.is_some() { + return; + } + let info = NSProcessInfo::processInfo(); + let reason = NSString::from_str("Periodic screenshot capture must not be throttled"); + let opts = + NSActivityOptions::LatencyCritical | NSActivityOptions::IdleSystemSleepDisabled; + *guard = Some(ActivityToken( + info.beginActivityWithOptions_reason(opts, &reason), + )); + eprintln!("[power] recording sleep/App Nap suppression ON"); + } + + /// End the recording assertion (no-op if none is held). + pub fn end_recording_assertion() { + let mut guard = ACTIVITY.lock().unwrap_or_else(|e| e.into_inner()); + if let Some(token) = guard.take() { + // SAFETY: `token.0` came from `beginActivityWithOptions_reason`, + // so it is the correct activity type. + unsafe { NSProcessInfo::processInfo().endActivity(&token.0) }; + eprintln!("[power] recording sleep/App Nap suppression OFF"); + } + } +} + #[cfg(target_os = "macos")] use objc2_core_foundation::{CFBoolean, CFDictionary, CFNumber, CFNumberType, CFString, CGRect}; #[cfg(target_os = "macos")] @@ -53,10 +107,20 @@ struct CaptureLoopHandle { /// Shared state for the Rust-side tray title timer. /// Uses atomics so the capture loop can update tracked_seconds /// without acquiring a mutex on every tick. +/// +/// This mirrors `useSessionTimerState` in @lookout/react. The menu bar, +/// the tray popup and the main window each tick their own clock (so a +/// throttled WebView can't stall the menu bar), which only works if all +/// three apply the *same* rules to the same anchor: ratchet the base +/// forward, cap interpolation at one capture interval, and drop the +/// interpolated remainder while paused. Diverge on any of those and the +/// menu bar visibly disagrees with the main window. struct TrayTimerState { /// Authoritative tracked seconds from the last server response. + /// Ratchets forward only — see `sync_tray_timer`. tracked_seconds: AtomicI64, - /// Wall-clock instant when tracking started (or last synced). + /// Wall-clock instant `tracked_seconds` last advanced (the + /// interpolation anchor). started_at: Mutex, /// Whether the timer is actively ticking (false = paused). is_running: AtomicBool, @@ -508,6 +572,11 @@ pub struct UploadUrlResponse { /// Sticky tracking mode for the session. Absent on pre-credit-mode servers. #[serde(rename = "trackingMode", default)] pub tracking_mode: Option, + /// GRANTED payload format — may differ from the requested one (the + /// server downgrades clip formats to "jpeg" on sessions without clips). + /// Absent on pre-clips servers. + #[serde(rename = "format", default)] + pub format: Option, } #[derive(Serialize, Deserialize)] @@ -560,57 +629,571 @@ fn get_blacklisted_apps(state: State<'_, AppState>) -> Result, Strin Ok(blacklist.clone()) } -/// List unique app names from all running windows (across all spaces). -/// Returns a sorted, deduplicated list of app names. -#[tauri::command] -fn list_running_apps() -> Vec { - #[cfg(target_os = "macos")] - { - let Some(entries) = CGWindowListCopyWindowInfo( - CGWindowListOption::OptionAll | CGWindowListOption::ExcludeDesktopElements, - 0, - ) else { - return Vec::new(); +/// One entry in the app list shown on the Filtered Apps page. +#[derive(Clone, Serialize)] +pub struct AppEntry { + pub name: String, + /// Platform-specific icon lookup key, passed back to `get_app_icon`: + /// macOS = .app bundle path, Windows = Start Menu .lnk path, + /// Linux = the .desktop entry's Icon= value. + pub path: Option, + /// Whether the app is currently running (used to sort open apps first). + pub running: bool, +} + +/// Read an app bundle's display name (CFBundleDisplayName, falling back to +/// CFBundleName). These are what `kCGWindowOwnerName` reports for the app's +/// windows, so blacklist entries created from this list match redaction. +#[cfg(target_os = "macos")] +fn bundle_display_name(path: &std::path::Path) -> Option { + use objc2_foundation::{NSBundle, NSString}; + + let ns_path = NSString::from_str(path.to_str()?); + let bundle = NSBundle::bundleWithPath(&ns_path)?; + for key in ["CFBundleDisplayName", "CFBundleName"] { + let key = NSString::from_str(key); + if let Some(value) = bundle.objectForInfoDictionaryKey(&key) { + if let Ok(s) = value.downcast::() { + let s = s.to_string(); + if !s.is_empty() { + return Some(s); + } + } + } + } + None +} + +/// Scan the standard application folders for installed .app bundles. +/// Slow-ish (reads each bundle's Info.plist), so callers cache the result. +#[cfg(target_os = "macos")] +fn scan_installed_apps() -> Vec { + let mut queue: Vec<(std::path::PathBuf, u8)> = vec![ + ("/Applications".into(), 0), + ("/System/Applications".into(), 0), + ]; + if let Ok(home) = std::env::var("HOME") { + queue.push((std::path::Path::new(&home).join("Applications"), 0)); + } + + let mut apps = Vec::new(); + // Scan one folder level deep: /Applications/Utilities/X.app and vendor + // folders like /Applications/Adobe .../X.app are common. + while let Some((dir, depth)) = queue.pop() { + let Ok(entries) = std::fs::read_dir(&dir) else { + continue; }; + for entry in entries.flatten() { + let path = entry.path(); + let Some(file_name) = path.file_name().and_then(|n| n.to_str()) else { + continue; + }; + if file_name.starts_with('.') { + continue; + } + if file_name.ends_with(".app") { + let name = bundle_display_name(&path) + .unwrap_or_else(|| file_name.trim_end_matches(".app").to_string()); + if name.is_empty() || name == "Lookout" || should_exclude_window(&name, "") { + continue; + } + apps.push(AppEntry { + name, + path: Some(path.to_string_lossy().into_owned()), + running: false, + }); + } else if depth < 1 && path.is_dir() { + queue.push((path, depth + 1)); + } + } + } + apps +} - let mut apps = std::collections::BTreeSet::new(); - for i in 0..entries.count() { - let dict_ref = unsafe { entries.value_at_index(i) } as *const CFDictionary; - if dict_ref.is_null() { +/// Scan Start Menu shortcuts — the canonical "installed apps" on Windows. +#[cfg(target_os = "windows")] +fn scan_installed_apps() -> Vec { + let mut queue: Vec<(std::path::PathBuf, u8)> = Vec::new(); + if let Ok(program_data) = std::env::var("ProgramData") { + queue.push(( + std::path::Path::new(&program_data).join(r"Microsoft\Windows\Start Menu\Programs"), + 0, + )); + } + if let Ok(app_data) = std::env::var("APPDATA") { + queue.push(( + std::path::Path::new(&app_data).join(r"Microsoft\Windows\Start Menu\Programs"), + 0, + )); + } + + let mut apps = Vec::new(); + while let Some((dir, depth)) = queue.pop() { + let Ok(entries) = std::fs::read_dir(&dir) else { + continue; + }; + for entry in entries.flatten() { + // `entry.file_type()` reads the attributes the directory + // enumeration already returned; `entry.path().is_dir()` would stat + // each entry again. On Windows that is a real syscall per shortcut, + // through whatever filter drivers and AV hooks are installed, and + // the Start Menu tree has hundreds of entries. + // ...but file_type() does NOT follow symlinks where is_dir() did, + // so a directory junction in the Start Menu would stop being + // traversed. Fall back to the stat only for that rare case. + let path = entry.path(); + let is_dir = match entry.file_type() { + Ok(t) if t.is_symlink() => path.is_dir(), + Ok(t) => t.is_dir(), + Err(_) => path.is_dir(), + }; + if is_dir { + if depth < 3 { + queue.push((path, depth + 1)); + } continue; } - let dict = unsafe { &*dict_ref }; - let app_name = dict_string(dict, "kCGWindowOwnerName").unwrap_or_default(); - if app_name.is_empty() || app_name == "Lookout" { + let is_lnk = path + .extension() + .and_then(|e| e.to_str()) + .is_some_and(|e| e.eq_ignore_ascii_case("lnk")); + if !is_lnk { continue; } - let title = dict_string(dict, "kCGWindowName").unwrap_or_default(); - if should_exclude_window(&app_name, &title) { + let Some(name) = path.file_stem().and_then(|n| n.to_str()) else { + continue; + }; + let name = name.to_string(); + let lower = name.to_lowercase(); + if name.is_empty() + || name == "Lookout" + || should_exclude_window(&name, "") + || lower.starts_with("uninstall") + || lower.contains("uninstaller") + { continue; } - apps.insert(app_name); + apps.push(AppEntry { + name, + path: Some(path.to_string_lossy().into_owned()), + running: false, + }); } - apps.into_iter().collect() + } + apps +} + +/// Parse the fields we need from a .desktop file's [Desktop Entry] section. +/// Returns (name, icon) or None if the entry isn't a visible application. +#[cfg(target_os = "linux")] +fn parse_desktop_entry(content: &str) -> Option<(String, Option)> { + let mut in_section = false; + let mut name = None; + let mut icon = None; + for line in content.lines() { + let line = line.trim(); + if line.starts_with('[') { + if in_section { + break; // end of [Desktop Entry] + } + in_section = line == "[Desktop Entry]"; + continue; + } + if !in_section { + continue; + } + if let Some(value) = line.strip_prefix("NoDisplay=") { + if value.trim() == "true" { + return None; + } + } else if let Some(value) = line.strip_prefix("Type=") { + if value.trim() != "Application" { + return None; + } + } else if let Some(value) = line.strip_prefix("Name=") { + name = Some(value.trim().to_string()); + } else if let Some(value) = line.strip_prefix("Icon=") { + icon = Some(value.trim().to_string()); + } + } + Some((name.filter(|n| !n.is_empty())?, icon)) +} + +/// Scan .desktop entries — the canonical "installed apps" on Linux. +#[cfg(target_os = "linux")] +fn scan_installed_apps() -> Vec { + let mut dirs: Vec = vec![ + "/usr/share/applications".into(), + "/usr/local/share/applications".into(), + "/var/lib/flatpak/exports/share/applications".into(), + ]; + if let Ok(home) = std::env::var("HOME") { + let home = std::path::Path::new(&home); + dirs.push(home.join(".local/share/applications")); + dirs.push(home.join(".local/share/flatpak/exports/share/applications")); + } + + let mut apps = Vec::new(); + for dir in dirs { + let Ok(entries) = std::fs::read_dir(&dir) else { + continue; + }; + for entry in entries.flatten() { + let path = entry.path(); + if path.extension().and_then(|e| e.to_str()) != Some("desktop") { + continue; + } + let Ok(content) = std::fs::read_to_string(&path) else { + continue; + }; + let Some((name, icon)) = parse_desktop_entry(&content) else { + continue; + }; + if name == "Lookout" || should_exclude_window(&name, "") { + continue; + } + apps.push(AppEntry { + name, + path: icon, + running: false, + }); + } + } + apps +} + +fn installed_apps_cached() -> &'static [AppEntry] { + static CACHE: std::sync::OnceLock> = std::sync::OnceLock::new(); + CACHE.get_or_init(scan_installed_apps) +} + +/// (name, icon-lookup key) pairs for currently running apps. Names come from +/// the same source redaction matches against (kCGWindowOwnerName on macOS, +/// xcap `app_name` elsewhere), so a running app always blacklists correctly +/// even when its installed entry is named differently. +fn running_apps() -> Vec<(String, Option)> { + #[cfg(target_os = "macos")] + { + use objc2_app_kit::{NSApplicationActivationPolicy, NSWorkspace}; + + let workspace = NSWorkspace::sharedWorkspace(); + workspace + .runningApplications() + .iter() + .filter(|app| app.activationPolicy() == NSApplicationActivationPolicy::Regular) + .filter_map(|app| { + let name = app.localizedName()?.to_string(); + let path = app + .bundleURL() + .and_then(|url| url.path()) + .map(|p| p.to_string()); + Some((name, path)) + }) + .collect() } #[cfg(not(target_os = "macos"))] { - // On non-macOS, return window app names from xcap use xcap::Window; - let mut apps = std::collections::BTreeSet::new(); + let mut names = std::collections::BTreeSet::new(); if let Ok(windows) = Window::all() { for w in windows { if let Ok(name) = w.app_name() { - if !name.is_empty() && name != "Lookout" && !should_exclude_window(&name, &w.title().unwrap_or_default()) { - apps.insert(name); + if !name.is_empty() && !should_exclude_window(&name, &w.title().unwrap_or_default()) { + names.insert(name); } } } } - apps.into_iter().collect() + names.into_iter().map(|name| (name, None)).collect() } } +/// List apps for the Filtered Apps page, sorted by name: every installed app +/// (scanned once per process and cached) merged with currently running apps. +/// Only real applications appear — helper/XPC processes that merely own +/// windows (e.g. "CursorUIViewService") don't. +/// +/// The work is BLOCKING — a Start Menu tree walk on the first call, and a +/// window enumeration on every call — so it runs on the blocking pool rather +/// than on the async runtime. `async fn` alone was not enough: the body never +/// yields, so it occupied a tokio worker for its whole duration, and the +/// capture loop lives on those same workers. A slow enumeration could +/// therefore delay a capture tick, not just the Settings page. +#[tauri::command] +async fn list_installed_apps() -> Vec { + tauri::async_runtime::spawn_blocking(list_installed_apps_blocking) + .await + .unwrap_or_default() +} + +/// Pre-warm the installed-app cache so the first visit to Filtered Apps doesn't +/// pay for the app scan while the user waits. +/// +/// DEFERRED on purpose. The scan is disk-bound — a Start Menu tree walk on +/// Windows, /Applications on macOS, .desktop files on Linux — and launch is +/// already the most I/O-contended moment in the process's life: the webview is +/// loading its own assets at the same time. Starting the scan immediately would +/// trade a faster Settings page for a slower app open, which is the wrong way +/// round. A few seconds' delay is still far earlier than anyone navigates to +/// Filtered Apps, and by then the launch I/O has settled. +fn prewarm_installed_apps() { + std::thread::Builder::new() + .name("app-scan-prewarm".into()) + .spawn(|| { + std::thread::sleep(std::time::Duration::from_secs(5)); + let _ = installed_apps_cached(); + }) + // A failed prewarm is not worth failing startup over: the cache just + // fills lazily on first use, exactly as it did before. + .ok(); +} + +fn list_installed_apps_blocking() -> Vec { + // name -> (path, running); BTreeMap keeps the result sorted by name. + let mut apps: std::collections::BTreeMap, bool)> = + installed_apps_cached() + .iter() + .map(|a| (a.name.clone(), (a.path.clone(), false))) + .collect(); + + for (name, path) in running_apps() { + if name.is_empty() || name == "Lookout" || should_exclude_window(&name, "") { + continue; + } + match apps.entry(name) { + std::collections::btree_map::Entry::Occupied(mut e) => { + let (existing_path, running) = e.get_mut(); + if existing_path.is_none() { + *existing_path = path; + } + *running = true; + } + std::collections::btree_map::Entry::Vacant(e) => { + e.insert((path, true)); + } + } + } + + apps.into_iter() + .map(|(name, (path, running))| AppEntry { + name, + path, + running, + }) + .collect() +} + +/// Return a small PNG (base64) of an app's icon. `path` is the icon lookup +/// key from `AppEntry.path`. Cached per key; async so lookups run off the +/// main thread (a sync command here froze the UI while icons rasterized). +#[tauri::command] +async fn get_app_icon(path: String) -> Option { + static CACHE: std::sync::OnceLock< + std::sync::Mutex>>, + > = std::sync::OnceLock::new(); + let cache = CACHE.get_or_init(Default::default); + if let Some(hit) = cache + .lock() + .unwrap_or_else(|e| e.into_inner()) + .get(&path) + { + return hit.clone(); + } + + let result = compute_app_icon(&path); + cache + .lock() + .unwrap_or_else(|e| e.into_inner()) + .insert(path, result.clone()); + result +} + +#[cfg(target_os = "macos")] +fn compute_app_icon(path: &str) -> Option { + use base64::Engine as _; + use objc2::AnyThread as _; + use objc2_app_kit::{NSBitmapImageFileType, NSBitmapImageRep, NSWorkspace}; + use objc2_core_foundation::{CGPoint, CGSize}; + use objc2_foundation::{NSDictionary, NSString}; + + let icon = NSWorkspace::sharedWorkspace().iconForFile(&NSString::from_str(path)); + // Ask for a small rect so IconServices hands back the small icon + // representation instead of rasterizing the full 1024px artwork. + let mut rect = CGRect { + origin: CGPoint { x: 0.0, y: 0.0 }, + size: CGSize { + width: 32.0, + height: 32.0, + }, + }; + unsafe { icon.CGImageForProposedRect_context_hints(&mut rect, None, None) } + .and_then(|cg| { + let rep = NSBitmapImageRep::initWithCGImage(NSBitmapImageRep::alloc(), &cg); + unsafe { + rep.representationUsingType_properties( + NSBitmapImageFileType::PNG, + &NSDictionary::new(), + ) + } + }) + .map(|png| base64::engine::general_purpose::STANDARD.encode(png.to_vec())) +} + +/// Windows: shell icon for the Start Menu .lnk (resolves to the target +/// exe's icon), converted HICON -> RGBA -> PNG. +#[cfg(target_os = "windows")] +fn compute_app_icon(path: &str) -> Option { + use base64::Engine as _; + use windows::core::PCWSTR; + use windows::Win32::Graphics::Gdi::{ + DeleteObject, GetDC, GetDIBits, GetObjectW, ReleaseDC, BITMAP, BITMAPINFO, + BITMAPINFOHEADER, BI_RGB, DIB_RGB_COLORS, + }; + use windows::Win32::Storage::FileSystem::FILE_FLAGS_AND_ATTRIBUTES; + use windows::Win32::System::Com::{CoInitializeEx, COINIT_APARTMENTTHREADED}; + use windows::Win32::UI::Shell::{SHGetFileInfoW, SHFILEINFOW, SHGFI_ICON, SHGFI_LARGEICON}; + use windows::Win32::UI::WindowsAndMessaging::{DestroyIcon, GetIconInfo, ICONINFO}; + + // SHGetFileInfoW needs COM for .lnk resolution; commands run on worker + // threads, so initialize per call (no-op if already initialized). + unsafe { + let _ = CoInitializeEx(None, COINIT_APARTMENTTHREADED); + } + + let wide: Vec = path.encode_utf16().chain(std::iter::once(0)).collect(); + let mut info = SHFILEINFOW::default(); + let ok = unsafe { + SHGetFileInfoW( + PCWSTR(wide.as_ptr()), + FILE_FLAGS_AND_ATTRIBUTES(0), + Some(&mut info), + std::mem::size_of::() as u32, + SHGFI_ICON | SHGFI_LARGEICON, + ) + }; + if ok == 0 || info.hIcon.is_invalid() { + return None; + } + + let png = (|| { + let mut icon_info = ICONINFO::default(); + unsafe { GetIconInfo(info.hIcon, &mut icon_info) }.ok()?; + + let result = (|| { + let mut bmp = BITMAP::default(); + let got = unsafe { + GetObjectW( + icon_info.hbmColor.into(), + std::mem::size_of::() as i32, + Some(&mut bmp as *mut _ as *mut _), + ) + }; + if got == 0 || bmp.bmWidth <= 0 || bmp.bmHeight <= 0 { + return None; + } + let (w, h) = (bmp.bmWidth, bmp.bmHeight); + + let mut bmi = BITMAPINFO::default(); + bmi.bmiHeader.biSize = std::mem::size_of::() as u32; + bmi.bmiHeader.biWidth = w; + bmi.bmiHeader.biHeight = -h; // negative = top-down rows + bmi.bmiHeader.biPlanes = 1; + bmi.bmiHeader.biBitCount = 32; + bmi.bmiHeader.biCompression = BI_RGB.0; + + let mut buf = vec![0u8; (w as usize) * (h as usize) * 4]; + let hdc = unsafe { GetDC(None) }; + let lines = unsafe { + GetDIBits( + hdc, + icon_info.hbmColor, + 0, + h as u32, + Some(buf.as_mut_ptr() as *mut _), + &mut bmi, + DIB_RGB_COLORS, + ) + }; + unsafe { ReleaseDC(None, hdc) }; + if lines == 0 { + return None; + } + + // BGRA -> RGBA; some icons come back with an empty alpha + // channel, which would render as fully transparent. + for px in buf.chunks_exact_mut(4) { + px.swap(0, 2); + } + if buf.chunks_exact(4).all(|px| px[3] == 0) { + for px in buf.chunks_exact_mut(4) { + px[3] = 255; + } + } + + let img = image::RgbaImage::from_raw(w as u32, h as u32, buf)?; + let mut out = std::io::Cursor::new(Vec::new()); + image::DynamicImage::ImageRgba8(img) + .write_to(&mut out, image::ImageFormat::Png) + .ok()?; + Some(out.into_inner()) + })(); + + unsafe { + let _ = DeleteObject(icon_info.hbmColor.into()); + let _ = DeleteObject(icon_info.hbmMask.into()); + } + result + })(); + + unsafe { + let _ = DestroyIcon(info.hIcon); + } + png.map(|bytes| base64::engine::general_purpose::STANDARD.encode(bytes)) +} + +/// Linux: resolve the .desktop Icon= value against the hicolor theme and +/// pixmaps dirs (PNG only) and return the file as-is. +#[cfg(target_os = "linux")] +fn compute_app_icon(icon: &str) -> Option { + use base64::Engine as _; + + let mut candidates: Vec = Vec::new(); + if icon.starts_with('/') { + candidates.push(icon.into()); + } else { + let mut base_dirs: Vec = vec![ + "/usr/share".into(), + "/usr/local/share".into(), + "/var/lib/flatpak/exports/share".into(), + ]; + if let Ok(home) = std::env::var("HOME") { + base_dirs.push(format!("{home}/.local/share")); + base_dirs.push(format!("{home}/.local/share/flatpak/exports/share")); + } + for base in &base_dirs { + for size in ["48x48", "64x64", "32x32", "128x128", "256x256"] { + candidates.push(format!("{base}/icons/hicolor/{size}/apps/{icon}.png").into()); + } + candidates.push(format!("{base}/pixmaps/{icon}.png").into()); + } + } + + for path in candidates { + let is_png = path + .extension() + .and_then(|e| e.to_str()) + .is_some_and(|e| e.eq_ignore_ascii_case("png")); + if !is_png { + continue; + } + if let Ok(bytes) = std::fs::read(&path) { + return Some(base64::engine::general_purpose::STANDARD.encode(bytes)); + } + } + None +} + /// List available capture sources (monitors + windows). #[tauri::command] fn list_capture_sources() -> Result { @@ -818,6 +1401,22 @@ fn take_screenshot( capture::take_screenshot(source, max_width, max_height, jpeg_quality, &pipewire_fds) } +/// Shared HTTP client for all server/R2 traffic. Building a `reqwest::Client` +/// allocates a fresh connection pool + TLS config, so constructing one per +/// request (as each capture tick used to) both wastes CPU and forces a new +/// TCP/TLS handshake every 60 seconds. One shared client keeps connections +/// alive between ticks. Timeouts differ per call site, so they're applied +/// per-request via `RequestBuilder::timeout` instead of on the client. +fn http_client() -> &'static reqwest::Client { + static CLIENT: std::sync::OnceLock = std::sync::OnceLock::new(); + CLIENT.get_or_init(|| { + reqwest::Client::builder() + .connect_timeout(std::time::Duration::from_secs(10)) + .build() + .unwrap_or_default() + }) +} + /// Free-form client telemetry string sent on every upload-url request, e.g. /// "Lookout Desktop/0.2.6 (macOS 14.3)". Computed once. NOT the HTTP /// User-Agent — explicit info for server-side telemetry/debugging. @@ -1036,24 +1635,65 @@ macro_rules! retry_upload_step { /// screenshot was actually taken. Optional — when `None`, the request /// matches the legacy bucket-mode payload byte-for-byte. When `Some`, it /// opts the session into credit-mode tracking on the first request. -async fn upload_and_confirm( - jpeg_base64: &str, +/// +/// Takes the JPEG as `bytes::Bytes` (cheap refcounted clones for retries — +/// no full-buffer copy per attempt) plus its base64 form, which is only +/// carried through for the JS preview. Callers that capture natively encode +/// base64 exactly once; nothing here decodes it back. +/// One capture unit ready for upload: the legacy single JPEG or an H.264 +/// MP4 clip. The content type must match the granted format — the +/// presigned URL is signed with it. +struct UploadPayload { + bytes: bytes::Bytes, + content_type: &'static str, + /// `format` query value for upload-url. None = legacy JPEG request. + format: Option<&'static str>, + /// Frames inside a clip (confirm-body telemetry). None for JPEG. + frame_count: Option, width: u32, height: u32, + /// JPEG preview (base64) of the unit's last frame, for the UI event. + preview_base64: String, +} + +impl UploadPayload { + fn jpeg(bytes: bytes::Bytes, base64: String, width: u32, height: u32) -> Self { + Self { + bytes, + content_type: "image/jpeg", + format: None, + frame_count: None, + width, + height, + preview_base64: base64, + } + } + + fn mp4(clip: clips::FinishedClip, preview_base64: String) -> Self { + Self { + bytes: bytes::Bytes::from(clip.mp4), + content_type: "video/mp4", + format: Some("mp4"), + frame_count: Some(clip.frame_count), + width: clip.width, + height: clip.height, + preview_base64, + } + } +} + +async fn upload_and_confirm( + payload: UploadPayload, captured_at: Option<&str>, config: &SessionConfig, app: &AppHandle, ) -> Result { - let jpeg_bytes = base64_decode(jpeg_base64)?; - let size_bytes = jpeg_bytes.len(); + let size_bytes = payload.bytes.len(); + const STEP_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30); // Step 1: Get presigned URL from server let _ = app.emit("capture-progress", "getting upload url from server..."); - let client = reqwest::Client::builder() - .timeout(std::time::Duration::from_secs(30)) - .connect_timeout(std::time::Duration::from_secs(10)) - .build() - .map_err(|e| format!("Failed to build HTTP client: {e}"))?; + let client = http_client(); let upload_url_url = format!( "{}/api/sessions/{}/upload-url", config.api_base_url, config.token @@ -1064,11 +1704,15 @@ async fn upload_and_confirm( if let Some(c) = captured_at { query.push(("capturedAt", c)); } + if let Some(f) = payload.format { + query.push(("format", f)); + } // Each attempt re-requests a FRESH presigned URL (it has a 120s expiry). let upload_url_resp: UploadUrlResponse = retry_upload_step!("upload-url", { let url_response = client .get(upload_url_url.as_str()) .query(&query) + .timeout(STEP_TIMEOUT) .send() .await .map_err(|e| StepError::Retryable(describe_reqwest_error(&e)))?; @@ -1091,6 +1735,20 @@ async fn upload_and_confirm( ), ); + // The presigned URL is signed for the GRANTED format's content type — + // uploading a clip against a jpeg grant would fail the signature. A + // downgrade here (clips disabled server-side, pre-clips server) is a + // terminal error for this payload; the capture loop retries the tick + // with its JPEG fallback. + if let Some(requested) = payload.format { + let granted = upload_url_resp.format.as_deref().unwrap_or("jpeg"); + if granted != requested { + return Err(format!( + "server granted \"{granted}\" for a \"{requested}\" clip upload" + )); + } + } + // Step 2: Upload JPEG to R2 let _ = app.emit( "capture-progress", @@ -1107,8 +1765,10 @@ async fn upload_and_confirm( retry_upload_step!(r2_label, { client .put(upload_url_resp.upload_url.as_str()) - .header("Content-Type", "image/jpeg") - .body(jpeg_bytes.clone()) + .header("Content-Type", payload.content_type) + // Bytes::clone is a refcount bump, not a buffer copy. + .body(payload.bytes.clone()) + .timeout(STEP_TIMEOUT) .send() .await .map_err(|e| StepError::Retryable(describe_reqwest_error(&e)))? @@ -1120,18 +1780,23 @@ async fn upload_and_confirm( // Step 3: Confirm upload with server let _ = app.emit("capture-progress", "confirming upload with server..."); + let mut confirm_body = serde_json::json!({ + "screenshotId": upload_url_resp.screenshot_id, + "width": payload.width, + "height": payload.height, + "fileSize": size_bytes, + }); + if let Some(fc) = payload.frame_count { + confirm_body["frameCount"] = fc.into(); + } let confirm_resp: ConfirmResponse = retry_upload_step!("confirm", { let confirm_response = client .post(format!( "{}/api/sessions/{}/screenshots", config.api_base_url, config.token )) - .json(&serde_json::json!({ - "screenshotId": upload_url_resp.screenshot_id, - "width": width, - "height": height, - "fileSize": size_bytes, - })) + .json(&confirm_body) + .timeout(STEP_TIMEOUT) .send() .await .map_err(|e| StepError::Retryable(describe_reqwest_error(&e)))?; @@ -1158,9 +1823,9 @@ async fn upload_and_confirm( confirmed: confirm_resp.confirmed, tracked_seconds: confirm_resp.tracked_seconds, next_expected_at: confirm_resp.next_expected_at, - preview_base64: jpeg_base64.to_string(), - preview_width: width, - preview_height: height, + preview_base64: payload.preview_base64, + preview_width: payload.width, + preview_height: payload.height, }) } @@ -1383,21 +2048,27 @@ async fn capture_and_upload( pipewire_fds = guard.clone(); } - let screenshot = capture::take_stitched_screenshots_with_blacklist( - &sources, - max_width, - max_height, - jpeg_quality, - &pipewire_fds, - &blacklisted, - )?; + // Screen capture + JPEG encode is heavy blocking work — keep it off the + // async runtime's worker threads (same as the Rust capture loop does). + let screenshot = tokio::task::spawn_blocking(move || { + capture::take_stitched_screenshots_raw_with_blacklist( + &sources, + max_width, + max_height, + jpeg_quality, + &pipewire_fds, + &blacklisted, + ) + }) + .await + .map_err(|e| format!("spawn_blocking panicked: {e}"))??; let _ = app.emit( "capture-progress", format!( "captured {}x{} ({}KB jpeg)", screenshot.width, screenshot.height, - screenshot.size_bytes / 1024 + screenshot.data.len() / 1024 ), ); @@ -1406,10 +2077,14 @@ async fn capture_and_upload( } else { None }; + let jpeg_base64 = base64_encode(&screenshot.data); upload_and_confirm( - &screenshot.base64, - screenshot.width, - screenshot.height, + UploadPayload::jpeg( + bytes::Bytes::from(screenshot.data), + jpeg_base64, + screenshot.width, + screenshot.height, + ), captured_at.as_deref(), &config, &app, @@ -1444,7 +2119,14 @@ async fn upload_frame( } else { None }; - upload_and_confirm(&base64, width, height, captured_at.as_deref(), &config, &app).await + let jpeg_bytes = bytes::Bytes::from(base64_decode(&base64)?); + upload_and_confirm( + UploadPayload::jpeg(jpeg_bytes, base64, width, height), + captured_at.as_deref(), + &config, + &app, + ) + .await } // ── Capture-loop interval (seconds) ───────────────────────────── @@ -1452,19 +2134,68 @@ const CAPTURE_INTERVAL_SECS: u64 = 60; /// If the wall-clock gap between ticks exceeds this, the machine /// probably slept (or the WebView was throttled hard). const SLEEP_THRESHOLD_SECS: u64 = CAPTURE_INTERVAL_SECS * 2 + 30; // 150s - -/// Format seconds into the same tray title format as the JS side: -/// >0h: "{h}h {m}m", 0m: "< 1m", else: "{m}m" +/// Fallback frame cadence when the server doesn't advertise one (pre-clips +/// servers): every 10s = 6 frames/min. Mirrors CLIP_FRAME_INTERVAL_MS in +/// @lookout/shared. When the server sends `frameIntervalMs` on the session +/// GET, that value wins — the cadence is server-authoritative. Frames go +/// through the identical redaction-aware capture path as uploads; in clips +/// mode they're recorded into the clip, and the JPEG preview side is only +/// produced while the window is focused. +const DEFAULT_FRAME_INTERVAL_MS: u64 = 10_000; + +/// Delay from capture start to the FIRST upload tick. Mirrors +/// CLIP_FIRST_CUT_DELAY_MS in @lookout/shared. +/// +/// Deliberately not a multiple of the frame cadence: the opening clip is the +/// session's seed capture, which credits 0 seconds and which the compiler +/// drops from the video outright, so its frame density doesn't matter. What +/// this delay does control is how long the user stares at an unstarted +/// session — and tying it to the cadence turned every slower cadence into a +/// 20-second-plus wait. +const CLIP_FIRST_CUT_DELAY_MS: u64 = 8_000; + +/// Consecutive clip-encoder failures tolerated before this capture run gives +/// up on clips and records plain JPEGs for the rest of the session. +/// +/// A broken encoder is already survivable one interval at a time (each +/// failure falls back to a JPEG), but "survivable" was not the same as +/// "quiet": on a machine where the encoder can never initialize, the loop +/// retried it on every single frame — for hours — each attempt paying the +/// full cost of constructing and tearing down an OS encoder, and writing a +/// line to stderr. Latching off after a few consecutive failures keeps the +/// recording intact and stops the thrash. +const MAX_CLIP_ENCODER_FAILURES: u32 = 3; + +/// Max seconds the menu-bar time may run ahead of the last server-credited +/// `tracked_seconds`. Must equal `MAX_INTERPOLATION_S` in +/// @lookout/react's useSessionTimer — one capture interval. Without the cap +/// the menu bar kept counting through a capture stall while the main window +/// froze at base + 60, and the two never reconverged. +const MAX_TRAY_INTERPOLATION_SECS: i64 = CAPTURE_INTERVAL_SECS as i64; + +/// The Rust mirror of `deriveDisplaySeconds` in @lookout/react. Keep the two +/// in step: the menu bar and the main window each tick their own clock, so any +/// difference here is directly visible as the two showing different times. +fn tray_display_seconds(base_seconds: i64, elapsed_secs: i64, running: bool) -> i64 { + if !running { + // Paused drops the interpolated remainder rather than freezing it, + // matching the main window's snap-down. + return base_seconds; + } + base_seconds + elapsed_secs.clamp(0, MAX_TRAY_INTERPOLATION_SECS) +} + +/// Format seconds into a clock-style tray title: +/// >0h: "{h}:{mm:02}:{ss:02}", else: "{mm:02}:{ss:02}" fn format_tray_time(total_seconds: i64) -> String { let total = total_seconds.max(0) as u64; let h = total / 3600; let m = (total % 3600) / 60; + let s = total % 60; if h > 0 { - format!("{h}h {m}m") - } else if m == 0 { - "< 1m".to_string() + format!("{h}:{m:02}:{s:02}") } else { - format!("{m}m") + format!("{m:02}:{s:02}") } } @@ -1480,6 +2211,14 @@ async fn tray_timer_task( let mut ticker = interval(Duration::from_secs(1)); ticker.set_missed_tick_behavior(MissedTickBehavior::Skip); + // The title only changes at minute granularity, so most 1s ticks would + // rewrite the exact same string. Cache the last text and skip redundant + // native tray updates. After a paused stretch the JS side may have + // overwritten the title (paused indicator), so force one refresh on the + // first running tick after a pause even if the text matches. + let mut last_title: Option = None; + let mut was_paused = false; + loop { tokio::select! { _ = ticker.tick() => {} @@ -1489,24 +2228,51 @@ async fn tray_timer_task( } } - if !timer_state.is_running.load(Ordering::Relaxed) { - continue; - } - let base_seconds = timer_state.tracked_seconds.load(Ordering::Relaxed); + let running = timer_state.is_running.load(Ordering::Relaxed); + let elapsed = { let started = timer_state.started_at.lock().unwrap(); started.elapsed().as_secs() as i64 }; - let display_seconds = base_seconds + elapsed; + let display_seconds = tray_display_seconds(base_seconds, elapsed, running); let time_text = format_tray_time(display_seconds); - if let Some(tray) = app.tray_by_id("timelapse_tray") { - let _ = tray.set_title(Some(time_text)); + + if !running { + // Write the frozen value once (a pause snaps the title down by + // the dropped remainder), then idle until resume. + if last_title.as_deref() != Some(time_text.as_str()) { + set_tray_title(&app, &time_text); + last_title = Some(time_text); + } + was_paused = true; + continue; } - // Also emit to the tray popup window so it stays in sync - let _ = app.emit("tray-timer-tick", display_seconds); + if was_paused || last_title.as_deref() != Some(time_text.as_str()) { + set_tray_title(&app, &time_text); + last_title = Some(time_text); + } + was_paused = false; + } +} + +/// Write the menu-bar time text (and, off macOS, the hover tooltip). +fn set_tray_title(app: &AppHandle, time_text: &str) { + #[cfg(target_os = "macos")] + { + let _ = app; + // None = keep the current pause state; the Swift side renders the + // tooltip and the numericText digit roll. + let _ = crate::native_tray::update(time_text, None); + } + #[cfg(not(target_os = "macos"))] + if let Some(tray) = app.tray_by_id("timelapse_tray") { + let _ = tray.set_title(Some(time_text)); + // Windows doesn't render tray titles — the hover tooltip is the + // only way to see the recorded time there. + let _ = tray.set_tooltip(Some(format!("Lookout — {time_text} recorded"))); } } @@ -1520,6 +2286,12 @@ fn start_tray_timer(app: &AppHandle, state: &AppState) -> Arc { return Arc::clone(&handle.state); } + // The tray timer lives exactly as long as a session is being recorded + // (screen sessions via start_capture_loop, camera via start_tray_ticker), + // so it's the right scope for the keep-awake assertion. + #[cfg(target_os = "macos")] + power::begin_recording_assertion(); + let timer_state = Arc::new(TrayTimerState { tracked_seconds: AtomicI64::new(0), started_at: Mutex::new(StdInstant::now()), @@ -1553,24 +2325,44 @@ fn stop_tray_timer(state: &AppState) { eprintln!("[tray-timer] stopping"); let _ = handle.cancel_tx.send(true); handle.join_handle.abort(); + + // Recording is over — let macOS nap/idle-sleep normally again. + #[cfg(target_os = "macos")] + power::end_recording_assertion(); + } +} + +/// Ratchet `tracked_seconds` to a new authoritative value, re-anchoring the +/// elapsed counter **only if the value actually advanced**. +/// +/// Both halves matter for staying in step with the main window: +/// - Ratchet: an idempotent retry can confirm against a stale read and +/// return a *lower* `trackedSeconds`. JS keeps the higher value, so +/// storing the lower one here made the menu bar jump backwards and sit +/// a minute behind until the next credit. +/// - Anchor only on advance: a repeated reading must not restart the +/// interpolation window, or the menu bar loses time the main window keeps. +fn ratchet_tray_tracked_seconds(timer_state: &TrayTimerState, tracked_seconds: i64) { + let prev = timer_state + .tracked_seconds + .fetch_max(tracked_seconds, Ordering::Relaxed); + if tracked_seconds > prev { + let mut started = timer_state.started_at.lock().unwrap(); + *started = StdInstant::now(); } } /// Sync the tray timer to a new authoritative tracked_seconds value -/// (typically from a capture result). Resets the elapsed counter. +/// (typically from a capture result). fn sync_tray_timer(state: &AppState, tracked_seconds: i64) { let guard = state.tray_timer.lock().unwrap(); if let Some(ref handle) = *guard { - handle - .state - .tracked_seconds - .store(tracked_seconds, Ordering::Relaxed); - let mut started = handle.state.started_at.lock().unwrap(); - *started = StdInstant::now(); + ratchet_tray_tracked_seconds(&handle.state, tracked_seconds); } } -/// Pause the tray timer (freeze the displayed time). +/// Pause the tray timer. The next tick drops the interpolated remainder and +/// shows the bare `tracked_seconds`, matching the main window's snap-down. fn pause_tray_timer(state: &AppState) { let guard = state.tray_timer.lock().unwrap(); if let Some(ref handle) = *guard { @@ -1578,7 +2370,7 @@ fn pause_tray_timer(state: &AppState) { } } -/// Resume the tray timer. Resets the elapsed counter so it continues +/// Resume the tray timer. Re-anchors the elapsed counter so it continues /// from the current tracked_seconds. fn resume_tray_timer(state: &AppState) { let guard = state.tray_timer.lock().unwrap(); @@ -1620,6 +2412,16 @@ struct CaptureTickError { message: String, } +/// Event payload for an in-between live-preview frame from the capture +/// loop (one per frame interval while the window is focused). +#[derive(Clone, Serialize)] +#[serde(rename_all = "camelCase")] +struct CapturePreviewFrame { + preview_base64: String, + preview_width: u32, + preview_height: u32, +} + /// Event payload emitted when the capture loop detects a terminal session state. #[derive(Clone, Serialize)] struct CaptureSessionTerminated { @@ -1635,6 +2437,165 @@ struct SessionStatusResponse { tracked_seconds: Option, } +/// What JPEG (if any) a frame grab should produce alongside the raw image. +#[derive(Clone, Copy, PartialEq)] +enum GrabJpeg { + /// No JPEG — clip frame while the window is unfocused. + None, + /// Preview-sized (≤854x480, q65) — matches the resolution the live + /// preview always used, and keeps the per-frame IPC payload ~5x + /// smaller than a full-res frame would be. + Preview, + /// Full capture resolution at upload quality — the tick frame, which + /// doubles as the JPEG upload/fallback payload. + Full, +} + +/// Downscale bounds + quality for preview JPEGs (mirrors the values the +/// dedicated preview protocol always served). +const PREVIEW_MAX_W: u32 = 854; +const PREVIEW_MAX_H: u32 = 480; +const PREVIEW_JPEG_QUALITY: u8 = 65; + +/// One frame off the capture pipeline: the raw (redacted, scaled) image +/// plus, when requested, its JPEG encoding. +struct FrameGrab { + image: image::DynamicImage, + jpeg: Option, +} + +/// Read the current blacklist (+ Linux PipeWire fds) and capture one +/// redaction-aware stitched frame on the blocking pool. Shared by the +/// upload tick, the clip frames, and the live preview, so everything goes +/// through the exact same capture path — Filtered Apps redaction included. +async fn grab_frame( + app: &AppHandle, + sources: &[CaptureSource], + max_width: u32, + max_height: u32, + jpeg_quality: u8, + jpeg: GrabJpeg, +) -> Result { + let blacklisted = { + let state = app.state::(); + state + .blacklisted_apps + .lock() + .map(|g| g.clone()) + .unwrap_or_default() + }; + + #[allow(unused_mut, unused_assignments)] + let mut pipewire_fds = std::collections::HashMap::new(); + #[cfg(target_os = "linux")] + { + let state = app.state::(); + if let Ok(guard) = state.pipewire_fds.lock() { + pipewire_fds = guard.clone(); + }; + } + + let sources_clone = sources.to_vec(); + tokio::task::spawn_blocking(move || { + let image = capture::take_stitched_screenshots_image_with_blacklist( + &sources_clone, + max_width, + max_height, + &pipewire_fds, + &blacklisted, + )?; + let encoded = match jpeg { + GrabJpeg::None => None, + GrabJpeg::Full => Some(capture::encode_frame_jpeg(&image, jpeg_quality)?), + GrabJpeg::Preview => { + let (w, h) = (image.width(), image.height()); + if w > PREVIEW_MAX_W || h > PREVIEW_MAX_H { + let scale = + f64::min(PREVIEW_MAX_W as f64 / w as f64, PREVIEW_MAX_H as f64 / h as f64); + let pw = ((w as f64 * scale).round() as u32).max(2); + let ph = ((h as f64 * scale).round() as u32).max(2); + // Borrowing resize: the full-res frame stays untouched + // for the clip encoder. + image + .as_rgba8() + .and_then(|rgba| capture::fast_resize_buffer(rgba, pw, ph)) + .map(|small| { + capture::encode_frame_jpeg( + &image::DynamicImage::ImageRgba8(small), + PREVIEW_JPEG_QUALITY, + ) + }) + .transpose()? + } else { + Some(capture::encode_frame_jpeg(&image, PREVIEW_JPEG_QUALITY)?) + } + } + }; + Ok(FrameGrab { + image, + jpeg: encoded, + }) + }) + .await + .map_err(|e| format!("spawn_blocking panicked: {e}")) + .and_then(|r| r) +} + +/// Record one clip-encoder failure, and latch clips off for the rest of the +/// run once they stop looking transient. +/// +/// The recording itself is never at risk either way — every clip failure +/// already falls back to a JPEG for that interval. This is about not +/// re-attempting a hopeless encoder several times a minute for hours. Any +/// clip that finalizes successfully resets the counter, so a one-off +/// hiccup (a display mode change, a busy GPU) never disables clips. +fn note_clip_failure(failures: &mut u32, clips_mode: &mut bool) { + *failures += 1; + if *failures >= MAX_CLIP_ENCODER_FAILURES && *clips_mode { + *clips_mode = false; + eprintln!( + "[capture-loop] {} consecutive clip-encoder failures — disabling clips \ + for this session, continuing with one JPEG per minute", + *failures + ); + } +} + +/// Clip capability the server advertises for a session (on the session +/// GET). Fetched once at capture-loop start; any failure means clips off, +/// i.e. legacy one-JPEG-per-minute behavior. +#[derive(Deserialize, Default)] +#[serde(rename_all = "camelCase")] +struct SessionClipCapabilities { + #[serde(default)] + clips_enabled: bool, + #[serde(default)] + frame_interval_ms: Option, +} + +async fn fetch_clip_capabilities(config: &SessionConfig) -> SessionClipCapabilities { + let url = format!("{}/api/sessions/{}", config.api_base_url, config.token); + match http_client() + .get(&url) + .timeout(std::time::Duration::from_secs(15)) + .send() + .await + { + Ok(res) if res.status().is_success() => res.json().await.unwrap_or_default(), + Ok(res) => { + eprintln!( + "[capture-loop] capability fetch returned HTTP {} — clips off", + res.status() + ); + SessionClipCapabilities::default() + } + Err(e) => { + eprintln!("[capture-loop] capability fetch failed ({e}) — clips off"); + SessionClipCapabilities::default() + } + } +} + /// The core capture loop, runs on a tokio task. Captures screenshots at /// a fixed interval, uploads them, and emits events back to JS. /// @@ -1668,12 +2629,10 @@ async fn capture_loop_task( app: &AppHandle, config: &SessionConfig, ) -> Result { - let client = reqwest::Client::builder() - .timeout(std::time::Duration::from_secs(15)) - .build() - .unwrap_or_default(); + let client = http_client(); + let status_timeout = std::time::Duration::from_secs(15); let url = format!("{}/api/sessions/{}/status", config.api_base_url, config.token); - match client.get(&url).send().await { + match client.get(&url).timeout(status_timeout).send().await { Ok(res) if res.status().is_success() => { if let Ok(data) = res.json::().await { eprintln!("[capture-loop] session status after sleep: {}", data.status); @@ -1685,7 +2644,7 @@ async fn capture_loop_task( "{}/api/sessions/{}/resume", config.api_base_url, config.token ); - let _ = client.post(&resume_url).send().await; + let _ = client.post(&resume_url).timeout(status_timeout).send().await; eprintln!("[capture-loop] session resumed after sleep"); } else if data.status != "active" && data.status != "pending" { eprintln!( @@ -1712,12 +2671,239 @@ async fn capture_loop_task( Ok(true) } - loop { - // ── First capture fires immediately; subsequent ones wait for the interval tick ── - // (We already consumed the first tick above, so this select waits for either - // the next 60s tick or cancellation.) + /// Apply a finished upload's outcome: sync the tray timer, refine the + /// next tick target from the server's `nextExpectedAt`, emit the UI + /// events, and run pause/termination recovery on failure. Returns false + /// when the capture loop should stop (terminal session state). + async fn apply_upload_result( + app: &AppHandle, + config: &SessionConfig, + result: Result, + next_fire: &mut tokio::time::Instant, + interval_dur: tokio::time::Duration, + ) -> bool { + match result { + Ok(result) => { + // Sync tray timer to authoritative server time + { + let state = app.state::(); + sync_tray_timer(&state, result.tracked_seconds); + } + // Compute next fire from the server-provided nextExpectedAt. + // If parsing fails or the target is in the past, default to + // "fire now" (catch-up). Upper-bounded at 2x interval as a + // guard against malformed responses. + let parsed_target_ms = parse_iso_to_unix_ms(&result.next_expected_at); + let now_ms = current_unix_ms(); + let delay_ms = match parsed_target_ms { + Some(target) => (target - now_ms).max(0) as u64, + None => CAPTURE_INTERVAL_SECS * 1000, + }; + let delay_ms = delay_ms.min(CAPTURE_INTERVAL_SECS * 2 * 1000); + *next_fire = + tokio::time::Instant::now() + tokio::time::Duration::from_millis(delay_ms); + let _ = app.emit("capture-tick-result", CaptureTickResult::from(result)); + true + } + Err(e) => { + eprintln!("[capture-loop] upload failed: {e}"); + let _ = app.emit( + "capture-tick-error", + CaptureTickError { message: e.clone() }, + ); + // No server target available — fall back to a full interval. + *next_fire = tokio::time::Instant::now() + interval_dur; + // Check if the server paused/stopped the session + match handle_sleep_recovery(app, config).await { + Ok(true) => true, + Ok(false) => false, + Err(_) => true, + } + } + } + } + + // Clip capability comes from the server, once per loop run. Any fetch + // failure (or clips off) means legacy JPEG mode, bit-for-bit. + let initial_config = { + let state = app.state::(); + let guard = state.config.lock().unwrap(); + guard.clone() + }; + let caps = match &initial_config { + Some(c) => fetch_clip_capabilities(c).await, + None => SessionClipCapabilities::default(), + }; + // Mutable: latches off after MAX_CLIP_ENCODER_FAILURES consecutive + // encoder failures, so a machine with a broken encoder settles into + // plain JPEG mode instead of retrying forever. + let mut clips_mode = caps.clips_enabled; + let mut clip_encoder_failures: u32 = 0; + // Server-authoritative cadence, clamped defensively against a + // misbehaving server so the loop can't spin or stall. + let frame_interval_ms = caps + .frame_interval_ms + .unwrap_or(DEFAULT_FRAME_INTERVAL_MS) + .clamp(500, 30_000); + let frame_dur = Duration::from_millis(frame_interval_ms); + let mut recorder: Option = None; + if clips_mode { + eprintln!("[capture-loop] clips enabled (frame every {frame_interval_ms}ms)"); + } + + // Clips: hold the first upload back so the opening clip has a few frames + // and the session activates promptly. Fixed delay, NOT a multiple of the + // cadence — see CLIP_FIRST_CUT_DELAY_MS. JPEG mode keeps the legacy + // immediate first tick. + next_fire = TokioInstant::now() + + if clips_mode { + Duration::from_millis(CLIP_FIRST_CUT_DELAY_MS) + } else { + Duration::ZERO + }; - // Run one capture tick + // The opening window is shorter than one frame interval, so at the normal + // cadence the first clip would hold a single frame. Capture it densely + // enough to carry a handful; after the first upload the cadence returns + // to the server's value. + let opening_frame_dur = Duration::from_millis((CLIP_FIRST_CUT_DELAY_MS / 4).max(500)); + let mut first_upload_done = false; + + // The in-flight upload, if any. Uploads run CONCURRENTLY with frame + // capture: a multi-second clip finalize+upload must not punch a hole in + // the recording every minute — serially that compounds to minutes of + // missing screen time per hour. Strictly one upload at a time: the next + // tick settles the previous one before cutting, which preserves + // capturedAt monotonicity and the per-session rate-limit assumptions. + let mut upload_handle: Option>> = + None; + let mut upload_cfg: Option = None; + + 'outer: loop { + // ── Wait until next_fire, collecting frames along the way ── + // Frames run at the clip cadence (server-set, 6/min) through the + // SAME redaction-aware capture path as uploads. In clips mode every + // frame is recorded into the current clip; the JPEG preview side + // is focus-gated either way (nobody can see it unfocused). + // sleep_until returns immediately when next_fire is already past + // (catch-up), which also skips frame collection. + let cadence = if first_upload_done { + frame_dur + } else { + opening_frame_dur + }; + loop { + let now = TokioInstant::now(); + if now >= next_fire { + break; + } + let wake = std::cmp::min(now + cadence, next_fire); + // Third arm: the in-flight upload finishing mid-wait. Its body + // only records the outcome — applying it (which needs mutable + // access to upload_handle/next_fire) happens after the select. + let mut upload_outcome: Option> = None; + tokio::select! { + _ = sleep_until(wake) => {} + _ = cancel_rx.changed() => { + eprintln!("[capture-loop] cancelled"); + break 'outer; + } + res = async { + match upload_handle.as_mut() { + Some(h) => match h.await { + Ok(r) => r, + Err(e) => Err(format!("upload task panicked: {e}")), + }, + None => unreachable!("guarded by select condition"), + } + }, if upload_handle.is_some() => { + upload_outcome = Some(res); + } + } + if let Some(res) = upload_outcome { + upload_handle = None; + let cfg = upload_cfg.take().expect("cfg tracks upload_handle"); + if !apply_upload_result(&app, &cfg, res, &mut next_fire, interval_dur).await { + break 'outer; + } + // next_fire was just refined by the confirm — recompute the + // wake target instead of falling through with a stale one. + continue; + } + // Woke for the upload tick, not a frame. + if TokioInstant::now() >= next_fire { + break; + } + + let focused = app + .get_webview_window("main") + .map(|w| w.is_focused().unwrap_or(false)) + .unwrap_or(false); + if !clips_mode && !focused { + continue; + } + + let jpeg_mode = if focused { + GrabJpeg::Preview + } else { + GrabJpeg::None + }; + match grab_frame(&app, &sources, max_width, max_height, jpeg_quality, jpeg_mode).await + { + Ok(grab) => { + if clips_mode { + if recorder.is_none() { + match clips::ClipRecorder::new( + grab.image.width(), + grab.image.height(), + frame_interval_ms, + ) { + Ok(r) => recorder = Some(r), + Err(e) => { + eprintln!( + "[capture-loop] clip encoder init failed: {e} — JPEG fallback this interval" + ); + note_clip_failure( + &mut clip_encoder_failures, + &mut clips_mode, + ); + } + } + } + if let Some(r) = recorder.as_mut() { + if let Err(e) = r.push_frame(&grab.image) { + eprintln!( + "[capture-loop] clip frame append failed: {e} — dropping clip, JPEG fallback" + ); + if let Some(r) = recorder.take() { + r.discard(); + } + note_clip_failure(&mut clip_encoder_failures, &mut clips_mode); + } + } + } + if focused { + if let Some(jpeg) = grab.jpeg { + let _ = app.emit( + "capture-preview-frame", + CapturePreviewFrame { + preview_base64: base64_encode(&jpeg.data), + preview_width: jpeg.width, + preview_height: jpeg.height, + }, + ); + } + } + } + Err(e) => { + // Frame-level failure: log and keep going — the upload + // tick has its own error handling and retry cadence. + eprintln!("[capture-loop] frame capture failed: {e}"); + } + } + } + + // ── Upload tick ── let now = StdInstant::now(); let elapsed_secs = now.duration_since(last_tick).as_secs(); last_tick = now; @@ -1746,6 +2932,11 @@ async fn capture_loop_task( "[capture-loop] detected sleep (gap: {}s), checking session status...", elapsed_secs ); + // A clip spanning a sleep gap would carry an hours-long hole — + // drop it and start fresh after recovery. + if let Some(r) = recorder.take() { + r.discard(); + } match handle_sleep_recovery(&app, &config).await { Ok(true) => { /* continue capturing */ } Ok(false) => break, @@ -1753,42 +2944,26 @@ async fn capture_loop_task( } } - // Take screenshot (blocking I/O via xcap — run on blocking threadpool) - let blacklisted = { - let state = app.state::(); - state - .blacklisted_apps - .lock() - .map(|g| g.clone()) - .unwrap_or_default() - }; - - #[allow(unused_mut, unused_assignments)] - let mut pipewire_fds = std::collections::HashMap::new(); - #[cfg(target_os = "linux")] - { - let state = app.state::(); - if let Ok(guard) = state.pipewire_fds.lock() { - pipewire_fds = guard.clone(); + // A previous upload still in flight (very slow network): settle it + // before cutting the next clip so uploads stay strictly ordered — + // capturedAt monotonicity and the per-session rate limits both + // assume order. + if let Some(handle) = upload_handle.take() { + let cfg = upload_cfg.take().expect("cfg tracks upload_handle"); + let res = match handle.await { + Ok(r) => r, + Err(e) => Err(format!("upload task panicked: {e}")), }; + if !apply_upload_result(&app, &cfg, res, &mut next_fire, interval_dur).await { + break; + } } - let sources_clone = sources.clone(); - let bl = blacklisted; - let pw_fds = pipewire_fds; - let screenshot_result = tokio::task::spawn_blocking(move || { - capture::take_stitched_screenshots_with_blacklist( - &sources_clone, - max_width, - max_height, - jpeg_quality, - &pw_fds, - &bl, - ) - }) - .await - .map_err(|e| format!("spawn_blocking panicked: {e}")) - .and_then(|r| r); + // Grab the tick frame — the clip's final frame, the UI preview, + // and the JPEG fallback, all from one capture. Full-size JPEG: + // this one may be uploaded. + let grab_result = + grab_frame(&app, &sources, max_width, max_height, jpeg_quality, GrabJpeg::Full).await; // Capture the wall-clock moment NOW — that's the value we'll send // as `capturedAt`, not when the upload eventually reaches the server. @@ -1798,57 +2973,113 @@ async fn capture_loop_task( None }; - match screenshot_result { - Ok(screenshot) => { - match upload_and_confirm( - &screenshot.base64, - screenshot.width, - screenshot.height, - captured_at.as_deref(), - &config, - &app, - ) - .await - { - Ok(result) => { - // Sync tray timer to authoritative server time - { - let state = app.state::(); - sync_tray_timer(&state, result.tracked_seconds); + match grab_result { + Ok(grab) => { + let capture::RawCaptureResult { + data: jpeg_data, + width: jpeg_w, + height: jpeg_h, + } = grab.jpeg.expect("tick grab always requests jpeg"); + let jpeg_base64 = base64_encode(&jpeg_data); + let jpeg_bytes = bytes::Bytes::from(jpeg_data); + + // Clips: append the final frame and cut this interval's clip. + let clip = if clips_mode { + if recorder.is_none() { + recorder = clips::ClipRecorder::new( + grab.image.width(), + grab.image.height(), + frame_interval_ms, + ) + .map_err(|e| { + eprintln!("[capture-loop] clip encoder init failed: {e}"); + note_clip_failure(&mut clip_encoder_failures, &mut clips_mode); + }) + .ok(); + } + if let Some(r) = recorder.as_mut() { + if let Err(e) = r.push_frame(&grab.image) { + eprintln!("[capture-loop] clip frame append failed: {e}"); + if let Some(r) = recorder.take() { + r.discard(); + } + note_clip_failure(&mut clip_encoder_failures, &mut clips_mode); } - // Compute next fire from the server-provided - // nextExpectedAt. If parsing fails or the target is - // in the past, default to "fire now" (catch-up). - let parsed_target_ms = parse_iso_to_unix_ms(&result.next_expected_at); - let now_ms = current_unix_ms(); - let delay_ms = match parsed_target_ms { - Some(target) => (target - now_ms).max(0) as u64, - None => CAPTURE_INTERVAL_SECS * 1000, - }; - // Safety upper-bound: never sleep longer than 2x interval, - // protects against malformed responses. - let delay_ms = delay_ms.min(CAPTURE_INTERVAL_SECS * 2 * 1000); - next_fire = TokioInstant::now() + Duration::from_millis(delay_ms); - let _ = app.emit("capture-tick-result", CaptureTickResult::from(result)); } - Err(e) => { - eprintln!("[capture-loop] upload failed: {e}"); - let _ = app.emit( - "capture-tick-error", - CaptureTickError { - message: e.clone(), - }, - ); - // No server target available — fall back to interval. - next_fire = TokioInstant::now() + interval_dur; - // Check if server paused the session - match handle_sleep_recovery(&app, &config).await { - Ok(true) => { /* continue */ } - Ok(false) => break, - Err(_) => {} + match recorder.take().map(|r| r.finish()) { + Some(Ok(c)) => { + // A clip made it out whole — the encoder works, + // so earlier failures were transient. + clip_encoder_failures = 0; + Some(c) } + Some(Err(e)) => { + eprintln!( + "[capture-loop] clip finalize failed: {e} — uploading JPEG instead" + ); + note_clip_failure(&mut clip_encoder_failures, &mut clips_mode); + None + } + None => None, } - } + } else { + None + }; + + // Spawn the upload as a background task — frame capture for + // the NEXT clip resumes immediately instead of stalling for + // the finalize+upload round trip (which would put a hole in + // the recording every minute). Clip first; ANY clip-upload + // failure (size cap, server downgrade, transient) retries + // the tick as a JPEG so the credit streak never skips a + // beat. + let task_app = app.clone(); + let task_config = config.clone(); + let task_captured_at = captured_at.clone(); + upload_handle = Some(tokio::spawn(async move { + let jpeg_fallback = + UploadPayload::jpeg(jpeg_bytes, jpeg_base64.clone(), jpeg_w, jpeg_h); + match clip { + Some(c) => { + match upload_and_confirm( + UploadPayload::mp4(c, jpeg_base64), + task_captured_at.as_deref(), + &task_config, + &task_app, + ) + .await + { + Ok(r) => Ok(r), + Err(e) => { + eprintln!( + "[capture-loop] clip upload failed ({e}) — retrying tick as JPEG" + ); + upload_and_confirm( + jpeg_fallback, + task_captured_at.as_deref(), + &task_config, + &task_app, + ) + .await + } + } + } + None => { + upload_and_confirm( + jpeg_fallback, + task_captured_at.as_deref(), + &task_config, + &task_app, + ) + .await + } + } + })); + upload_cfg = Some(config.clone()); + // Provisional next tick one interval out; refined to the + // server's nextExpectedAt when the confirm lands mid-wait + // (see the wait-loop's third select arm). + next_fire = TokioInstant::now() + interval_dur; } Err(e) => { eprintln!("[capture-loop] screenshot failed: {e}"); @@ -1863,15 +3094,16 @@ async fn capture_loop_task( } } - // Wait until next_fire or cancellation. sleep_until returns - // immediately if next_fire is already in the past (catch-up). - tokio::select! { - _ = sleep_until(next_fire) => {} - _ = cancel_rx.changed() => { - eprintln!("[capture-loop] cancelled"); - break; - } - } + // Whatever happened, the opening window is over — later intervals + // are full-length, so the normal cadence applies (a failed first + // upload must not run the fast cadence across a 60s retry window). + first_upload_done = true; + } + + // Never leave a half-recorded clip (or its temp file) behind on + // pause/stop/cancel. + if let Some(r) = recorder.take() { + r.discard(); } eprintln!("[capture-loop] stopped"); @@ -2025,9 +3257,14 @@ async fn start_tray_ticker( app: AppHandle, ) -> Result<(), String> { let timer_state = start_tray_timer(&app, &state); - timer_state.tracked_seconds.store(tracked_seconds, Ordering::Relaxed); - let mut started = timer_state.started_at.lock().unwrap(); - *started = StdInstant::now(); + // Ratchet, don't store: `start_tray_timer` returns the *existing* state + // if a session is already being tracked, and a re-entrant call with a + // stale (or zero) baseline would knock the menu bar backwards. + ratchet_tray_tracked_seconds(&timer_state, tracked_seconds); + { + let mut started = timer_state.started_at.lock().unwrap(); + *started = StdInstant::now(); + } timer_state.is_running.store(true, Ordering::Relaxed); Ok(()) } @@ -2045,12 +3282,7 @@ fn resume_tray_ticker( tracked_seconds: i64, state: State<'_, AppState>, ) -> Result<(), String> { - { - let guard = state.tray_timer.lock().unwrap(); - if let Some(ref handle) = *guard { - handle.state.tracked_seconds.store(tracked_seconds, Ordering::Relaxed); - } - } + sync_tray_timer(&state, tracked_seconds); resume_tray_timer(&state); Ok(()) } @@ -2079,6 +3311,11 @@ fn base64_decode(b64: &str) -> Result, String> { .map_err(|e| format!("Base64 decode failed: {e}")) } +fn base64_encode(data: &[u8]) -> String { + use base64_engine::*; + ENGINE.encode(data) +} + mod base64_engine { pub use base64::engine::general_purpose::STANDARD as ENGINE; pub use base64::Engine; @@ -2254,11 +3491,14 @@ pub fn run() { disable_vibrancy, is_wayland, open_external_url, + native_menu::show_add_menu, + native_menu::prefetch_add_menu_icons, request_screencast, add_screencast, set_blacklisted_apps, get_blacklisted_apps, - list_running_apps, + list_installed_apps, + get_app_icon, tray::show_tray, tray::update_tray_time, tray::hide_tray, @@ -2268,25 +3508,15 @@ pub fn run() { ]) .manage(tray::TrayStateMutex(std::sync::Mutex::new(tray::TrayState::default()))) .setup(|app| { + // Warm the installed-app cache off-thread so the first visit to + // Filtered Apps is instant rather than paying for the scan. + prewarm_installed_apps(); + #[cfg(target_os = "macos")] { - // Disable App Nap so macOS doesn't throttle WebView timers when - // the window is occluded or Low Power Mode is on. The capture - // loop runs entirely in JS, so throttled timers = missed screenshots. - // The returned activity token is intentionally leaked (never ended) - // so the assertion lasts for the lifetime of the process. - { - use objc2_foundation::{NSActivityOptions, NSProcessInfo, NSString}; - let info = NSProcessInfo::processInfo(); - let reason = NSString::from_str("Periodic screenshot capture must not be throttled"); - let opts = NSActivityOptions::LatencyCritical - | NSActivityOptions::IdleSystemSleepDisabled; - let _activity = info.beginActivityWithOptions_reason(opts, &reason); - // Leak the token so the activity assertion persists. - std::mem::forget(_activity); - eprintln!("[power] App Nap suppression enabled"); - } - + // NOTE: App Nap / idle-sleep suppression is scoped to active + // recordings — see the `power` module. It is deliberately NOT + // asserted here for the whole process lifetime. use tauri::menu::{AboutMetadata, Menu, MenuItem, PredefinedMenuItem, Submenu}; let app_menu = Submenu::with_items( @@ -2479,15 +3709,17 @@ pub fn run() { eprintln!("[exit] pausing session before exit"); let app_handle = app.clone(); tauri::async_runtime::spawn(async move { - let client = reqwest::Client::builder() - .timeout(std::time::Duration::from_secs(5)) - .build() - .unwrap_or_default(); + let client = http_client(); let url = format!( "{}/api/sessions/{}/pause", config.api_base_url, config.token ); - match client.post(&url).send().await { + match client + .post(&url) + .timeout(std::time::Duration::from_secs(5)) + .send() + .await + { Ok(res) => eprintln!("[exit] pause response: {}", res.status()), Err(e) => eprintln!("[exit] pause failed (best-effort): {e}"), } @@ -2510,6 +3742,93 @@ pub fn run() { // compat guarantee: an unupgraded user's binary in the wild keeps working. // ────────────────────────────────────────────────────────────────── +/// The menu-bar clock must agree with the main window's clock. Both tick +/// independently, so they only stay together if these rules match +/// `deriveDisplaySeconds` / `useSessionTimerState` in @lookout/react. +#[cfg(test)] +mod tray_timer_tests { + use super::{ + format_tray_time, ratchet_tray_tracked_seconds, tray_display_seconds, TrayTimerState, + MAX_TRAY_INTERPOLATION_SECS, + }; + use std::sync::atomic::{AtomicBool, AtomicI64, Ordering}; + use std::sync::Mutex; + use std::time::Instant; + + fn state(tracked: i64) -> TrayTimerState { + TrayTimerState { + tracked_seconds: AtomicI64::new(tracked), + started_at: Mutex::new(Instant::now()), + is_running: AtomicBool::new(true), + } + } + + #[test] + fn cap_matches_the_js_side() { + // MAX_INTERPOLATION_S in useSessionTimer.ts is + // SCREENSHOT_INTERVAL_MS / 1000 = 60. + assert_eq!(MAX_TRAY_INTERPOLATION_SECS, 60); + } + + #[test] + fn interpolates_at_wall_clock_rate() { + assert_eq!(tray_display_seconds(120, 0, true), 120); + assert_eq!(tray_display_seconds(120, 30, true), 150); + } + + #[test] + fn interpolation_is_capped_at_one_interval() { + // Without the cap the menu bar kept counting through a capture stall + // while the main window froze at base + 60, and the two never + // reconverged — the reported "menu bar shows a different time". + assert_eq!(tray_display_seconds(120, 90, true), 180); + assert_eq!(tray_display_seconds(120, 600, true), 180); + } + + #[test] + fn pause_drops_the_interpolated_remainder() { + // The main window snaps down to the base on pause. Freezing at the + // interpolated value here left the menu bar up to a minute ahead for + // the whole pause. + assert_eq!(tray_display_seconds(120, 45, false), 120); + // Clock-style title: the paused value is the base, formatted exactly — + // 299s is 04:59, not the 4m the minute-granularity title used to show. + assert_eq!( + format_tray_time(tray_display_seconds(299, 59, false)), + "04:59" + ); + } + + #[test] + fn ratchet_ignores_a_stale_lower_reading() { + // An idempotent retry can confirm against a stale read and return a + // lower trackedSeconds. JS keeps the higher value; storing the lower + // one here made the menu bar jump backwards and sit behind. + let s = state(120); + ratchet_tray_tracked_seconds(&s, 60); + assert_eq!(s.tracked_seconds.load(Ordering::Relaxed), 120); + ratchet_tray_tracked_seconds(&s, 180); + assert_eq!(s.tracked_seconds.load(Ordering::Relaxed), 180); + } + + #[test] + fn ratchet_re_anchors_only_on_advance() { + let s = state(120); + let before = *s.started_at.lock().unwrap(); + + // A repeated reading must not restart the interpolation window, or + // the menu bar loses time the main window is still counting. + ratchet_tray_tracked_seconds(&s, 120); + assert_eq!(*s.started_at.lock().unwrap(), before); + ratchet_tray_tracked_seconds(&s, 60); + assert_eq!(*s.started_at.lock().unwrap(), before); + + // A real advance re-anchors. + ratchet_tray_tracked_seconds(&s, 180); + assert!(*s.started_at.lock().unwrap() > before); + } +} + #[cfg(test)] mod compat_tests { use super::{ diff --git a/clients/desktop/src-tauri/src/native_menu.rs b/clients/desktop/src-tauri/src/native_menu.rs new file mode 100644 index 00000000..0cd8df20 --- /dev/null +++ b/clients/desktop/src-tauri/src/native_menu.rs @@ -0,0 +1,131 @@ +//! Raycast-style popup menu for the gallery's "+" button, rendered by Swift +//! (swift/lookout-tray/Sources/AddMenu.swift) as a borderless NSPanel with +//! SwiftUI content. The frontend invokes `show_add_menu` with the items and +//! the button's rect (CSS px, viewport-relative — the webview spans the whole +//! window on macOS, so those are window coordinates); the command resolves to +//! the chosen item's id, or None when the menu is dismissed. + +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AddMenuEntry { + pub id: Option, + pub label: Option, + pub symbol: Option, + /// Remote image shown instead of `symbol`, which stays the fallback. + #[serde(rename = "iconUrl")] + pub icon_url: Option, + #[serde(default)] + pub separator: bool, +} + +#[derive(Debug, Clone, Copy, Deserialize)] +pub struct AddMenuAnchor { + pub x: f64, + pub y: f64, + pub width: f64, + pub height: f64, +} + +#[cfg(target_os = "macos")] +mod imp { + use super::{AddMenuAnchor, AddMenuEntry}; + use std::ffi::{c_char, c_void, CStr, CString}; + use std::sync::Mutex; + use tokio::sync::oneshot; + + extern "C" { + fn lookout_add_menu_show( + items_json: *const c_char, + ns_window: *mut c_void, + x: f64, + y: f64, + w: f64, + h: f64, + cb: extern "C" fn(*const c_char), + ); + fn lookout_add_menu_prefetch_icons(urls_json: *const c_char); + } + + pub fn prefetch_icons(urls: &[String]) -> Result<(), String> { + let json = serde_json::to_string(urls).map_err(|e| e.to_string())?; + let json = CString::new(json).map_err(|e| e.to_string())?; + unsafe { lookout_add_menu_prefetch_icons(json.as_ptr()) }; + Ok(()) + } + + /// Only one menu can be open; replacing the sender cancels the previous + /// command's await (its receiver resolves to None). + static PENDING: Mutex>>> = Mutex::new(None); + + /// Selection callback from Swift; runs on the main thread. Null = dismissed. + extern "C" fn on_select(id: *const c_char) { + let value = if id.is_null() { + None + } else { + Some(unsafe { CStr::from_ptr(id) }.to_string_lossy().into_owned()) + }; + if let Some(tx) = PENDING.lock().unwrap().take() { + let _ = tx.send(value); + } + } + + pub async fn show( + window: tauri::WebviewWindow, + entries: Vec, + anchor: AddMenuAnchor, + ) -> Result, String> { + let json = serde_json::to_string(&entries).map_err(|e| e.to_string())?; + let json = CString::new(json).map_err(|e| e.to_string())?; + let (tx, rx) = oneshot::channel(); + *PENDING.lock().unwrap() = Some(tx); + // Scoped so the (!Send) NSWindow pointer isn't held across the await. + { + let ns_window = window.ns_window().map_err(|e| e.to_string())?; + unsafe { + lookout_add_menu_show( + json.as_ptr(), + ns_window, + anchor.x, + anchor.y, + anchor.width, + anchor.height, + on_select, + ); + } + } + Ok(rx.await.unwrap_or(None)) + } +} + +/// Warm the Swift-side icon cache so the menu never shows fallback symbols +/// for programs whose icons are known ahead of time. No-op off macOS. +#[tauri::command] +pub fn prefetch_add_menu_icons(urls: Vec) -> Result<(), String> { + #[cfg(target_os = "macos")] + { + imp::prefetch_icons(&urls) + } + #[cfg(not(target_os = "macos"))] + { + let _ = urls; + Ok(()) + } +} + +#[tauri::command] +pub async fn show_add_menu( + window: tauri::WebviewWindow, + entries: Vec, + anchor: AddMenuAnchor, +) -> Result, String> { + #[cfg(target_os = "macos")] + { + imp::show(window, entries, anchor).await + } + #[cfg(not(target_os = "macos"))] + { + let _ = (window, entries, anchor); + Err("native add menu is only implemented on macOS".into()) + } +} diff --git a/clients/desktop/src-tauri/src/native_tray.rs b/clients/desktop/src-tauri/src/native_tray.rs new file mode 100644 index 00000000..fe615097 --- /dev/null +++ b/clients/desktop/src-tauri/src/native_tray.rs @@ -0,0 +1,60 @@ +//! Thin FFI wrapper around the Swift-implemented menu-bar item +//! (swift/lookout-tray). The Swift side owns the NSStatusItem and renders the +//! recorded time with SwiftUI's `contentTransition(.numericText())`, so digit +//! changes roll like the system timer instead of snapping. Clicks come back +//! through a C callback carrying the item's screen rect (logical points, +//! top-left origin), which feeds the same tray-window toggle used by the +//! tauri tray on other platforms. + +use std::ffi::CString; +use std::os::raw::c_char; +use std::sync::OnceLock; + +use tauri::AppHandle; + +extern "C" { + fn lookout_tray_set_callback(cb: extern "C" fn(f64, f64, f64, f64)); + fn lookout_tray_show(text: *const c_char, icon: *const u8, icon_len: i32); + fn lookout_tray_update(text: *const c_char, paused: i32); + fn lookout_tray_hide(); +} + +static APP: OnceLock = OnceLock::new(); + +/// Click callback from Swift; runs on the main thread. +extern "C" fn on_tray_click(x: f64, y: f64, w: f64, h: f64) { + if let Some(app) = APP.get() { + let rect = tauri::Rect { + position: tauri::LogicalPosition::new(x, y).into(), + size: tauri::LogicalSize::new(w, h).into(), + }; + crate::tray::toggle_tray_window(app, rect); + } +} + +pub fn show(app: &AppHandle, time_text: &str) -> Result<(), String> { + let _ = APP.set(app.clone()); + let text = CString::new(time_text).map_err(|e| e.to_string())?; + let icon: &[u8] = include_bytes!("../icons/timelapse_template.png"); + unsafe { + lookout_tray_set_callback(on_tray_click); + lookout_tray_show(text.as_ptr(), icon.as_ptr(), icon.len() as i32); + } + Ok(()) +} + +/// `paused: None` keeps the current pause state (used by the 1s ticker). +pub fn update(time_text: &str, paused: Option) -> Result<(), String> { + let text = CString::new(time_text).map_err(|e| e.to_string())?; + let p = match paused { + None => -1, + Some(false) => 0, + Some(true) => 1, + }; + unsafe { lookout_tray_update(text.as_ptr(), p) }; + Ok(()) +} + +pub fn hide() { + unsafe { lookout_tray_hide() }; +} diff --git a/clients/desktop/src-tauri/src/tray.rs b/clients/desktop/src-tauri/src/tray.rs index 7bdc6e94..54877c31 100644 --- a/clients/desktop/src-tauri/src/tray.rs +++ b/clients/desktop/src-tauri/src/tray.rs @@ -1,16 +1,28 @@ use serde::{Deserialize, Serialize}; use std::sync::Mutex; +#[cfg(not(target_os = "macos"))] use tauri::image::Image; +#[cfg(not(target_os = "macos"))] use tauri::tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent}; use tauri::{AppHandle, Emitter, LogicalPosition, Manager, WebviewUrl, WebviewWindowBuilder}; +/// State handed to the tray popup window, which ticks its own clock so it +/// stays live while the main WebView is throttled. +/// +/// It carries the interpolation *anchor*, not a display value: the popup +/// re-derives the ticking time with the same rules as the main window +/// (see `useSessionTimerState` in @lookout/react). Passing the main +/// window's already-interpolated `displaySeconds` here meant the popup +/// extrapolated on top of an extrapolation and drifted ahead of it. #[derive(Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct TrayState { - pub display_seconds: u32, + /// Ratcheted server-authoritative tracked seconds. + pub base_seconds: u32, pub screenshot_count: u32, pub control_mode: String, - pub updated_at: u64, + /// `Date.now()` (ms) when `base_seconds` last advanced. + pub anchor_at: u64, } impl Default for TrayState { @@ -21,10 +33,10 @@ impl Default for TrayState { .unwrap() .as_millis() as u64; Self { - display_seconds: 0, + base_seconds: 0, screenshot_count: 0, control_mode: "recording".to_string(), - updated_at: now, + anchor_at: now, } } } @@ -33,6 +45,18 @@ pub struct TrayStateMutex(pub Mutex); #[tauri::command] pub fn show_tray(time_text: String, app: AppHandle) -> Result<(), String> { + // macOS gets a native NSStatusItem rendered with SwiftUI so the time + // digits animate with contentTransition(.numericText()) — see + // swift/lookout-tray. Other platforms keep the tauri tray. + #[cfg(target_os = "macos")] + return crate::native_tray::show(&app, &time_text); + + #[cfg(not(target_os = "macos"))] + show_tauri_tray(time_text, app) +} + +#[cfg(not(target_os = "macos"))] +fn show_tauri_tray(time_text: String, app: AppHandle) -> Result<(), String> { if app.tray_by_id("timelapse_tray").is_some() { return Ok(()); } @@ -42,6 +66,9 @@ pub fn show_tray(time_text: String, app: AppHandle) -> Result<(), String> { let _tray = TrayIconBuilder::with_id("timelapse_tray") .title(&time_text) + // Windows doesn't render tray titles — the tooltip is the only place + // the recorded time is visible there. + .tooltip(format!("Lookout — {time_text} recorded")) .icon(icon) .icon_as_template(true) .on_tray_icon_event(move |tray, event| { @@ -63,7 +90,7 @@ pub fn show_tray(time_text: String, app: AppHandle) -> Result<(), String> { Ok(()) } -fn toggle_tray_window(app: &AppHandle, rect: tauri::Rect) { +pub(crate) fn toggle_tray_window(app: &AppHandle, rect: tauri::Rect) { if let Some(window) = app.get_webview_window("tray") { if window.is_visible().unwrap_or(false) { let _ = window.hide(); @@ -164,15 +191,42 @@ fn position_and_show_window( } #[tauri::command] -pub fn update_tray_time(time_text: String, _is_paused: bool, app: AppHandle) -> Result<(), String> { - if let Some(tray) = app.tray_by_id("timelapse_tray") { - let _ = tray.set_title(Some(time_text)); +pub fn update_tray_time(time_text: String, is_paused: bool, app: AppHandle) -> Result<(), String> { + // The Swift side renders its own pause glyph and tooltip. + #[cfg(target_os = "macos")] + { + let _ = app; + return crate::native_tray::update(&time_text, Some(is_paused)); + } + + // Show a pause glyph in the menu bar while paused. The Rust ticker skips + // its updates while the timer is paused, so this sticks until resume — + // and its first running tick force-refreshes the plain title back. + #[cfg(not(target_os = "macos"))] + { + let title = if is_paused { + format!("⏸ {time_text}") + } else { + time_text.clone() + }; + let tooltip = if is_paused { + format!("Lookout — paused at {time_text}") + } else { + format!("Lookout — {time_text} recorded") + }; + if let Some(tray) = app.tray_by_id("timelapse_tray") { + let _ = tray.set_title(Some(title)); + let _ = tray.set_tooltip(Some(tooltip)); + } + Ok(()) } - Ok(()) } #[tauri::command] pub fn hide_tray(app: AppHandle) -> Result<(), String> { + #[cfg(target_os = "macos")] + crate::native_tray::hide(); + #[cfg(not(target_os = "macos"))] app.remove_tray_by_id("timelapse_tray"); if let Some(w) = app.get_webview_window("tray") { let _ = w.close(); diff --git a/clients/desktop/src-tauri/swift/lookout-tray/Package.swift b/clients/desktop/src-tauri/swift/lookout-tray/Package.swift new file mode 100644 index 00000000..bbcbd204 --- /dev/null +++ b/clients/desktop/src-tauri/swift/lookout-tray/Package.swift @@ -0,0 +1,13 @@ +// swift-tools-version:5.5 +import PackageDescription + +let package = Package( + name: "lookout-tray", + platforms: [.macOS(.v10_15)], + products: [ + .library(name: "lookout-tray", type: .static, targets: ["lookout-tray"]) + ], + targets: [ + .target(name: "lookout-tray", path: "Sources") + ] +) diff --git a/clients/desktop/src-tauri/swift/lookout-tray/Sources/AddMenu.swift b/clients/desktop/src-tauri/swift/lookout-tray/Sources/AddMenu.swift new file mode 100644 index 00000000..59857c82 --- /dev/null +++ b/clients/desktop/src-tauri/swift/lookout-tray/Sources/AddMenu.swift @@ -0,0 +1,399 @@ +// Raycast-style popup menu for the gallery's "+" button. A borderless +// NSPanel with a SwiftUI list over an NSVisualEffectView, anchored under the +// button — native chrome (blur, shadow, key handling) without the stock +// NSMenu look. Rust calls lookout_add_menu_show with the items as JSON and +// the button rect in window coordinates (logical points, top-left origin); +// the selected item id comes back through a C callback, nil on dismissal. + +import AppKit +import SwiftUI + +public typealias AddMenuCallback = @convention(c) (UnsafePointer?) -> Void + +/// Transparent padding around the menu inside its panel, so the spring's +/// overshoot (scale > 1) and the SwiftUI-drawn drop shadow have room to draw +/// instead of clipping at the window edge. The positioning math subtracts it +/// back out. +private let addMenuOvershootMargin: CGFloat = 28 + +struct AddMenuEntry: Decodable { + var id: String? + var label: String? + var symbol: String? + var iconUrl: String? + var separator: Bool? + var isSeparator: Bool { separator == true } +} + +/// In-memory icon store, warmed via lookout_add_menu_prefetch_icons when the +/// frontend loads the program registry — AsyncImage alone re-fetches on every +/// open, which showed the fallback symbol for ~0.5s each time. Main-thread +/// access only. +final class AddMenuIconCache { + static let shared = AddMenuIconCache() + private var images: [String: NSImage] = [:] + private var inflight: Set = [] + + func image(for url: String) -> NSImage? { images[url] } + + func prefetch(_ urls: [String]) { + for u in urls where images[u] == nil && !inflight.contains(u) { + guard let url = URL(string: u) else { continue } + inflight.insert(u) + URLSession.shared.dataTask(with: url) { data, _, _ in + DispatchQueue.main.async { + self.inflight.remove(u) + if let data, let img = NSImage(data: data) { + self.images[u] = img + } + } + }.resume() + } + } +} + +@available(macOS 12.0, *) +final class AddMenuModel: ObservableObject { + let entries: [AddMenuEntry] + @Published var selection: Int? + /// Drives the fling-in scale. Set by the controller once the panel is on + /// screen — SwiftUI's own onAppear fires a commit later than orderFront, + /// which read as a frozen frame before the spring started. + @Published var appeared = false + let onActivate: (String?) -> Void + + init(entries: [AddMenuEntry], onActivate: @escaping (String?) -> Void) { + self.entries = entries + self.onActivate = onActivate + } + + private var selectable: [Int] { + entries.indices.filter { !entries[$0].isSeparator } + } + + func moveSelection(_ delta: Int) { + let indices = selectable + guard !indices.isEmpty else { return } + guard let current = selection, let pos = indices.firstIndex(of: current) else { + selection = delta > 0 ? indices.first : indices.last + return + } + let next = (pos + delta + indices.count) % indices.count + selection = indices[next] + } + + func activateSelection() { + guard let i = selection, !entries[i].isSeparator else { return } + onActivate(entries[i].id) + } +} + +@available(macOS 12.0, *) +private struct MenuEffectBackground: NSViewRepresentable { + func makeNSView(context: Context) -> NSVisualEffectView { + let view = NSVisualEffectView() + view.material = .menu + view.blendingMode = .behindWindow + view.state = .active + return view + } + func updateNSView(_ nsView: NSVisualEffectView, context: Context) {} +} + +@available(macOS 12.0, *) +private struct AddMenuRow: View { + let entry: AddMenuEntry + let highlighted: Bool + + var body: some View { + HStack(spacing: 9) { + icon + Text(entry.label ?? "") + .font(.system(size: 13.5, weight: .medium)) + .foregroundColor(.primary) + .lineLimit(1) + Spacer(minLength: 0) + } + .padding(.horizontal, 9) + .padding(.vertical, 7) + .frame(maxWidth: .infinity, alignment: .leading) + .background( + RoundedRectangle(cornerRadius: 9, style: .continuous) + .fill(highlighted ? Color.primary.opacity(0.09) : Color.clear) + ) + .contentShape(RoundedRectangle(cornerRadius: 9, style: .continuous)) + } + + /// Program logo when the entry carries one; SF Symbol as the fallback + /// (and as the placeholder while the image loads or if it fails). + @ViewBuilder + private var icon: some View { + if let iconUrl = entry.iconUrl, let cached = AddMenuIconCache.shared.image(for: iconUrl) { + Image(nsImage: cached) + .resizable() + .aspectRatio(contentMode: .fit) + .clipShape(RoundedRectangle(cornerRadius: 5, style: .continuous)) + .frame(width: 18, height: 18) + } else if let iconUrl = entry.iconUrl, let url = URL(string: iconUrl) { + // Cold miss (first open before the prefetch landed) — load in + // place, symbol as the placeholder. + AsyncImage(url: url) { phase in + if let image = phase.image { + image + .resizable() + .aspectRatio(contentMode: .fit) + .clipShape(RoundedRectangle(cornerRadius: 5, style: .continuous)) + } else { + symbolIcon + } + } + .frame(width: 18, height: 18) + } else { + symbolIcon + .frame(width: 18) + } + } + + @ViewBuilder + private var symbolIcon: some View { + if let symbol = entry.symbol { + Image(systemName: symbol) + .font(.system(size: 13, weight: .medium)) + .foregroundColor(highlighted ? .primary : .secondary) + } + } +} + +@available(macOS 12.0, *) +struct AddMenuView: View { + @ObservedObject var model: AddMenuModel + + var body: some View { + container( + VStack(alignment: .leading, spacing: 1) { + ForEach(Array(model.entries.enumerated()), id: \.offset) { i, entry in + if entry.isSeparator { + Divider() + .padding(.vertical, 4) + .padding(.horizontal, 10) + } else { + AddMenuRow(entry: entry, highlighted: model.selection == i) + .onHover { inside in + if inside { + model.selection = i + } else if model.selection == i { + model.selection = nil + } + } + .onTapGesture { model.onActivate(entry.id) } + } + } + } + .padding(6) + .frame(minWidth: 220, maxWidth: 320, alignment: .leading) + ) + // Drawn here rather than by the window (hasShadow) — AppKit snapshots + // the window shadow once, mid-fling, leaving a stale outline at the + // wrong scale. This one tracks the animation. + .shadow(color: Color.black.opacity(0.28), radius: 16, x: 0, y: 6) + .scaleEffect(model.appeared ? 1 : 0.85, anchor: .topTrailing) + .padding(addMenuOvershootMargin) + } + + private func container(_ content: V) -> some View { + content + .background(MenuEffectBackground()) + .clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous)) + .overlay( + RoundedRectangle(cornerRadius: 14, style: .continuous) + .strokeBorder(Color.primary.opacity(0.12), lineWidth: 1) + ) + } +} + +/// Borderless windows refuse key status by default; the menu needs it for +/// Escape/arrow keys and to learn when the user clicks away (resignKey). +@available(macOS 12.0, *) +private final class AddMenuPanel: NSPanel { + override var canBecomeKey: Bool { true } +} + +@available(macOS 12.0, *) +final class AddMenuController: NSObject, NSWindowDelegate { + static let shared = AddMenuController() + + private var panel: NSPanel? + private var callback: AddMenuCallback? + private var keyMonitor: Any? + private var finished = true + + func show(entries: [AddMenuEntry], parent: NSWindow, anchor: NSRect, cb: @escaping AddMenuCallback) { + // A stale panel here means Rust already abandoned its callback — just + // tear the old one down without firing anything. + closePanel() + finished = false + callback = cb + + let model = AddMenuModel(entries: entries) { [weak self] id in + self?.finish(id) + } + let hosting = NSHostingView(rootView: AddMenuView(model: model)) + // fittingSize includes the transparent overshoot margin on all sides; + // the menu's own width is clamped by the SwiftUI frame modifier. + let size = hosting.fittingSize + + let panel = AddMenuPanel( + contentRect: NSRect(origin: .zero, size: size), + styleMask: [.borderless, .nonactivatingPanel], + backing: .buffered, + defer: false + ) + panel.isOpaque = false + panel.backgroundColor = .clear + panel.hasShadow = false // shadow is drawn in SwiftUI; see AddMenuView + panel.level = .popUpMenu + panel.isReleasedWhenClosed = false + panel.collectionBehavior = [.transient, .ignoresCycle] + panel.contentView = hosting + panel.delegate = self + panel.setFrame(frame(for: size, parent: parent, anchor: anchor), display: false) + self.panel = panel + + keyMonitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { [weak self] event in + guard let self, let panel = self.panel, event.window === panel else { return event } + switch event.keyCode { + case 53: // escape + self.finish(nil) + return nil + case 125: // down + model.moveSelection(1) + return nil + case 126: // up + model.moveSelection(-1) + return nil + case 36, 76: // return, keypad enter + model.activateSelection() + return nil + default: + return event + } + } + + panel.alphaValue = 0 + panel.makeKeyAndOrderFront(nil) + // Kick fade and fling on the next tick, after the first frame (at + // 0.85 scale, alpha 0) has committed — starting them together is what + // makes the pop read as one motion. + DispatchQueue.main.async { [weak self] in + guard let self, self.panel === panel else { return } + NSAnimationContext.runAnimationGroup { ctx in + ctx.duration = 0.15 + panel.animator().alphaValue = 1 + } + // Damping scales with √stiffness to keep the same slight overshoot. + withAnimation(.interpolatingSpring(stiffness: 1200, damping: 46, initialVelocity: 0)) { + model.appeared = true + } + } + } + + /// Screen frame for the menu: right edge aligned with the button, dropped + /// just below it, clamped to the visible screen (flips above the button + /// when there's no room underneath). The anchor rect is in window + /// coordinates with a top-left origin; the webview spans the whole window + /// (Overlay titlebar), so window-relative math is enough. + private func frame(for size: NSSize, parent: NSWindow, anchor: NSRect) -> NSRect { + let margin = addMenuOvershootMargin + // The visible menu box, excluding the transparent overshoot margin. + let menu = NSSize(width: size.width - 2 * margin, height: size.height - 2 * margin) + let wf = parent.frame + let gap: CGFloat = 6 + var x = wf.origin.x + anchor.origin.x + anchor.width - menu.width + let anchorBottomY = wf.maxY - (anchor.origin.y + anchor.height) + var y = anchorBottomY - gap - menu.height + + if let visible = (parent.screen ?? NSScreen.main)?.visibleFrame { + x = max(visible.minX + 8, min(x, visible.maxX - menu.width - 8)) + if y < visible.minY + 8 { + let anchorTopY = wf.maxY - anchor.origin.y + y = anchorTopY + gap + } + } + // Expand back out so the window carries the margin on every side. + return NSRect(x: x - margin, y: y - margin, width: size.width, height: size.height) + } + + func windowDidResignKey(_ notification: Notification) { + finish(nil) + } + + private func finish(_ id: String?) { + guard !finished else { return } + finished = true + let cb = callback + callback = nil + if let id { + id.withCString { cb?($0) } + } else { + cb?(nil) + } + closePanel() + } + + private func closePanel() { + if let monitor = keyMonitor { + NSEvent.removeMonitor(monitor) + keyMonitor = nil + } + guard let panel = panel else { return } + self.panel = nil + panel.delegate = nil + NSAnimationContext.runAnimationGroup({ ctx in + ctx.duration = 0.17 + panel.animator().alphaValue = 0 + }, completionHandler: { + panel.orderOut(nil) + }) + } +} + +@_cdecl("lookout_add_menu_prefetch_icons") +public func lookoutAddMenuPrefetchIcons(_ urlsJson: UnsafePointer) { + let json = String(cString: urlsJson) + DispatchQueue.main.async { + guard let data = json.data(using: .utf8), + let urls = try? JSONDecoder().decode([String].self, from: data) + else { return } + AddMenuIconCache.shared.prefetch(urls) + } +} + +@_cdecl("lookout_add_menu_show") +public func lookoutAddMenuShow( + _ itemsJson: UnsafePointer, + _ windowPtr: UnsafeMutableRawPointer, + _ x: Double, + _ y: Double, + _ w: Double, + _ h: Double, + _ cb: @escaping AddMenuCallback +) { + let json = String(cString: itemsJson) + DispatchQueue.main.async { + guard #available(macOS 12.0, *), + let data = json.data(using: .utf8), + let entries = try? JSONDecoder().decode([AddMenuEntry].self, from: data), + !entries.isEmpty + else { + cb(nil) + return + } + let window = Unmanaged.fromOpaque(windowPtr).takeUnretainedValue() + AddMenuController.shared.show( + entries: entries, + parent: window, + anchor: NSRect(x: x, y: y, width: w, height: h), + cb: cb + ) + } +} diff --git a/clients/desktop/src-tauri/swift/lookout-tray/Sources/Tray.swift b/clients/desktop/src-tauri/swift/lookout-tray/Sources/Tray.swift new file mode 100644 index 00000000..b8541b91 --- /dev/null +++ b/clients/desktop/src-tauri/swift/lookout-tray/Sources/Tray.swift @@ -0,0 +1,189 @@ +// Native macOS menu-bar item for Lookout. Owns an NSStatusItem whose content +// is a SwiftUI view, so the recorded-time digits animate with the real +// `contentTransition(.numericText())` (the system timer's rolling-digit +// effect). Rust talks to this through the @_cdecl functions at the bottom; +// clicks come back through a C callback carrying the item's screen rect +// (logical points, top-left origin) so Rust can position the tray window. + +import AppKit +import SwiftUI + +public typealias TrayClickCallback = @convention(c) (Double, Double, Double, Double) -> Void + +@available(macOS 10.15, *) +final class TrayModel: ObservableObject { + static let shared = TrayModel() + @Published var text: String = "0m" + @Published var paused: Bool = false + var icon: NSImage? +} + +/// Reports the content's natural width up to TrayController, which sets the +/// NSStatusItem length — the status-bar button doesn't size itself from +/// subview constraints, so without this the text truncates to "…". +@available(macOS 10.15, *) +struct TrayWidthKey: PreferenceKey { + static var defaultValue: CGFloat = 0 + static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) { + value = nextValue() + } +} + +@available(macOS 10.15, *) +struct TrayContentView: View { + @ObservedObject var model = TrayModel.shared + + var body: some View { + HStack(spacing: 4) { + if let icon = model.icon { + Image(nsImage: icon) + .renderingMode(.template) + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: 17, height: 17) + } + if model.paused, #available(macOS 11.0, *) { + Image(systemName: "pause.fill") + .font(.system(size: 9)) + } + timeText + } + .padding(.horizontal, 3) + .fixedSize() + .background( + GeometryReader { geo in + Color.clear.preference(key: TrayWidthKey.self, value: geo.size.width) + } + ) + .onPreferenceChange(TrayWidthKey.self) { width in + TrayController.shared.setLength(width) + } + } + + @ViewBuilder + private var timeText: some View { + if #available(macOS 13.0, *) { + Text(model.text) + .font(.system(size: 13.5).monospacedDigit()) + .contentTransition(.numericText()) + } else { + Text(model.text) + .font(.system(size: 13.5)) + } + } +} + +/// NSHostingView swallows mouse events, which would break the status-bar +/// button's target/action (and its click highlight). Punching through hitTest +/// lets the button own the interaction while SwiftUI only draws. +@available(macOS 10.15, *) +final class PassthroughHostingView: NSHostingView { + override func hitTest(_ point: NSPoint) -> NSView? { nil } +} + +@available(macOS 10.15, *) +final class TrayController: NSObject { + static let shared = TrayController() + var item: NSStatusItem? + var callback: TrayClickCallback? + + func show() { + guard item == nil else { return } + let item = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength) + self.item = item + guard let button = item.button else { return } + + let hosting = PassthroughHostingView(rootView: TrayContentView()) + hosting.translatesAutoresizingMaskIntoConstraints = false + button.addSubview(hosting) + NSLayoutConstraint.activate([ + hosting.topAnchor.constraint(equalTo: button.topAnchor), + hosting.bottomAnchor.constraint(equalTo: button.bottomAnchor), + hosting.leadingAnchor.constraint(equalTo: button.leadingAnchor), + hosting.trailingAnchor.constraint(equalTo: button.trailingAnchor), + ]) + + button.target = self + button.action = #selector(clicked(_:)) + } + + /// Called by the SwiftUI view whenever its natural width changes. + func setLength(_ width: CGFloat) { + guard width > 0 else { return } + item?.length = width + } + + @objc func clicked(_ sender: Any?) { + guard let button = item?.button, let window = button.window else { return } + let frame = window.frame + // AppKit rects are bottom-left origin; the Rust side wants top-left. + let screenH = NSScreen.screens.first?.frame.height ?? 0 + callback?( + frame.origin.x, + screenH - frame.origin.y - frame.height, + frame.width, + frame.height + ) + } + + func hide() { + if let item = item { + NSStatusBar.system.removeStatusItem(item) + } + item = nil + } +} + +@_cdecl("lookout_tray_set_callback") +public func lookoutTraySetCallback(_ cb: @escaping TrayClickCallback) { + DispatchQueue.main.async { + guard #available(macOS 10.15, *) else { return } + TrayController.shared.callback = cb + } +} + +@_cdecl("lookout_tray_show") +public func lookoutTrayShow(_ text: UnsafePointer, _ iconBytes: UnsafePointer?, _ iconLen: Int32) { + let s = String(cString: text) + let iconData = iconBytes.map { Data(bytes: $0, count: Int(iconLen)) } + DispatchQueue.main.async { + guard #available(macOS 10.15, *) else { return } + if TrayModel.shared.icon == nil, let data = iconData, let img = NSImage(data: data) { + img.isTemplate = true + TrayModel.shared.icon = img + } + TrayModel.shared.text = s + TrayModel.shared.paused = false + TrayController.shared.show() + TrayController.shared.item?.button?.toolTip = "Lookout — \(s) recorded" + } +} + +/// paused: -1 keeps the current pause state (the 1s ticker), 0/1 set it. +@_cdecl("lookout_tray_update") +public func lookoutTrayUpdate(_ text: UnsafePointer, _ paused: Int32) { + let s = String(cString: text) + DispatchQueue.main.async { + guard #available(macOS 10.15, *) else { return } + let p = paused < 0 ? TrayModel.shared.paused : (paused != 0) + if #available(macOS 13.0, *) { + withAnimation(.spring(response: 0.4, dampingFraction: 0.9)) { + TrayModel.shared.text = s + TrayModel.shared.paused = p + } + } else { + TrayModel.shared.text = s + TrayModel.shared.paused = p + } + TrayController.shared.item?.button?.toolTip = + p ? "Lookout — paused at \(s)" : "Lookout — \(s) recorded" + } +} + +@_cdecl("lookout_tray_hide") +public func lookoutTrayHide() { + DispatchQueue.main.async { + guard #available(macOS 10.15, *) else { return } + TrayController.shared.hide() + } +} diff --git a/clients/desktop/src-tauri/tauri.conf.json b/clients/desktop/src-tauri/tauri.conf.json index f54e394f..6f0d80f4 100644 --- a/clients/desktop/src-tauri/tauri.conf.json +++ b/clients/desktop/src-tauri/tauri.conf.json @@ -2,7 +2,7 @@ "$schema": "https://raw.githubusercontent.com/nicehash/tauri/refs/tags/tauri-v2.3.1/crates/tauri-utils/schema.json", "productName": "Lookout", "identifier": "com.hackclub.lookout", - "version": "0.3.5", + "version": "0.3.7", "build": { "frontendDist": "../dist", "devUrl": "http://localhost:1420", @@ -31,14 +31,21 @@ } ], "security": { - "csp": "default-src 'self'; connect-src https://lookout.hackclub.com https://*.r2.cloudflarestorage.com http://localhost:* ws://localhost:* ipc: tauri: lookout-preview: http://lookout-preview.localhost https://*.ingest.sentry.io https://*.ingest.us.sentry.io; img-src 'self' blob: data: https://lookout.hackclub.com https://*.r2.cloudflarestorage.com lookout-preview: http://lookout-preview.localhost; style-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-inline'; media-src 'self' blob: https://lookout.hackclub.com https://*.r2.cloudflarestorage.com; font-src 'self' data:", + "csp": "default-src 'self'; connect-src https: http://localhost:* ws://localhost:* ipc: tauri: lookout-preview: http://lookout-preview.localhost; img-src 'self' blob: data: https: lookout-preview: http://lookout-preview.localhost; style-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-inline'; media-src 'self' blob: https:; font-src 'self' data:", "dangerousDisableAssetCspModification": true } }, "bundle": { "active": true, "createUpdaterArtifacts": true, - "targets": ["app", "dmg", "nsis", "deb", "appimage", "rpm"], + "targets": [ + "app", + "dmg", + "nsis", + "deb", + "appimage", + "rpm" + ], "macOS": { "entitlements": "./Entitlements.plist" }, @@ -48,12 +55,38 @@ "icons/128x128@2x.png", "icons/icon.icns", "icons/icon.ico" - ] + ], + "linux": { + "deb": { + "depends": [ + "gstreamer1.0-pipewire", + "gstreamer1.0-plugins-base", + "gstreamer1.0-plugins-good", + "gstreamer1.0-plugins-bad" + ], + "recommends": [ + "gstreamer1.0-plugins-ugly" + ] + }, + "rpm": { + "depends": [ + "pipewire-gstreamer", + "gstreamer1-plugins-base", + "gstreamer1-plugins-good", + "gstreamer1-plugins-bad-free" + ], + "recommends": [ + "gstreamer1-plugin-openh264" + ] + } + } }, "plugins": { "deep-link": { "desktop": { - "schemes": ["lookout"] + "schemes": [ + "lookout" + ] } }, "updater": { diff --git a/clients/desktop/src/App.tsx b/clients/desktop/src/App.tsx index 864f937f..c7527520 100644 --- a/clients/desktop/src/App.tsx +++ b/clients/desktop/src/App.tsx @@ -4,6 +4,7 @@ import { listen } from "@tauri-apps/api/event"; import { confirm } from "@tauri-apps/plugin-dialog"; import { invoke } from "./logger.js"; import { getCurrentWindow } from "@tauri-apps/api/window"; +import { Menu, MenuItem, PredefinedMenuItem } from "@tauri-apps/api/menu"; import { AnimatePresence, motion } from "motion/react"; import { Gallery, @@ -11,24 +12,51 @@ import { useTokenStore, useGallery, useHashRouter, - spacing, + type AddAnchor, } from "@lookout/react"; import { getVersion } from "@tauri-apps/api/app"; +import { ArrowSquareOutIcon, PlusIcon } from "@phosphor-icons/react"; import { isValidToken, extractToken } from "./utils.js"; -import { PermissionScreen } from "./components/PermissionScreen.js"; +import { + checkCameraPermission, + checkScreenRecordingPermission, +} from "tauri-plugin-macos-permissions-api"; +import { PermissionScreen, permCacheKey } from "./components/PermissionScreen.js"; import { RecordPage } from "./components/RecordPage.js"; import { AddSessionPage } from "./components/AddSessionPage.js"; import { SettingsPage } from "./components/SettingsPage.js"; import { TrayApp } from "./components/TrayApp.js"; +import { + EditorWindow, + EditorOpenPlaceholder, + useEditorWindowOpen, + openEditorWindow, + EDITED_EVENT, +} from "./components/EditorWindow.js"; import { useBlacklistedApps } from "./hooks/useBlacklistedApps.js"; -import { useUpdateCheck } from "./hooks/useUpdateCheck.js"; -import { useBackgroundUpdate } from "./hooks/useBackgroundUpdate.js"; +import { useAppUpdate } from "./hooks/useAppUpdate.js"; import { useAnnouncement } from "./hooks/useAnnouncement.js"; import { ensureNotificationPermission } from "./hooks/useSessionNotifications.js"; -import { UpdateBanner } from "./components/UpdateBanner.js"; +import { UpdatePill } from "./components/UpdatePill.js"; +import { AddMenuPopup, type AddMenuPopupItem } from "./components/AddMenuPopup.js"; import { AnnouncementBanner } from "./components/AnnouncementBanner.js"; - -const API_BASE = "https://lookout.hackclub.com"; +import { getApiBase } from "./serverConfig.js"; + +// Read once per webview load; Settings → Server reloads the view on change. +const API_BASE = getApiBase(); + +// How long to keep watching a post-edit cut-compile for `complete` before +// giving up on firing the redirect hook. The worker's assemble step alone +// can run up to 30 min (ASSEMBLE_TIMEOUT_MS); add slack for queue wait and +// the final upload so a legitimately slow compile is never abandoned. +const REDIRECT_POLL_MAX_MS = 35 * 60_000; + +interface Program { + name: string; + displayName?: string; + newSessionUrl: string; + iconUrl?: string | null; +} /** Pause a session by token. Fire-and-forget, logs errors. */ async function pauseSession(token: string): Promise { @@ -58,35 +86,156 @@ export function App() { if (isTray) { return ; } + // Dedicated editor window (see EditorWindow.tsx). Branches before + // MainWindowApp so it skips the permission gates, vibrancy, deep-link + // handlers, and the rest of the main-window machinery. + const editorMatch = window.location.hash.match(/^#\/?editor\?token=([0-9a-fA-F]{64})/); + if (editorMatch) { + return ; + } return ; } function MainWindowApp() { const isMacOS = navigator.userAgent.includes("Mac"); - const [screenPermGranted, setScreenPermGranted] = useState(!isMacOS); - const [cameraPermGranted, setCameraPermGranted] = useState(!isMacOS); + // A cached grant skips the permission gate (no boot flicker); a background + // re-check below yanks it back if the permission was revoked since. + const [screenPermGranted, setScreenPermGranted] = useState( + () => !isMacOS || localStorage.getItem(permCacheKey("screen")) === "1", + ); + const [cameraPermGranted, setCameraPermGranted] = useState( + () => !isMacOS || localStorage.getItem(permCacheKey("camera")) === "1", + ); + + useEffect(() => { + if (!isMacOS) return; + (async () => { + try { + if (localStorage.getItem(permCacheKey("screen")) === "1" && !(await checkScreenRecordingPermission())) { + console.warn("[permissions] screen recording revoked — regating"); + localStorage.removeItem(permCacheKey("screen")); + setScreenPermGranted(false); + } + if (localStorage.getItem(permCacheKey("camera")) === "1" && !(await checkCameraPermission())) { + console.warn("[permissions] camera revoked — regating"); + localStorage.removeItem(permCacheKey("camera")); + setCameraPermGranted(false); + } + } catch { + // Plugin unavailable — leave the cached grants alone + } + })(); + }, [isMacOS]); const [isWayland, setIsWayland] = useState(false); const { route, navigate } = useHashRouter(); const tokenStore = useTokenStore(); - const updateStatus = useUpdateCheck(); + const appUpdate = useAppUpdate(); const gallery = useGallery({ apiBaseUrl: API_BASE, tokens: tokenStore.getAllTokenValues(), }); + // While the editor window is up, the main window steps aside entirely — + // two views of the same session competing for attention is worse than + // one clear pointer to where the work is happening. + const editorWindowToken = useEditorWindowOpen(); + + // Bumped when an editor window applies cuts — remounts the open + // SessionDetail so it re-fetches (picks up the compiling → complete flip + // and the recompiled video) and refreshes gallery thumbnails. + const [editNonce, setEditNonce] = useState(0); + const galleryRefreshRef = React.useRef(gallery.refresh); + galleryRefreshRef.current = gallery.refresh; + + // The redirect hook must fire exactly once per session, from whichever + // path observes the timelapse finish. Both paths funnel through here. + const redirectFiredRef = React.useRef>(new Set()); + const fireRedirect = useCallback((token: string, url: string | null) => { + if (!url || redirectFiredRef.current.has(token)) return; + redirectFiredRef.current.add(token); + console.log("[app] firing redirect hook"); + invoke("open_external_url", { url }).catch((e) => + console.error("[app] redirect hook failed:", e), + ); + }, []); + + useEffect(() => { + let unlisten: (() => void) | undefined; + let cancelled = false; + + listen<{ token: string; status?: string | null; redirectUrl?: string | null }>( + EDITED_EVENT, + (event) => { + console.log("[app] editor window published — refreshing"); + setEditNonce((n) => n + 1); + galleryRefreshRef.current(); + + // Publishing from the editor can land instantly (no cuts) or after a + // cut-compile. Either way SessionDetail may mount on an + // already-complete session, and its onComplete deliberately doesn't + // fire for that — so the redirect hook would be silently skipped in + // the whole edit flow. Fire it from here instead. + const token = event.payload?.token; + if (!token) return; + + // Instant publish (no cuts): the /compile response already told us + // it's `complete` and carried the redirect URL. Fire now — no poll. + if (event.payload?.status === "complete") { + fireRedirect(token, event.payload.redirectUrl ?? null); + return; + } + + // A compile is running server-side. Poll until it's terminal. + // The worker's assemble step alone can run up to ASSEMBLE_TIMEOUT_MS + // (30 min); a fixed few-minute deadline abandoned long compiles + // before they finished. Cap at that budget plus queue/upload slack, + // and back off so a busy worker isn't hammered. + const deadline = Date.now() + REDIRECT_POLL_MAX_MS; + let delay = 2500; + const poll = async () => { + if (cancelled || Date.now() > deadline) return; + try { + const res = await fetch(`${API_BASE}/api/sessions/${token}/status`); + if (res.ok) { + const data = await res.json(); + if (data.status === "complete") { + galleryRefreshRef.current(); + fireRedirect(token, data.redirectUrl ?? null); + return; + } + if (data.status === "failed") return; + } + } catch { + // Transient — the retry below covers it. + } + delay = Math.min(delay * 1.5, 15_000); + setTimeout(poll, delay); + }; + void poll(); + }, + ).then((fn) => { unlisten = fn; }); + + return () => { + cancelled = true; + if (unlisten) unlisten(); + }; + }, [fireRedirect]); + // Initialize blacklisted apps sync from localStorage to Rust backend useBlacklistedApps(); - // Request notification permission as soon as the app is past the update - // gate — so the OS prompt appears at launch, not deferred to recording start. - const updateSettled = updateStatus.state === "idle"; + // Boot timing: first React commit and the frame after it (≈ first paint). useEffect(() => { - if (updateSettled) void ensureNotificationPermission(); - }, [updateSettled]); + console.log(`[boot] app mounted at ${Math.round(performance.now())}ms`); + requestAnimationFrame(() => + console.log(`[boot] first frame at ${Math.round(performance.now())}ms`), + ); + }, []); - // Poll the update server in the background once past the launch update gate; - // surfaces a "restart to update" banner on the gallery when a build ships. - const backgroundUpdate = useBackgroundUpdate(updateSettled); + // Request notification permission at launch, not deferred to recording start. + useEffect(() => { + void ensureNotificationPermission(); + }, []); // Admin-authored announcement banner; checked on open and every 15 min. const announcement = useAnnouncement(); @@ -96,6 +245,173 @@ function MainWindowApp() { invoke("is_wayland").then(setIsWayland).catch(() => {}); }, []); + // Program registry cache for the + button's native popup menu. Warmed at + // launch and refreshed on every open so the menu appears instantly with + // whatever we have; the AddSessionPage stays the fallback (paste-a-link, + // empty registry, non-macOS). + const programsRef = React.useRef([]); + const fetchPrograms = useCallback(async () => { + try { + const res = await fetch(`${API_BASE}/api/programs`); + if (!res.ok) return; + const data = await res.json(); + if (Array.isArray(data.programs)) { + programsRef.current = data.programs; + // Warm the icon cache so the menu never opens with fallback symbols + // while images load — the Swift-side cache on macOS, the browser's + // HTTP cache for the DOM popup elsewhere. + const urls = programsRef.current + .map((p) => p.iconUrl) + .filter((u): u is string => !!u); + if (urls.length) { + if (isMacOS) { + invoke("prefetch_add_menu_icons", { urls }).catch(() => {}); + } else { + for (const url of urls) new Image().src = url; + } + } + } + } catch (e) { + console.warn("[programs] failed to load registry:", e); + } + }, [isMacOS]); + useEffect(() => { + void fetchPrograms(); + }, [fetchPrograms]); + + // Windows/Linux add menu — a DOM replica of the macOS NSPanel popup. + const [addMenu, setAddMenu] = useState<{ items: AddMenuPopupItem[]; anchor: AddAnchor } | null>(null); + + /** Acts on an add-menu choice, from either the native panel or the DOM popup. */ + const handleMenuChoice = useCallback( + async (choice: string | null) => { + if (!choice) return; // dismissed + if (choice === "create-new") { + navigate({ page: "add" }); + return; + } + const program = programsRef.current.find((p) => `program:${p.name}` === choice); + if (!program) return; + try { + await invoke("open_external_url", { url: program.newSessionUrl }); + } catch (e) { + console.error("[add-menu] failed to open program url:", e); + navigate({ page: "add" }); + } + }, + [navigate], + ); + + const handleAdd = useCallback( + async (anchor: AddAnchor) => { + // Clicking the + while the DOM popup is open toggles it closed (the + // popup ignores pointerdowns on the anchor so this click reaches us). + if (addMenu) { + setAddMenu(null); + return; + } + const programs = programsRef.current; + void fetchPrograms(); // refresh behind the menu for next open + if (programs.length === 0) { + navigate({ page: "add" }); + return; + } + if (!isMacOS) { + setAddMenu({ + items: [ + ...programs.map((p) => ({ + id: `program:${p.name}`, + label: p.displayName || p.name, + iconUrl: p.iconUrl ?? undefined, + // Stays visible while the icon loads or when a program has none. + fallbackIcon: , + })), + { separator: true }, + { id: "create-new", label: "Create new timelapse", fallbackIcon: }, + ], + anchor, + }); + return; + } + const entries = [ + ...programs.map((p) => ({ + id: `program:${p.name}`, + label: p.displayName || p.name, + // The symbol stays as the fallback while the icon loads or when a + // program has none. + symbol: "arrow.up.forward.app", + iconUrl: p.iconUrl ?? undefined, + })), + { separator: true }, + { id: "create-new", label: "Start from link", symbol: "plus" }, + ]; + let choice: string | null; + try { + choice = await invoke("show_add_menu", { entries, anchor }); + } catch (e) { + console.warn("[add-menu] native menu failed, falling back to page:", e); + navigate({ page: "add" }); + return; + } + await handleMenuChoice(choice); + }, + [isMacOS, addMenu, fetchPrograms, navigate, handleMenuChoice], + ); + + // Opens a session the way clicking its card would: recordable sessions go to + // the record page, finished ones to their detail view. + const openSession = useCallback( + (token: string) => { + const session = gallery.sessions.find((s) => s.token === token); + if (session && ["pending", "active", "paused"].includes(session.status)) { + navigate({ page: "record", token }); + } else { + navigate({ page: "session", token }); + } + }, + [gallery.sessions, navigate], + ); + + const archiveSession = useCallback( + async (token: string) => { + const yes = await confirm("Are you sure you want to archive this session?", { + title: "Archive Session", + kind: "warning", + }); + if (yes) { + tokenStore.archiveToken(token); + gallery.refresh(); + } + }, + [tokenStore, gallery], + ); + + // Native right-click menu for a gallery card. Uses Tauri's menu plugin so the + // popup is a real OS context menu rather than a DOM overlay. + const handleSessionContextMenu = useCallback( + async (token: string) => { + const session = gallery.sessions.find((s) => s.token === token); + const items: (MenuItem | PredefinedMenuItem)[] = [ + await MenuItem.new({ text: "Open", action: () => openSession(token) }), + ]; + if (session && session.status === "complete") { + items.push( + await MenuItem.new({ + text: "Open in Editor", + action: () => { void openEditorWindow(token); }, + }), + ); + } + items.push(await PredefinedMenuItem.new({ item: "Separator" })); + items.push( + await MenuItem.new({ text: "Archive", action: () => { void archiveSession(token); } }), + ); + const menu = await Menu.new({ items }); + await menu.popup(); + }, + [gallery.sessions, openSession, archiveSession], + ); + // Deep link handler -- saves token and navigates appropriately. // If currently recording another session, pauses it first. // Tracks the last processed URL to deduplicate retried cold-start emits. @@ -213,14 +529,14 @@ function MainWindowApp() { // So we just rely on standard browser matchMedia to get the universal native standard. const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)"); const getSystemTheme = () => mediaQuery.matches ? "dark" : "light"; - + const applyTheme = () => { const theme = getSystemTheme(); updateTheme(theme); // Force the Tauri window GTK decorations to match the media query since winit is confused getCurrentWindow().setTheme(theme).catch(() => {}); }; - + applyTheme(); const listener = () => applyTheme(); @@ -269,7 +585,7 @@ function MainWindowApp() { const prevRootBg = root?.style.background ?? ""; let effectsApplied = false; - + const isLinux = navigator.userAgent.toLowerCase().includes("linux"); if (!isLinux) { invoke("enable_vibrancy") @@ -302,6 +618,10 @@ function MainWindowApp() { // Step 2: Route const content = (() => { + // The editor owns the session while its window is open. + if (editorWindowToken) { + return ; + } switch (route.page) { case "gallery": return ( @@ -309,38 +629,14 @@ function MainWindowApp() { sessions={gallery.sessions} loading={gallery.loading} error={gallery.error} - onSessionClick={(token) => { - const session = gallery.sessions.find((s) => s.token === token); - if (session && ["pending", "active", "paused"].includes(session.status)) { - navigate({ page: "record", token }); - } else { - navigate({ page: "session", token }); - } - }} - onArchive={async (token) => { - const yes = await confirm("Are you sure you want to archive this session?", { title: "Archive Session", kind: "warning" }); - if (yes) { - tokenStore.archiveToken(token); - gallery.refresh(); - } - }} - onAdd={() => navigate({ page: "add" })} - onSettings={isWayland ? undefined : () => navigate({ page: "settings" })} - banner={ - announcement || backgroundUpdate.availableVersion ? ( -
- {/* Announcement sits above the update banner. */} - {announcement && } - {backgroundUpdate.availableVersion && ( - - )} -
- ) : undefined - } + onSessionClick={openSession} + onArchive={archiveSession} + onSessionContextMenu={handleSessionContextMenu} + onAdd={handleAdd} + // Always available: the Server subpage works everywhere; only the + // Filtered Apps subpage is Wayland-restricted (it shows a notice). + onSettings={() => navigate({ page: "settings" })} + banner={announcement ? : undefined} /> ); case "settings": @@ -378,9 +674,17 @@ function MainWindowApp() { case "session": return ( { void openEditorWindow(route.token); }} + onComplete={({ redirectUrl }) => { + // Redirect hook: the session's creator asked us to send the + // user somewhere once their timelapse is ready. Shared + // de-dupe with the post-edit watcher above, so a session + // seen finishing by both paths only redirects once. + fireRedirect(route.token, redirectUrl); + }} onBack={() => { gallery.refresh(); navigate({ page: "gallery" }); @@ -429,46 +733,45 @@ function MainWindowApp() { Sentry.setTag("session_token", token ?? null); }, [route]); - const routeKey = `${route.page}:${(route as { token?: string }).token ?? ""}`; - - if (updateStatus.state !== "idle") { - return ( -
- {isMacOS && ( -
- )} -
- {updateStatus.state === "checking" && "Checking for updates…"} - {updateStatus.state === "no-update" && updateStatus.message} - {updateStatus.state === "downloading" && `Updating… ${updateStatus.progress}%`} - {updateStatus.state === "installing" && "Installing update…"} - {updateStatus.state === "done" && "Restarting…"} -
-
- ); - } + // The editor placeholder participates in the route transition, so the + // main window slides to it and back instead of hard-cutting. + const routeKey = editorWindowToken + ? `editor-open:${editorWindowToken}` + : `${route.page}:${(route as { token?: string }).token ?? ""}`; return ( -
- {/* Draggable Titlebar Area that dodges the traffic lights (macOS only) */} - {isMacOS && ( +
+ {/* Draggable Titlebar Area that dodges the traffic lights (macOS only). + The update pill lives here, Ghostty-style — the titlebar is a + transparent webview overlay, so it renders inside the real titlebar. */} + {isMacOS ? (
+ style={{ height: 32, flexShrink: 0, width: "100%", zIndex: 9999, background: "transparent", cursor: "default", display: "flex", alignItems: "center", justifyContent: "flex-end", paddingRight: 6, boxSizing: "border-box" }} + > + +
+ ) : ( + /* No overlay titlebar on Windows/Linux — float the pill bottom-left. */ +
+ +
)} + {/* Windows/Linux + menu. Rendered here, outside the route transition's + transformed wrapper, so position:fixed anchors to the viewport. */} + + {addMenu && ( + { + setAddMenu(null); + void handleMenuChoice(choice); + }} + /> + )} +
void; +} + +const GAP = 6; // px between the button and the menu +const EDGE = 8; // min distance from the viewport edges + +function Row({ item, highlighted, onHover, onLeave, onActivate }: { + item: AddMenuPopupItem; + highlighted: boolean; + onHover: () => void; + onLeave: () => void; + onActivate: () => void; +}) { + const [failed, setFailed] = useState(false); + const showImage = !!item.iconUrl && !failed; + return ( +
+ + {showImage ? ( + setFailed(true)} + style={{ width: 18, height: 18, objectFit: "contain", borderRadius: 5, display: "block" }} + /> + ) : ( + item.fallbackIcon + )} + + + {item.label} + +
+ ); +} + +export function AddMenuPopup({ items, anchor, onSelect }: AddMenuPopupProps) { + const ref = useRef(null); + const [selection, setSelection] = useState(null); + // Drops below the anchor by default; flipped above when there's no room. + const [flipped, setFlipped] = useState(false); + + // Keep the latest onSelect without re-binding the listeners below. + const onSelectRef = useRef(onSelect); + onSelectRef.current = onSelect; + + useLayoutEffect(() => { + const el = ref.current; + if (!el) return; + const below = anchor.y + anchor.height + GAP; + setFlipped(below + el.offsetHeight > window.innerHeight - EDGE); + }, [anchor, items]); + + // Click-away and window blur dismiss, like the NSPanel's resignKey. + useEffect(() => { + const onPointerDown = (e: PointerEvent) => { + const el = ref.current; + if (el && el.contains(e.target as Node)) return; + // The + button itself toggles the menu in its click handler; closing + // here too would make that click immediately reopen it. + if ( + e.clientX >= anchor.x && e.clientX <= anchor.x + anchor.width && + e.clientY >= anchor.y && e.clientY <= anchor.y + anchor.height + ) return; + onSelectRef.current(null); + }; + const onBlur = () => onSelectRef.current(null); + document.addEventListener("pointerdown", onPointerDown, true); + window.addEventListener("blur", onBlur); + return () => { + document.removeEventListener("pointerdown", onPointerDown, true); + window.removeEventListener("blur", onBlur); + }; + }, [anchor]); + + // Escape / arrows / enter, mirroring the Swift key monitor. + useEffect(() => { + const selectable = items + .map((item, i) => ({ item, i })) + .filter(({ item }) => !item.separator) + .map(({ i }) => i); + const onKeyDown = (e: KeyboardEvent) => { + switch (e.key) { + case "Escape": + e.preventDefault(); + onSelectRef.current(null); + break; + case "ArrowDown": + case "ArrowUp": { + e.preventDefault(); + if (selectable.length === 0) return; + const delta = e.key === "ArrowDown" ? 1 : -1; + setSelection((current) => { + const pos = current === null ? -1 : selectable.indexOf(current); + if (pos === -1) return delta > 0 ? selectable[0] : selectable[selectable.length - 1]; + return selectable[(pos + delta + selectable.length) % selectable.length]; + }); + break; + } + case "Enter": + e.preventDefault(); + setSelection((current) => { + if (current !== null && !items[current].separator) { + onSelectRef.current(items[current].id ?? null); + } + return current; + }); + break; + } + }; + document.addEventListener("keydown", onKeyDown, true); + return () => document.removeEventListener("keydown", onKeyDown, true); + }, [items]); + + const right = Math.max(EDGE, window.innerWidth - (anchor.x + anchor.width)); + + return ( + + {items.map((item, i) => + item.separator ? ( +
+ ) : ( + setSelection(i)} + onLeave={() => setSelection((s) => (s === i ? null : s))} + onActivate={() => onSelect(item.id ?? null)} + /> + ), + )} + + ); +} diff --git a/clients/desktop/src/components/AddSessionPage.tsx b/clients/desktop/src/components/AddSessionPage.tsx index e6622694..d183afe4 100644 --- a/clients/desktop/src/components/AddSessionPage.tsx +++ b/clients/desktop/src/components/AddSessionPage.tsx @@ -13,7 +13,10 @@ import { invoke } from "../logger.js"; import { extractToken } from "../utils.js"; import { PageLayout } from "./PageLayout.js"; -const API_BASE = "https://lookout.hackclub.com"; +import { getApiBase } from "../serverConfig.js"; + +// Read once per webview load; Settings → Server reloads the view on change. +const API_BASE = getApiBase(); interface Program { name: string; @@ -21,6 +24,7 @@ interface Program { // unset, so this is always present, but guard anyway for older servers. displayName?: string; newSessionUrl: string; + iconUrl?: string | null; } interface AddSessionPageProps { @@ -36,7 +40,6 @@ export function AddSessionPage({ onBack, onStart }: AddSessionPageProps) { const [link, setLink] = useState(""); const [error, setError] = useState(null); const [loading, setLoading] = useState(false); - const [showLink, setShowLink] = useState(false); // Fetch the program registry. Failures and empty lists are non-fatal — the // paste-a-link backup always remains available. @@ -62,6 +65,16 @@ export function AddSessionPage({ onBack, onStart }: AddSessionPageProps) { const programLabel = (p: Program) => p.displayName || p.name; + // If the deep link never comes back (user closed the browser tab, changed + // their mind, the program errored), the buttons used to stay disabled with + // a spinner forever. Re-enable them after a grace period so retrying + // doesn't require leaving and re-entering the page. + useEffect(() => { + if (!launched) return; + const id = setTimeout(() => setLaunched(null), 15_000); + return () => clearTimeout(id); + }, [launched]); + const handleOpenProgram = async (program: Program) => { setError(null); setLaunched(programLabel(program)); @@ -108,64 +121,43 @@ export function AddSessionPage({ onBack, onStart }: AddSessionPageProps) {

{error}

)} {/* Backup: paste a lookout:// link, for when the deep link doesn't fire. */} - {showLink ? ( - <> - { - setLink(e.target.value); - setError(null); - }} - onKeyDown={(e) => { - if (e.key === "Enter" && !loading) handleStart(); - }} - placeholder="Paste a lookout:// link here" - disabled={loading} - style={{ - width: "100%", - padding: `${spacing.md}px ${spacing.lg}px`, - fontSize: fontSize.md, - fontWeight: fontWeight.medium, - color: colors.text.primary, - background: colors.bg.sunken, - border: `1px solid ${error ? colors.status.danger : colors.border.default}`, - borderRadius: radii.lg, - outline: "none", - boxSizing: "border-box", - height: 48, - opacity: loading ? 0.5 : 1, - }} - /> - - - ) : ( - - )} + { + setLink(e.target.value); + setError(null); + }} + onKeyDown={(e) => { + if (e.key === "Enter" && !loading) handleStart(); + }} + placeholder="Paste a lookout:// link here" + disabled={loading} + style={{ + width: "100%", + padding: `${spacing.md}px ${spacing.lg}px`, + fontSize: fontSize.md, + fontWeight: fontWeight.medium, + color: colors.text.primary, + background: colors.bg.sunken, + border: `1px solid ${error ? colors.status.danger : colors.border.default}`, + borderRadius: radii.lg, + outline: "none", + boxSizing: "border-box", + height: 48, + opacity: loading ? 0.5 : 1, + }} + /> + } > @@ -199,6 +191,19 @@ export function AddSessionPage({ onBack, onStart }: AddSessionPageProps) { disabled={loading || (launched !== null && launched !== programLabel(p))} onClick={() => handleOpenProgram(p)} > + {p.iconUrl && ( + + )} {programLabel(p)} ))} diff --git a/clients/desktop/src/components/AnnouncementBanner.tsx b/clients/desktop/src/components/AnnouncementBanner.tsx index b1c133da..44c9e02b 100644 --- a/clients/desktop/src/components/AnnouncementBanner.tsx +++ b/clients/desktop/src/components/AnnouncementBanner.tsx @@ -10,9 +10,8 @@ const LEVEL_COLOR: Record = { }; /** - * Gallery banner for an admin-authored announcement. Same shape as - * UpdateBanner, tinted by level; if a URL is set, an "Open" button launches it - * in the OS browser. + * Gallery banner for an admin-authored announcement, tinted by level; if a + * URL is set, an "Open" button launches it in the OS browser. */ export function AnnouncementBanner({ announcement }: { announcement: Announcement }) { const color = LEVEL_COLOR[announcement.level] ?? colors.status.info; diff --git a/clients/desktop/src/components/DesktopRecorder.tsx b/clients/desktop/src/components/DesktopRecorder.tsx index 2d6816e7..6f3d8739 100644 --- a/clients/desktop/src/components/DesktopRecorder.tsx +++ b/clients/desktop/src/components/DesktopRecorder.tsx @@ -3,7 +3,8 @@ import { invoke } from "../logger.js"; import { listen, emit } from "@tauri-apps/api/event"; import { useSession, - useSessionTimer, + useSessionTimerState, + computeBestTrackedSeconds, formatTime, Button, ErrorDisplay, @@ -18,9 +19,11 @@ import { } from "@lookout/react"; import { getReport } from "../logger.js"; import { NamingModal } from "./NamingModal.js"; +import { openEditorWindow } from "./EditorWindow.js"; import { useNativeCapture } from "../hooks/useNativeCapture.js"; import type { CaptureSource } from "../hooks/useNativeCapture.js"; import { useScreenPreview } from "../hooks/useScreenPreview.js"; +import { useWindowFocus } from "../hooks/useWindowFocus.js"; import { useCameraCapture, waitForVideoReady } from "../hooks/useCameraCapture.js"; import { useSessionNotifications } from "../hooks/useSessionNotifications.js"; @@ -32,24 +35,29 @@ interface DesktopRecorderProps { onViewSession: (token: string) => void; } -const API_BASE = "https://lookout.hackclub.com"; - -function RecorderPreviewItem({ - src, - isMain, - captureUrl, - isMulti -}: { - src: CaptureSource; - isMain: boolean; - captureUrl: string | null; +import { getApiBase } from "../serverConfig.js"; + +// Read once per webview load; Settings → Server reloads the view on change. +const API_BASE = getApiBase(); + +function RecorderPreviewItem({ + src, + captureUrl, + isMulti, + live, +}: { + src: CaptureSource; + /** Latest uploaded capture, shown when the live loop is parked. */ + captureUrl: string | null; isMulti: boolean; + /** Poll the native live preview. When false, useScreenPreview fetches at + * most one frame and parks — the tile freezes (or shows captureUrl) + * instead of burning a native capture per second off-focus. */ + live: boolean; }) { - const { previewUrl: livePreviewUrl } = useScreenPreview( - isMain && captureUrl ? null : src, - 1 - ); - const previewUrl = (isMain ? captureUrl : null) || livePreviewUrl; + const { previewUrl: livePreviewUrl } = useScreenPreview(src, 1, live); + const showingLive = live && !!livePreviewUrl; + const previewUrl = showingLive ? livePreviewUrl : captureUrl ?? livePreviewUrl; if (!previewUrl) { return ( @@ -75,13 +83,13 @@ function RecorderPreviewItem({ alt="Screen preview" style={{ width: "100%", height: "100%", objectFit: isMulti ? "cover" : "contain", display: "block" }} /> - {isMain && ( + {!isMulti && ( - {captureUrl ? "Latest capture" : "Live preview"} + {showingLive ? "Preview" : captureUrl ? "Latest capture" : "Preview"} )}
@@ -91,15 +99,15 @@ function RecorderPreviewItem({ function formatTimeTray(totalSeconds: number): string { const h = Math.floor(totalSeconds / 3600); const m = Math.floor((totalSeconds % 3600) / 60); - + if (h > 0) return `${h}h ${m}m`; - if (m === 0) return `< 1m`; return `${m}m`; } export function DesktopRecorder({ token, source, onChangeSource: _onChangeSource, onBack, onViewSession }: DesktopRecorderProps) { const isMacOS = navigator.userAgent.includes("Mac"); const isCamera = source.length === 1 && source[0].type === "camera"; + const windowFocused = useWindowFocus(); const session = useSession(); const camera = useCameraCapture(); @@ -122,6 +130,7 @@ export function DesktopRecorder({ token, source, onChangeSource: _onChangeSource source, isCamera ? cameraFrameCapture : undefined, handleSessionTerminated, + session.trackedSeconds, ); // Native OS notifications: alert the user when a session pauses, errors, @@ -157,10 +166,23 @@ export function DesktopRecorder({ token, source, onChangeSource: _onChangeSource }; }, [capture.isCapturing, capture.lastCaptureAt]); - const displaySeconds = useSessionTimer( - capture.trackedSeconds || session.trackedSeconds, - capture.isCapturing, - ); + // The single authoritative baseline for every surface that shows the time + // (main window, menu-bar title, tray popup). Both inputs are server-derived; + // `max` rather than `||` so a capture-local value that hasn't caught up with + // the session poll yet can't drag the baseline down. + const bestTrackedSeconds = computeBestTrackedSeconds({ + sessionTrackedSeconds: session.trackedSeconds, + uploaderTrackedSeconds: capture.trackedSeconds, + }); + + const timer = useSessionTimerState(bestTrackedSeconds, capture.isCapturing); + const displaySeconds = timer.displaySeconds; + + // Read the baseline from a ref inside async handlers: `session.resume()` + // awaits a round trip that itself refreshes `session.trackedSeconds`, so the + // value captured at render time is stale by the time we seed the ticker. + const bestTrackedRef = useRef(bestTrackedSeconds); + bestTrackedRef.current = bestTrackedSeconds; const [pauseLoading, setPauseLoading] = useState(false); const [resumeLoading, setResumeLoading] = useState(false); @@ -211,7 +233,11 @@ export function DesktopRecorder({ token, source, onChangeSource: _onChangeSource await startCameraAndWait(); // Camera sources use JS-side capture loop, so we need to // explicitly start the Rust tray ticker for the menu bar time. - invoke("start_tray_ticker", { trackedSeconds: 0 }).catch(console.error); + // Seed it with the session's existing time — hardcoding 0 made the + // menu bar restart from "0m" when recording an already-started session. + invoke("start_tray_ticker", { + trackedSeconds: session.trackedSeconds, + }).catch(console.error); } capture.startCapturing(); })(); @@ -237,12 +263,14 @@ export function DesktopRecorder({ token, source, onChangeSource: _onChangeSource }; }, []); - // Finalize stop: optionally name, then stop the session. - const handleConfirmStop = useCallback(async (name: string | null) => { + // Finalize stop: optionally name, then stop the session. With `edit`, + // the timelapse is held unpublished after compiling so the user can cut + // it first — programs only ever observe it once, finished. + const finalizeStop = useCallback(async (name: string | null, edit: boolean) => { if (stopActionHandled.current) return; stopActionHandled.current = true; setStopLoading(true); - console.log(`[session] stopping, name: ${name?.trim() || "(none)"}`); + console.log(`[session] stopping, name: ${name?.trim() || "(none)"}, edit: ${edit}`); if (name && name.trim()) { try { await fetch(`${API_BASE}/api/sessions/${token}/name`, { @@ -256,9 +284,11 @@ export function DesktopRecorder({ token, source, onChangeSource: _onChangeSource } try { - await session.stop(); + // Name was already applied above via the rename endpoint. + await session.stop(undefined, { edit }); console.log("[session] stopped, navigating to session detail"); if (isCamera) camera.stopStream(); + if (edit) void openEditorWindow(token); setIsPrompting(false); setStopLoading(false); } catch (e) { @@ -268,6 +298,16 @@ export function DesktopRecorder({ token, source, onChangeSource: _onChangeSource } }, [token, session, isCamera, camera]); + const handleConfirmStop = useCallback( + (name: string | null) => finalizeStop(name, false), + [finalizeStop], + ); + + const handleEditAndSave = useCallback( + (name: string | null) => finalizeStop(name, true), + [finalizeStop], + ); + const handlePause = useCallback(async () => { console.log("[session] pausing..."); setPauseLoading(true); @@ -287,7 +327,7 @@ export function DesktopRecorder({ token, source, onChangeSource: _onChangeSource console.log(`[session] restarting camera stream for device ${cameraDeviceId}`); await startCameraAndWait(); } - invoke("resume_tray_ticker", { trackedSeconds: capture.trackedSeconds }).catch(console.error); + invoke("resume_tray_ticker", { trackedSeconds: bestTrackedRef.current }).catch(console.error); await capture.startCapturing(); console.log("[session] resumed"); setResumeLoading(false); @@ -312,7 +352,7 @@ export function DesktopRecorder({ token, source, onChangeSource: _onChangeSource if (isCamera) { await startCameraAndWait(); } - invoke("resume_tray_ticker", { trackedSeconds: capture.trackedSeconds }).catch(console.error); + invoke("resume_tray_ticker", { trackedSeconds: bestTrackedRef.current }).catch(console.error); await capture.startCapturing(); setResumeLoading(false); }, [capture, session, isCamera, cameraDeviceId, camera, startCameraAndWait]); @@ -336,9 +376,27 @@ export function DesktopRecorder({ token, source, onChangeSource: _onChangeSource const screenshotCount = session.screenshotCount + capture.screenshotCount; - // Keep a ref of the latest state - const trayStateRef = useRef({ displaySeconds, screenshotCount, controlMode }); - trayStateRef.current = { displaySeconds, screenshotCount, controlMode }; + // Keep a ref of the latest state for the tray popup. + // + // This carries the interpolation ANCHOR (`baseSeconds` + `anchorAt`), never + // `displaySeconds`. The popup ticks its own clock, so handing it an + // already-interpolated value made it extrapolate on top of an + // extrapolation — it drifted ahead of the main window and never came back. + // `anchorAt` must likewise be when the base last advanced, not `Date.now()` + // at push time: re-stamping it on every push restarted the popup's + // interpolation window and lost the seconds the main window kept. + const trayStateRef = useRef({ + baseSeconds: timer.baseSeconds, + screenshotCount, + controlMode, + anchorAt: timer.anchorAt, + }); + trayStateRef.current = { + baseSeconds: timer.baseSeconds, + screenshotCount, + controlMode, + anchorAt: timer.anchorAt, + }; // Listen for tray requesting initial state (fallback) useEffect(() => { @@ -355,7 +413,7 @@ export function DesktopRecorder({ token, source, onChangeSource: _onChangeSource // The Rust tray ticker is the authoritative source for the menu-bar title // and runs at 1s cadence; calling update_tray_time from JS every second // creates two writers fighting over the title, which produces the visible - // flicker between "<1m"/"1m" and "1m"/"2m" at the minute boundaries + // flicker between "0m"/"1m" and "1m"/"2m" at the minute boundaries // (JS interpolates to one second, Rust to another). // // We still split the writes: @@ -377,23 +435,34 @@ export function DesktopRecorder({ token, source, onChangeSource: _onChangeSource // eslint-disable-next-line react-hooks/exhaustive-deps }, [controlMode]); - // Per-second state sync (tray window, not menu-bar title) + // Tray-window state sync — event-driven, NOT per-second. + // + // The tray window ticks its own clock from the anchor we push, and the Rust + // ticker owns the menu-bar title. So the only things worth pushing over IPC + // are the ones the tray can't derive locally: screenshot count changes, + // pause/resume, and a new anchor when the server credits time. Syncing every + // second (3 IPC calls + a broadcast event) was pure overhead that also + // drowned the debug log in [ipc] noise. useEffect(() => { - const state = { - displaySeconds, - screenshotCount, - controlMode, - updatedAt: Date.now(), - }; + const state = trayStateRef.current; invoke("set_tray_state", { state }).catch(console.error); emit("tray-state", state).catch(console.error); + }, [screenshotCount, controlMode, timer.baseSeconds, timer.anchorAt]); - // Sync tracked seconds to Rust tray timer so it stays accurate - // after server corrections or session timer updates. - if (capture.trackedSeconds > 0) { - invoke("sync_tray_tracked_seconds", { trackedSeconds: capture.trackedSeconds }).catch(console.error); + // Sync the baseline to the Rust tray timer whenever it advances. Rust + // ratchets and re-anchors on its own, so pushing the same value twice is a + // no-op there. + // + // Uses `bestTrackedSeconds`, not `capture.trackedSeconds`: the latter is + // hook-local state that starts at 0, so resuming a session that already had + // recorded time (app reopened on a paused session) seeded the menu bar with + // 0 and it showed "0m" next to a main window reading 25:00 until the first + // confirm landed. + useEffect(() => { + if (bestTrackedSeconds > 0) { + invoke("sync_tray_tracked_seconds", { trackedSeconds: bestTrackedSeconds }).catch(console.error); } - }, [displaySeconds, screenshotCount, controlMode]); + }, [bestTrackedSeconds]); // Hide tray on unmount or session end useEffect(() => { @@ -502,11 +571,17 @@ export function DesktopRecorder({ token, source, onChangeSource: _onChangeSource
- {screenshotCount} {screenshotCount === 1 ? "screenshot" : "screenshots"} + {/* One capture unit per recorded minute — a JPEG screenshot on + legacy sessions, a ~15-frame clip on clips sessions. */} + {screenshotCount} {screenshotCount === 1 ? "capture" : "captures"}
- {/* Preview — fills available space */} + {/* Preview — fills available space. Once the Rust capture loop is + running it feeds this image directly: in-between frames arrive at + the clip cadence (20/min) while the window is focused, and stop + when it isn't — so the same element is a live preview when watched + and the latest capture when not. No separate preview loop. */}
- Latest capture + {windowFocused && !isCamera ? "Preview" : "Latest capture"}
) : ( @@ -549,9 +624,9 @@ export function DesktopRecorder({ token, source, onChangeSource: _onChangeSource 1} + live={windowFocused && !isCamera} /> )) )} @@ -641,6 +716,7 @@ export function DesktopRecorder({ token, source, onChangeSource: _onChangeSource )} diff --git a/clients/desktop/src/components/EditorWindow.tsx b/clients/desktop/src/components/EditorWindow.tsx new file mode 100644 index 00000000..bed89c4d --- /dev/null +++ b/clients/desktop/src/components/EditorWindow.tsx @@ -0,0 +1,400 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import { emit } from "@tauri-apps/api/event"; +import { getCurrentWindow } from "@tauri-apps/api/window"; +import { WebviewWindow } from "@tauri-apps/api/webviewWindow"; +import { confirm } from "@tauri-apps/plugin-dialog"; +import { createLookoutClient, type CutInterval } from "@lookout/react"; +import { TimelapseEditor, colors, fontSize, fontWeight, spacing } from "@lookout/react"; +import { invoke } from "../logger.js"; +import { getApiBase } from "../serverConfig.js"; + +/** Event the editor window emits after applying cuts, so the main window + * can refresh the session detail + gallery. Payload: { token }. */ +export const EDITED_EVENT = "lookout-edited"; + +/** Emitted when an editor window is opened, so the main window can step + * out of the way. Payload: { token }. */ +export const EDITOR_OPENED_EVENT = "lookout-editor-opened"; + +/** Tauri window label for a session's editor. */ +export function editorWindowLabel(token: string): string { + return `editor-${token.slice(0, 8)}`; +} + +/** Is the editor window for this session currently open? */ +export async function isEditorWindowOpen(token: string): Promise { + try { + return (await WebviewWindow.getByLabel(editorWindowLabel(token))) !== null; + } catch { + return false; + } +} + +/** Bring an already-open editor window to the front. */ +export async function focusEditorWindow(token: string): Promise { + const win = await WebviewWindow.getByLabel(editorWindowLabel(token)); + await win?.setFocus().catch(() => {}); +} + +/** + * Open (or focus) the dedicated editor window for a session. The main + * window is a fixed 480×640 — far too small to scrub a multi-hour + * timeline with any precision — so editing gets its own resizable window. + */ +export async function openEditorWindow(token: string): Promise { + const label = editorWindowLabel(token); + const existing = await WebviewWindow.getByLabel(label); + if (existing) { + await existing.setFocus().catch(() => {}); + await emit(EDITOR_OPENED_EVENT, { token }).catch(() => {}); + return; + } + const isMacOS = navigator.userAgent.includes("Mac"); + const win = new WebviewWindow(label, { + url: `${window.location.pathname}#/editor?token=${token}`, + title: "Edit timelapse", + width: 940, + height: 660, + // The floor is what the shell actually needs: chrome + a legible + // stage + the dock. Below this the layout would be cramped rather + // than broken — it still reflows — but there's no reason to allow it. + minWidth: 620, + minHeight: 480, + resizable: true, + center: true, + // Transparent + overlay titlebar is what lets the vibrancy material + // show through, matching the main window's chrome exactly. + transparent: true, + // Overlay titlebar with the title VISIBLE: macOS draws and centers it + // for us, so it can't drift out of alignment with the traffic lights + // the way a hand-placed label does. + ...(isMacOS ? { titleBarStyle: "overlay" as const } : {}), + }); + win.once("tauri://error", (e) => { + console.error("[editor] failed to open editor window:", e); + }); + await emit(EDITOR_OPENED_EVENT, { token }).catch(() => {}); +} + +/** + * Close this editor window and hand focus back to the app. + * + * Never swallow the failure: if the close is refused (a missing + * capability, say) a silent catch leaves the user staring at a window + * that says it saved and won't go away. Log it, then fall back to + * destroy(), which skips the close-requested round trip entirely. + */ +async function closeEditorWindow(): Promise { + // Bring the main window forward first — closing the frontmost window + // otherwise drops the user behind whatever app is underneath. + try { + const main = await WebviewWindow.getByLabel("main"); + await main?.setFocus(); + } catch (e) { + console.warn("[editor] could not focus main window:", e); + } + + try { + await getCurrentWindow().close(); + } catch (e) { + console.error("[editor] close() failed, destroying instead:", e); + try { + await getCurrentWindow().destroy(); + } catch (e2) { + console.error("[editor] destroy() failed too:", e2); + } + } +} + +/** + * What the main window shows while the editor window is up. The editing + * happens over there, so anything rendered here would just be a second, + * stale copy of the same session competing for attention. + */ +export function EditorOpenPlaceholder({ token }: { token: string }) { + const [focusing, setFocusing] = useState(false); + + return ( +
{ + setFocusing(true); + void focusEditorWindow(token).finally(() => setFocusing(false)); + }} + onKeyDown={(e) => { + if (e.key === "Enter" || e.key === " ") void focusEditorWindow(token); + }} + style={{ + height: "100%", + display: "flex", + flexDirection: "column", + alignItems: "center", + justifyContent: "center", + gap: spacing.md, + padding: spacing.xxl, + textAlign: "center", + cursor: "pointer", + userSelect: "none", + opacity: focusing ? 0.6 : 1, + transition: "opacity 0.15s ease", + }} + > + +
+ Edit your timelapse in the edit window. +
+
+ Click here to bring it to the front. +
+
+ ); +} + +/** + * Tracks whether an editor window is open, for the main window. + * + * Listens for the open event, then polls for the window's existence — the + * poll is what guarantees the main window can never get stuck behind the + * placeholder if the editor window is force-quit or crashes. + */ +export function useEditorWindowOpen(): string | null { + const [token, setToken] = useState(null); + + useEffect(() => { + const unlisteners: Array<() => void> = []; + void import("@tauri-apps/api/event").then(async ({ listen }) => { + unlisteners.push( + await listen<{ token: string }>(EDITOR_OPENED_EVENT, (e) => { + if (e.payload?.token) setToken(e.payload.token); + }), + // Publishing closes the editor window; clear immediately rather + // than waiting for the poll, so the session view is back the + // instant the user saves. + await listen(EDITED_EVENT, () => setToken(null)), + ); + }); + return () => { + for (const un of unlisteners) un(); + }; + }, []); + + useEffect(() => { + if (!token) return; + let cancelled = false; + const id = setInterval(async () => { + if (cancelled) return; + if (!(await isEditorWindowOpen(token))) setToken(null); + }, 1000); + return () => { + cancelled = true; + clearInterval(id); + }; + }, [token]); + + return token; +} + +/** + * The editor window's root view (route `#/editor?token=…`). + * + * An app shell, not a page: a draggable strip clearing the window + * controls, a body that owns all remaining height, and nothing that can + * push content past the window edge. The chrome is the system vibrancy + * material, so this window reads as the same app as the main one in both + * light and dark. + * + * The title is the WINDOW's title, drawn by the OS — centered and aligned + * to the traffic lights for free. A hand-placed label next to them has to + * be pixel-matched against a position that varies by OS version, and it + * was visibly off. + */ +export function EditorWindow({ token }: { token: string }) { + const isMacOS = navigator.userAgent.includes("Mac"); + + // Vibrancy: the main window does this too. The webview must be + // transparent for the material to show, so only go transparent once the + // native side confirms it applied — otherwise (Linux, or a failure) the + // window would render see-through with nothing behind it. + useEffect(() => { + const html = document.documentElement; + const body = document.body; + const root = document.getElementById("root"); + const isLinux = navigator.userAgent.toLowerCase().includes("linux"); + + if (isLinux) { + html.style.background = "var(--color-bg-body)"; + body.style.background = "var(--color-bg-body)"; + if (root) root.style.background = "var(--color-bg-body)"; + return; + } + + let applied = false; + invoke("enable_vibrancy") + .then(() => { + applied = true; + html.style.background = "transparent"; + body.style.background = "transparent"; + if (root) root.style.background = "transparent"; + }) + .catch((err) => { + console.warn("[editor] vibrancy unavailable, falling back:", err); + html.style.background = "var(--color-bg-body)"; + body.style.background = "var(--color-bg-body)"; + if (root) root.style.background = "var(--color-bg-body)"; + }); + + return () => { + if (applied) invoke("disable_vibrancy").catch(() => {}); + }; + }, []); + + // The window title carries the session name on the native title bar; + // the in-window strip shows it too, since the title is hidden on macOS. + useEffect(() => { + fetch(`${getApiBase()}/api/sessions/${token}`) + .then((r) => (r.ok ? r.json() : null)) + .then((d) => { + if (d?.name) void getCurrentWindow().setTitle(`Edit — ${d.name}`); + }) + .catch(() => { + // Name is decoration — the editor works without it. + }); + }, [token]); + + // Closing the window IS the decision to finish: the timelapse publishes + // with whatever cuts are on screen. There's no "leave it hanging" exit — + // the session is unpublished until someone decides, so an editor that + // could be dismissed without deciding would just strand it until the + // lease lapsed. Hence: confirm, publish, then close. + const cutsRef = useRef([]); + const dirtyRef = useRef(false); + const finishedRef = useRef(false); + const client = useRef( + createLookoutClient({ baseUrl: getApiBase(), token }), + ).current; + + const finishAndClose = useCallback(async () => { + finishedRef.current = true; + let published: Awaited> | null = null; + try { + await client.setCuts(cutsRef.current); + published = await client.applyCuts(); + } catch (e) { + console.error("[editor] publish on close failed:", e); + // Don't trap the user in a window they asked to close: the hold + // lapses on its own and publishes as recorded shortly after. + } + // Fire-and-forget: the close must not wait on the notification. Carry + // the publish result so the main window can fire the redirect the + // instant it's done (`complete`) or watch the compile to completion. + emit(EDITED_EVENT, { + token, + status: published?.status ?? null, + redirectUrl: published?.redirectUrl ?? null, + }).catch((e) => console.error("[editor] emit failed:", e)); + await closeEditorWindow(); + }, [client, token]); + + useEffect(() => { + let unlisten: (() => void) | undefined; + void getCurrentWindow() + .onCloseRequested(async (event) => { + if (finishedRef.current) return; + event.preventDefault(); + const removed = cutsRef.current.length; + const ok = await confirm( + dirtyRef.current && removed > 0 + ? `Closing publishes this timelapse with ${removed} cut${ + removed === 1 ? "" : "s" + } applied. This can't be undone.` + : "Closing publishes this timelapse as recorded. This can't be undone.", + { title: "Finish timelapse?", kind: "warning" }, + ); + if (ok) void finishAndClose(); + }) + .then((fn) => { + unlisten = fn; + }); + return () => { + if (unlisten) unlisten(); + }; + }, [finishAndClose]); + + return ( +
+ {/* Drag strip clearing the window controls and the OS-drawn title. + macOS only: elsewhere the window has real decorations above the + webview, so the content can start at the very top. */} + {isMacOS && ( +
+ )} + + {/* Body owns the rest. min-height:0 is what lets the stage inside + letterboxe down instead of clipping the dock off the bottom. */} +
+ { + cutsRef.current = cuts; + dirtyRef.current = dirty; + }} + onApplied={(result) => { + // Saved from inside the editor. Flag it first so the close + // handler doesn't prompt to publish what's already published. + finishedRef.current = true; + emit(EDITED_EVENT, { + token, + status: result.status, + redirectUrl: result.redirectUrl, + }).catch((e) => console.error("[editor] emit failed:", e)); + void closeEditorWindow(); + }} + /> +
+
+ ); +} diff --git a/clients/desktop/src/components/NamingModal.tsx b/clients/desktop/src/components/NamingModal.tsx index f24949bd..eb8a05a1 100644 --- a/clients/desktop/src/components/NamingModal.tsx +++ b/clients/desktop/src/components/NamingModal.tsx @@ -5,11 +5,17 @@ import { Button, Card, colors, spacing, radii, fontSize, fontWeight } from "@loo interface NamingModalProps { loading: boolean; onConfirm: (name: string | null) => void; + /** Stop, then open the editor before anything is published. Editing is + * offered HERE, not after the timelapse goes out: `complete` is when + * programs consume a session (heartbeats, tracked time, video), so the + * data has to be final the first time they see it. */ + onEditAndSave: (name: string | null) => void; onResume: () => void; } -export function NamingModal({ loading, onConfirm, onResume }: NamingModalProps) { +export function NamingModal({ loading, onConfirm, onEditAndSave, onResume }: NamingModalProps) { const [name, setName] = useState(""); + const [choice, setChoice] = useState<"stop" | "edit" | null>(null); const inputRef = useRef(null); useEffect(() => { @@ -66,26 +72,44 @@ export function NamingModal({ loading, onConfirm, onResume }: NamingModalProps) opacity: loading ? 0.5 : 1, }} /> -
+
- +
+ + +
diff --git a/clients/desktop/src/components/PermissionScreen.tsx b/clients/desktop/src/components/PermissionScreen.tsx index e35ef225..ed65cfe6 100644 --- a/clients/desktop/src/components/PermissionScreen.tsx +++ b/clients/desktop/src/components/PermissionScreen.tsx @@ -12,6 +12,13 @@ type PermissionStatus = "checking" | "granted" | "denied"; type PermissionType = "screen" | "camera"; +/** + * localStorage key remembering that a permission check passed once, so later + * boots skip the gate (and its flicker) and re-verify in the background. + * Only a real grant is cached — "Skip" never writes it. + */ +export const permCacheKey = (type: PermissionType) => `lookout-perm-${type}`; + const PERMISSION_CONFIG: Record { + const stopSession = useCallback(async (name: string | null, edit: boolean) => { setStopping(true); - console.log(`[record] stopping session, name: ${name?.trim() || "(none)"}`); + console.log( + `[record] stopping session, name: ${name?.trim() || "(none)"}, edit: ${edit}`, + ); if (name && name.trim()) { try { await fetch(`${API_BASE}/api/sessions/${token}/name`, { @@ -82,14 +88,39 @@ export function RecordPage({ token, onBack, onViewSession }: RecordPageProps) { } } try { - await fetch(`${API_BASE}/api/sessions/${token}/stop`, { method: "POST" }); + // `edit: true` holds the timelapse unpublished after it compiles so + // the user can cut it first — programs only ever see it finished. + await fetch(`${API_BASE}/api/sessions/${token}/stop`, { + method: "POST", + ...(edit + ? { + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ edit: true }), + } + : {}), + }); console.log("[record] session stopped"); } catch (e) { console.error("[record] stop failed:", e); } + // Either way the user lands on the session view; when held, it shows + // the review panel (and the editor window opens from there). + if (edit) { + void openEditorWindow(token); + } onViewSession(token); }, [token, onViewSession]); + const handleConfirmStop = useCallback( + (name: string | null) => stopSession(name, false), + [stopSession], + ); + + const handleEditAndSave = useCallback( + (name: string | null) => stopSession(name, true), + [stopSession], + ); + const handleResumeFromModal = useCallback(() => { setIsPrompting(false); }, []); @@ -226,6 +257,7 @@ export function RecordPage({ token, onBack, onViewSession }: RecordPageProps) { )} diff --git a/clients/desktop/src/components/SettingsPage.tsx b/clients/desktop/src/components/SettingsPage.tsx index b76fe452..b393f0df 100644 --- a/clients/desktop/src/components/SettingsPage.tsx +++ b/clients/desktop/src/components/SettingsPage.tsx @@ -1,5 +1,13 @@ -import { useState, useEffect, useCallback, useRef } from "react"; -import { motion, AnimatePresence } from "motion/react"; +import { useState, useEffect, useRef, type ReactNode, type CSSProperties } from "react"; +import { AnimatePresence, motion } from "motion/react"; +import { + CaretLeftIcon, + CaretRightIcon, + CheckIcon, + FunnelIcon, + WrenchIcon, +} from "@phosphor-icons/react"; +import { confirm } from "@tauri-apps/plugin-dialog"; import { Button, colors, @@ -11,52 +19,78 @@ import { import { invoke } from "../logger.js"; import { cardButtonStyle } from "./PageLayout.js"; import { useBlacklistedApps } from "../hooks/useBlacklistedApps.js"; +import { + DEFAULT_API_BASE, + getApiBase, + isDefaultApiBase, + normalizeServerUrl, + setApiBase, +} from "../serverConfig.js"; interface SettingsPageProps { onBack: () => void; isWayland?: boolean; } -export function SettingsPage({ onBack, isWayland }: SettingsPageProps) { - const { blacklistedApps, toggleApp } = useBlacklistedApps(); - const [runningApps, setRunningApps] = useState([]); - const [loading, setLoading] = useState(true); - const [searchQuery, setSearchQuery] = useState(""); - const refreshTimerRef = useRef | null>(null); - - const fetchRunningApps = useCallback(async () => { - try { - const apps = await invoke("list_running_apps"); - setRunningApps(apps); - } catch (e) { - console.warn("[settings] failed to list running apps:", e); - } finally { - setLoading(false); - } - }, []); - - // Fetch on mount, then refresh every 5 seconds - useEffect(() => { - fetchRunningApps(); - refreshTimerRef.current = setInterval(fetchRunningApps, 5000); - return () => { - if (refreshTimerRef.current) clearInterval(refreshTimerRef.current); - }; - }, [fetchRunningApps]); - - // Merge running apps with already-blacklisted apps (some may not be running) - const allApps = Array.from( - new Set([...runningApps, ...blacklistedApps]) - ).sort((a, b) => a.localeCompare(b)); +type SettingsSubpage = "menu" | "filtered-apps" | "advanced"; - const filtered = searchQuery - ? allApps.filter((app) => - app.toLowerCase().includes(searchQuery.toLowerCase()) - ) - : allApps; +interface AppEntry { + name: string; + /** Bundle path used to look up the app's icon (macOS only). */ + path?: string | null; + /** Whether the app is currently running (open apps sort first). */ + running?: boolean; +} - const blacklistedCount = blacklistedApps.length; +/** + * 20px app icon with a plain-box placeholder. Fades in only when the icon + * arrives *after* mount (initial load) — rows remounting with a cached icon + * (e.g. while typing in search) render it instantly with no animation. + */ +function AppIcon({ icon }: { icon: string | undefined }) { + const fadeInRef = useRef(icon === undefined); + if (icon) { + return ( + + ); + } + return ( +
+ ); +} +/** Shared page scaffold: back button + title + description. */ +function PageChrome({ + title, + description, + onBack, + children, +}: { + title: string; + description: ReactNode; + onBack: () => void; + children: ReactNode; +}) { return (
← Back ) : ( - - - + )} @@ -96,7 +128,7 @@ export function SettingsPage({ onBack, isWayland }: SettingsPageProps) { marginBottom: spacing.xs, }} > - Filtered Apps + {title}

+ {description} +

+
+ + {children} +
+ ); +} + +/** A tappable settings-menu row: icon, title, description, chevron. */ +function MenuRow({ + icon, + title, + description, + onClick, +}: { + icon: ReactNode; + title: string; + description: ReactNode; + onClick: () => void; +}) { + return ( + + ); +} + +export function SettingsPage({ onBack, isWayland }: SettingsPageProps) { + const [subpage, setSubpage] = useState("menu"); + // Single hook instance shared with the Filtered Apps subpage so the menu + // row's count stays live as apps are toggled. + const { blacklistedApps, toggleApp } = useBlacklistedApps(); + + const backToMenu = () => setSubpage("menu"); + + const content = (() => { + switch (subpage) { + case "filtered-apps": + return ( + + ); + case "advanced": + return ; + default: + return ( + + {/* Row hover/press styles shared with the app list */} + +
+
+
+ ); + } + })(); + + // Subpage transition — same directional slide as the app's route + // transitions in App.tsx: drilling into a subpage slides forward, + // returning to the menu slides back, and AnimatePresence keeps the + // outgoing page mounted so it animates out instead of vanishing. + // With only two levels, direction derives from the destination. + const direction = subpage === "menu" ? -1 : 1; + + return ( +
+ {/* initial={false}: on first mount the route-level transition in + App.tsx already animates the whole page in — don't double up. */} + + ({ opacity: 0, x: d > 0 ? 14 : -14 }), + center: { + opacity: 1, + x: 0, + transition: { + x: { type: "spring", stiffness: 460, damping: 36, mass: 0.7 }, + opacity: { duration: 0.16, delay: 0.04, ease: "easeOut" }, + }, + }, + exit: (d: number) => ({ + opacity: 0, + x: d > 0 ? -14 : 14, + transition: { + x: { type: "spring", stiffness: 460, damping: 36, mass: 0.7 }, + opacity: { duration: 0.14, ease: "easeOut" }, + }, + }), + }} + style={{ position: "absolute", inset: 0, overflowY: "auto" }} + > + {content} + + +
+ ); +} + +// ── Advanced subpage ──────────────────────────────────────── + +function AdvancedSettings({ onBack }: { onBack: () => void }) { + const current = getApiBase(); + const [value, setValue] = useState(current); + const [error, setError] = useState(null); + const [saving, setSaving] = useState(false); + + const normalized = normalizeServerUrl(value); + const isDirty = normalized !== current; + + /** Confirm → probe the server → persist → reload the webview so every + * module-scope API_BASE read picks up the new value. */ + const save = async (target: string | null) => { + setError(null); + if (target !== null && target !== DEFAULT_API_BASE) { + // Native dialog as a final speed bump — a wrong server means every + // recording from here on lands somewhere else. + const yes = await confirm( + `Point this app at ${target}?\n\nAll new recordings will upload there instead of the official Lookout server. Only continue if someone from Hack Club asked you to.`, + { title: "Switch Lookout server", kind: "warning" }, + ); + if (!yes) return; + } + setSaving(true); + try { + if (target !== null) { + // Probe a cheap public endpoint so a typo'd host fails here, not + // silently during the next recording. + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), 6_000); + try { + const res = await fetch(`${target}/api/programs`, { + signal: controller.signal, + }); + if (!res.ok) { + throw new Error(`server responded ${res.status}`); + } + } finally { + clearTimeout(timer); + } + } + setApiBase(target); + window.location.reload(); + } catch (e) { + const msg = e instanceof Error ? e.message : String(e); + setError( + msg.includes("abort") + ? "Could not reach the server (timed out)." + : `Could not reach the server: ${msg}`, + ); + setSaving(false); + } + }; + + const inputStyle: CSSProperties = { + width: "100%", + padding: `${spacing.sm}px ${spacing.md}px`, + fontSize: fontSize.md, + color: colors.text.primary, + background: colors.bg.surface, + border: `1px solid ${error ? colors.status.danger : colors.border.default}`, + borderRadius: radii.md, + outline: "none", + boxSizing: "border-box", + fontFamily: "monospace", + }; + + return ( + +
+
+
+ Lookout server +
+ { + setValue(e.target.value); + setError(null); + }} + style={inputStyle} + onFocus={(e) => { + e.currentTarget.style.borderColor = colors.border.hover; + }} + onBlur={(e) => { + e.currentTarget.style.borderColor = error + ? colors.status.danger + : colors.border.default; + }} + /> +
+ Please enter a VALID lookout service URL. +
+
+ + {value.trim() !== "" && normalized === null && ( +
+ Enter a valid HTTPS URL (e.g. https://lookout-stage.example.com). +
+ )} + {error && ( +
+ {error} +
+ )} + +
+ + {current !== DEFAULT_API_BASE && ( + + )} +
+
+ + {current !== DEFAULT_API_BASE && ( +
+ Using a custom server. Timelapses recorded here won't appear on the + default Lookout server. +
+ )} +
+ ); +} + +// ── Filtered Apps subpage ─────────────────────────────────── + +function FilteredAppsSettings({ + onBack, + isWayland, + blacklistedApps, + toggleApp, +}: { + onBack: () => void; + isWayland?: boolean; + blacklistedApps: string[]; + toggleApp: (appName: string) => void; +}) { + const [apps, setApps] = useState([]); + const [loading, setLoading] = useState(true); + const [searchQuery, setSearchQuery] = useState(""); + // App icons as base64 PNG keyed by app name, fetched once per app. + // Requested names live in a ref so re-renders never refetch. + const [appIcons, setAppIcons] = useState>({}); + const requestedIconsRef = useRef(new Set()); + // Apps that were already filtered when the page OPENED get pinned to the + // top. Snapshot in a ref, not live state: unchecking (or re-checking) an + // app must not reshuffle rows under the user's cursor mid-visit — the new + // order applies on the next visit. + const pinnedAppsRef = useRef>(new Set(blacklistedApps)); + const pinnedSet = pinnedAppsRef.current; + + // The installed-app list is static while the page is open — fetch once. + useEffect(() => { + let cancelled = false; + invoke("list_installed_apps") + .then((list) => { + if (!cancelled) setApps(list); + }) + .catch((e) => console.warn("[settings] failed to list apps:", e)) + .finally(() => { + if (!cancelled) setLoading(false); + }); + return () => { + cancelled = true; + }; + }, []); + + // Merge installed apps with already-blacklisted ones (which may have been + // uninstalled since); those keep the letter-tile fallback icon. + const byName = new Set(apps.map((a) => a.name)); + const allApps: AppEntry[] = [ + ...apps, + ...blacklistedApps.filter((n) => !byName.has(n)).map((n) => ({ name: n })), + ].sort((a, b) => a.name.localeCompare(b.name)); + + // Load icons lazily: a shared IntersectionObserver requests an icon only + // when its row scrolls near the viewport, instead of hitting the backend + // for every installed app at once. Each fetch is cached on both sides. + const observerRef = useRef(null); + const observedAppsRef = useRef(new Map()); + + useEffect(() => { + const observer = new IntersectionObserver( + (entries) => { + for (const entry of entries) { + if (!entry.isIntersecting) continue; + const app = observedAppsRef.current.get(entry.target); + observer.unobserve(entry.target); + observedAppsRef.current.delete(entry.target); + if (!app?.path || requestedIconsRef.current.has(app.name)) continue; + requestedIconsRef.current.add(app.name); + invoke("get_app_icon", { path: app.path }) + .then((icon) => { + if (icon) setAppIcons((prev) => ({ ...prev, [app.name]: icon })); + }) + .catch(() => {}); + } + }, + // Start fetching slightly before a row becomes visible so icons are + // usually there by the time it scrolls in. + { rootMargin: "200px" } + ); + observerRef.current = observer; + return () => observer.disconnect(); + }, []); + + const observeIcon = (el: Element | null, app: AppEntry) => { + const observer = observerRef.current; + if (!el || !observer) return undefined; + if (!app.path || requestedIconsRef.current.has(app.name)) return undefined; + observedAppsRef.current.set(el, app); + observer.observe(el); + return () => { + observer.unobserve(el); + observedAppsRef.current.delete(el); + }; + }; + + const filtered = searchQuery + ? allApps.filter((app) => + app.name.toLowerCase().includes(searchQuery.toLowerCase()) + ) + : allApps; + + // Already-filtered apps first (what the user came to review), then open + // apps (the likeliest new filter targets), then everything else. + const pinnedApps = filtered.filter((app) => pinnedSet.has(app.name)); + const openApps = filtered.filter((app) => app.running && !pinnedSet.has(app.name)); + const otherApps = filtered.filter((app) => !app.running && !pinnedSet.has(app.name)); + + const blacklistedCount = blacklistedApps.length; + + const renderRow = (app: AppEntry) => { + const isBlacklisted = blacklistedApps.includes(app.name); + return ( + + ); + }; + + return ( + Selected apps will be blacked out in monitor screen captures. {blacklistedCount > 0 && ( {" "}{blacklistedCount} app{blacklistedCount !== 1 ? "s" : ""} filtered. )} -

-
- + + } + onBack={onBack} + > {/* Search + App list (hidden on Wayland) */} {isWayland ? (
) : ( <> + {/* Row hover/press animation + icon fade-in, as real CSS so rows stay + plain DOM nodes and search filtering has zero animation overhead */} + {/* Search */}
{loading ? ( -
- Loading apps... + // Skeleton rows while the app list loads +
+ {Array.from({ length: 12 }).map((_, i) => ( + +
+
+
+ + ))}
) : filtered.length === 0 ? (
) : (
- - {filtered.map((app) => { - const isBlacklisted = blacklistedApps.includes(app); - return ( - toggleApp(app)} - style={{ - position: "relative", - display: "flex", - alignItems: "center", - gap: spacing.md, - width: "100%", - padding: `${spacing.sm}px ${spacing.md}px`, - background: "transparent", - border: "none", - borderRadius: radii.md, - cursor: "pointer", - textAlign: "left", - color: colors.text.primary, - fontSize: fontSize.md, - }} - > - - - {/* Checkbox */} -
- {isBlacklisted && ( - - - - )} -
- - {/* App name */} -
- - {app} - -
-
- ); - })} -
+ {pinnedApps.map(renderRow)} + {openApps.map(renderRow)} + {otherApps.map(renderRow)}
)}
)} -
+ ); } diff --git a/clients/desktop/src/components/SourcePicker.tsx b/clients/desktop/src/components/SourcePicker.tsx index bb012b94..e9c72db0 100644 --- a/clients/desktop/src/components/SourcePicker.tsx +++ b/clients/desktop/src/components/SourcePicker.tsx @@ -50,6 +50,33 @@ function sourcesEqual(a: CaptureSource | null, b: CaptureSource | null): boolean return a.type === b.type && a.id === b.id; } +// Most people record the same screen(s) every session — remember the last +// monitor selection so it's preselected next time. Only monitors: window ids +// aren't stable across app launches, and cameras load asynchronously. +const LAST_MONITORS_KEY = "lookout-last-monitor-selection"; + +function loadLastMonitorIds(): number[] { + try { + const raw = localStorage.getItem(LAST_MONITORS_KEY); + if (raw) { + const parsed = JSON.parse(raw); + if (Array.isArray(parsed) && parsed.every((id) => typeof id === "number")) { + return parsed; + } + } + } catch { /* corrupted entry — fall back to the primary-monitor default */ } + return []; +} + +function saveLastMonitorSelection(sources: CaptureSource[]) { + const ids = sources.filter((s) => s.type === "monitor").map((s) => s.id); + try { + if (ids.length > 0) { + localStorage.setItem(LAST_MONITORS_KEY, JSON.stringify(ids)); + } + } catch { /* storage unavailable — non-fatal */ } +} + type TabId = "screens" | "windows" | "cameras" | "cast"; function PreviewImage({ @@ -248,12 +275,20 @@ export function SourcePicker({ onSelect, submitLabel = "Start Capture" }: Source // Wait for render then check scroll setTimeout(handleScroll, 10); - // Auto-select primary monitor if nothing selected yet + // Auto-select if nothing selected yet: prefer the monitors used last + // session (when every one of them is still connected), else the primary. if (selected.length === 0 && !wayland) { - const primary = result.monitors.find((m) => m.isPrimary) ?? result.monitors[0]; - if (primary) { - console.log(`[sources] auto-selected: monitor id=${primary.id} (${primary.name})`); - setSelected([{ type: "monitor", id: primary.id }]); + const lastIds = loadLastMonitorIds(); + const remembered = lastIds.filter((id) => result.monitors.some((m) => m.id === id)); + if (lastIds.length > 0 && remembered.length === lastIds.length) { + console.log(`[sources] auto-selected remembered monitors: ${remembered.join(", ")}`); + setSelected(remembered.map((id) => ({ type: "monitor" as const, id }))); + } else { + const primary = result.monitors.find((m) => m.isPrimary) ?? result.monitors[0]; + if (primary) { + console.log(`[sources] auto-selected: monitor id=${primary.id} (${primary.name})`); + setSelected([{ type: "monitor", id: primary.id }]); + } } } } catch (err) { @@ -801,7 +836,16 @@ export function SourcePicker({ onSelect, submitLabel = "Start Capture" }: Source {/* Start button */}
{selected.length > 0 && ( - )} diff --git a/clients/desktop/src/components/TrayApp.tsx b/clients/desktop/src/components/TrayApp.tsx index 766cfe11..5c670ff4 100644 --- a/clients/desktop/src/components/TrayApp.tsx +++ b/clients/desktop/src/components/TrayApp.tsx @@ -2,9 +2,34 @@ import { useState, useEffect } from "react"; import { listen } from "@tauri-apps/api/event"; import { invoke } from "../logger.js"; import { isGlassSupported, setLiquidGlassEffect, GlassMaterialVariant } from "tauri-plugin-liquid-glass-api"; -import { Button, colors, spacing, fontSize, fontWeight } from "@lookout/react"; +import { Button, colors, spacing, fontSize, fontWeight, deriveDisplaySeconds } from "@lookout/react"; import NumberFlow from "@number-flow/react"; +interface TrayState { + /** Ratcheted server-authoritative tracked seconds (the anchor value). */ + baseSeconds: number; + screenshotCount: number; + controlMode: "recording" | "paused"; + /** `Date.now()` when `baseSeconds` last advanced. */ + anchorAt: number; +} + +/** + * Re-derive the ticking clock from the anchor the main window pushed, using + * the *same* shared function the main window's own timer uses. Rolling a + * local version of this is how the popup ended up showing a different time + * from the main app: it extrapolated without a cap, from an already + * interpolated value. + */ +function deriveLiveSeconds(state: TrayState, now: number): number { + return deriveDisplaySeconds( + state.baseSeconds, + state.anchorAt, + state.controlMode === "recording", + now, + ); +} + function TrayTimer({ totalSeconds }: { totalSeconds: number }) { const h = Math.floor(totalSeconds / 3600); const m = Math.floor((totalSeconds % 3600) / 60); @@ -31,11 +56,11 @@ function TrayTimer({ totalSeconds }: { totalSeconds: number }) { } export function TrayApp() { - const [state, setState] = useState({ - displaySeconds: 0, + const [state, setState] = useState({ + baseSeconds: 0, screenshotCount: 0, - controlMode: "recording" as "recording" | "paused", - updatedAt: Date.now(), + controlMode: "recording", + anchorAt: Date.now(), }); const [liveSeconds, setLiveSeconds] = useState(0); @@ -91,20 +116,13 @@ export function TrayApp() { // Derive the live real-time seconds ticking up useEffect(() => { - // Immediate sync - let current = state.displaySeconds; - if (state.controlMode === "recording") { - const elapsed = Math.floor((Date.now() - state.updatedAt) / 1000); - current += Math.max(0, elapsed); - } - setLiveSeconds(current); + setLiveSeconds(deriveLiveSeconds(state, Date.now())); if (state.controlMode !== "recording") return; // Tick locally so we don't depend on the asleep main window const interval = setInterval(() => { - const elapsed = Math.floor((Date.now() - state.updatedAt) / 1000); - setLiveSeconds(state.displaySeconds + Math.max(0, elapsed)); + setLiveSeconds(deriveLiveSeconds(state, Date.now())); }, 1000); return () => clearInterval(interval); @@ -116,7 +134,7 @@ export function TrayApp() { const syncState = async () => { try { - const backendState = await invoke("get_tray_state"); + const backendState = await invoke("get_tray_state"); setState(backendState); } catch (e) { console.error("Failed to sync tray state from backend", e); @@ -125,12 +143,12 @@ export function TrayApp() { const setup = async () => { // Listen for regular state updates - unlistenState = await listen("tray-state", (event) => { + unlistenState = await listen("tray-state", (event) => { setState(event.payload); }); - + // When the tray window is opened, explicitly request the latest state - unlistenOpened = await listen("tray-opened", syncState); + unlistenOpened = await listen("tray-opened", syncState); // Request initial state on first mount syncState(); diff --git a/clients/desktop/src/components/UpdateBanner.tsx b/clients/desktop/src/components/UpdateBanner.tsx deleted file mode 100644 index a6b94713..00000000 --- a/clients/desktop/src/components/UpdateBanner.tsx +++ /dev/null @@ -1,43 +0,0 @@ -import { Button, colors, spacing, fontSize, fontWeight, radii } from "@lookout/react"; - -interface UpdateBannerProps { - version: string; - restarting?: boolean; - onRestart: () => void; -} - -/** Gallery banner shown when a newer app version is available. */ -export function UpdateBanner({ version, restarting, onRestart }: UpdateBannerProps) { - return ( -
-
- - Update available - - - Version {version} is ready — restart to update. - -
- -
- ); -} diff --git a/clients/desktop/src/components/UpdatePill.tsx b/clients/desktop/src/components/UpdatePill.tsx new file mode 100644 index 00000000..b7e70198 --- /dev/null +++ b/clients/desktop/src/components/UpdatePill.tsx @@ -0,0 +1,123 @@ +import { useState } from "react"; +import { AnimatePresence, motion } from "motion/react"; +import NumberFlow from "@number-flow/react"; +import { colors, fontSize, fontWeight } from "@lookout/react"; +import type { UpdatePhase } from "../hooks/useAppUpdate.js"; + +interface UpdatePillProps { + phase: UpdatePhase; + onRestart: () => void; + /** Which screen edge the pill is anchored to — sets the slide direction. */ + origin?: "top" | "bottom"; +} + +/** Tiny circular progress ring for the downloading state. */ +function ProgressRing({ progress }: { progress: number }) { + const r = 5; + const c = 2 * Math.PI * r; + return ( + + ); +} + +function PowerIcon() { + return ( + + ); +} + +/** + * Ghostty-style update pill that lives in the titlebar. Shows download + * progress while an update streams in, then becomes a "Restart to Complete + * Update" button. Renders nothing when no update is in flight. + */ +export function UpdatePill({ phase, onRestart, origin = "top" }: UpdatePillProps) { + const [hovered, setHovered] = useState(false); + const clickable = phase.state === "ready"; + // Slide in from whichever edge the pill is anchored to. + const offset = origin === "bottom" ? 8 : -8; + // Ready/restarting render as a solid, borderless capsule (inverted colors); + // downloading stays a quiet outlined pill. + const solid = phase.state === "ready" || phase.state === "restarting"; + + return ( + + {phase.state !== "idle" && ( + setHovered(true)} + onMouseLeave={() => setHovered(false)} + title={ + phase.state === "downloading" + ? `Downloading v${phase.version}` + : `Restart to update to v${phase.version}` + } + style={{ + display: "inline-flex", + alignItems: "center", + gap: 6, + height: 22, + padding: "0 10px", + borderRadius: 999, + border: `1px solid ${solid ? "transparent" : colors.border.default}`, + background: solid + ? clickable && hovered + ? `color-mix(in srgb, ${colors.text.primary} 19%, transparent)` + : `color-mix(in srgb, ${colors.text.primary} 16%, transparent)` + : colors.bg.surface, + color: solid ? colors.text.primary : colors.text.secondary, + fontSize: fontSize.xs, + fontWeight: fontWeight.medium, + fontFamily: "inherit", + whiteSpace: "nowrap", + cursor: "default", + transition: "background 0.15s ease, color 0.15s ease, border-color 0.15s ease", + WebkitAppRegion: "no-drag", + } as React.CSSProperties} + > + {phase.state === "downloading" ? ( + <> + + + Downloading Update…{" "} + + + + ) : ( + <> + + Restart to Complete Update + + )} + + )} + + ); +} diff --git a/clients/desktop/src/hooks/useAnnouncement.ts b/clients/desktop/src/hooks/useAnnouncement.ts index 06709084..23dad9b5 100644 --- a/clients/desktop/src/hooks/useAnnouncement.ts +++ b/clients/desktop/src/hooks/useAnnouncement.ts @@ -1,6 +1,9 @@ import { useEffect, useState } from "react"; -const API_BASE = "https://lookout.hackclub.com"; +import { getApiBase } from "../serverConfig.js"; + +// Read once per webview load; Settings → Server reloads the view on change. +const API_BASE = getApiBase(); // Re-check for a new/cleared announcement while the app stays open. const CHECK_INTERVAL_MS = 15 * 60_000; // 15 minutes diff --git a/clients/desktop/src/hooks/useAppUpdate.ts b/clients/desktop/src/hooks/useAppUpdate.ts new file mode 100644 index 00000000..3dd188d3 --- /dev/null +++ b/clients/desktop/src/hooks/useAppUpdate.ts @@ -0,0 +1,134 @@ +import { check, type Update } from "@tauri-apps/plugin-updater"; +import { relaunch } from "@tauri-apps/plugin-process"; +import { useCallback, useEffect, useRef, useState } from "react"; + +export const LAST_UPDATE_KEY = "lookout_last_update_ts"; +const UPDATE_COOLDOWN_MS = 60_000; +const CHECK_INTERVAL_MS = 30 * 60_000; // 30 minutes + +export type UpdatePhase = + | { state: "idle" } + | { state: "downloading"; version: string; progress: number } + | { state: "ready"; version: string } + | { state: "restarting"; version: string }; + +/** + * Ghostty-style background updater. Checks at launch and every 30 minutes, + * downloads a found update in the background (progress surfaces in the + * titlebar pill), then waits for the user to click "Restart to Complete + * Update". Nothing ever blocks the app at launch. + * + * install() stays behind the user's click: on Windows it exits the app to + * run the installer, so it must never fire automatically. + */ +export function useAppUpdate(): { phase: UpdatePhase; restart: () => void } { + const [phase, setPhase] = useState({ state: "idle" }); + const updateRef = useRef(null); + const demoRef = useRef(false); + + // Dev-only: run `__updatePillDemo()` in the webview console to watch the + // full pill lifecycle (enter → download progress → ready → restart → exit) + // without a real update. Clicking the pill in demo mode fakes the restart. + useEffect(() => { + if (!import.meta.env.DEV) return; + (window as unknown as Record).__updatePillDemo = () => { + demoRef.current = true; + let p = 0; + setPhase({ state: "downloading", version: "9.9.9", progress: 0 }); + const id = setInterval(() => { + p += 1 + Math.random() * 4; + if (p >= 100) { + clearInterval(id); + setPhase({ state: "ready", version: "9.9.9" }); + } else { + setPhase({ state: "downloading", version: "9.9.9", progress: Math.round(p) }); + } + }, 80); + }; + return () => { + delete (window as unknown as Record).__updatePillDemo; + }; + }, []); + + useEffect(() => { + let cancelled = false; + + // Right after an update-relaunch, skip the immediate re-check so a bad + // update manifest can't relaunch-loop the app. + let skipFirstCheck = false; + const lastUpdate = localStorage.getItem(LAST_UPDATE_KEY); + if (lastUpdate && Date.now() - Number(lastUpdate) < UPDATE_COOLDOWN_MS) { + console.log("[updater] skipping immediate check — just updated"); + localStorage.removeItem(LAST_UPDATE_KEY); + skipFirstCheck = true; + } + + const runCheck = async () => { + if (updateRef.current) return; // already downloading or downloaded + try { + const update = await check(); + if (cancelled || !update) return; + updateRef.current = update; + console.log(`[updater] found v${update.version}, downloading in background`); + setPhase({ state: "downloading", version: update.version, progress: 0 }); + + let totalBytes = 0; + let downloadedBytes = 0; + await update.download((event) => { + if (cancelled) return; + if (event.event === "Started" && event.data.contentLength) { + totalBytes = event.data.contentLength; + } else if (event.event === "Progress") { + downloadedBytes += event.data.chunkLength; + const progress = + totalBytes > 0 + ? Math.round((downloadedBytes / totalBytes) * 100) + : 0; + setPhase({ state: "downloading", version: update.version, progress }); + } + }); + + if (!cancelled) { + console.log(`[updater] v${update.version} downloaded — waiting for restart`); + setPhase({ state: "ready", version: update.version }); + } + } catch (e) { + console.warn("[updater] background update failed:", e); + // Clear the ref so the next interval tick retries from scratch. + updateRef.current = null; + if (!cancelled) setPhase({ state: "idle" }); + } + }; + + if (!skipFirstCheck) runCheck(); + const id = setInterval(runCheck, CHECK_INTERVAL_MS); + return () => { + cancelled = true; + clearInterval(id); + }; + }, []); + + const restart = useCallback(async () => { + if (demoRef.current) { + demoRef.current = false; + setPhase({ state: "idle" }); + return; + } + const update = updateRef.current; + if (!update) return; // the pill only becomes clickable once downloaded + setPhase({ state: "restarting", version: update.version }); + try { + // The bytes are already on disk, so install() is near-instant. + // relaunch() only fires after a successful install; on Windows + // install() itself exits the app to run the installer. + await update.install(); + localStorage.setItem(LAST_UPDATE_KEY, String(Date.now())); + await relaunch(); + } catch (e) { + console.error("[updater] install failed:", e); + setPhase({ state: "ready", version: update.version }); + } + }, []); + + return { phase, restart }; +} diff --git a/clients/desktop/src/hooks/useBackgroundUpdate.ts b/clients/desktop/src/hooks/useBackgroundUpdate.ts deleted file mode 100644 index 673cdba1..00000000 --- a/clients/desktop/src/hooks/useBackgroundUpdate.ts +++ /dev/null @@ -1,69 +0,0 @@ -import { check, type Update } from "@tauri-apps/plugin-updater"; -import { relaunch } from "@tauri-apps/plugin-process"; -import { useCallback, useEffect, useRef, useState } from "react"; -import { LAST_UPDATE_KEY } from "./useUpdateCheck.js"; - -// How often to ask the update server whether a newer build exists. -const CHECK_INTERVAL_MS = 30 * 60_000; // 30 minutes - -/** - * Periodically checks the update server while the app is running and reports - * when a newer version becomes available. Unlike useUpdateCheck — which - * auto-updates at launch behind a full-screen gate — this never interrupts the - * user. It only surfaces availability so the UI can show a "restart to update" - * banner; the actual download + install is deferred until the user opts in via - * restart(), reusing the same flow the launch-time updater uses. - */ -export function useBackgroundUpdate(enabled: boolean) { - const [availableVersion, setAvailableVersion] = useState(null); - const [restarting, setRestarting] = useState(false); - const updateRef = useRef(null); - - useEffect(() => { - if (!enabled) return; - let cancelled = false; - - const runCheck = async () => { - if (updateRef.current) return; // already found one — nothing more to do - try { - const update = await check(); - if (cancelled || !update) return; - console.log(`[updater] background check found v${update.version}`); - updateRef.current = update; - setAvailableVersion(update.version); - } catch (e) { - console.warn("[updater] background check failed:", e); - } - }; - - // The launch-time updater already checked once, so wait a full interval - // before the first background check. - const id = setInterval(runCheck, CHECK_INTERVAL_MS); - return () => { - cancelled = true; - clearInterval(id); - }; - }, [enabled]); - - const restart = useCallback(async () => { - const update = updateRef.current; - if (!update) return; // the banner only renders when we have one in hand - setRestarting(true); - try { - console.log(`[updater] installing v${update.version} on user request`); - // downloadAndInstall() and relaunch() are both Tauri's own APIs (updater - // + process plugins) — this is the exact same sequence the launch-time - // updater runs. relaunch() only fires after a successful install, so a - // failed download never restarts the app. - await update.downloadAndInstall(); - // Skip the launch-time re-check on the very next start. - localStorage.setItem(LAST_UPDATE_KEY, String(Date.now())); - await relaunch(); - } catch (e) { - console.error("[updater] restart-to-update failed:", e); - setRestarting(false); - } - }, []); - - return { availableVersion, restarting, restart }; -} diff --git a/clients/desktop/src/hooks/useNativeCapture.ts b/clients/desktop/src/hooks/useNativeCapture.ts index 2a7a4218..e237e38b 100644 --- a/clients/desktop/src/hooks/useNativeCapture.ts +++ b/clients/desktop/src/hooks/useNativeCapture.ts @@ -55,6 +55,11 @@ export function useNativeCapture( cameraCapture?: CameraFrameCapture, /** Called when the capture loop discovers the server moved the session to a terminal state. */ onSessionTerminated?: (status: string) => void, + /** The session's server-known tracked seconds, used to seed a freshly + * started Rust tray timer. Needed because `trackedSeconds` below is + * hook-local state that starts at 0 — on a session that already has + * recorded time it can't seed anything until the first confirm lands. */ + sessionTrackedSeconds = 0, ) { const [isCapturing, setIsCapturing] = useState(false); const [trackedSeconds, setTrackedSeconds] = useState(0); @@ -71,6 +76,12 @@ export function useNativeCapture( // check if the user intentionally stopped (avoids auto-resume race). const capturingRef = useRef(false); + // Best known tracked seconds, for seeding a freshly started Rust tray timer. + // Both inputs are server-derived; take the higher one so neither a + // not-yet-confirmed capture nor a lagging session poll seeds a low baseline. + const trackedSecondsRef = useRef(0); + trackedSecondsRef.current = Math.max(trackedSeconds, sessionTrackedSeconds); + // Track blob URL for cleanup const blobUrlRef = useRef(null); @@ -253,6 +264,13 @@ export function useNativeCapture( maxWidth: MAX_WIDTH, maxHeight: MAX_HEIGHT, jpegQuality: Math.round(JPEG_QUALITY * 100), + }).then(() => { + // A freshly started Rust tray timer counts from 0 — seed it with the + // last known tracked seconds so the menu-bar time doesn't reset while + // waiting for the first confirm (visible on pause → resume). + if (trackedSecondsRef.current > 0) { + invoke("sync_tray_tracked_seconds", { trackedSeconds: trackedSecondsRef.current }).catch(console.error); + } }).catch((err) => { console.error("[capture] failed to start Rust capture loop:", err); setError(String(err)); @@ -274,6 +292,16 @@ export function useNativeCapture( setError(null); updatePreview(result.previewBase64); }), + // In-between live-preview frames from the capture loop (20/min while + // the window is focused). Same redaction-aware capture path as the + // uploads, delivered through the same preview pipeline — so + // lastScreenshotUrl is simply live whenever someone's looking. + listen<{ previewBase64: string; previewWidth: number; previewHeight: number }>( + "capture-preview-frame", + (event) => { + updatePreview(event.payload.previewBase64); + }, + ), listen<{ message: string }>("capture-tick-error", (event) => { console.error(`[capture] Rust capture error: ${event.payload.message}`); setError(event.payload.message); diff --git a/clients/desktop/src/hooks/useScreenPreview.ts b/clients/desktop/src/hooks/useScreenPreview.ts index 546a52fb..8e21808f 100644 --- a/clients/desktop/src/hooks/useScreenPreview.ts +++ b/clients/desktop/src/hooks/useScreenPreview.ts @@ -65,7 +65,8 @@ export function useScreenPreview( let cancelled = false; let timerId: ReturnType; - + let removeVisibilityListener: (() => void) | null = null; + // Convert fps to ms interval, min 16ms (60fps) const intervalMs = Math.max(16, Math.floor(1000 / targetFps)); @@ -73,10 +74,25 @@ export function useScreenPreview( const loop = async () => { if (cancelled) return; - + const s = sourceRef.current; if (!s) return; - + + // Each preview frame is a full native screen capture + JPEG encode. + // Nobody can see the result while the window is hidden/minimized, so + // park the loop until the document becomes visible again. + if (document.hidden) { + const onVisibility = () => { + document.removeEventListener("visibilitychange", onVisibility); + removeVisibilityListener = null; + if (!cancelled) loop(); + }; + document.addEventListener("visibilitychange", onVisibility); + removeVisibilityListener = () => + document.removeEventListener("visibilitychange", onVisibility); + return; + } + const startTime = performance.now(); const scheduleNext = () => { if (cancelled) return; @@ -137,6 +153,7 @@ export function useScreenPreview( return () => { cancelled = true; clearTimeout(timerId); + removeVisibilityListener?.(); console.debug("[preview] stopping preview loop"); }; }, [sourceKey, targetFps, live]); diff --git a/clients/desktop/src/hooks/useUpdateCheck.ts b/clients/desktop/src/hooks/useUpdateCheck.ts deleted file mode 100644 index 26f4f5a0..00000000 --- a/clients/desktop/src/hooks/useUpdateCheck.ts +++ /dev/null @@ -1,88 +0,0 @@ -import { check } from "@tauri-apps/plugin-updater"; -import { relaunch } from "@tauri-apps/plugin-process"; -import { useState, useEffect } from "react"; - -type UpdateStatus = - | { state: "checking" } - | { state: "no-update"; message: string } - | { state: "downloading"; progress: number } - | { state: "installing" } - | { state: "done" } - | { state: "idle" }; - -export const LAST_UPDATE_KEY = "lookout_last_update_ts"; -const UPDATE_COOLDOWN_MS = 60_000; -const FAIL_DISPLAY_MS = 1500; // Show failure message briefly before continuing - -export function useUpdateCheck() { - const [status, setStatus] = useState({ state: "checking" }); - - useEffect(() => { - let cancelled = false; - - // Guard against infinite relaunch loop - const lastUpdate = localStorage.getItem(LAST_UPDATE_KEY); - if (lastUpdate && Date.now() - Number(lastUpdate) < UPDATE_COOLDOWN_MS) { - console.log("[updater] skipping check — just updated"); - localStorage.removeItem(LAST_UPDATE_KEY); - setStatus({ state: "idle" }); - return; - } - - const continueAfterFail = (message: string) => { - if (cancelled) return; - setStatus({ state: "no-update", message }); - setTimeout(() => { - if (!cancelled) setStatus({ state: "idle" }); - }, FAIL_DISPLAY_MS); - }; - - (async () => { - try { - const update = await check(); - if (cancelled) return; - - if (!update) { - if (!cancelled) setStatus({ state: "idle" }); - return; - } - - console.log(`[updater] found v${update.version}, downloading...`); - setStatus({ state: "downloading", progress: 0 }); - - let totalBytes = 0; - let downloadedBytes = 0; - await update.downloadAndInstall((event) => { - if (cancelled) return; - if (event.event === "Started" && event.data.contentLength) { - totalBytes = event.data.contentLength; - } else if (event.event === "Progress") { - downloadedBytes += event.data.chunkLength; - const progress = - totalBytes > 0 - ? Math.round((downloadedBytes / totalBytes) * 100) - : 0; - setStatus({ state: "downloading", progress }); - } else if (event.event === "Finished") { - setStatus({ state: "installing" }); - } - }); - - if (!cancelled) { - localStorage.setItem(LAST_UPDATE_KEY, String(Date.now())); - setStatus({ state: "done" }); - await relaunch(); - } - } catch (e) { - console.warn("[updater] failed:", e); - continueAfterFail("Checking for update failed. Continuing…"); - } - })(); - - return () => { - cancelled = true; - }; - }, []); - - return status; -} diff --git a/clients/desktop/src/hooks/useWindowFocus.ts b/clients/desktop/src/hooks/useWindowFocus.ts new file mode 100644 index 00000000..73e59252 --- /dev/null +++ b/clients/desktop/src/hooks/useWindowFocus.ts @@ -0,0 +1,27 @@ +import { useState, useEffect } from "react"; + +/** + * Whether the app window currently has focus. + * + * Used to gate work that's pointless without the user's eyes on it — e.g. + * the recorder's live preview polls native captures while focused and falls + * back to the latest uploaded capture when not. Complements the + * `document.hidden` parking inside useScreenPreview: `hidden` covers + * minimized/other-desktop, focus covers "visible but behind another window". + */ +export function useWindowFocus(): boolean { + const [focused, setFocused] = useState(() => document.hasFocus()); + + useEffect(() => { + const onFocus = () => setFocused(true); + const onBlur = () => setFocused(false); + window.addEventListener("focus", onFocus); + window.addEventListener("blur", onBlur); + return () => { + window.removeEventListener("focus", onFocus); + window.removeEventListener("blur", onBlur); + }; + }, []); + + return focused; +} diff --git a/clients/desktop/src/main.tsx b/clients/desktop/src/main.tsx index 15c6c8a5..53f878ea 100644 --- a/clients/desktop/src/main.tsx +++ b/clients/desktop/src/main.tsx @@ -8,6 +8,10 @@ import { App } from "./App.js"; declare const __APP_VERSION__: string; +// Boot timing — measured from the webview's navigation start. Shows up in +// the console and the backtick debug log as [boot] lines. +console.log(`[boot] main.tsx eval at ${Math.round(performance.now())}ms`); + Sentry.init({ dsn: import.meta.env.VITE_SENTRY_DSN, release: `lookout-desktop@${__APP_VERSION__}`, @@ -31,7 +35,11 @@ const originalFetch = window.fetch; window.fetch = function (input, init) { const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : (input as Request).url; const method = init?.method || "GET"; - console.log(`[net] ${method} ${url}`); + // Preview frames fetch once per second per source — logging them floods + // the 200-entry debug buffer and buries real diagnostics. Failures are + // still logged below. + const isPreviewFrame = url.includes("lookout-preview"); + if (!isPreviewFrame) console.log(`[net] ${method} ${url}`); // On Windows, Tauri v2 uses fetch('http://ipc.localhost/...') for IPC and // 'http://asset.localhost/...' for assets. We must NOT intercept these or @@ -44,7 +52,7 @@ window.fetch = function (input, init) { return (doFetch as Promise).then( (res) => { - console.log(`[net] ${method} ${url} → ${res.status}`); + if (!isPreviewFrame) console.log(`[net] ${method} ${url} → ${res.status}`); return res; }, (err: Error) => { diff --git a/clients/desktop/src/serverConfig.ts b/clients/desktop/src/serverConfig.ts new file mode 100644 index 00000000..e30dbcc8 --- /dev/null +++ b/clients/desktop/src/serverConfig.ts @@ -0,0 +1,79 @@ +/** + * Runtime-configurable Lookout server. + * + * The desktop app historically hardcoded https://lookout.hackclub.com in + * several modules. The base URL now lives here: persisted in localStorage + * (like the app blacklist), read once at module-load time by every consumer, + * and changed from Settings → Server — which reloads the webview so all + * module-scope `API_BASE` reads pick up the new value. The Rust side needs + * no storage of its own: it receives the URL per session via the + * `configure` command, which the frontend calls with this value. + * + * NOTE: the webview CSP (tauri.conf.json) allows `https:` for connect/img/ + * media sources, so custom servers must be HTTPS. Plain-http servers are + * blocked by the CSP (localhost excepted, for development). + */ + +export const DEFAULT_API_BASE = "https://lookout.hackclub.com"; + +const STORAGE_KEY = "lookout-api-base"; + +/** + * Validate and canonicalize a user-entered server URL to its origin + * (scheme + host + port). Returns null when the input isn't a usable + * server URL. HTTPS only, except localhost for development — anything + * else would be blocked by the webview CSP anyway. + */ +export function normalizeServerUrl(input: string): string | null { + const trimmed = input.trim(); + if (!trimmed) return null; + let url: URL; + try { + // Accept bare hostnames like "lookout-stage.dino.icu". + url = new URL(/^https?:\/\//i.test(trimmed) ? trimmed : `https://${trimmed}`); + } catch { + return null; + } + const isLocalhost = + url.hostname === "localhost" || url.hostname === "127.0.0.1"; + if (url.protocol !== "https:" && !(url.protocol === "http:" && isLocalhost)) { + return null; + } + return url.origin; +} + +/** The active server base URL (no trailing slash). */ +export function getApiBase(): string { + try { + const stored = localStorage.getItem(STORAGE_KEY); + if (stored) { + const normalized = normalizeServerUrl(stored); + if (normalized) return normalized; + } + } catch { + // localStorage unavailable — fall through to the default + } + return DEFAULT_API_BASE; +} + +/** True when the app is pointed at the production server. */ +export function isDefaultApiBase(): boolean { + return getApiBase() === DEFAULT_API_BASE; +} + +/** + * Persist a new server base URL (pass null to reset to production). + * Callers should reload the webview afterwards — consumers read the + * value once at module load. + */ +export function setApiBase(url: string | null): void { + try { + if (url === null || url === DEFAULT_API_BASE) { + localStorage.removeItem(STORAGE_KEY); + } else { + localStorage.setItem(STORAGE_KEY, url); + } + } catch (e) { + console.error("[server-config] failed to persist server URL:", e); + } +} diff --git a/clients/playground/README.md b/clients/playground/README.md new file mode 100644 index 00000000..f380e86b --- /dev/null +++ b/clients/playground/README.md @@ -0,0 +1,67 @@ +# SDK playground + +A harness for exercising `@lookout/react` against a real server — built to +check the edit (cuts) flow, which is hard to eyeball inside the desktop app. + +```bash +npm run dev --workspace @lookout/playground # http://localhost:5199 +``` + +Paste an API base URL and a session token. Both persist in localStorage. + +## Getting an editable session + +Editing only exists during a session's **edit hold**, so a plain stop won't +do. Create and stop one with the hold: + +```bash +# 1. Create (needs a program API key) +curl -X POST "$API/api/internal/sessions" \ + -H 'Content-Type: application/json' -H "X-API-Key: $KEY" \ + -d '{"metadata":{"why":"playground"}}' + +# 2. Record a few minutes in the Record tab with the returned token. + +# 3. Stop it WITH a hold — this is what makes it editable. +curl -X POST "$API/api/sessions/$TOKEN/stop" \ + -H 'Content-Type: application/json' -d '{"edit":true}' +``` + +The hold is a lease: it lapses about two minutes after the last +`POST /:token/editing`. The editor renews it while open, so leaving the +Editor tab up keeps the session alive; leaving the playground closed lets +it publish itself, after which it is no longer editable (by design — +published data must not change under the programs consuming it). + +## Tabs + +- **Editor** — `` inside a resizable box. Presets cover the + shapes that broke layout before (short, narrow, the desktop window's + actual minimum); the corner drags to anything else. The dock must stay + on screen and the video must letterbox at every size. +- **Detail** — ``, which renders the hold's review panel. +- **Record** — the full `` flow, including the stop modal + with "Edit & save". + +## The Server truth panel + +Polls `/status` and `/units` every 2s and shows them next to the editor. +Most bugs in this feature were the client and server disagreeing, so the +panel exists to make that visible rather than inferable from a 400: + +- `editable` / `editableReason` — `preparing` while the preview compiles + (the editor should show a progress ring, not an error), `published` once + it's out. +- **Verify against server** sends the editor's current cut list to + `PUT /cuts` and prints the response. **`unitsCut` must equal the number + the editor's footer says was removed.** A mismatch there is what used to + surface as "Cut list would remove the entire timelapse" on Save. The + button writes the cut list; it does not publish. + +## Note on the SDK build + +The playground excludes `@lookout/react` from Vite's dep pre-bundling, so a +rebuild of the SDK shows up on reload without restarting the dev server. +The other clients don't, which is why SDK changes can appear not to take +effect there — restart their dev server after +`npm run build --workspace @lookout/react`. diff --git a/clients/playground/index.html b/clients/playground/index.html new file mode 100644 index 00000000..5bf6a7a6 --- /dev/null +++ b/clients/playground/index.html @@ -0,0 +1,21 @@ + + + + + + Lookout SDK playground + + + +
+ + + diff --git a/clients/playground/package.json b/clients/playground/package.json new file mode 100644 index 00000000..39113910 --- /dev/null +++ b/clients/playground/package.json @@ -0,0 +1,24 @@ +{ + "name": "@lookout/playground", + "version": "0.3.7", + "license": "AGPL-3.0-or-later", + "private": true, + "type": "module", + "scripts": { + "build": "vite build", + "dev": "vite" + }, + "dependencies": { + "@lookout/react": "*", + "@lookout/shared": "*", + "react": "^19.0.0", + "react-dom": "^19.0.0" + }, + "devDependencies": { + "@types/react": "^19.0.0", + "@types/react-dom": "^19.0.0", + "@vitejs/plugin-react": "^4.3.0", + "typescript": "^5.7.0", + "vite": "^6.0.0" + } +} diff --git a/clients/playground/src/App.tsx b/clients/playground/src/App.tsx new file mode 100644 index 00000000..a75a1275 --- /dev/null +++ b/clients/playground/src/App.tsx @@ -0,0 +1,521 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import { + LookoutProvider, + LookoutRecorder, + SessionDetail, + TimelapseEditor, + createLookoutClient, + setAccentColor, + colors, + fontSize, + fontWeight, + radii, + spacing, + type CutInterval, +} from "@lookout/react"; + +/** + * A harness for the edit feature. + * + * The point isn't to look like the product — it's to make the parts that + * are hard to eyeball checkable: the editor at arbitrary sizes, and the + * server's own numbers next to what the editor is claiming. Most of the + * bugs in this feature were disagreements between those two. + */ + +type Tab = "record" | "editor" | "detail"; + +const LS_KEY = "lookout-playground"; + +interface Settings { + apiBaseUrl: string; + token: string; + /** Brand accent an embedding program would pass to LookoutProvider. */ + accent: string; +} + +function loadSettings(): Settings { + try { + const raw = localStorage.getItem(LS_KEY); + if (raw) return JSON.parse(raw) as Settings; + } catch { + // Fall through to defaults. + } + return { + apiBaseUrl: "https://lookout-stage.dino.icu", + token: "", + accent: "#3b82f6", + }; +} + +export function App() { + const [settings, setSettings] = useState(loadSettings); + const [applied, setApplied] = useState(() => { + const s = loadSettings(); + return s.token ? s : null; + }); + const [tab, setTab] = useState("editor"); + const [cuts, setCuts] = useState([]); + + // Mirrors what does, so the editor and + // both dialogs can be checked against a brand colour without wiring a + // provider around every tab. + useEffect(() => { + setAccentColor(applied?.accent ?? null); + }, [applied?.accent]); + + const apply = () => { + localStorage.setItem(LS_KEY, JSON.stringify(settings)); + setApplied({ ...settings }); + }; + + return ( +
+
+ + {!applied?.token ? ( + + ) : ( +
+
+ {tab === "record" && ( + + + + )} + {tab === "editor" && ( + + )} + {tab === "detail" && ( + + )} +
+ +
+ )} +
+ ); +} + +function Header({ + settings, + onChange, + onApply, + tab, + onTab, + ready, +}: { + settings: Settings; + onChange: (s: Settings) => void; + onApply: () => void; + tab: Tab; + onTab: (t: Tab) => void; + ready: boolean; +}) { + const input: React.CSSProperties = { + background: colors.bg.sunken, + border: `1px solid ${colors.border.default}`, + borderRadius: radii.md, + color: colors.text.primary, + padding: "6px 10px", + fontSize: fontSize.md, + fontFamily: "inherit", + outline: "none", + }; + + return ( +
+ Lookout SDK + onChange({ ...settings, apiBaseUrl: e.target.value })} + /> + onChange({ ...settings, token: e.target.value.trim() })} + onKeyDown={(e) => { + if (e.key === "Enter") onApply(); + }} + /> + + + + {ready && ( +
+ {(["editor", "detail", "record"] as Tab[]).map((t) => ( + + ))} +
+ )} +
+ ); +} + +function Empty() { + return ( +
+ Paste a session token to begin. +
+ + Stop it with an edit hold first, or the editor will report it published. + +
+ ); +} + +/** + * The editor inside a box you can resize to arbitrary dimensions. + * + * The clipping bug was only visible at particular window shapes, and a + * maximised browser window never reproduces it. Presets cover the corners: + * short (the dock must survive), narrow (the action row must wrap), and + * the real desktop window's minimum. + */ +function ResizableEditor({ + settings, + onCuts, +}: { + settings: Settings; + onCuts: (cuts: CutInterval[]) => void; +}) { + const [size, setSize] = useState({ w: 900, h: 620 }); + const presets: Array<[string, number, number]> = [ + ["desktop default", 900, 620], + ["desktop minimum", 620, 480], + ["short", 900, 360], + ["narrow", 480, 620], + ["tiny", 420, 320], + ]; + + return ( +
+
+ {presets.map(([label, w, h]) => ( + + ))} + + or drag the corner + +
+ +
+ console.log("[playground] published")} + onCutsChange={(cuts, dirty) => { + onCuts(cuts); + console.log("[playground] cuts", { dirty, cuts }); + }} + /> +
+
+ ); +} + +/** + * What the server actually thinks, polled live. + * + * Every serious bug in this feature was the client and the server + * disagreeing — over-counted cut units, a stale hold, a status the editor + * read as terminal. Putting the server's own numbers on screen makes those + * disagreements visible instead of inferable from a 400. + */ +function ServerTruth({ + settings, + cuts, +}: { + settings: Settings; + cuts: CutInterval[]; +}) { + const [status, setStatus] = useState | null>(null); + const [units, setUnits] = useState | null>(null); + const [error, setError] = useState(null); + const [preview, setPreview] = useState | null>(null); + const clientRef = useRef( + createLookoutClient({ baseUrl: settings.apiBaseUrl, token: settings.token }), + ); + + useEffect(() => { + clientRef.current = createLookoutClient({ + baseUrl: settings.apiBaseUrl, + token: settings.token, + }); + }, [settings.apiBaseUrl, settings.token]); + + // /status is the endpoint built for polling (60/min). /units presigns a + // URL and allows only 10/min, so it is fetched on load, when /status + // reports a change worth re-reading, and on demand — never on a timer. + const [unitsAt, setUnitsAt] = useState(0); + const lastUnitsRef = useRef(0); + const signatureRef = useRef(""); + + const loadUnits = useCallback(async () => { + // Hard floor between reads so no combination of triggers can walk + // into the limit. + if (Date.now() - lastUnitsRef.current < 6000) return; + lastUnitsRef.current = Date.now(); + try { + const r = await fetch( + `${settings.apiBaseUrl}/api/sessions/${settings.token}/units`, + ); + const u = (await r.json()) as Record; + const { units: list, originalVideoUrl: _url, ...rest } = u; + setUnits({ ...rest, unitCount: Array.isArray(list) ? list.length : 0 }); + setUnitsAt(Date.now()); + } catch (e) { + setError(e instanceof Error ? e.message : String(e)); + } + }, [settings.apiBaseUrl, settings.token]); + + useEffect(() => { + let cancelled = false; + const tick = async () => { + try { + const r = await fetch( + `${settings.apiBaseUrl}/api/sessions/${settings.token}/status`, + ); + const s = (await r.json()) as Record; + if (cancelled) return; + setError(null); + setStatus(s); + // Re-read /units only when something that changes it changed. + const sig = `${s.status}:${s.editable}`; + if (sig !== signatureRef.current) { + signatureRef.current = sig; + void loadUnits(); + } + } catch (e) { + if (!cancelled) setError(e instanceof Error ? e.message : String(e)); + } + }; + void tick(); + const id = setInterval(tick, 3000); + return () => { + cancelled = true; + clearInterval(id); + }; + }, [settings.apiBaseUrl, settings.token, loadUnits]); + + // Dry-run the cut list the editor most recently reported, so the + // server's own arithmetic sits next to the editor's footer. + const dryRun = useCallback(async (cuts: CutInterval[]) => { + try { + setPreview({ ...(await clientRef.current.setCuts(cuts)) }); + } catch (e) { + setPreview({ error: e instanceof Error ? e.message : String(e) }); + } + }, []); + + const box: React.CSSProperties = { + background: colors.bg.sunken, + border: `1px solid ${colors.border.default}`, + borderRadius: radii.md, + padding: spacing.sm, + fontSize: 11, + fontFamily: "ui-monospace, SFMono-Regular, monospace", + whiteSpace: "pre-wrap", + wordBreak: "break-all", + color: colors.text.secondary, + }; + + return ( +
+
+ Server truth +
+
+ /status every 3s; /units only on change + or demand (10/min limit). Compare editable and the + tracked-time pair against what the editor shows. +
+ + {error &&
{error}
} + + +
{JSON.stringify(status, null, 1)}
+ + +
+ {unitsAt ? `read ${new Date(unitsAt).toLocaleTimeString()}` : "not read yet"} + {" · 10/min limit, so not polled"} +
+
{JSON.stringify(units, null, 1)}
+ + + +
+ Sends the editor's current list and shows what the server counts. + unitsCut here must match the editor's "removed" — a + mismatch is the class of bug that made Save fail with "would remove + the entire timelapse". This writes the cut list (it does not + publish). +
+ {preview &&
{JSON.stringify(preview, null, 1)}
} +
+ ); +} + +function Label({ children }: { children: React.ReactNode }) { + return ( +
+ {children} +
+ ); +} diff --git a/clients/playground/src/main.tsx b/clients/playground/src/main.tsx new file mode 100644 index 00000000..e586c5a6 --- /dev/null +++ b/clients/playground/src/main.tsx @@ -0,0 +1,4 @@ +import { createRoot } from "react-dom/client"; +import { App } from "./App.js"; + +createRoot(document.getElementById("root")!).render(); diff --git a/clients/playground/tsconfig.json b/clients/playground/tsconfig.json new file mode 100644 index 00000000..3fe4ec9d --- /dev/null +++ b/clients/playground/tsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "target": "ES2022", + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "module": "ESNext", + "moduleResolution": "bundler", + "jsx": "react-jsx", + "strict": true, + "skipLibCheck": true, + "noEmit": true, + "types": ["vite/client"] + }, + "include": ["src"] +} diff --git a/clients/playground/vite.config.ts b/clients/playground/vite.config.ts new file mode 100644 index 00000000..61bd235f --- /dev/null +++ b/clients/playground/vite.config.ts @@ -0,0 +1,12 @@ +import { defineConfig } from "vite"; +import react from "@vitejs/plugin-react"; + +export default defineConfig({ + plugins: [react()], + // The SDK is a workspace package resolved to its built dist, which Vite + // would otherwise pre-bundle and cache — the reason SDK edits kept not + // showing up during this feature's development. Excluding it means a + // rebuild of @lookout/react is picked up on reload, no server restart. + optimizeDeps: { exclude: ["@lookout/react", "@lookout/shared"] }, + server: { port: 5199 }, +}); diff --git a/clients/react/API.md b/clients/react/API.md index 9d45cf9d..84ad9ee8 100644 --- a/clients/react/API.md +++ b/clients/react/API.md @@ -1,6 +1,6 @@ # @lookout/react — React SDK Documentation -**Package:** `@lookout/react` v0.1.0 +**Package:** `@lookout/react` v0.3.7 **Peer Dependencies:** React 18+ or 19+ **Exports:** ESM + CJS with TypeScript declarations @@ -97,6 +97,8 @@ Context provider that configures the API client and settings for all child hooks | `statusPollIntervalMs` | `number` | `3000` | Compilation status poll interval (ms) | | `autoStart` | `boolean` | `false` | Auto-start screen sharing on mount | | `appName` | `string` | — | Host program embedding Lookout (e.g. `"Fallout"`). Reported in client telemetry as `Lookout Sdk (Fallout)/ (…)` and surfaced server-side as the session's `clientInfo`. | +| `accentColor` | `string` | `#3b82f6` | Replace Lookout's blue with your brand colour — primary buttons, focus rings, progress. Any CSS colour. | +| `accentTextColor` | `string` | `#fff` | Colour drawn *on* the accent. Set it if your accent is light enough that white labels would be unreadable. | | `children` | `ReactNode` | *required* | Child components | #### `TokenProvider` @@ -183,7 +185,7 @@ const { state, actions } = useLookout(); | `stopSharing` | `() => void` | Stop capture source without stopping session (auto-pauses) | | `pause` | `() => Promise` | Pause the session | | `resume` | `() => Promise` | Resume a paused session | -| `stop` | `(options?: { name?: string }) => Promise` | Stop the session and trigger compilation. Optionally name the timelapse before stopping. | +| `stop` | `(options?: { name?: string; edit?: boolean }) => Promise` | Stop the session and trigger compilation. Optionally name it first. `edit: true` holds it unpublished so the user can cut it before programs see it. | | `selectCamera` | `(deviceId: string) => void` | Select a camera device by ID. Works during preview and recording. | | `startPreview` | `() => Promise` | Acquire camera stream for live preview without starting the capture loop. Camera mode only. | | `stopPreview` | `() => void` | Stop the preview stream. Camera mode only. | @@ -334,7 +336,7 @@ const session = useSession(); | `error` | `string \| null` | Error message | | `pause` | `() => Promise` | Pause the session | | `resume` | `() => Promise` | Resume the session | -| `stop` | `(name?: string) => Promise` | Stop the session. Optionally name the timelapse before stopping (non-fatal if rename fails). | +| `stop` | `(name?: string, opts?: { edit?: boolean }) => Promise` | Stop the session. Optionally name it first (non-fatal if rename fails). `edit: true` requests an edit hold. | | `reload` | `() => Promise` | Re-fetch session from server | | `syncStatus` | `() => Promise` | Best-effort fetch of latest server status (used when a 409 surfaces in the uploader to reconcile local state) | | `updateTrackedSeconds` | `(seconds: number) => void` | Update tracked seconds locally | @@ -367,6 +369,33 @@ const displaySeconds = useSessionTimer(trackedSeconds, isActive); --- +### `useSessionTimerState(serverTrackedSeconds, isActive)` + +Same timer, but returns the interpolation **anchor** alongside the display value. Use this when another surface has to tick its own copy of the clock — the desktop app's menu-bar title (Rust) and tray popup window both do, so they stay live while the main WebView is throttled. + +**Returns:** `SessionTimerState` + +| Field | Type | Description | +|-------|------|-------------| +| `displaySeconds` | `number` | What to render | +| `baseSeconds` | `number` | The ratcheted server-authoritative value the display is anchored to | +| `anchorAt` | `number` | `Date.now()` when `baseSeconds` last advanced | + +### `deriveDisplaySeconds(baseSeconds, anchorAt, isActive, now)` + +The pure function behind the hook, exported so independently-ticking surfaces derive the clock identically instead of reimplementing it: + +```ts +const seconds = deriveDisplaySeconds(baseSeconds, anchorAt, isRecording, Date.now()); +``` + +Any surface ticking its own clock must go through this (or mirror it exactly — see `tray_display_seconds` in the desktop crate). Two rules are easy to get wrong and both produce a visibly wrong clock: + +- **Interpolate from `baseSeconds`, never from `displaySeconds`.** The latter already contains the interpolated remainder, so extrapolating from it double-counts and the surface drifts ahead of the main window. +- **Pass through `anchorAt` unchanged.** It marks when the base last advanced; re-stamping it to "now" on each push restarts the interpolation window and loses time the main window is still counting. + +--- + ### `useTokenStore()` Manages session tokens in `localStorage` with cross-tab sync. No provider required. @@ -468,7 +497,18 @@ Drop-in recorder widget. Handles the full lifecycle: capture, upload, pause/resu ``` -No props — reads everything from context. +**Props (`LookoutRecorderProps`):** + +| Prop | Type | Description | +|------|------|-------------| +| `editing` | `boolean?` | Offer "Edit & save" when stopping (default `true`). Pass `false` to keep stopping a single click. | + +Everything else is read from context. + +**Stopping** opens a `` with three ways out: keep +recording, stop and save, or edit and save. Choosing to edit stops the +session with a hold, so it compiles without publishing, and swaps the view +for a `` until the user publishes. **Renders based on status:** - `loading` — spinner @@ -699,6 +739,107 @@ Full session detail view with video player, stats, and compilation polling. Stan | `apiBaseUrl` | `string` | Server API base URL | | `onBack` | `() => void?` | Back button handler | | `onArchive` | `() => void?` | Archive button handler | +| `onEdit` | `() => void?` | Override the review panel's "Edit & save" — open your own editor surface instead of the inline one | + +A session in its **edit hold** (stopped with `edit`, not yet published) +renders a review panel instead of the compile spinner: "Edit & save" opens +the editor, "Publish as recorded" ends the hold immediately, and a +countdown shows when it publishes on its own. Pass `onEdit` to open your +own editor surface (the desktop app opens a separate window). + +--- + +### `useEditLease(client, active?)` + +Holds a held session's **edit lease** open while an editing surface is +mounted. The server publishes a held timelapse once nothing has renewed the +lease for ~2 minutes, so "is the user still editing?" is answered by the +surface existing rather than by a countdown. Returns `false` once the +session is no longer held (published, failed, or past the ceiling). + +Called automatically by `` and by ``'s +review panel. Use it directly only if you build your own editing surface. + +--- + +### `` + +The stop confirmation: keep recording, stop and save, or edit and save. +Rendered automatically by ``; exported for custom +recorders. + +**Props (`StopChoiceModalProps`):** + +| Prop | Type | Description | +|------|------|-------------| +| `onResume` | `() => void` | Keep recording | +| `onStopAndSave` | `(name: string \| null) => void` | Stop and publish as recorded | +| `onEditAndSave` | `((name: string \| null) => void)?` | Stop with a hold, then edit. Omit to hide the option | +| `withName` | `boolean?` | Show a name field (default `false`) | +| `loading` | `boolean?` | Disable inputs while the stop is in flight | + +--- + +### `` + +The "Edit & save" step. The session is compiled but deliberately +**unpublished**, so nothing downstream has consumed it yet; this previews +that video (1 second = 1 capture unit = 1 real-world minute), lets the user +drag out cut regions on a filmstrip timeline, and publishes — with the cuts +baked in (a lossless server-side stream copy) or without them. Standalone +(no provider needed). + +```tsx + refetchStatus()} +/> +``` + +**Props (`TimelapseEditorProps`):** + +| Prop | Type | Description | +|------|------|-------------| +| `token` | `string` | Session token | +| `apiBaseUrl` | `string` | Server API base URL | +| `onApplied` | `() => void?` | The timelapse was published — return to your detail view and poll `/status` | +| `onCancel` | `() => void?` | Dismiss the editor. Only surfaced when it can't load; there is no "leave without deciding" exit | +| `onCutsChange` | `((cuts, dirty) => void)?` | Fires on every cut-list change, so a host can publish the working edit when the user closes it | + +The editor normally opens **before** the preview video exists — the compile +starts at stop and takes tens of seconds — so it polls through that state +and shows a `` sized from the session's capture count, then +swaps to the timeline when the video lands. + +`` and `` both present this in an +**``** — a modal panel portalled to `document.body`, so it gets +the viewport rather than whatever width the host gave the recorder, and so +a transformed ancestor in the host page can't trap it. It is deliberately +not dismissible: closing without deciding would leave the session +unpublished, so Save is the way out (and if the tab goes away, the edit +lease lapses and it publishes as recorded). + +The timeline ruler labels at a step chosen from the track width +(`rulerStep`), with a grabbable playhead tag above it. While mounted the +editor holds the session's **edit lease** (see `useEditLease`), +so there's no deadline for the user to race: the timelapse stays +unpublished for as long as the editor is open, and publishes on its own +about two minutes after it closes. + +**Interactions:** +- **Drag on the filmstrip** creates a cut region in one gesture (edges snap to + whole minutes); **plain click seeks**; the **ruler lane scrubs**. +- Regions are first-class objects: drag to move, edge handles to resize + (the preview follows the dragged edge, showing the boundary frame), + click to select, Delete/Backspace to remove. +- **Space** plays/pauses. Playback **skips cut regions** (previewing the + published result); scrubbing passes through them with a "will be removed" + overlay so edges can be judged. +- Footer shows server-authoritative "kept / removed" durations; recording + pauses appear as dashed gap markers on the strip. +- Shows "n edits remaining" as the per-session recompile budget runs low, + and a not-editable state once the original video has been purged. --- @@ -777,10 +918,14 @@ const session = await client.getSession(); | `uploadToR2` | `(uploadUrl, blob) => Promise` | PUT blob to presigned URL | | `pause` | `() => Promise` | Pause session | | `resume` | `() => Promise` | Resume session | -| `stop` | `() => Promise` | Stop session | +| `stop` | `(opts?: { edit?: boolean }) => Promise` | Stop session; `edit: true` holds it for editing before publication | | `rename` | `(name: string) => Promise` | Rename the timelapse | | `getStatus` | `() => Promise` | Poll compilation status | | `getVideo` | `() => Promise` | Get video URL | +| `getUnits` | `() => Promise` | Editor metadata: unit map, cuts, presigned preview-video URL | +| `setCuts` | `(cuts: CutInterval[]) => Promise` | Replace the session's cut list (`[]` clears). Only during an edit hold | +| `applyCuts` | `() => Promise` | Publish the held timelapse with its cuts baked in | +| `heartbeatEditing` | `() => Promise` | Renew the edit lease — "an editor is still open" | --- @@ -792,12 +937,41 @@ The SDK exports styled UI primitives used by its components. All use inline styl |--------|-------------| | `Button` | Styled button with variants: `primary`, `secondary`, `success`, `warning`, `danger`, `ghost` and sizes: `sm`, `md`, `lg` | | `Spinner` | Loading spinner with sizes: `sm`, `md`, `lg` | +| `ProgressRing` | Determinate circular progress (`progress` 0–1, optional `showPercent`) — for waits long enough that a spinner under-informs | +| `MinutesFlow` | A minute count with rolling digits (`@number-flow/react`), splitting into hours past 60 | | `Badge` | Status badge with variants: `default`, `overlay` | | `Card` | Styled card container | | `ErrorDisplay` | Error message display with variants: `inline`, `banner`, `page` | | `PageContainer` | Page layout wrapper | +| `Overlay` | Modal panel portalled to `document.body` (immune to transformed ancestors), with backdrop, scroll lock, and optional dismiss | | `Skeleton` / `GallerySkeleton` / `SessionDetailSkeleton` / `RecordPageSkeleton` | Loading skeletons | | `colors` / `spacing` / `radii` / `fontSize` / `fontWeight` / `statusConfig` | Theme tokens | +| `setAccentColor(accent, on?)` | Imperative accent override, for surfaces used without `` (``, ``). Pass `null` to restore the default. | + +### Theming the accent + +```tsx + + + +``` + +That recolours the primary buttons ("Edit & save", "Save"), keyboard focus +rings, and the compile progress ring — everywhere the UI says *this is the +main action*. The hover shade is derived from your colour with +`color-mix`, so you don't supply a second one. + +Two deliberate limits: + +- **Semantic colours don't change.** Success green, warning amber, and the + red that marks removed footage carry meaning rather than brand, and a + green "this will be deleted" would be worse than an off-brand one. +- **It's set on the document root, not a wrapper.** The stop dialog and the + editor portal to `document.body`, so a scoped subtree wouldn't reach + them. The provider restores the previous value on unmount. + +For surfaces rendered outside a provider, call `setAccentColor("#16a34a")` +once at startup instead. --- diff --git a/clients/react/package.json b/clients/react/package.json index 7376c055..90ae3677 100644 --- a/clients/react/package.json +++ b/clients/react/package.json @@ -1,6 +1,6 @@ { "name": "@lookout/react", - "version": "0.3.3", + "version": "0.3.7", "license": "AGPL-3.0-or-later", "type": "module", "main": "./dist/index.cjs", @@ -41,6 +41,8 @@ }, "dependencies": { "@lookout/shared": "*", + "@number-flow/react": "^0.6.2", + "@phosphor-icons/react": "^2.1.10", "@squircle-js/react": "^1.3.0", "@videojs/react": "^10.0.0-beta.8", "motion": "^12.38.0" diff --git a/clients/react/src/LookoutProvider.tsx b/clients/react/src/LookoutProvider.tsx index 26ec6f03..72fd12a0 100644 --- a/clients/react/src/LookoutProvider.tsx +++ b/clients/react/src/LookoutProvider.tsx @@ -2,6 +2,7 @@ import { createContext, useContext, useEffect, useMemo, type ReactNode } from "r import { buildBrowserClientInfo } from "@lookout/shared"; import { createLookoutClient, type LookoutClient } from "./api/client.js"; import { resolveConfig } from "./defaults.js"; +import { setAccentColor } from "./ui/theme.js"; import type { LookoutConfig, ResolvedConfig } from "./types.js"; // Injected at build time by tsup (see tsup.config.ts `define`). Falls back to @@ -30,14 +31,33 @@ export function useLookoutContext(): LookoutContextValue { export interface LookoutProviderProps extends LookoutConfig { children: ReactNode; + /** Replace Lookout's blue accent with your own brand colour. Applies to + * primary buttons, focus rings, and progress — everywhere the UI is + * saying "this is the main action". Any CSS colour. */ + accentColor?: string; + /** Colour drawn ON the accent (button labels). Defaults to white; set it + * when your accent is light enough that white would be unreadable. */ + accentTextColor?: string; } export function LookoutProvider({ children, + accentColor, + accentTextColor, ...config }: LookoutProviderProps) { const resolved = useMemo(() => resolveConfig(config), [config]); + // Applied to the document root, not a wrapper: the stop dialog and the + // editor portal to document.body, so a scoped subtree wouldn't reach + // them. Restored on unmount so a page that mounts Lookout temporarily + // doesn't leave its accent behind. + useEffect(() => { + if (!accentColor && !accentTextColor) return; + setAccentColor(accentColor ?? null, accentTextColor ?? null); + return () => setAccentColor(null, null); + }, [accentColor, accentTextColor]); + // Telemetry string, e.g. "Lookout Sdk (Fallout)/0.2.6 (macOS 14.3; Chrome 120.0)". const clientInfo = useMemo( () => diff --git a/clients/react/src/api/client.ts b/clients/react/src/api/client.ts index b72c5478..fec6d634 100644 --- a/clients/react/src/api/client.ts +++ b/clients/react/src/api/client.ts @@ -1,4 +1,6 @@ +import { UPLOAD_STEP_TIMEOUT_MS } from "@lookout/shared"; import type { + CaptureFormat, SessionResponse, UploadUrlResponse, ConfirmScreenshotRequest, @@ -9,6 +11,11 @@ import type { RenameSessionResponse, StatusResponse, VideoResponse, + UnitsResponse, + SetCutsResponse, + ApplyCutsResponse, + EditHeartbeatResponse, + CutInterval, } from "@lookout/shared"; import type { TokenProvider } from "../types.js"; @@ -17,16 +24,41 @@ export interface LookoutClient { getSession(): Promise; /** `capturedAt` is optional. Sending it on the first request of a new * session opts the session into credit-mode tracking; subsequent - * requests must keep sending it. Omit for legacy bucket-count behavior. */ - getUploadUrl(opts?: { capturedAt?: string }): Promise; + * requests must keep sending it. Omit for legacy bucket-count behavior. + * `format` requests a clip upload ('webm'/'mp4'); omit for a single + * JPEG. The response's `format` is the GRANTED format — the caller + * must upload exactly that. */ + getUploadUrl(opts?: { + capturedAt?: string; + format?: CaptureFormat; + }): Promise; confirmScreenshot(body: ConfirmScreenshotRequest): Promise; - uploadToR2(uploadUrl: string, blob: Blob): Promise; + uploadToR2(uploadUrl: string, blob: Blob, contentType?: string): Promise; pause(): Promise; resume(): Promise; - stop(): Promise; + /** Stop the session. Pass `{ edit: true }` to hold it unpublished after + * compiling so the user can cut it first — programs never see + * `complete` until the edits are baked in. The hold auto-publishes if + * the user walks away, so this can never strand a timelapse. Only send + * it from a client that can actually render the editor. */ + stop(opts?: { edit?: boolean }): Promise; rename(name: string): Promise; getStatus(): Promise; getVideo(): Promise; + /** Editor metadata: the compiled original's unit map (video second i ↔ + * wall clock), current cut list, and a token-gated presigned URL for the + * UNCUT original video. */ + getUnits(): Promise; + /** Replace the session's cut list (full replace; [] clears all edits). + * Returns the normalized list plus a server-authoritative preview. */ + setCuts(cuts: CutInterval[]): Promise; + /** Apply the current cut list to the published video (a cut-compile — + * usually a lossless stream copy, seconds not minutes). */ + applyCuts(): Promise; + /** Renew the edit lease — "an editor is still open". Call every + * EDIT_HEARTBEAT_SECONDS while an editing surface is showing; stop when + * the response reports `held: false`. */ + heartbeatEditing(): Promise; } export class HttpError extends Error { @@ -52,6 +84,27 @@ async function resolveTokenValue(provider: TokenProvider): Promise { return result instanceof Promise ? result : result; } +/** + * An AbortSignal that fires after UPLOAD_STEP_TIMEOUT_MS. + * + * `fetch` never times out on its own, so one half-open socket would + * otherwise park a request forever — and the capture loop has nothing to + * retry until it settles. Returns undefined on engines without + * `AbortSignal.timeout` (pre-2022 Safari/Firefox) rather than shimming it: + * those users get exactly today's behaviour, nobody gets a hard failure. + */ +function stepDeadline(): AbortSignal | undefined { + return typeof AbortSignal !== "undefined" && + typeof AbortSignal.timeout === "function" + ? AbortSignal.timeout(UPLOAD_STEP_TIMEOUT_MS) + : undefined; +} + +/** True for the AbortError a stepDeadline fires. */ +function isTimeout(err: unknown): boolean { + return err instanceof Error && (err.name === "TimeoutError" || err.name === "AbortError"); +} + async function fetchJson(url: string, init?: RequestInit): Promise { const headers: Record = {}; if (init?.body) { @@ -59,8 +112,17 @@ async function fetchJson(url: string, init?: RequestInit): Promise { } let res: Response; try { - res = await fetch(url, { ...init, headers: { ...headers, ...(init?.headers as Record) } }); + res = await fetch(url, { + signal: stepDeadline(), + ...init, + headers: { ...headers, ...(init?.headers as Record) }, + }); } catch (err) { + if (isTimeout(err)) { + throw new Error( + `Timed out after ${UPLOAD_STEP_TIMEOUT_MS / 1000}s fetching ${url}`, + ); + } // Network-level failure (DNS, connection refused, CORS, SSL) // WebKit just says "Load failed" — add the URL for context const msg = err instanceof Error ? err.message : String(err); @@ -104,6 +166,7 @@ export function createLookoutClient(options: CreateClientOptions): LookoutClient const base = await sessionUrl("/upload-url"); const params = new URLSearchParams(); if (opts?.capturedAt) params.set("capturedAt", opts.capturedAt); + if (opts?.format) params.set("format", opts.format); if (clientInfo) params.set("clientInfo", clientInfo); const qs = params.toString(); return fetchJson(qs ? `${base}?${qs}` : base); @@ -116,7 +179,7 @@ export function createLookoutClient(options: CreateClientOptions): LookoutClient }); }, - async uploadToR2(uploadUrl, blob) { + async uploadToR2(uploadUrl, blob, contentType = "image/jpeg") { if (!uploadUrl.startsWith("https://") && !uploadUrl.startsWith("/")) { throw new Error("Invalid upload URL: must be HTTPS or a relative path."); } @@ -125,9 +188,20 @@ export function createLookoutClient(options: CreateClientOptions): LookoutClient res = await fetch(uploadUrl, { method: "PUT", body: blob, - headers: { "Content-Type": "image/jpeg" }, + // Must match the content type the presigned URL was signed with. + headers: { "Content-Type": contentType }, + // The step most exposed to a weak uplink: this is the multi-MB + // payload, and a stalled PUT used to hang the capture loop with + // no error for it to fall back on. + signal: stepDeadline(), }); } catch (err) { + if (isTimeout(err)) { + throw new Error( + `R2 upload timed out after ${UPLOAD_STEP_TIMEOUT_MS / 1000}s ` + + `(${blob.size} bytes) — the connection stalled mid-transfer.`, + ); + } if (err instanceof TypeError) { throw new Error( "Upload failed: network error or CORS misconfiguration on R2 bucket.", @@ -137,8 +211,32 @@ export function createLookoutClient(options: CreateClientOptions): LookoutClient } if (!res.ok) { const text = await res.text().catch(() => ""); + // R2 answers with S3-style XML. A bare "403" is unactionable and + // every cause has a different fix, so name the cause rather than + // making the next person bisect it. + const code = /([^<]+)<\/Code>/.exec(text)?.[1] ?? ""; + const detail = /([^<]+)<\/Message>/.exec(text)?.[1] ?? ""; + const hint = + code === "SignatureDoesNotMatch" + ? " The request didn't match the presigned URL — something between the client and R2 is altering the request (a proxy, or a rewritten method/headers)." + : code === "RequestTimeTooSkewed" + ? " The signing server's clock is off relative to R2; fix NTP on the API server." + : code === "AccessDenied" || /expire/i.test(detail) + ? " The presigned URL had expired (they last ~2 minutes) or the credentials can't write this key. A slow upload of a large clip can outrun the expiry." + : res.status === 403 && !text + ? " Empty 403 body usually means CORS stripped the response: check the R2 bucket's CORS rules allow PUT from this origin." + : ""; + // The UI truncates; make the whole thing reachable in the console. + console.error("[lookout] R2 upload failed", { + status: res.status, + code, + detail, + body: text.slice(0, 1000), + }); throw new Error( - `R2 upload failed: HTTP ${res.status}${text ? " — " + text.slice(0, 200) : ""}`, + `R2 upload failed: HTTP ${res.status}${code ? ` (${code})` : ""}${ + detail ? ` — ${detail}` : text ? " — " + text.slice(0, 200) : "" + }${hint}`, ); } }, @@ -155,9 +253,12 @@ export function createLookoutClient(options: CreateClientOptions): LookoutClient }); }, - async stop() { + async stop(opts) { return fetchJson(await sessionUrl("/stop"), { method: "POST", + // Old servers ignore an unknown body; omit it entirely for the + // plain stop so the request stays byte-identical to before. + ...(opts?.edit ? { body: JSON.stringify({ edit: true }) } : {}), }); }, @@ -175,5 +276,28 @@ export function createLookoutClient(options: CreateClientOptions): LookoutClient async getVideo() { return fetchJson(await sessionUrl("/video")); }, + + async getUnits() { + return fetchJson(await sessionUrl("/units")); + }, + + async setCuts(cuts) { + return fetchJson(await sessionUrl("/cuts"), { + method: "PUT", + body: JSON.stringify({ cuts }), + }); + }, + + async applyCuts() { + return fetchJson(await sessionUrl("/compile"), { + method: "POST", + }); + }, + + async heartbeatEditing() { + return fetchJson(await sessionUrl("/editing"), { + method: "POST", + }); + }, }; } diff --git a/clients/react/src/components/CameraPreview.tsx b/clients/react/src/components/CameraPreview.tsx index 8f26a7eb..b393b5a3 100644 --- a/clients/react/src/components/CameraPreview.tsx +++ b/clients/react/src/components/CameraPreview.tsx @@ -68,7 +68,7 @@ export function CameraPreview({ stream, fallbackImageUrl }: CameraPreviewProps) borderRadius: radii.sm, }} > - {stream ? "Live preview" : "Latest capture"} + {stream ? "Preview" : "Latest capture"}
); diff --git a/clients/react/src/components/Gallery.tsx b/clients/react/src/components/Gallery.tsx index 0588adc1..f0d47a92 100644 --- a/clients/react/src/components/Gallery.tsx +++ b/clients/react/src/components/Gallery.tsx @@ -1,4 +1,5 @@ import React, { useRef, useState, useEffect, useCallback } from "react"; +import { GearSixIcon, PlusIcon } from "@phosphor-icons/react"; import type { SessionSummary } from "@lookout/shared"; import { SessionCard } from "./SessionCard.js"; import { Button } from "../ui/Button.js"; @@ -6,14 +7,24 @@ import { ErrorDisplay } from "../ui/ErrorDisplay.js"; import { GallerySkeleton } from "../ui/Skeleton.js"; import { colors, spacing, fontSize, fontWeight, radii } from "../ui/theme.js"; +/** Viewport-relative rect of the + button, for hosts that anchor a popup to it. */ +export interface AddAnchor { + x: number; + y: number; + width: number; + height: number; +} + export interface GalleryProps { sessions: SessionSummary[]; loading: boolean; error: string | null; onSessionClick?: (token: string) => void; onArchive?: (token: string) => void; + /** Right-click on a session card. The host decides how to present the menu. */ + onSessionContextMenu?: (token: string, e: React.MouseEvent) => void; onRefresh?: () => void; - onAdd?: () => void; + onAdd?: (anchor: AddAnchor) => void; onSettings?: () => void; /** Optional content rendered just below the header (e.g. an update banner). */ banner?: React.ReactNode; @@ -22,26 +33,37 @@ export interface GalleryProps { const addButtonStyle: React.CSSProperties = { borderRadius: radii.md, fontSize: fontSize.xxl, - width: 36, - height: 36, + width: 40, + height: 40, padding: 0, + display: "inline-flex", + alignItems: "center", + justifyContent: "center", }; -function GalleryHeader({ onAdd, onSettings }: { onAdd?: () => void; onSettings?: () => void }) { +function GalleryHeader({ onAdd, onSettings }: { onAdd?: (anchor: AddAnchor) => void; onSettings?: () => void }) { return (

Your Timelapses

{onSettings && ( - )} {onAdd && ( - )}
@@ -58,6 +80,7 @@ export function Gallery({ error, onSessionClick, onArchive, + onSessionContextMenu, onRefresh, onAdd, onSettings, @@ -154,6 +177,7 @@ export function Gallery({ session={s} onClick={() => onSessionClick?.(s.token)} onArchive={onArchive ? () => onArchive(s.token) : undefined} + onContextMenu={onSessionContextMenu ? (e) => onSessionContextMenu(s.token, e) : undefined} /> ))}
diff --git a/clients/react/src/components/LookoutRecorder.tsx b/clients/react/src/components/LookoutRecorder.tsx index 10bbde11..55b91ca3 100644 --- a/clients/react/src/components/LookoutRecorder.tsx +++ b/clients/react/src/components/LookoutRecorder.tsx @@ -1,6 +1,9 @@ -import React from "react"; +import React, { useEffect, useState } from "react"; import { useLookout } from "../hooks/useLookout.js"; +import { useLookoutContext } from "../LookoutProvider.js"; import { StatusBar } from "./StatusBar.js"; +import { TimelapseEditor } from "./TimelapseEditor.js"; +import { StopChoiceModal } from "./StopChoiceModal.js"; import { ScreenPreview } from "./ScreenPreview.js"; import { CameraPreview } from "./CameraPreview.js"; import { CameraSelector } from "./CameraSelector.js"; @@ -10,6 +13,7 @@ import { Button } from "../ui/Button.js"; import { Spinner } from "../ui/Spinner.js"; import { ErrorDisplay } from "../ui/ErrorDisplay.js"; import { PageContainer } from "../ui/PageContainer.js"; +import { Overlay } from "../ui/Overlay.js"; import { colors, fontSize, fontWeight, spacing } from "../ui/theme.js"; /** @@ -22,8 +26,55 @@ import { colors, fontSize, fontWeight, spacing } from "../ui/theme.js"; * * Must be used within a ``. */ -export function LookoutRecorder() { +export interface LookoutRecorderProps { + /** Offer "Edit & save" when stopping (default true). Programs embedding + * the recorder can pass false to keep stopping a one-click action. */ + editing?: boolean; +} + +export function LookoutRecorder({ editing = true }: LookoutRecorderProps = {}) { const { state, actions } = useLookout(); + const { client, config } = useLookoutContext(); + const [resolvedToken, setResolvedToken] = useState(null); + // Set when the user chose "Edit & save": the session is held unpublished + // and this view owns the editor until they publish. + const [editorOpen, setEditorOpen] = useState(false); + + // The editor needs a concrete token string; resolve it once, up front, + // so opening the editor is instant when the user asks for it. + useEffect(() => { + if (!editing) return; + let cancelled = false; + client + .resolveToken() + .then((t) => { + if (!cancelled) setResolvedToken(t); + }) + .catch(() => { + // Best-effort: without a token we simply don't offer editing. + }); + return () => { + cancelled = true; + }; + }, [editing, client]); + + const canEdit = editing && resolvedToken !== null; + const [stopPrompt, setStopPrompt] = useState(false); + const [stopping, setStopping] = useState(false); + + const confirmStop = async (withEdit: boolean) => { + setStopping(true); + try { + // Open the editor optimistically for the edit path: the session is + // held, so the editor can show its own "preparing" state while the + // compile runs instead of leaving the user on a dead screen. + if (withEdit) setEditorOpen(true); + await actions.stop({ edit: withEdit }); + setStopPrompt(false); + } finally { + setStopping(false); + } + }; if (state.status === "loading") { return ( @@ -63,12 +114,70 @@ export function LookoutRecorder() { state.status === "failed" ) { return ( - - - + <> + + + + + {/* "Edit & save" opens over the host page rather than inside the + recorder's own box. Embedders place the recorder in columns and + cards of any width, and a timeline squeezed into one is a + precision tool you can't be precise with; the overlay gets the + viewport regardless. + + Deliberately not dismissible: there is no "leave without + deciding" exit, because the session is unpublished until + someone decides. Save is the way out — and if the tab goes + away entirely, the edit lease lapses and it publishes as + recorded. */} + {editorOpen && resolvedToken && state.status !== "failed" && ( + +
+
+ Review your timelapse +
+
+ Cut anything you'd rather not share. Nothing is published + until you save. +
+
+
+ setEditorOpen(false)} + /> +
+
+ )} + ); } @@ -111,7 +220,7 @@ export function LookoutRecorder() { status={state.status} onStartPreview={actions.startPreview} onStartRecording={actions.startSharing} - onStop={actions.stop} + onStop={() => setStopPrompt(true)} /> ) : state.isPreviewing && !state.isSharing ? ( /* Phase 2: Previewing — show "Start Recording" */ @@ -127,10 +236,18 @@ export function LookoutRecorder() { onStartSharing={actions.startSharing} onPause={actions.pause} onResume={actions.resume} - onStop={actions.stop} + onStop={() => setStopPrompt(true)} captureMode="camera" /> )} + {stopPrompt && ( + setStopPrompt(false)} + onStopAndSave={() => void confirmStop(false)} + onEditAndSave={canEdit ? () => void confirmStop(true) : undefined} + /> + )} ); } @@ -150,9 +267,17 @@ export function LookoutRecorder() { onStartSharing={actions.startSharing} onPause={actions.pause} onResume={actions.resume} - onStop={actions.stop} + onStop={() => setStopPrompt(true)} captureMode="screen" /> + {stopPrompt && ( + setStopPrompt(false)} + onStopAndSave={() => void confirmStop(false)} + onEditAndSave={canEdit ? () => void confirmStop(true) : undefined} + /> + )} ); } diff --git a/clients/react/src/components/ScreenPreview.tsx b/clients/react/src/components/ScreenPreview.tsx index 0d402293..bd78879c 100644 --- a/clients/react/src/components/ScreenPreview.tsx +++ b/clients/react/src/components/ScreenPreview.tsx @@ -11,8 +11,10 @@ export function ScreenPreview({ imageUrl }: ScreenPreviewProps) { return (
- Last captured screenshot - Latest screenshot + {/* On clip sessions this is the final frame of the latest clip (the + upload itself is video); on legacy sessions it's the screenshot. */} + Latest capture + Latest capture
); } diff --git a/clients/react/src/components/SessionCard.tsx b/clients/react/src/components/SessionCard.tsx index 4cb1038a..f7f525ab 100644 --- a/clients/react/src/components/SessionCard.tsx +++ b/clients/react/src/components/SessionCard.tsx @@ -10,9 +10,11 @@ export interface SessionCardProps { session: SessionSummary; onClick?: () => void; onArchive?: () => void; + /** Right-click on the card. The host decides how to present the menu. */ + onContextMenu?: (e: React.MouseEvent) => void; } -export function SessionCard({ session, onClick, onArchive }: SessionCardProps) { +export function SessionCard({ session, onClick, onArchive, onContextMenu }: SessionCardProps) { const date = new Date(session.createdAt); const dateStr = date.toLocaleDateString(undefined, { month: "short", @@ -21,7 +23,11 @@ export function SessionCard({ session, onClick, onArchive }: SessionCardProps) { }); return ( - + { e.preventDefault(); onContextMenu(e); } : undefined} + style={{ position: "relative" }} + > {/* Thumbnail */}
{session.thumbnailUrl ? ( diff --git a/clients/react/src/components/SessionDetail.tsx b/clients/react/src/components/SessionDetail.tsx index ffb8bc74..422d028d 100644 --- a/clients/react/src/components/SessionDetail.tsx +++ b/clients/react/src/components/SessionDetail.tsx @@ -1,20 +1,142 @@ -import { useState, useEffect, useCallback, useRef } from "react"; +import { useState, useEffect, useCallback, useMemo, useRef } from "react"; import { motion, AnimatePresence } from "motion/react"; import type { StatusResponse, VideoResponse, SessionResponse } from "@lookout/shared"; import { formatTrackedTime } from "../hooks/useSessionTimer.js"; import { Button } from "../ui/Button.js"; +import { ProgressRing } from "../ui/ProgressRing.js"; +import { Overlay } from "../ui/Overlay.js"; import { ErrorDisplay } from "../ui/ErrorDisplay.js"; import { ProcessingState } from "./ProcessingState.js"; +import { TimelapseEditor } from "./TimelapseEditor.js"; +import { createLookoutClient, type LookoutClient } from "../api/client.js"; +import { useEditLease } from "../hooks/useEditLease.js"; +import { estimateBuildProgress } from "../hooks/buildProgress.js"; import { SessionDetailSkeleton } from "../ui/Skeleton.js"; import { Card } from "../ui/Card.js"; import { Badge } from "../ui/Badge.js"; import { statusConfig, colors, spacing, fontSize, fontWeight, radii } from "../ui/theme.js"; +/** + * Shown while a session sits in its edit hold: compiled, but deliberately + * unpublished so the owner can cut it before anything downstream consumes + * it. Both exits are one click, and doing nothing publishes it anyway — + * the hold can delay publication, never cancel it. + */ +function HoldPanel({ + editable, + progress, + client, + onEdit, + onPublish, +}: { + editable: boolean; + /** Real compile progress from /status, when the worker is reporting it. + * Null/undefined → fall back to the time estimate. */ + progress?: number | null; + client: LookoutClient; + onEdit: () => void; + onPublish: () => void | Promise; +}) { + const [publishing, setPublishing] = useState(false); + // Sitting on this panel counts as still deciding, so it holds the lease + // too. Without this, reading the panel for a couple of minutes would + // publish the timelapse out from under the person reading it. + useEditLease(client, !publishing); + + // Real worker progress wins when present; otherwise ease along the same + // asymptotic time estimate the editor uses. Either way the `editable` flip + // is what actually ends the wait, and the ring stays monotonic. + const [buildProgress, setBuildProgress] = useState(0); + const startedAtRef = useRef(null); + const sawRealRef = useRef(false); + useEffect(() => { + if (typeof progress !== "number") return; + sawRealRef.current = true; + setBuildProgress((prev) => Math.max(prev, progress)); + }, [progress]); + useEffect(() => { + if (editable) return; + // Anchor the start once; a re-run must never rewind the ring. + if (startedAtRef.current === null) startedAtRef.current = Date.now(); + const startedAt = startedAtRef.current; + const tick = () => { + // Once real progress has arrived it owns the ring — don't let the + // estimate race ahead of ground truth. + if (sawRealRef.current) return; + setBuildProgress((prev) => + Math.max(prev, estimateBuildProgress(Date.now() - startedAt, 30_000)), + ); + }; + tick(); + const id = setInterval(tick, 250); + return () => clearInterval(id); + }, [editable]); + + return ( + +
+ {!editable && ( + + )} +
+
+ {editable ? "Ready to review" : "Preparing your timelapse…"} +
+
+ {editable + ? "Cut out anything you don't want to share, then save. Nothing is published until you do." + : "Your recording is compiling. You'll be able to trim it in a moment."} + {" "} + + If you close Lookout, it publishes as recorded. + +
+
+
+
+ + +
+
+ ); +} + export interface SessionDetailProps { token: string; apiBaseUrl: string; onBack?: () => void; onArchive?: () => void; + /** Fired once when the session is observed transitioning to "complete" + * while this view is polling (i.e. the timelapse just finished compiling). + * NOT fired when opening a session that is already complete. Carries the + * session's redirect-hook URL, if one was set at creation. */ + onComplete?: (info: { redirectUrl: string | null }) => void; + /** Override for the Edit button. When provided, clicking Edit calls this + * instead of opening the inline editor — e.g. the desktop app opens a + * dedicated resizable editor window (the main window is a fixed 480px). */ + onEdit?: () => void; } export function SessionDetail({ @@ -22,6 +144,8 @@ export function SessionDetail({ apiBaseUrl, onBack, onArchive, + onComplete, + onEdit, }: SessionDetailProps) { const [sessionInfo, setSessionInfo] = useState<{ name: string; createdAt: string } | null>(null); const [isRenaming, setIsRenaming] = useState(false); @@ -45,6 +169,19 @@ export function SessionDetail({ const [status, setStatus] = useState(null); const [videoUrl, setVideoUrl] = useState(null); const [error, setError] = useState(null); + const [editing, setEditing] = useState(false); + const client = useMemo( + () => createLookoutClient({ baseUrl: apiBaseUrl, token }), + [apiBaseUrl, token], + ); + + // Completion detection for the redirect hook: only a live transition from + // an in-flight state counts — a session opened when already "complete" + // must not re-fire. Refs (not state) so fetchStatus stays stable. + const prevStatusRef = useRef(null); + const completeFiredRef = useRef(false); + const onCompleteRef = useRef(onComplete); + onCompleteRef.current = onComplete; // Fetch session info (name, createdAt) once useEffect(() => { @@ -71,6 +208,17 @@ export function SessionDetail({ const data: StatusResponse = await res.json(); setStatus(data); + const prevStatus = prevStatusRef.current; + prevStatusRef.current = data.status; + if ( + data.status === "complete" && + (prevStatus === "stopped" || prevStatus === "compiling") && + !completeFiredRef.current + ) { + completeFiredRef.current = true; + onCompleteRef.current?.({ redirectUrl: data.redirectUrl ?? null }); + } + // Fetch video URL when complete if (data.status === "complete" && !videoUrl) { try { @@ -99,6 +247,11 @@ export function SessionDetail({ return () => clearInterval(interval); }, [status?.status, fetchStatus]); + // A live edit hold owns the view: the review panel replaces the compile + // spinner. A failed compile is not a hold worth waiting on, even if the + // deadline hasn't passed yet — show the normal failure state instead. + const inHold = Boolean(status?.editHoldUntil) && status?.status !== "failed"; + const cardButtonStyle: React.CSSProperties = { background: colors.bg.surface, border: `1px solid ${colors.border.default}`, @@ -129,16 +282,64 @@ export function SessionDetail({ {!status && !error && } + {status && editing && ( + // Same overlay the recorder uses, so editing looks and behaves + // identically wherever it's entered from. + +
+ setEditing(false)} + onApplied={() => { + // Publishing flips the session compiling → complete (or + // straight to complete when there were no cuts); the poll + // below picks it up. Drop the cached URL so the published + // MP4 is re-fetched. + setEditing(false); + setVideoUrl(null); + fetchStatus(); + }} + /> +
+
+ )} + + {/* Edit hold: the recording is compiled but deliberately not + published yet, so this is the user's one chance to cut it. */} + {status && !editing && inHold && ( + (onEdit ? onEdit() : setEditing(true))} + onPublish={async () => { + try { + await fetch(`${apiBaseUrl}/api/sessions/${token}/compile`, { + method: "POST", + }); + } catch { + // Non-fatal: the hold publishes on its own if this fails. + } + fetchStatus(); + }} + /> + )} + {status && ( <> - {/* Video area */} -
- -
+ {/* Video area. Suppressed during an edit hold: the session reads + as "stopped", but showing a compile spinner under a panel that + says "ready to review" would contradict it. */} + {!inHold && ( +
+ +
+ )} {/* Session name + date */} {sessionInfo && ( diff --git a/clients/react/src/components/StatusBar.tsx b/clients/react/src/components/StatusBar.tsx index 5319f07f..9277a1cc 100644 --- a/clients/react/src/components/StatusBar.tsx +++ b/clients/react/src/components/StatusBar.tsx @@ -29,7 +29,9 @@ export function StatusBar({ displaySeconds, screenshotCount, uploads }: StatusBa {formatTime(displaySeconds)}
- {screenshotCount} {screenshotCount === 1 ? "screenshot" : "screenshots"} + {/* One capture unit per recorded minute — a JPEG screenshot on + legacy sessions, a ~15-frame clip on clips sessions. */} + {screenshotCount} {screenshotCount === 1 ? "capture" : "captures"} {uploads.pending > 0 && ( {uploads.pending} uploading... )} diff --git a/clients/react/src/components/StopChoiceModal.tsx b/clients/react/src/components/StopChoiceModal.tsx new file mode 100644 index 00000000..03ec3387 --- /dev/null +++ b/clients/react/src/components/StopChoiceModal.tsx @@ -0,0 +1,155 @@ +import { useEffect, useRef, useState } from "react"; +import { Button } from "../ui/Button.js"; +import { Card } from "../ui/Card.js"; +import { Overlay } from "../ui/Overlay.js"; +import { colors, fontSize, fontWeight, radii, spacing } from "../ui/theme.js"; + +export interface StopChoiceModalProps { + /** Keep recording — the user hit Stop by accident or changed their mind. */ + onResume: () => void; + /** Stop and publish as recorded. */ + onStopAndSave: (name: string | null) => void; + /** Stop, then review and cut before anything is published. Omit to hide + * the option (programs that don't want an editing step). */ + onEditAndSave?: (name: string | null) => void; + /** Show a name field (the desktop app names timelapses at stop time). */ + withName?: boolean; + loading?: boolean; +} + +/** + * The stop confirmation. Editing is offered HERE rather than after the + * timelapse is published, because publishing is the point at which + * programs consume a session — its heartbeats, its tracked time, its + * video. Once that's happened, quietly changing the numbers underneath + * them isn't an edit, it's a rewrite of something already acted on. + */ +export function StopChoiceModal({ + onResume, + onStopAndSave, + onEditAndSave, + withName = false, + loading = false, +}: StopChoiceModalProps) { + const [name, setName] = useState(""); + const inputRef = useRef(null); + const [choice, setChoice] = useState<"stop" | "edit" | null>(null); + + useEffect(() => { + if (withName) setTimeout(() => inputRef.current?.focus(), 50); + }, [withName]); + + const value = () => name.trim() || null; + + return ( + +
+ +

+ Finish this timelapse? +

+

+ {onEditAndSave + ? "Save it as recorded, or review it first and cut out anything you'd rather not share." + : "This ends the recording and compiles your timelapse."} +

+ + {withName && ( + setName(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter" && !loading) { + setChoice("stop"); + onStopAndSave(value()); + } + }} + placeholder="My timelapse" + maxLength={255} + disabled={loading} + style={{ + width: "100%", + padding: `${spacing.md}px ${spacing.lg}px`, + fontSize: fontSize.lg, + fontWeight: fontWeight.medium, + color: colors.text.primary, + background: colors.bg.sunken, + border: `1px solid ${colors.border.default}`, + borderRadius: radii.md, + outline: "none", + boxSizing: "border-box", + marginBottom: spacing.lg, + opacity: loading ? 0.5 : 1, + }} + /> + )} + +
+ {onEditAndSave && ( + + )} + + +
+
+
+
+ ); +} diff --git a/clients/react/src/components/TimelapseEditor.tsx b/clients/react/src/components/TimelapseEditor.tsx new file mode 100644 index 00000000..536a2073 --- /dev/null +++ b/clients/react/src/components/TimelapseEditor.tsx @@ -0,0 +1,1314 @@ +import { + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from "react"; +import { AnimatePresence, motion } from "motion/react"; +import { + countCutUnits, + type ApplyCutsResponse, + type CutInterval, + type UnitsResponse, +} from "@lookout/shared"; +import { createLookoutClient, type LookoutClient } from "../api/client.js"; +import { + cutsToRegions, + gapIndices, + normalizeRegions, + regionAtTime, + regionsToCuts, + elapsedLabel, + rulerStep, + rulerTicks, + unitAtTime, + unitClockLabel, + type UnitRegion, +} from "../hooks/editorMath.js"; +import { useEditLease } from "../hooks/useEditLease.js"; +import { compileEstimateMs, estimateBuildProgress } from "../hooks/buildProgress.js"; +import { injectEditorStyles } from "./editorStyles.js"; +import { Button } from "../ui/Button.js"; +import { MinutesFlow } from "../ui/MinutesFlow.js"; +import { Spinner } from "../ui/Spinner.js"; +import { ProgressRing } from "../ui/ProgressRing.js"; +import { ErrorDisplay } from "../ui/ErrorDisplay.js"; +import { colors, fontSize, fontWeight, radii, spacing } from "../ui/theme.js"; + +export interface TimelapseEditorProps { + token: string; + apiBaseUrl: string; + /** The timelapse was published — with cuts baked in, or without them. + * The caller should return to its detail view and poll status. The + * publish response is passed through: `instant`/`complete` means it's + * already done (fire any redirect now), otherwise a compile is running. */ + onApplied?: (result: ApplyCutsResponse) => void; + /** Dismiss the editor. Only offered when it can't load — there is no + * "leave without deciding" exit, because closing the editor is itself + * the decision: the session publishes. */ + onCancel?: () => void; + /** Fired whenever the cut list changes, with the normalized list and + * whether it differs from what's saved. Lets a host (the desktop + * window) publish the current edit when the user closes it. */ + onCutsChange?: (cuts: CutInterval[], dirty: boolean) => void; +} + +const STRIP_HEIGHT = 56; +/** Diagonal hatch marking removed stretches on the timeline — the + * conventional "excluded" texture, and a second channel beyond colour + * alone. Kept faint: it should register as texture, not as content + * competing with the thumbnails underneath. */ +const hatch = (periodPx: number) => + `repeating-linear-gradient(45deg, ${colors.editor.cutStripe} 0 ${ + periodPx / 2 + }px, transparent ${periodPx / 2}px ${periodPx}px)`; + +const RULER_HEIGHT = 22; +/** Playhead cap: a slim pill, bottom-aligned to the ruler so it tucks + * under the labels instead of covering them. Small on purpose — it marks + * a position, it isn't a control that should dominate the timeline. */ +const HEAD_W = 9; +const HEAD_H = 13; +/** Invisible grab area around the cap. The cap is too small to hit + * comfortably; the target isn't. */ +const HEAD_HIT = 22; +/** Upper bound on filmstrip tiles. The real count comes from the track + * width (see buildFilmstrip); this only stops an ultra-wide display from + * queueing hundreds of seeks. */ +const FILMSTRIP_MAX_TILES = 48; +/** Canvases are sized in device pixels and scaled down by CSS — without + * this a 2x display renders every thumbnail at half resolution, which + * reads as a blurry, low-quality preview. Capped at 2 because 3x gains + * nothing visible here and triples the decode cost. */ +const pixelRatio = () => + Math.min(2, typeof window === "undefined" ? 1 : window.devicePixelRatio || 1); + +type DragState = + | { kind: "maybe"; downUnitF: number } + | { kind: "scrub" } + | { + kind: "region"; + index: number; + mode: "move" | "start" | "end"; + grabOffset: number; + anchorUnit: number; + } + | null; + +/** + * The "Edit & save" step of stopping a recording. The session is compiled + * but deliberately UNPUBLISHED (held), so nothing downstream has consumed + * it yet; this view previews that video (1 second = 1 capture unit = 1 + * real-world minute), lets the user drag out cut regions, and publishes — + * with the cuts baked in, or without them. + * + * Layout is a three-row shell: a fixed transport bar, a stage that shrinks + * (the only flexible row), and a dock pinned to the bottom. Every ancestor + * of the stage carries `min-height: 0` so the video letterboxes down + * instead of shoving the timeline out of the window. + */ +export function TimelapseEditor({ + token, + apiBaseUrl, + onApplied, + onCancel, + onCutsChange, +}: TimelapseEditorProps) { + const client = useMemo( + () => createLookoutClient({ baseUrl: apiBaseUrl, token }), + [apiBaseUrl, token], + ); + + useEffect(() => injectEditorStyles(), []); + + const [data, setData] = useState(null); + const [loadError, setLoadError] = useState(null); + /** Unit count while the preview video is still compiling; null once it's + * ready. Deliberately a NUMBER, not an object: the poll below re-sets it + * every 1.5s, and a fresh object literal would change identity on every + * poll, re-running the progress effect and restarting the ring at 0. */ + const [preparingUnits, setPreparingUnits] = useState(null); + const [buildProgress, setBuildProgress] = useState(0); + /** Real compile progress from /status, when the worker reports it; null + * until the first metered poll (or forever, for cut-apply/old workers). */ + const [realProgress, setRealProgress] = useState(null); + /** Once true, real progress owns the ring and the time estimate stands down. */ + const sawRealRef = useRef(false); + /** Anchored once per preparing spell, so even a genuine change in the + * unit count can't restart the estimate. */ + const prepareStartRef = useRef(null); + const [regions, setRegions] = useState([]); + const [selected, setSelected] = useState(null); + const [time, setTime] = useState(0); + const [playing, setPlaying] = useState(false); + const [filmstrip, setFilmstrip] = useState([]); + const [saving, setSaving] = useState(false); + const [saveError, setSaveError] = useState(null); + + const videoRef = useRef(null); + const timelineRef = useRef(null); + const dragRef = useRef(null); + const regionsRef = useRef(regions); + regionsRef.current = regions; + const rafRef = useRef(0); + + const units = data?.units ?? []; + const unitCount = units.length; + + // ── Load ──────────────────────────────────────────────────── + // The preview video is built by the compile that ran at stop, so the + // editor almost always opens BEFORE it exists: `/units` reports + // `preparing` for the whole build. That is the normal path, not a + // failure — but it must be waited out on `/status`, not `/units`. + // + // `/units` presigns a URL and is rate limited to 10/min; polling it + // every 1.5s is 40/min, so the wait itself would 429 after ~15s and the + // editor would report a rate-limit error instead of a video. `/status` + // is the cheap endpoint built for polling (60/min) and already carries + // `editable`, so wait on that and fetch `/units` only at the edges. + useEffect(() => { + let cancelled = false; + let timer: ReturnType | undefined; + + const fail = (reason: UnitsResponse["editableReason"]) => + setLoadError( + reason === "published" + ? "This timelapse has already been published, so it can't be edited." + : reason === "failed" + ? "This timelapse couldn't be compiled, so there's nothing to edit." + : reason === "recompiles_exhausted" + ? "This timelapse has reached its edit limit." + : "This timelapse isn't available for editing.", + ); + + const loadUnits = async () => { + const res = await client.getUnits(); + if (cancelled) return; + if (res.editable && res.originalVideoUrl) { + setPreparingUnits(null); + setData(res); + setRegions(cutsToRegions(res.cuts, res.units)); + return; + } + if (res.editableReason === "preparing" || res.editableReason === "no_original") { + // Keep the unit count for the progress estimate, then hand the + // waiting over to /status. + setPreparingUnits(res.expectedUnits ?? 0); + timer = setTimeout(waitForReady, 2000); + return; + } + fail(res.editableReason); + }; + + const waitForReady = async () => { + if (cancelled) return; + try { + const status = await client.getStatus(); + if (cancelled) return; + if (typeof status.progress === "number") setRealProgress(status.progress); + if (status.editable) { + await loadUnits(); + return; + } + if (status.status === "complete") { + fail("published"); + return; + } + if (status.status === "failed") { + fail("failed"); + return; + } + } catch (err) { + // Transient: keep waiting rather than dropping the user out of an + // edit because one poll failed. + console.warn("[editor] status poll failed:", err); + } + timer = setTimeout(waitForReady, 2000); + }; + + void (async () => { + try { + await loadUnits(); + } catch (err) { + if (!cancelled) setLoadError(err instanceof Error ? err.message : String(err)); + } + })(); + + return () => { + cancelled = true; + if (timer) clearTimeout(timer); + }; + }, [client]); + + // ── Build progress ────────────────────────────────────────── + // Real worker progress wins when the /status poll reports it. Until then + // (and for cut-apply/old-worker compiles that never report it) this is a + // time estimate scaled by how much footage there is to compile. Either + // source eases toward — and stops short of — 100%, and only the real + // thing completing ends the wait; a ring that sat at 100% while the user + // waited would be worse than none. + useEffect(() => { + if (realProgress === null) return; + sawRealRef.current = true; + setBuildProgress((prev) => Math.max(prev, realProgress)); + }, [realProgress]); + useEffect(() => { + if (preparingUnits === null) { + prepareStartRef.current = null; + return; + } + if (prepareStartRef.current === null) prepareStartRef.current = Date.now(); + const startedAt = prepareStartRef.current; + const estimateMs = compileEstimateMs(preparingUnits); + const tick = () => { + // Ground truth, once it arrives, owns the ring. + if (sawRealRef.current) return; + const next = estimateBuildProgress(Date.now() - startedAt, estimateMs); + setBuildProgress((prev) => Math.max(prev, next)); + }; + tick(); + const id = setInterval(tick, 200); + return () => clearInterval(id); + }, [preparingUnits]); + + // ── Edit lease ────────────────────────────────────────────── + // This editor being open IS the signal that editing is in progress, so + // it renews the lease while mounted. No countdown, no deadline to race: + // the session waits as long as the window is up, and publishes on its + // own shortly after it isn't. Stops once the session is no longer held. + const leaseHeld = useEditLease(client, !saving); + useEffect(() => { + if (leaseHeld || saving) return; + setLoadError( + "This timelapse was already published, so it can no longer be edited.", + ); + }, [leaseHeld, saving]); + + // ── Playhead tracking (rAF for a smooth 60fps playhead) ───── + useEffect(() => { + const tick = () => { + const v = videoRef.current; + if (v) setTime(v.currentTime); + rafRef.current = requestAnimationFrame(tick); + }; + rafRef.current = requestAnimationFrame(tick); + return () => cancelAnimationFrame(rafRef.current); + }, []); + + // ── Playback skips cut regions (scrubbing passes through) ── + useEffect(() => { + const v = videoRef.current; + if (!v) return; + const onTimeUpdate = () => { + if (v.paused) return; + const region = regionAtTime(v.currentTime, regionsRef.current); + if (!region) return; + if (region.endUnit >= unitCount) { + v.pause(); + v.currentTime = region.startUnit; + } else { + v.currentTime = region.endUnit; + } + }; + v.addEventListener("timeupdate", onTimeUpdate); + return () => v.removeEventListener("timeupdate", onTimeUpdate); + }, [unitCount, data?.originalVideoUrl]); + + // ── Offscreen frame source: filmstrip + scrubber preview ──── + // + // Both features read pixels out of a