@agent-native/core 0.175.2-nightly-20260826150235 → 0.175.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (64) hide show
  1. package/corpus/README.md +1 -1
  2. package/corpus/templates/clips/app/components/player/comments-panel.tsx +1 -1
  3. package/corpus/templates/clips/app/components/player/video-player.tsx +5 -3
  4. package/corpus/templates/clips/desktop/src/app.tsx +171 -51
  5. package/corpus/templates/clips/desktop/src/lib/pill-completion.ts +111 -0
  6. package/corpus/templates/clips/desktop/src/lib/pill-session.ts +34 -0
  7. package/corpus/templates/clips/desktop/src/lib/recorder.ts +181 -79
  8. package/corpus/templates/clips/desktop/src/lib/rewind-recording-start.ts +28 -4
  9. package/corpus/templates/clips/desktop/src/main.tsx +4 -4
  10. package/corpus/templates/clips/desktop/src/overlays/record-pill.tsx +1320 -0
  11. package/corpus/templates/clips/desktop/src/overlays/recording-pill.tsx +1 -1
  12. package/corpus/templates/clips/desktop/src/styles.css +20 -308
  13. package/corpus/templates/clips/desktop/src/tailwind.css +85 -0
  14. package/corpus/templates/clips/desktop/src-tauri/src/clips/mod.rs +153 -70
  15. package/corpus/templates/clips/desktop/src-tauri/src/lib.rs +15 -0
  16. package/corpus/templates/clips/desktop/src-tauri/src/native_screen/custom_capture.rs +25 -7
  17. package/corpus/templates/clips/desktop/src-tauri/src/native_screen/live_upload.rs +3 -3
  18. package/corpus/templates/clips/desktop/src-tauri/src/native_screen.rs +375 -24
  19. package/corpus/templates/clips/desktop/src-tauri/src/recording_indicator.rs +22 -30
  20. package/corpus/templates/clips/desktop/src-tauri/src/rewind_clip.rs +5 -0
  21. package/corpus/templates/clips/desktop/src-tauri/src/screen_memory.rs +134 -14
  22. package/corpus/templates/clips/desktop/src-tauri/src/tray.rs +233 -4
  23. package/corpus/templates/clips/desktop/src-tauri/src/util.rs +8 -5
  24. package/corpus/templates/design/.generated/bridge/editor-chrome.generated.ts +1 -1
  25. package/corpus/templates/design/.generated/bridge/embedded-wheel.generated.ts +3 -2
  26. package/corpus/templates/design/.generated/bridge/zoom.generated.ts +8 -2
  27. package/corpus/templates/design/app/components/design/DesignCanvas.tsx +46 -10
  28. package/corpus/templates/design/app/components/design/MultiScreenCanvas.tsx +89 -63
  29. package/corpus/templates/design/app/components/design/bridge/editor-chrome.bridge.ts +3 -1
  30. package/corpus/templates/design/app/components/design/bridge/embedded-wheel.bridge.ts +8 -2
  31. package/corpus/templates/design/app/components/design/bridge/zoom.bridge.ts +11 -2
  32. package/corpus/templates/design/app/components/design/multi-screen/types.ts +3 -4
  33. package/corpus/templates/design/app/components/design/multi-screen/zoom-gesture.ts +19 -63
  34. package/corpus/templates/design/app/pages/design-editor/overview-camera.ts +2 -1
  35. package/corpus/templates/design/shared/canvas-math.ts +11 -1
  36. package/corpus/templates/slides/app/components/editor/EditorSidebar.tsx +44 -18
  37. package/corpus/templates/slides/app/components/editor/SlideEditor.tsx +88 -23
  38. package/corpus/templates/slides/app/context/DeckContext.tsx +155 -86
  39. package/corpus/templates/slides/app/pages/DeckEditor.tsx +7 -5
  40. package/corpus/templates/slides/changelog/2026-08-26-thumbnail-arrow-keys-now-navigate-slides-after-selection.md +6 -0
  41. package/dist/client/hooks/index.d.ts +1 -0
  42. package/dist/client/hooks/index.js +1 -0
  43. package/dist/client/use-pinch-zoom.js +17 -4
  44. package/dist/client/zoom-gesture.d.ts +74 -0
  45. package/dist/client/zoom-gesture.js +100 -0
  46. package/dist/collab/routes.d.ts +1 -1
  47. package/dist/collab/struct-routes.d.ts +1 -1
  48. package/dist/observability/routes.d.ts +3 -3
  49. package/dist/provider-api/actions/custom-provider-registration.d.ts +12 -12
  50. package/dist/provider-api/actions/provider-api.d.ts +13 -13
  51. package/dist/server/agent-engine-api-key-route.d.ts +1 -1
  52. package/dist/server/builder-browser.d.ts +13 -13
  53. package/dist/server/builder-browser.js +46 -15
  54. package/dist/server/builder-oauth.d.ts +12 -0
  55. package/dist/server/builder-oauth.js +13 -2
  56. package/dist/server/core-routes-plugin.js +54 -19
  57. package/dist/server/realtime-token.d.ts +1 -1
  58. package/dist/server/transcribe-voice.d.ts +1 -1
  59. package/dist/vite/client.js +2 -0
  60. package/docs/content/template-chat-developers.mdx +100 -13
  61. package/docs/content/template-chat-first-edits.mdx +5 -5
  62. package/docs/content/template-chat.mdx +84 -36
  63. package/package.json +4 -3
  64. package/corpus/templates/clips/desktop/src/overlays/toolbar.tsx +0 -458
@@ -733,6 +733,7 @@ pub(crate) fn start_segmented_custom_screencapturekit_backend_at(
733
733
  capture_region,
734
734
  defer_recording_output,
735
735
  true,
736
+ None,
736
737
  )
737
738
  }
738
739
 
@@ -932,8 +933,14 @@ fn spawn_disk_monitor(app: AppHandle, recording_path: PathBuf) -> Arc<AtomicBool
932
933
  stop
933
934
  }
934
935
 
936
+ /// `recording_id` names the take this progress belongs to. The recording pill
937
+ /// reuses one window across takes, so an untagged progress event from an
938
+ /// earlier upload would move a newer take's completion card and refresh its
939
+ /// stall timeout. Required rather than optional so the compiler, not review,
940
+ /// is what catches a new emit site that forgets it.
935
941
  fn emit_native_upload_progress(
936
942
  app: &AppHandle,
943
+ recording_id: &str,
937
944
  stage: &str,
938
945
  message: impl Into<String>,
939
946
  detail: Option<String>,
@@ -942,6 +949,7 @@ fn emit_native_upload_progress(
942
949
  let _ = app.emit(
943
950
  "clips:native-upload-progress",
944
951
  serde_json::json!({
952
+ "recordingId": recording_id,
945
953
  "stage": stage,
946
954
  "message": message.into(),
947
955
  "detail": detail,
@@ -1009,7 +1017,11 @@ async fn wait_for_native_upload_retry_cancel(recording_id: &str) {
1009
1017
  }
1010
1018
  }
1011
1019
 
1012
- fn reset_native_upload_completion_state() {
1020
+ /// Clear the take-once completion slot and the open-claim latch. Every path
1021
+ /// that starts a recording must call this: the slot has no expiry, and the
1022
+ /// pill drains it when a completion card opens, so a result left behind by an
1023
+ /// earlier take would surface on the next one's card.
1024
+ pub(crate) fn reset_native_upload_completion_state() {
1013
1025
  if let Ok(mut last) = last_native_upload_finished().lock() {
1014
1026
  *last = None;
1015
1027
  }
@@ -1808,7 +1820,14 @@ pub async fn native_fullscreen_recording_stop_and_upload(
1808
1820
  has_camera: bool,
1809
1821
  ) -> Result<NativeFullscreenUploadResult, String> {
1810
1822
  let upload_mode = NativeUploadMode::from_option(upload_mode);
1811
- emit_native_upload_progress(&app, "finalizing", "Optimizing clip", None, None);
1823
+ emit_native_upload_progress(
1824
+ &app,
1825
+ &recording_id,
1826
+ "finalizing",
1827
+ "Optimizing clip",
1828
+ None,
1829
+ None,
1830
+ );
1812
1831
  // The recorder's ScreenCaptureKit stream is now fully stopped and its moov
1813
1832
  // atom is written (or has definitively failed). Signal the UI so it can tear
1814
1833
  // down the separate live-transcription SCStream (system_audio.rs) now,
@@ -1861,7 +1880,7 @@ pub async fn native_fullscreen_recording_stop_and_upload(
1861
1880
  Ok(()) => merge_err.clone(),
1862
1881
  });
1863
1882
  write_saved_recording_metadata(&app, &saved)?;
1864
- emit_native_upload_progress(&app, "failed", "Upload paused", None, None);
1883
+ emit_native_upload_progress(&app, &recording_id, "failed", "Upload paused", None, None);
1865
1884
  let error = format!(
1866
1885
  "{merge_err}. The raw clip segments were saved locally and can be retried from the Clips menu."
1867
1886
  );
@@ -1901,7 +1920,7 @@ pub async fn native_fullscreen_recording_stop_and_upload(
1901
1920
  if stop_err.starts_with(CAPTURE_FINALIZE_INCOMPLETE_PREFIX) {
1902
1921
  saved.last_error = Some(stop_err.clone());
1903
1922
  write_saved_recording_metadata(&app, &saved)?;
1904
- emit_native_upload_progress(&app, "failed", "Upload paused", None, None);
1923
+ emit_native_upload_progress(&app, &recording_id, "failed", "Upload paused", None, None);
1905
1924
  let error = format!(
1906
1925
  "{stop_err}. The clip was saved locally and can be retried from the Clips menu."
1907
1926
  );
@@ -1936,7 +1955,14 @@ pub async fn native_fullscreen_recording_stop_and_upload(
1936
1955
  );
1937
1956
  }
1938
1957
  write_saved_recording_metadata(&app, &saved)?;
1939
- emit_native_upload_progress(&app, "failed", "Upload paused", None, None);
1958
+ emit_native_upload_progress(
1959
+ &app,
1960
+ &recording_id,
1961
+ "failed",
1962
+ "Upload paused",
1963
+ None,
1964
+ None,
1965
+ );
1940
1966
  let suffix = if saved.corrupt {
1941
1967
  "The local file is incomplete and cannot be recovered. Discard it from the Clips menu and record again."
1942
1968
  } else {
@@ -1974,7 +2000,7 @@ pub async fn native_fullscreen_recording_stop_and_upload(
1974
2000
  );
1975
2001
  eprintln!("[clips-tray] recording missing moov after Ok stop outcome (likely finalize timeout) — saving as retryable, skipping upload");
1976
2002
  write_saved_recording_metadata(&app, &saved)?;
1977
- emit_native_upload_progress(&app, "failed", "Upload paused", None, None);
2003
+ emit_native_upload_progress(&app, &recording_id, "failed", "Upload paused", None, None);
1978
2004
  let error =
1979
2005
  "Recorded MP4 is missing playback metadata. Please retry the recording.".to_string();
1980
2006
  emit_native_upload_finished(
@@ -1988,7 +2014,14 @@ pub async fn native_fullscreen_recording_stop_and_upload(
1988
2014
  return Err(error);
1989
2015
  }
1990
2016
  write_saved_recording_metadata(&app, &saved)?;
1991
- emit_native_upload_progress(&app, "preparing", "Optimizing clip", None, None);
2017
+ emit_native_upload_progress(
2018
+ &app,
2019
+ &recording_id,
2020
+ "preparing",
2021
+ "Optimizing clip",
2022
+ None,
2023
+ None,
2024
+ );
1992
2025
 
1993
2026
  #[cfg(target_os = "macos")]
1994
2027
  eprintln!(
@@ -2022,7 +2055,14 @@ pub async fn native_fullscreen_recording_stop_and_upload(
2022
2055
  saved.last_error = Some(error.clone());
2023
2056
  saved.retry_count = saved.retry_count.saturating_add(1);
2024
2057
  let _ = write_saved_recording_metadata(&app, &saved);
2025
- emit_native_upload_progress(&app, "failed", "Upload paused", None, None);
2058
+ emit_native_upload_progress(
2059
+ &app,
2060
+ &recording_id,
2061
+ "failed",
2062
+ "Upload paused",
2063
+ None,
2064
+ None,
2065
+ );
2026
2066
  let error = format!(
2027
2067
  "{error} The clip was saved locally and can be retried from the Clips menu."
2028
2068
  );
@@ -2046,7 +2086,14 @@ pub async fn native_fullscreen_recording_stop_and_upload(
2046
2086
  saved.last_error = Some(error.clone());
2047
2087
  saved.retry_count = saved.retry_count.saturating_add(1);
2048
2088
  let _ = write_saved_recording_metadata(&app, &saved);
2049
- emit_native_upload_progress(&app, "failed", "Upload paused", None, None);
2089
+ emit_native_upload_progress(
2090
+ &app,
2091
+ &recording_id,
2092
+ "failed",
2093
+ "Upload paused",
2094
+ None,
2095
+ None,
2096
+ );
2050
2097
  let error = format!(
2051
2098
  "{error} The clip was saved locally and can be retried from the Clips menu."
2052
2099
  );
@@ -2064,7 +2111,14 @@ pub async fn native_fullscreen_recording_stop_and_upload(
2064
2111
  eprintln!(
2065
2112
  "[live-upload] stop: signalling finalize for {recording_id} (measured_duration_ms={verified_duration_ms})"
2066
2113
  );
2067
- emit_native_upload_progress(&app, "uploading", "Uploading clip", None, None);
2114
+ emit_native_upload_progress(
2115
+ &app,
2116
+ &recording_id,
2117
+ "uploading",
2118
+ "Uploading clip",
2119
+ None,
2120
+ None,
2121
+ );
2068
2122
  live.ctrl
2069
2123
  .duration_ms
2070
2124
  .store(verified_duration_ms as u64, Ordering::SeqCst);
@@ -2100,7 +2154,14 @@ pub async fn native_fullscreen_recording_stop_and_upload(
2100
2154
  saved.last_error = Some(err.clone());
2101
2155
  saved.retry_count = saved.retry_count.saturating_add(1);
2102
2156
  let _ = write_saved_recording_metadata(&app, &saved);
2103
- emit_native_upload_progress(&app, "failed", "Upload paused", None, None);
2157
+ emit_native_upload_progress(
2158
+ &app,
2159
+ &recording_id,
2160
+ "failed",
2161
+ "Upload paused",
2162
+ None,
2163
+ None,
2164
+ );
2104
2165
  let error = format!(
2105
2166
  "{err}. The clip was saved locally and can be retried from the Clips menu."
2106
2167
  );
@@ -2142,7 +2203,7 @@ pub async fn native_fullscreen_recording_stop_and_upload(
2142
2203
  saved.last_error = Some(err.clone());
2143
2204
  saved.retry_count = saved.retry_count.saturating_add(1);
2144
2205
  let _ = write_saved_recording_metadata(&app, &saved);
2145
- emit_native_upload_progress(&app, "failed", "Upload paused", None, None);
2206
+ emit_native_upload_progress(&app, &recording_id, "failed", "Upload paused", None, None);
2146
2207
  let error = format!(
2147
2208
  "{err}. The clip was saved locally and can be retried from the Clips menu."
2148
2209
  );
@@ -2189,7 +2250,7 @@ pub async fn native_fullscreen_recording_stop_and_upload(
2189
2250
  saved.corrupt = true;
2190
2251
  }
2191
2252
  let _ = write_saved_recording_metadata(&app, &saved);
2192
- emit_native_upload_progress(&app, "failed", "Upload paused", None, None);
2253
+ emit_native_upload_progress(&app, &recording_id, "failed", "Upload paused", None, None);
2193
2254
  let error = format!(
2194
2255
  "{err}. The clip was saved locally and can be retried from the Clips menu."
2195
2256
  );
@@ -2296,6 +2357,27 @@ pub(crate) fn kill_active_screencapture_child(state: &NativeFullscreenRecordingS
2296
2357
  }
2297
2358
  }
2298
2359
 
2360
+ /// The `screencapture` fallback backend owns an OS child process, and
2361
+ /// `kill_active_screencapture_child` can only find it while the session is in
2362
+ /// the state slot. A cancel takes the session out of that slot and hands it to
2363
+ /// a detached thread, so a quit landing before that thread reaches its
2364
+ /// kill/reap path would leave `screencapture` running after Clips is gone.
2365
+ /// Stop and reap it here, on the caller's thread, and detach only the file
2366
+ /// cleanup. Bounded by `stop_screencapture`'s discard grace (~250ms) and only
2367
+ /// reached by the fallback backend — the ScreenCaptureKit paths, which carry
2368
+ /// the restart latency this detach exists for, still tear down detached.
2369
+ fn terminate_screencapture_child_before_detach(session: &mut NativeFullscreenSession) {
2370
+ if !matches!(
2371
+ session.backend.as_ref(),
2372
+ Some(NativeFullscreenBackend::Screencapture { .. })
2373
+ ) {
2374
+ return;
2375
+ }
2376
+ if let Err(err) = finalize_active_backend(session, false) {
2377
+ eprintln!("[clips-tray] cancel: screencapture stop before detach failed: {err}");
2378
+ }
2379
+ }
2380
+
2299
2381
  #[tauri::command]
2300
2382
  pub async fn native_fullscreen_recording_cancel(
2301
2383
  app: AppHandle,
@@ -2312,7 +2394,12 @@ pub async fn native_fullscreen_recording_cancel(
2312
2394
  guard.take()
2313
2395
  };
2314
2396
  if let Some(mut session) = session {
2315
- discard_session(&mut session);
2397
+ eprintln!(
2398
+ "[clips-tray] cancel: detaching discard for {}",
2399
+ session.path.display()
2400
+ );
2401
+ terminate_screencapture_child_before_detach(&mut session);
2402
+ spawn_detached_discard(session);
2316
2403
  }
2317
2404
  // An aborted start (countdown/warm cancelled before `begin`) never reaches
2318
2405
  // `hide_recording_chrome`, so the picker's monitor override must also be
@@ -2612,6 +2699,7 @@ fn rotate_screencapturekit_segment(
2612
2699
  restart.target_display_id,
2613
2700
  restart.capture_region,
2614
2701
  false,
2702
+ None,
2615
2703
  );
2616
2704
 
2617
2705
  let (backend, _, _) = match start_result {
@@ -2800,6 +2888,26 @@ fn recover_from_unusable_current_segment(
2800
2888
  false
2801
2889
  }
2802
2890
 
2891
+ /// Discard a cancelled session on a detached thread. Backend teardown blocks
2892
+ /// for however long the OS takes — the stock SCK path's `stop_capture()` can
2893
+ /// hang indefinitely — and the renderer awaits the cancel command before it
2894
+ /// continues its own discard chain, so the discard must not run on the
2895
+ /// command's thread. The session was already taken out of the state slot
2896
+ /// under the state lock, and nothing here re-locks it, so a new recording
2897
+ /// can start while the old stream tears down (the same concurrent-stream
2898
+ /// model pause/resume relies on). Tradeoff: if the process dies before this
2899
+ /// thread deletes the files, crash recovery can resurrect the cancelled take.
2900
+ fn spawn_detached_discard(mut session: NativeFullscreenSession) -> std::thread::JoinHandle<()> {
2901
+ std::thread::spawn(move || {
2902
+ let recording_path = session.path.clone();
2903
+ discard_session(&mut session);
2904
+ eprintln!(
2905
+ "[clips-tray] cancel: detached discard finished for {}",
2906
+ recording_path.display()
2907
+ );
2908
+ })
2909
+ }
2910
+
2803
2911
  /// Best-effort cleanup of a session being discarded (cancel, or a stale
2804
2912
  /// session displaced by a new start). Finalizes any active backend and
2805
2913
  /// deletes every on-disk artifact — segment files and the final path.
@@ -2811,7 +2919,9 @@ fn discard_session(session: &mut NativeFullscreenSession) {
2811
2919
  if let Some(stop) = &session.disk_monitor_stop {
2812
2920
  stop.store(true, Ordering::Relaxed);
2813
2921
  }
2814
- let _ = finalize_active_backend(session, false);
2922
+ if let Err(err) = finalize_active_backend(session, false) {
2923
+ eprintln!("[clips-tray] discard: backend finalize failed (continuing cleanup): {err}");
2924
+ }
2815
2925
  for segment in &session.segments {
2816
2926
  remove_recording_intent(segment);
2817
2927
  let _ = std::fs::remove_file(segment);
@@ -2820,6 +2930,79 @@ fn discard_session(session: &mut NativeFullscreenSession) {
2820
2930
  let _ = std::fs::remove_file(&session.path);
2821
2931
  }
2822
2932
 
2933
+ #[cfg(test)]
2934
+ mod detached_discard_tests {
2935
+ use super::*;
2936
+
2937
+ /// A session with no live backend — the shape cancel sees after an
2938
+ /// aborted warm or a paused recording — so the discard path runs without
2939
+ /// capture hardware.
2940
+ fn hardware_free_session(path: PathBuf, segments: Vec<PathBuf>) -> NativeFullscreenSession {
2941
+ NativeFullscreenSession {
2942
+ backend: None,
2943
+ path,
2944
+ mime_type: MP4_RECORDING_MIME_TYPE,
2945
+ started_at: Instant::now(),
2946
+ width: None,
2947
+ height: None,
2948
+ segments,
2949
+ paused_total: Duration::ZERO,
2950
+ current_segment_started_at: Instant::now(),
2951
+ lost_segment_duration: Duration::ZERO,
2952
+ lost_segment_count: 0,
2953
+ paused_at: None,
2954
+ restart: RestartInfo {
2955
+ safe_id: "test".to_string(),
2956
+ include_audio: false,
2957
+ capture_system_audio: false,
2958
+ mic_captured_in_file: false,
2959
+ mic_device_id: None,
2960
+ mic_device_label: None,
2961
+ segment_counter: 1,
2962
+ target_display_id: None,
2963
+ capture_region: None,
2964
+ },
2965
+ pending_recording_output: false,
2966
+ custom_pipeline: false,
2967
+ audio_cleanup_applied: false,
2968
+ #[cfg(target_os = "macos")]
2969
+ live_upload: None,
2970
+ had_live_upload: false,
2971
+ disk_monitor_stop: None,
2972
+ }
2973
+ }
2974
+
2975
+ #[test]
2976
+ fn detached_discard_deletes_files_off_the_calling_thread() {
2977
+ let root = std::env::temp_dir().join(format!(
2978
+ "clips-detached-discard-test-{}-{}",
2979
+ std::process::id(),
2980
+ std::time::SystemTime::now()
2981
+ .duration_since(std::time::UNIX_EPOCH)
2982
+ .unwrap()
2983
+ .as_nanos()
2984
+ ));
2985
+ std::fs::create_dir_all(&root).unwrap();
2986
+ let path = root.join("recording.mp4");
2987
+ let segment = root.join("recording-seg2.mp4");
2988
+ std::fs::write(&path, b"video").unwrap();
2989
+ std::fs::write(&segment, b"video").unwrap();
2990
+ std::fs::write(recording_intent_path(&path), b"{}").unwrap();
2991
+
2992
+ let caller_thread = std::thread::current().id();
2993
+ let handle =
2994
+ spawn_detached_discard(hardware_free_session(path.clone(), vec![segment.clone()]));
2995
+ assert_ne!(handle.thread().id(), caller_thread);
2996
+ handle.join().unwrap();
2997
+
2998
+ assert!(!path.exists());
2999
+ assert!(!segment.exists());
3000
+ assert!(!recording_intent_path(&path).exists());
3001
+
3002
+ let _ = std::fs::remove_dir_all(root);
3003
+ }
3004
+ }
3005
+
2823
3006
  /// Sibling path next to the original pending recording, numbered with
2824
3007
  /// the segment counter so multiple resume cycles don't clobber each
2825
3008
  /// other. Example: `clips-pending-recording-<id>-<pid>-seg2.mp4`.
@@ -2876,6 +3059,7 @@ fn start_segment_backend(
2876
3059
  capture_region,
2877
3060
  false,
2878
3061
  false,
3062
+ None,
2879
3063
  )
2880
3064
  } else {
2881
3065
  start_screencapturekit_backend_at(
@@ -2887,6 +3071,7 @@ fn start_segment_backend(
2887
3071
  target_display_id,
2888
3072
  capture_region,
2889
3073
  false,
3074
+ None,
2890
3075
  )
2891
3076
  };
2892
3077
  match sck_result {
@@ -2941,6 +3126,104 @@ fn start_segment_backend(
2941
3126
  }
2942
3127
  }
2943
3128
 
3129
+ /// How long a popover-open `SCShareableContent` prefetch stays usable for a
3130
+ /// recording start. Short on purpose: there is no display-configuration-change
3131
+ /// hook invalidating the snapshot, so the TTL is the staleness bound.
3132
+ #[cfg(target_os = "macos")]
3133
+ const SHAREABLE_CONTENT_PREFETCH_TTL: Duration = Duration::from_secs(15);
3134
+
3135
+ #[cfg(target_os = "macos")]
3136
+ static PREFETCHED_SHAREABLE_CONTENT: Mutex<Option<(Instant, SCShareableContent)>> =
3137
+ Mutex::new(None);
3138
+
3139
+ /// Set while a prefetch is running. The cache alone cannot debounce: it stays
3140
+ /// empty for the multi-second duration of `SCShareableContent::get`, so rapid
3141
+ /// popover reopens would each see "nothing cached" and pile up another
3142
+ /// blocking fetch.
3143
+ #[cfg(target_os = "macos")]
3144
+ static SHAREABLE_CONTENT_PREFETCH_IN_FLIGHT: AtomicBool = AtomicBool::new(false);
3145
+
3146
+ /// Clears the in-flight flag however the prefetch ends, including a panic in
3147
+ /// the blocking task.
3148
+ #[cfg(target_os = "macos")]
3149
+ struct ShareableContentPrefetchGuard;
3150
+
3151
+ #[cfg(target_os = "macos")]
3152
+ impl Drop for ShareableContentPrefetchGuard {
3153
+ fn drop(&mut self) {
3154
+ SHAREABLE_CONTENT_PREFETCH_IN_FLIGHT.store(false, Ordering::SeqCst);
3155
+ }
3156
+ }
3157
+
3158
+ /// Consume the popover-open prefetch for an initial recording start. Returns
3159
+ /// `None` (callers then do their own fresh fetch) when the snapshot is
3160
+ /// missing, expired, or does not contain the requested display — a monitor
3161
+ /// plugged in after the prefetch must not be resolved against stale content.
3162
+ /// Take-once so resume/rotation paths can never reuse an old snapshot.
3163
+ #[cfg(target_os = "macos")]
3164
+ fn take_prefetched_shareable_content(target_display_id: Option<u32>) -> Option<SCShareableContent> {
3165
+ let mut guard = PREFETCHED_SHAREABLE_CONTENT.lock().ok()?;
3166
+ let (fetched_at, content) = guard.take()?;
3167
+ if fetched_at.elapsed() > SHAREABLE_CONTENT_PREFETCH_TTL {
3168
+ return None;
3169
+ }
3170
+ if let Some(id) = target_display_id {
3171
+ if !content.displays().iter().any(|d| d.display_id() == id) {
3172
+ return None;
3173
+ }
3174
+ }
3175
+ Some(content)
3176
+ }
3177
+
3178
+ /// Fire-and-forget warm-up called when the recorder popover opens: fetch the
3179
+ /// multi-second `SCShareableContent` snapshot now so a recording start within
3180
+ /// the TTL skips it. Best-effort — failures are logged and the start paths
3181
+ /// fall back to their own fetch, so this can never fail a recording.
3182
+ #[tauri::command]
3183
+ pub async fn native_fullscreen_prefetch_capture_content() -> Result<(), String> {
3184
+ #[cfg(target_os = "macos")]
3185
+ {
3186
+ // Debounce rapid popover reopen toggles so blocking-pool fetches
3187
+ // don't pile up; a snapshot this young is fresh enough to keep.
3188
+ let recently_fetched = PREFETCHED_SHAREABLE_CONTENT
3189
+ .lock()
3190
+ .ok()
3191
+ .and_then(|guard| {
3192
+ guard
3193
+ .as_ref()
3194
+ .map(|(at, _)| at.elapsed() < SHAREABLE_CONTENT_PREFETCH_TTL / 3)
3195
+ })
3196
+ .unwrap_or(false);
3197
+ if recently_fetched {
3198
+ return Ok(());
3199
+ }
3200
+ // Coalesce with a prefetch that is already running rather than queueing
3201
+ // a second multi-second fetch behind it; the one in flight populates
3202
+ // the same cache this call would have.
3203
+ if SHAREABLE_CONTENT_PREFETCH_IN_FLIGHT
3204
+ .compare_exchange(false, true, Ordering::SeqCst, Ordering::SeqCst)
3205
+ .is_err()
3206
+ {
3207
+ return Ok(());
3208
+ }
3209
+ let _in_flight = ShareableContentPrefetchGuard;
3210
+ match tauri::async_runtime::spawn_blocking(SCShareableContent::get).await {
3211
+ Ok(Ok(content)) => {
3212
+ if let Ok(mut guard) = PREFETCHED_SHAREABLE_CONTENT.lock() {
3213
+ *guard = Some((Instant::now(), content));
3214
+ }
3215
+ }
3216
+ Ok(Err(err)) => {
3217
+ eprintln!("[clips-tray] shareable-content prefetch unavailable: {err:?}");
3218
+ }
3219
+ Err(join_err) => {
3220
+ eprintln!("[clips-tray] shareable-content prefetch task panicked: {join_err}");
3221
+ }
3222
+ }
3223
+ }
3224
+ Ok(())
3225
+ }
3226
+
2944
3227
  /// Configure and start a fresh ScreenCaptureKit capture writing into
2945
3228
  /// `output_path`. Shared by the initial start and the resume path.
2946
3229
  #[cfg(target_os = "macos")]
@@ -2959,9 +3242,15 @@ pub(crate) fn start_screencapturekit_backend_at(
2959
3242
  // the no-warm fallback), preserving the original record-immediately
2960
3243
  // behavior.
2961
3244
  defer_recording_output: bool,
3245
+ // Popover-open prefetch (initial start only — resume/rotation pass `None`
3246
+ // and keep their self-contained fresh fetch).
3247
+ prefetched_content: Option<SCShareableContent>,
2962
3248
  ) -> Result<(NativeFullscreenBackend, Option<u32>, Option<u32>), String> {
2963
- let content =
2964
- SCShareableContent::get().map_err(|e| format!("shareable content lookup failed: {e:?}"))?;
3249
+ let content = match prefetched_content {
3250
+ Some(content) => content,
3251
+ None => SCShareableContent::get()
3252
+ .map_err(|e| format!("shareable content lookup failed: {e:?}"))?,
3253
+ };
2965
3254
  let displays = content.displays();
2966
3255
  let display = target_display_id
2967
3256
  .and_then(|id| displays.iter().find(|d| d.display_id() == id))
@@ -3682,7 +3971,7 @@ pub async fn native_fullscreen_recording_retry_upload(
3682
3971
  cookie: Option<String>,
3683
3972
  ) -> Result<NativeFullscreenUploadResult, String> {
3684
3973
  if take_native_upload_retry_cancelled(&recording_id) {
3685
- emit_native_upload_progress(&app, "paused", "Retry cancelled", None, None);
3974
+ emit_native_upload_progress(&app, &recording_id, "paused", "Retry cancelled", None, None);
3686
3975
  return Err(NATIVE_UPLOAD_RETRY_CANCELLED.to_string());
3687
3976
  }
3688
3977
  let mut saved = read_saved_recording_metadata(&app, &recording_id)?;
@@ -3769,6 +4058,7 @@ pub async fn native_fullscreen_recording_retry_upload(
3769
4058
  NativeRetryUploadPlan::Resume(resume) => {
3770
4059
  emit_native_upload_progress(
3771
4060
  &app,
4061
+ &recording_id,
3772
4062
  "uploading",
3773
4063
  "Resuming upload",
3774
4064
  None,
@@ -3787,6 +4077,7 @@ pub async fn native_fullscreen_recording_retry_upload(
3787
4077
  } => {
3788
4078
  emit_native_upload_progress(
3789
4079
  &app,
4080
+ &recording_id,
3790
4081
  "uploading",
3791
4082
  "Restarting upload",
3792
4083
  None,
@@ -3929,14 +4220,21 @@ pub async fn native_fullscreen_recording_retry_upload(
3929
4220
  Err(err) => {
3930
4221
  clear_native_upload_retry_cancelled(&recording_id);
3931
4222
  if err == NATIVE_UPLOAD_RETRY_CANCELLED {
3932
- emit_native_upload_progress(&app, "paused", "Retry cancelled", None, None);
4223
+ emit_native_upload_progress(
4224
+ &app,
4225
+ &recording_id,
4226
+ "paused",
4227
+ "Retry cancelled",
4228
+ None,
4229
+ None,
4230
+ );
3933
4231
  return Err(err);
3934
4232
  }
3935
4233
  if is_moov_corrupt_error(&err) {
3936
4234
  saved.corrupt = true;
3937
4235
  }
3938
4236
  persist_saved_recording_error(&app, &mut saved, &err);
3939
- emit_native_upload_progress(&app, "failed", "Retry paused", None, None);
4237
+ emit_native_upload_progress(&app, &recording_id, "failed", "Retry paused", None, None);
3940
4238
  let suffix = if saved.corrupt {
3941
4239
  "The file is corrupted and cannot be recovered."
3942
4240
  } else {
@@ -4920,6 +5218,7 @@ fn start_screencapturekit_recording(
4920
5218
  capture_region,
4921
5219
  defer_recording_output,
4922
5220
  false,
5221
+ take_prefetched_shareable_content(target_display_id),
4923
5222
  )?
4924
5223
  } else {
4925
5224
  start_screencapturekit_backend_at(
@@ -4931,6 +5230,7 @@ fn start_screencapturekit_recording(
4931
5230
  target_display_id,
4932
5231
  capture_region,
4933
5232
  defer_recording_output,
5233
+ take_prefetched_shareable_content(target_display_id),
4934
5234
  )?
4935
5235
  };
4936
5236
  let (fallback_width, fallback_height) = primary_monitor_size(app);
@@ -5216,7 +5516,9 @@ pub(crate) fn stop_native_recording(
5216
5516
  wait_for_finalize: bool,
5217
5517
  ) -> Result<(), String> {
5218
5518
  match backend {
5219
- NativeFullscreenBackend::Screencapture { child } => stop_screencapture(child),
5519
+ NativeFullscreenBackend::Screencapture { child } => {
5520
+ stop_screencapture(child, wait_for_finalize)
5521
+ }
5220
5522
  #[cfg(target_os = "macos")]
5221
5523
  NativeFullscreenBackend::CustomScreenCaptureKit {
5222
5524
  stream,
@@ -5318,7 +5620,7 @@ pub(crate) fn stop_native_recording(
5318
5620
  }
5319
5621
  }
5320
5622
 
5321
- fn stop_screencapture(child: &mut Child) -> Result<(), String> {
5623
+ fn stop_screencapture(child: &mut Child, wait_for_finalize: bool) -> Result<(), String> {
5322
5624
  if child
5323
5625
  .try_wait()
5324
5626
  .map_err(|e| format!("screencapture status check failed: {e}"))?
@@ -5336,6 +5638,26 @@ fn stop_screencapture(child: &mut Child) -> Result<(), String> {
5336
5638
  .stderr(Stdio::null())
5337
5639
  .status();
5338
5640
 
5641
+ // Discard path: the movie file is deleted immediately after this returns,
5642
+ // so the long graceful wait for `screencapture` to finish writing it just
5643
+ // delays the cancel. Give SIGINT a short grace, then hard-kill and reap.
5644
+ if !wait_for_finalize {
5645
+ let grace_deadline = Instant::now() + Duration::from_millis(250);
5646
+ while Instant::now() < grace_deadline {
5647
+ if child
5648
+ .try_wait()
5649
+ .map_err(|e| format!("screencapture wait failed: {e}"))?
5650
+ .is_some()
5651
+ {
5652
+ return Ok(());
5653
+ }
5654
+ std::thread::sleep(Duration::from_millis(25));
5655
+ }
5656
+ let _ = child.kill();
5657
+ let _ = child.wait();
5658
+ return Ok(());
5659
+ }
5660
+
5339
5661
  let deadline = Instant::now() + Duration::from_secs(15);
5340
5662
  loop {
5341
5663
  if child
@@ -5370,6 +5692,7 @@ pub(crate) async fn upload_finalized_native_artifact(
5370
5692
  ) -> Result<NativeFullscreenUploadResult, String> {
5371
5693
  let prepared = prepare_recording_file(
5372
5694
  app,
5695
+ &recording_id,
5373
5696
  &artifact.path,
5374
5697
  artifact.mime_type,
5375
5698
  artifact.width,
@@ -5437,6 +5760,7 @@ fn prepare_saved_recording_file(
5437
5760
  .to_path_buf();
5438
5761
  let prepared = prepare_recording_file(
5439
5762
  app,
5763
+ &saved.recording_id,
5440
5764
  &source_path,
5441
5765
  &saved.mime_type,
5442
5766
  saved.width,
@@ -5593,6 +5917,7 @@ async fn upload_prepared_recording_file(
5593
5917
  .unwrap_or(0);
5594
5918
  emit_native_upload_progress(
5595
5919
  app,
5920
+ &recording_id,
5596
5921
  "uploading",
5597
5922
  if streaming_resume.is_some() {
5598
5923
  "Resuming upload"
@@ -5661,6 +5986,7 @@ async fn upload_prepared_recording_file(
5661
5986
  }?;
5662
5987
  emit_native_upload_progress(
5663
5988
  app,
5989
+ &recording_id,
5664
5990
  "uploading",
5665
5991
  "Uploading clip",
5666
5992
  None,
@@ -5676,6 +6002,7 @@ async fn upload_prepared_recording_file(
5676
6002
 
5677
6003
  emit_native_upload_progress(
5678
6004
  app,
6005
+ &recording_id,
5679
6006
  "processing",
5680
6007
  "Uploading clip",
5681
6008
  None,
@@ -5747,6 +6074,7 @@ async fn upload_prepared_recording_file(
5747
6074
  }?;
5748
6075
  emit_native_upload_progress(
5749
6076
  app,
6077
+ &recording_id,
5750
6078
  "uploading",
5751
6079
  "Uploading clip",
5752
6080
  None,
@@ -5756,6 +6084,7 @@ async fn upload_prepared_recording_file(
5756
6084
 
5757
6085
  emit_native_upload_progress(
5758
6086
  app,
6087
+ &recording_id,
5759
6088
  "processing",
5760
6089
  "Uploading clip",
5761
6090
  None,
@@ -5790,7 +6119,14 @@ async fn upload_prepared_recording_file(
5790
6119
  }?;
5791
6120
  }
5792
6121
 
5793
- emit_native_upload_progress(app, "opening", "Uploading clip", None, Some(1.0));
6122
+ emit_native_upload_progress(
6123
+ app,
6124
+ &recording_id,
6125
+ "opening",
6126
+ "Uploading clip",
6127
+ None,
6128
+ Some(1.0),
6129
+ );
5794
6130
  Ok(NativeFullscreenUploadResult {
5795
6131
  recording_id,
5796
6132
  duration_ms: verified_local_duration_ms,
@@ -5853,6 +6189,7 @@ async fn get_native_retry_upload_plan(
5853
6189
  if tokio::time::Instant::now() + delay <= deadline {
5854
6190
  emit_native_upload_progress(
5855
6191
  app,
6192
+ recording_id,
5856
6193
  "uploading",
5857
6194
  "Waiting for prior retry",
5858
6195
  None,
@@ -7138,6 +7475,9 @@ fn verify_prepared_audio_signal(
7138
7475
 
7139
7476
  fn prepare_recording_file(
7140
7477
  app: &AppHandle,
7478
+ // Only so the compression progress this emits can name its take; the
7479
+ // preparation itself is per-file and knows nothing about the recording.
7480
+ recording_id: &str,
7141
7481
  path: &Path,
7142
7482
  mime_type: &str,
7143
7483
  width: Option<u32>,
@@ -7189,7 +7529,14 @@ fn prepare_recording_file(
7189
7529
  );
7190
7530
  }
7191
7531
  }
7192
- emit_native_upload_progress(app, "preparing", "Optimizing clip", None, None);
7532
+ emit_native_upload_progress(
7533
+ app,
7534
+ recording_id,
7535
+ "preparing",
7536
+ "Optimizing clip",
7537
+ None,
7538
+ None,
7539
+ );
7193
7540
 
7194
7541
  let original = PreparedRecordingFile {
7195
7542
  path: path.to_path_buf(),
@@ -7289,6 +7636,7 @@ fn prepare_recording_file(
7289
7636
  for (index, preset) in presets.iter().enumerate() {
7290
7637
  emit_native_upload_progress(
7291
7638
  app,
7639
+ &recording_id,
7292
7640
  "compressing",
7293
7641
  "Optimizing clip",
7294
7642
  None,
@@ -7389,6 +7737,7 @@ fn prepare_recording_file(
7389
7737
  }
7390
7738
  emit_native_upload_progress(
7391
7739
  app,
7740
+ &recording_id,
7392
7741
  "compressing",
7393
7742
  "Optimizing clip",
7394
7743
  None,
@@ -7424,6 +7773,7 @@ fn prepare_recording_file(
7424
7773
  for (index, preset) in presets.iter().enumerate() {
7425
7774
  emit_native_upload_progress(
7426
7775
  app,
7776
+ &recording_id,
7427
7777
  "compressing",
7428
7778
  "Optimizing clip",
7429
7779
  None,
@@ -7510,6 +7860,7 @@ fn prepare_recording_file(
7510
7860
  }
7511
7861
  emit_native_upload_progress(
7512
7862
  app,
7863
+ &recording_id,
7513
7864
  "compressing",
7514
7865
  "Optimizing clip",
7515
7866
  None,