@agent-native/core 0.77.11 → 0.77.12

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.
@@ -1,4 +1,5 @@
1
1
  import {
2
+ IconAlertTriangle,
2
3
  IconLoader2,
3
4
  IconPlayerPauseFilled,
4
5
  IconPlayerPlayFilled,
@@ -57,6 +58,9 @@ export function Toolbar() {
57
58
  // Stop / Pause are disabled until the recorder actually begins, at which
58
59
  // point `clips:toolbar-enabled` fires with `true` from the recorder.
59
60
  const [enabled, setEnabled] = useState(false);
61
+ const [diskSpaceLevel, setDiskSpaceLevel] = useState<
62
+ "ok" | "warning" | "critical"
63
+ >("ok");
60
64
  const fallbackTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
61
65
  const expandedRef = useRef(false);
62
66
 
@@ -94,6 +98,26 @@ export function Toolbar() {
94
98
  trackListen(
95
99
  listen<boolean>("clips:toolbar-enabled", (ev) => {
96
100
  setEnabled(!!ev.payload);
101
+ if (!ev.payload) {
102
+ setDiskSpaceLevel("ok");
103
+ }
104
+ }),
105
+ );
106
+ trackListen(
107
+ listen<{ freeMb: number }>("clips:disk-space-warning", () => {
108
+ setDiskSpaceLevel((prev) =>
109
+ prev === "critical" ? "critical" : "warning",
110
+ );
111
+ }),
112
+ );
113
+ trackListen(
114
+ listen<{ freeMb: number }>("clips:disk-space-critical", () => {
115
+ setDiskSpaceLevel("critical");
116
+ }),
117
+ );
118
+ trackListen(
119
+ listen<{ freeMb: number }>("clips:disk-space-ok", () => {
120
+ setDiskSpaceLevel("ok");
97
121
  }),
98
122
  );
99
123
  return () => {
@@ -221,7 +245,7 @@ export function Toolbar() {
221
245
 
222
246
  return (
223
247
  <div
224
- className={`toolbar-v ${paused ? "toolbar-v-paused" : ""} ${enabled ? "" : "toolbar-v-disabled"}`}
248
+ className={`toolbar-v ${paused ? "toolbar-v-paused" : ""} ${enabled ? "" : "toolbar-v-disabled"} ${diskSpaceLevel !== "ok" ? `toolbar-v-disk-${diskSpaceLevel}` : ""}`}
225
249
  onMouseDown={handleToolbarMouseDown}
226
250
  onMouseEnter={() => resizeToolbarWindow(true)}
227
251
  onMouseLeave={() => resizeToolbarWindow(false)}
@@ -257,6 +281,19 @@ export function Toolbar() {
257
281
  )}
258
282
  </button>
259
283
  <div className="toolbar-v-time">{formatTime(elapsed)}</div>
284
+ {diskSpaceLevel !== "ok" && (
285
+ <div
286
+ className={`toolbar-v-disk-indicator toolbar-v-disk-indicator-${diskSpaceLevel}`}
287
+ title={
288
+ diskSpaceLevel === "critical"
289
+ ? "Disk almost full — stop recording now to avoid losing your clip"
290
+ : "Low disk space — save your recording soon"
291
+ }
292
+ data-no-drag
293
+ >
294
+ <IconAlertTriangle size={12} />
295
+ </div>
296
+ )}
260
297
  <button
261
298
  className="toolbar-v-pause"
262
299
  onClick={togglePause}
@@ -2676,12 +2676,47 @@ body[data-clips-route="recording-pill"] #root {
2676
2676
  cursor: not-allowed;
2677
2677
  }
2678
2678
 
2679
+ .toolbar-v-disk-indicator {
2680
+ display: flex;
2681
+ align-items: center;
2682
+ justify-content: center;
2683
+ color: #f59e0b;
2684
+ opacity: 0.9;
2685
+ flex-shrink: 0;
2686
+ }
2687
+
2688
+ .toolbar-v-disk-indicator-critical {
2689
+ color: #ef4444;
2690
+ animation: toolbar-v-disk-pulse 1.4s ease-in-out infinite;
2691
+ }
2692
+
2693
+ @keyframes toolbar-v-disk-pulse {
2694
+ 0%,
2695
+ 100% {
2696
+ opacity: 0.7;
2697
+ }
2698
+ 50% {
2699
+ opacity: 1;
2700
+ }
2701
+ }
2702
+
2703
+ .toolbar-v-disk-warning {
2704
+ border-color: rgba(245, 158, 11, 0.45);
2705
+ }
2706
+
2707
+ .toolbar-v-disk-critical {
2708
+ border-color: rgba(239, 68, 68, 0.5);
2709
+ }
2710
+
2679
2711
  @media (prefers-reduced-motion: reduce) {
2680
2712
  .toolbar-v,
2681
2713
  .toolbar-v-hover-actions,
2682
2714
  .toolbar-v-action {
2683
2715
  transition: none;
2684
2716
  }
2717
+ .toolbar-v-disk-indicator-critical {
2718
+ animation: none;
2719
+ }
2685
2720
  }
2686
2721
 
2687
2722
  /* ------------------------------------------------------------------------- */
@@ -26,7 +26,6 @@ use screencapturekit::stream::{
26
26
  configuration::SCStreamConfiguration, content_filter::SCContentFilter,
27
27
  output_type::SCStreamOutputType, sc_stream::SCStream,
28
28
  };
29
- #[cfg(target_os = "macos")]
30
29
  use std::sync::atomic::{AtomicBool, Ordering};
31
30
 
32
31
  const QUICKTIME_RECORDING_MIME_TYPE: &str = "video/quicktime";
@@ -85,6 +84,16 @@ const THUMBNAIL_MIME_TYPE: &str = "image/jpeg";
85
84
  const THUMBNAIL_MAX_BYTES: u64 = 2 * 1024 * 1024;
86
85
  const THUMBNAIL_WIDTH: &str = "1280";
87
86
  const SIPS_PATH: &str = "/usr/bin/sips";
87
+ // Minimum free space required to start recording; below this we hard-block.
88
+ const DISK_SPACE_BLOCK_BYTES: u64 = 500 * 1024 * 1024;
89
+ // Free space below this at start time is logged as a warning but not blocked.
90
+ const DISK_SPACE_WARN_BYTES: u64 = 2 * 1024 * 1024 * 1024;
91
+ // Mid-recording warning threshold (emits clips:disk-space-warning).
92
+ const DISK_MONITOR_WARN_BYTES: u64 = 1024 * 1024 * 1024;
93
+ // Mid-recording critical threshold (emits clips:disk-space-critical).
94
+ const DISK_MONITOR_CRITICAL_BYTES: u64 = 250 * 1024 * 1024;
95
+ // How often the background monitor checks free space.
96
+ const DISK_MONITOR_INTERVAL_SECS: u64 = 30;
88
97
 
89
98
  #[derive(Default)]
90
99
  pub struct NativeFullscreenRecordingState {
@@ -131,6 +140,9 @@ struct NativeFullscreenSession {
131
140
  /// warming up) but the recording output hasn't been attached yet, so
132
141
  /// nothing is written to disk. `begin` attaches it and flips this false.
133
142
  pending_recording_output: bool,
143
+ /// Stop flag for the background disk-space monitor thread. Set to true
144
+ /// when the session is finalized or discarded so the thread exits cleanly.
145
+ disk_monitor_stop: Option<Arc<AtomicBool>>,
134
146
  }
135
147
 
136
148
  #[derive(Clone)]
@@ -245,6 +257,89 @@ fn format_mb(bytes: u64) -> String {
245
257
  format!("{:.1} MB", bytes as f64 / (1024.0 * 1024.0))
246
258
  }
247
259
 
260
+ /// Returns free bytes on the volume containing `path`, or `None` on error.
261
+ #[cfg(target_os = "macos")]
262
+ fn free_disk_bytes(path: &Path) -> Option<u64> {
263
+ use std::ffi::CString;
264
+ let c_path = CString::new(path.to_str()?).ok()?;
265
+ let mut stat: libc::statvfs = unsafe { std::mem::zeroed() };
266
+ if unsafe { libc::statvfs(c_path.as_ptr(), &mut stat) } == 0 {
267
+ Some(stat.f_bavail as u64 * stat.f_frsize as u64)
268
+ } else {
269
+ None
270
+ }
271
+ }
272
+
273
+ /// Spawns a background thread that checks free disk space every
274
+ /// [`DISK_MONITOR_INTERVAL_SECS`] seconds and emits warning/critical events
275
+ /// to the frontend. Returns a stop flag the caller sets to shut the thread down.
276
+ #[cfg(target_os = "macos")]
277
+ fn spawn_disk_monitor(app: AppHandle, recording_path: PathBuf) -> Arc<AtomicBool> {
278
+ // Use the parent directory for the statvfs call. The recording file itself
279
+ // may not exist yet (warm/begin path defers writing until after countdown),
280
+ // and statvfs returns ENOENT on non-existent paths. The parent dir is the
281
+ // pending-uploads folder, which always exists.
282
+ let check_path = recording_path
283
+ .parent()
284
+ .map(|p| p.to_path_buf())
285
+ .unwrap_or(recording_path);
286
+ let stop = Arc::new(AtomicBool::new(false));
287
+ let stop_clone = Arc::clone(&stop);
288
+ std::thread::spawn(move || {
289
+ let tick_ms = 500u64;
290
+ let ticks_per_check = (DISK_MONITOR_INTERVAL_SECS * 1000) / tick_ms;
291
+ // Start at ticks_per_check so the first iteration runs an immediate check
292
+ // rather than waiting the full 30s interval. Subsequent checks are every 30s.
293
+ let mut ticks = ticks_per_check;
294
+ // True once a warning/critical event has been emitted; used to gate the
295
+ // recovery ok event so we only emit it on actual state transitions.
296
+ let mut was_elevated = false;
297
+ loop {
298
+ std::thread::sleep(Duration::from_millis(tick_ms));
299
+ if stop_clone.load(Ordering::Relaxed) {
300
+ return;
301
+ }
302
+ ticks += 1;
303
+ if ticks < ticks_per_check {
304
+ continue;
305
+ }
306
+ ticks = 0;
307
+ if let Some(free) = free_disk_bytes(&check_path) {
308
+ let free_mb = free / (1024 * 1024);
309
+ if free < DISK_MONITOR_CRITICAL_BYTES {
310
+ eprintln!(
311
+ "[clips-tray] disk space critical during recording: {} free",
312
+ format_mb(free)
313
+ );
314
+ let _ = app.emit(
315
+ "clips:disk-space-critical",
316
+ serde_json::json!({ "freeMb": free_mb }),
317
+ );
318
+ was_elevated = true;
319
+ } else if free < DISK_MONITOR_WARN_BYTES {
320
+ eprintln!(
321
+ "[clips-tray] disk space low during recording: {} free",
322
+ format_mb(free)
323
+ );
324
+ let _ = app.emit(
325
+ "clips:disk-space-warning",
326
+ serde_json::json!({ "freeMb": free_mb }),
327
+ );
328
+ was_elevated = true;
329
+ } else if was_elevated {
330
+ // Space recovered — notify the UI to clear its warning.
331
+ let _ = app.emit(
332
+ "clips:disk-space-ok",
333
+ serde_json::json!({ "freeMb": free_mb }),
334
+ );
335
+ was_elevated = false;
336
+ }
337
+ }
338
+ }
339
+ });
340
+ stop
341
+ }
342
+
248
343
  fn emit_native_upload_progress(
249
344
  app: &AppHandle,
250
345
  stage: &str,
@@ -285,6 +380,10 @@ struct SavedNativeRecording {
285
380
  last_attempt_at: Option<String>,
286
381
  last_error: Option<String>,
287
382
  retry_count: u32,
383
+ /// True when the SCK finalization callback reported an error, meaning the
384
+ /// MP4 is missing its moov atom and cannot be recovered by retrying.
385
+ #[serde(default)]
386
+ corrupt: bool,
288
387
  }
289
388
 
290
389
  #[derive(Debug, Clone, Serialize)]
@@ -303,6 +402,7 @@ pub struct PendingNativeRecording {
303
402
  last_attempt_at: Option<String>,
304
403
  last_error: Option<String>,
305
404
  retry_count: u32,
405
+ corrupt: bool,
306
406
  }
307
407
 
308
408
  #[derive(Serialize)]
@@ -364,6 +464,7 @@ impl From<&SavedNativeRecording> for PendingNativeRecording {
364
464
  last_attempt_at: saved.last_attempt_at.clone(),
365
465
  last_error: saved.last_error.clone(),
366
466
  retry_count: saved.retry_count,
467
+ corrupt: saved.corrupt,
367
468
  }
368
469
  }
369
470
  }
@@ -671,6 +772,35 @@ pub async fn native_fullscreen_recording_stop_and_upload(
671
772
  )?;
672
773
  if let Err(stop_err) = &stop_outcome {
673
774
  saved.last_error = Some(stop_err.clone());
775
+ // Only mark corrupt when the SCK delegate explicitly called recording_did_fail
776
+ // (error contains "finalize failed"). Transient stop_capture /
777
+ // remove_recording_output errors also return Err but don't prove the moov
778
+ // was never written — they should remain retryable.
779
+ // "finalization callback failed" is unique to the delegate path;
780
+ // "recording finalize failed" also appears on remove_recording_output errors.
781
+ let is_definitive = stop_err.contains("finalization callback failed");
782
+ if is_definitive && mp4_has_moov(&saved.file_path) == Some(false) {
783
+ saved.corrupt = true;
784
+ eprintln!(
785
+ "[clips-tray] recording marked corrupt: definitive finalize error + missing moov atom"
786
+ );
787
+ }
788
+ } else if mp4_has_moov(&saved.file_path) == Some(false) {
789
+ // stop_outcome was Ok but the finalize callback timed out — SCK may still be
790
+ // flushing the moov atom. Persist metadata so the clip appears as retryable
791
+ // in the UI, then bail out before upload_recording_file re-checks moov and
792
+ // permanently marks it corrupt.
793
+ saved.last_error = Some(
794
+ "Recorded MP4 is missing playback metadata. Please retry the recording."
795
+ .to_string(),
796
+ );
797
+ eprintln!("[clips-tray] recording missing moov after Ok stop outcome (likely finalize timeout) — saving as retryable, skipping upload");
798
+ write_saved_recording_metadata(&app, &saved)?;
799
+ emit_native_upload_progress(&app, "failed", "Upload paused", None, None);
800
+ return Err(
801
+ "Recorded MP4 is missing playback metadata. Please retry the recording."
802
+ .to_string(),
803
+ );
674
804
  } else if let Err(merge_err) = &consolidate_outcome {
675
805
  saved.last_error = Some(merge_err.clone());
676
806
  }
@@ -711,6 +841,9 @@ pub async fn native_fullscreen_recording_stop_and_upload(
711
841
  saved.last_attempt_at = Some(now_iso());
712
842
  saved.last_error = Some(err.clone());
713
843
  saved.retry_count = saved.retry_count.saturating_add(1);
844
+ if is_moov_corrupt_error(&err) {
845
+ saved.corrupt = true;
846
+ }
714
847
  let _ = write_saved_recording_metadata(&app, &saved);
715
848
  emit_native_upload_progress(&app, "failed", "Upload paused", None, None);
716
849
  Err(format!(
@@ -749,6 +882,37 @@ pub async fn native_fullscreen_recording_stop_and_save(
749
882
  ));
750
883
  }
751
884
  }
885
+ // Only treat the file as permanently unrecoverable when the SCK delegate
886
+ // explicitly called recording_did_fail (error contains "finalization callback failed",
887
+ // the unique prefix used by the delegate path). Transient stop_capture /
888
+ // remove_recording_output errors use "recording finalize failed" and should
889
+ // remain retryable — deleting on those would risk silent data loss.
890
+ let is_definitive_finalize_error = stop_outcome
891
+ .as_ref()
892
+ .err()
893
+ .map(|e| e.contains("finalization callback failed"))
894
+ .unwrap_or(false);
895
+ if is_definitive_finalize_error {
896
+ if mp4_has_moov(&session.path) == Some(false) {
897
+ eprintln!(
898
+ "[clips-tray] native local recording corrupt (finalize error + missing moov) — not exporting"
899
+ );
900
+ let _ = std::fs::remove_file(&session.path);
901
+ return Err(
902
+ "Recorded file is corrupted — the video is incomplete and cannot be saved. \
903
+ Please record again."
904
+ .into(),
905
+ );
906
+ }
907
+ } else if mp4_has_moov(&session.path) == Some(false) {
908
+ // Non-definitive case (transient error or finalize timeout): the moov may
909
+ // still be flushing. Proceed with the export so the file lands in the
910
+ // user-requested folder and is accessible — stranding it in an internal
911
+ // pending folder with no metadata would leave it unrecoverable.
912
+ eprintln!(
913
+ "[clips-tray] native local recording has no moov after finalize; exporting anyway so user can access the file"
914
+ );
915
+ }
752
916
 
753
917
  save_native_recording_to_local_export(&app, &session, &folder_name, &file_role, duration_ms)
754
918
  }
@@ -842,6 +1006,20 @@ pub async fn native_fullscreen_recording_resume(
842
1006
  let segment_path = segment_path_for(&app, &restart.safe_id, extension, next_counter)?;
843
1007
  let _ = std::fs::remove_file(&segment_path);
844
1008
 
1009
+ // Re-check disk space before starting the new segment. The mid-recording
1010
+ // monitor warns but does not block, so space can drop below the hard limit
1011
+ // between the initial start and a resume without being caught here.
1012
+ #[cfg(target_os = "macos")]
1013
+ if let Some(free) = free_disk_bytes(segment_path.parent().unwrap_or(&segment_path)) {
1014
+ if free < DISK_SPACE_BLOCK_BYTES {
1015
+ return Err(format!(
1016
+ "Not enough disk space to resume recording. Free up at least {} and try again (currently {} free).",
1017
+ format_mb(DISK_SPACE_BLOCK_BYTES),
1018
+ format_mb(free)
1019
+ ));
1020
+ }
1021
+ }
1022
+
845
1023
  // Start the new segment backend FIRST. Only clear paused state if it
846
1024
  // succeeds — otherwise the session would be left with no backend but
847
1025
  // appear running, which silently drops everything after the resume.
@@ -896,6 +1074,10 @@ fn take_and_finalize_active_session(
896
1074
  }
897
1075
  .ok_or_else(|| "No native full-screen recording is active.".to_string())?;
898
1076
 
1077
+ // Signal the disk monitor to stop before tearing down the backend.
1078
+ if let Some(stop) = &session.disk_monitor_stop {
1079
+ stop.store(true, Ordering::Relaxed);
1080
+ }
899
1081
  // Try to finalize capture, but don't early-return on failure: the
900
1082
  // underlying MP4 file is already on disk after stop_capture(), and
901
1083
  // ScreenCaptureKit's StreamError("invalid parameter") on
@@ -952,6 +1134,9 @@ fn finalize_active_backend(
952
1134
  /// session displaced by a new start). Finalizes any active backend and
953
1135
  /// deletes every on-disk artifact — segment files and the final path.
954
1136
  fn discard_session(session: &mut NativeFullscreenSession) {
1137
+ if let Some(stop) = &session.disk_monitor_stop {
1138
+ stop.store(true, Ordering::Relaxed);
1139
+ }
955
1140
  let _ = finalize_active_backend(session, false);
956
1141
  for segment in &session.segments {
957
1142
  let _ = std::fs::remove_file(segment);
@@ -1371,11 +1556,17 @@ pub async fn native_fullscreen_recording_retry_upload(
1371
1556
  Ok(result)
1372
1557
  }
1373
1558
  Err(err) => {
1559
+ if is_moov_corrupt_error(&err) {
1560
+ saved.corrupt = true;
1561
+ }
1374
1562
  persist_saved_recording_error(&app, &mut saved, &err);
1375
1563
  emit_native_upload_progress(&app, "failed", "Retry paused", None, None);
1376
- Err(format!(
1377
- "{err}. The local copy is still saved, so you can retry again."
1378
- ))
1564
+ let suffix = if saved.corrupt {
1565
+ "The file is corrupted and cannot be recovered."
1566
+ } else {
1567
+ "The local copy is still saved, so you can retry again."
1568
+ };
1569
+ Err(format!("{err}. {suffix}"))
1379
1570
  }
1380
1571
  }
1381
1572
  }
@@ -1780,6 +1971,7 @@ fn saved_recording_from_session(
1780
1971
  last_attempt_at: None,
1781
1972
  last_error: None,
1782
1973
  retry_count: 0,
1974
+ corrupt: false,
1783
1975
  })
1784
1976
  }
1785
1977
 
@@ -1912,6 +2104,21 @@ fn start_screencapturekit_recording(
1912
2104
  "[clips-tray] starting ScreenCaptureKit recording -> {}",
1913
2105
  path.display()
1914
2106
  );
2107
+ let check_path = path.parent().unwrap_or(&path);
2108
+ if let Some(free) = free_disk_bytes(check_path) {
2109
+ if free < DISK_SPACE_BLOCK_BYTES {
2110
+ return Err(format!(
2111
+ "Not enough disk space to record. Free up at least {} and try again (currently {} free).",
2112
+ format_mb(DISK_SPACE_BLOCK_BYTES),
2113
+ format_mb(free)
2114
+ ));
2115
+ } else if free < DISK_SPACE_WARN_BYTES {
2116
+ eprintln!(
2117
+ "[clips-tray] low disk space at recording start: {} free — recording may fail if space runs out",
2118
+ format_mb(free)
2119
+ );
2120
+ }
2121
+ }
1915
2122
  let (backend, width, height) = start_screencapturekit_backend_at(
1916
2123
  &path,
1917
2124
  include_audio,
@@ -1941,6 +2148,7 @@ fn start_screencapturekit_recording(
1941
2148
  },
1942
2149
  );
1943
2150
  session.pending_recording_output = defer_recording_output;
2151
+ session.disk_monitor_stop = Some(spawn_disk_monitor(app.clone(), session.path.clone()));
1944
2152
  Ok(session)
1945
2153
  }
1946
2154
 
@@ -1959,6 +2167,20 @@ fn start_screencapture_recording(
1959
2167
  "[clips-tray] starting screencapture (fallback) recording -> {}",
1960
2168
  path.display()
1961
2169
  );
2170
+ if let Some(free) = free_disk_bytes(path.parent().unwrap_or(&path)) {
2171
+ if free < DISK_SPACE_BLOCK_BYTES {
2172
+ return Err(format!(
2173
+ "Not enough disk space to record. Free up at least {} and try again (currently {} free).",
2174
+ format_mb(DISK_SPACE_BLOCK_BYTES),
2175
+ format_mb(free)
2176
+ ));
2177
+ } else if free < DISK_SPACE_WARN_BYTES {
2178
+ eprintln!(
2179
+ "[clips-tray] low disk space at recording start: {} free — recording may fail if space runs out",
2180
+ format_mb(free)
2181
+ );
2182
+ }
2183
+ }
1962
2184
  let (backend, w, h) = start_screencapture_backend_at(
1963
2185
  app,
1964
2186
  &path,
@@ -1967,7 +2189,7 @@ fn start_screencapture_recording(
1967
2189
  capture_region,
1968
2190
  )?;
1969
2191
  let (fallback_width, fallback_height) = primary_monitor_size(app);
1970
- Ok(new_fullscreen_session(
2192
+ let mut session = new_fullscreen_session(
1971
2193
  backend,
1972
2194
  path,
1973
2195
  QUICKTIME_RECORDING_MIME_TYPE,
@@ -1985,7 +2207,9 @@ fn start_screencapture_recording(
1985
2207
  target_display_id,
1986
2208
  capture_region,
1987
2209
  },
1988
- ))
2210
+ );
2211
+ session.disk_monitor_stop = Some(spawn_disk_monitor(app.clone(), session.path.clone()));
2212
+ Ok(session)
1989
2213
  }
1990
2214
 
1991
2215
  /// Build a fresh `NativeFullscreenSession` around a freshly-started
@@ -2011,6 +2235,7 @@ fn new_fullscreen_session(
2011
2235
  paused_at: None,
2012
2236
  restart,
2013
2237
  pending_recording_output: false,
2238
+ disk_monitor_stop: None,
2014
2239
  }
2015
2240
  }
2016
2241
 
@@ -2118,17 +2343,17 @@ fn stop_native_recording(
2118
2343
  .map_err(|e| format!("ScreenCaptureKit stop failed: {e:?}"));
2119
2344
  // remove_recording_output() occasionally fails with
2120
2345
  // StreamError("Failed due to an invalid parameter") when the audio
2121
- // tap or stream state hasn't fully drained yet. Retry once after a
2122
- // short pause before giving up the underlying MP4 file is
2123
- // already on disk by this point, so the recovery path can still
2124
- // pick it up via write_saved_recording_metadata().
2346
+ // tap or stream state hasn't fully drained yet. We retry once after
2347
+ // a 200ms drain delay; the sleep is in the error branch only so
2348
+ // healthy clips pay no extra stop latency.
2125
2349
  let remove_result = stream
2126
2350
  .remove_recording_output(recording)
2127
2351
  .map_err(|e| format!("ScreenCaptureKit recording finalize failed: {e:?}"));
2128
2352
  let remove_result = match remove_result {
2129
2353
  Ok(v) => Ok(v),
2130
2354
  Err(first_err) => {
2131
- std::thread::sleep(Duration::from_millis(150));
2355
+ eprintln!("[clips-tray] waiting 200ms for SCK frame buffer to drain before remove_recording_output retry");
2356
+ std::thread::sleep(Duration::from_millis(200));
2132
2357
  stream.remove_recording_output(recording).map_err(|e| {
2133
2358
  format!(
2134
2359
  "ScreenCaptureKit recording finalize failed (retry): {e:?}; first attempt: {first_err}"
@@ -2158,12 +2383,25 @@ fn stop_native_recording(
2158
2383
  None
2159
2384
  };
2160
2385
 
2386
+ // Check the delegate outcome BEFORE returning on stop_result. When
2387
+ // stop_capture() fails AND recording_did_fail fires, callers must see
2388
+ // the "finalization callback failed" prefix to correctly identify
2389
+ // permanent corruption — the stop_capture error string would mask it.
2390
+ if let Some(Err(err)) = &finalize_outcome {
2391
+ eprintln!("[clips-tray] SCK finalize failed: {err}");
2392
+ // Use a unique prefix so callers can distinguish the SCK delegate
2393
+ // reporting failure (recording_did_fail) from teardown API errors.
2394
+ return Err(format!("ScreenCaptureKit finalization callback failed: {err}"));
2395
+ }
2396
+
2161
2397
  if let Err(err) = stop_result {
2162
2398
  return Err(err);
2163
2399
  }
2164
-
2165
- if let Some(Err(err)) = &finalize_outcome {
2166
- return Err(format!("ScreenCaptureKit recording finalize failed: {err}"));
2400
+ if waited_for_finalize && finalize_outcome.is_none() {
2401
+ eprintln!(
2402
+ "[clips-tray] SCK finalize timed out after {}s — moov atom may be missing",
2403
+ SCK_FINALIZE_TIMEOUT.as_secs()
2404
+ );
2167
2405
  }
2168
2406
 
2169
2407
  match remove_result {
@@ -2583,6 +2821,81 @@ fn upload_url(
2583
2821
  Ok(url.to_string())
2584
2822
  }
2585
2823
 
2824
+ /// Returns true when an upload error string indicates the file is permanently
2825
+ /// corrupt (missing moov atom) and cannot be recovered by retrying.
2826
+ fn is_moov_corrupt_error(err: &str) -> bool {
2827
+ // Matches both the native prepare_recording_file error and the server-side
2828
+ // finalize-recording.ts error so the corrupt flag is set regardless of
2829
+ // which layer first detected the missing moov atom.
2830
+ err.contains("video is missing required metadata")
2831
+ || err.contains("corrupted or incomplete")
2832
+ }
2833
+
2834
+ /// Walk the top-level ISO BMFF boxes of a file and return `Some(true)` when a
2835
+ /// `moov` box is present, `Some(false)` when the scan reached EOF without
2836
+ /// finding one (file is unplayable), or `None` when the file could not be
2837
+ /// read (transient I/O error — callers must not treat this as permanent
2838
+ /// corruption).
2839
+ fn mp4_has_moov(path: &Path) -> Option<bool> {
2840
+ use std::io::{ErrorKind, Read, Seek, SeekFrom};
2841
+ let mut f = match std::fs::File::open(path) {
2842
+ Ok(f) => f,
2843
+ Err(e) => {
2844
+ eprintln!("[clips-tray] mp4_has_moov: could not open file for moov scan: {e}");
2845
+ return None;
2846
+ }
2847
+ };
2848
+ let mut buf = [0u8; 8];
2849
+ loop {
2850
+ match f.read_exact(&mut buf) {
2851
+ Ok(()) => {}
2852
+ Err(e) if e.kind() == ErrorKind::UnexpectedEof => {
2853
+ // Clean EOF — moov was never found; file is missing the atom.
2854
+ return Some(false);
2855
+ }
2856
+ Err(_) => return None, // Transient read error; don't mark as corrupt.
2857
+ }
2858
+ let box_size_raw = u32::from_be_bytes([buf[0], buf[1], buf[2], buf[3]]);
2859
+ let box_type = &buf[4..8];
2860
+ if box_type == b"moov" {
2861
+ return Some(true);
2862
+ }
2863
+ let skip: u64 = match box_size_raw {
2864
+ 0 => return Some(false), // box extends to EOF — moov not before it
2865
+ 1 => {
2866
+ // 64-bit extended-size: next 8 bytes hold the real size.
2867
+ let mut ext = [0u8; 8];
2868
+ match f.read_exact(&mut ext) {
2869
+ Ok(()) => {}
2870
+ Err(e) if e.kind() == ErrorKind::UnexpectedEof => return Some(false),
2871
+ Err(_) => return None,
2872
+ }
2873
+ let full = u64::from_be_bytes(ext);
2874
+ // full includes the 8-byte header + 8-byte ext field (16 total).
2875
+ full.saturating_sub(16)
2876
+ }
2877
+ // Sizes 2-7 are below the minimum valid box size (8 bytes).
2878
+ // saturating_sub would produce 0, causing skip=0 and an infinite
2879
+ // loop since the file position would never advance.
2880
+ n if n < 8 => return Some(false),
2881
+ n => (n as u64).saturating_sub(8),
2882
+ };
2883
+ if skip > 0 {
2884
+ // Use SeekFrom::Current with a checked i64 cast; a valid box
2885
+ // whose payload exceeds i64::MAX (~9 EiB) is treated as
2886
+ // malformed — return Some(false) so callers can surface the
2887
+ // error without wrapping or seeking backwards.
2888
+ let offset = match i64::try_from(skip) {
2889
+ Ok(v) => v,
2890
+ Err(_) => return Some(false),
2891
+ };
2892
+ if f.seek(SeekFrom::Current(offset)).is_err() {
2893
+ return None; // seek I/O error — don't assume corruption
2894
+ }
2895
+ }
2896
+ }
2897
+ }
2898
+
2586
2899
  fn prepare_recording_file(
2587
2900
  app: &AppHandle,
2588
2901
  path: &Path,
@@ -2606,6 +2919,22 @@ fn prepare_recording_file(
2606
2919
  );
2607
2920
  return Err("Native recording produced an empty file.".into());
2608
2921
  }
2922
+ // For MP4/QuickTime files check that a top-level moov atom exists. SCK
2923
+ // finalization errors (-5814) produce a file with ftyp + mdat but no
2924
+ // moov, making it permanently unplayable. Catching this here avoids a
2925
+ // full chunked upload that the server will reject anyway.
2926
+ if mime_type == "video/mp4" || mime_type == "video/quicktime" {
2927
+ if mp4_has_moov(path) == Some(false) {
2928
+ eprintln!(
2929
+ "[clips-tray] native recording corrupt moov check failed — skipping upload"
2930
+ );
2931
+ return Err(
2932
+ "Recorded file is corrupted or incomplete — the video is missing required \
2933
+ metadata. Please record again."
2934
+ .into(),
2935
+ );
2936
+ }
2937
+ }
2609
2938
  emit_native_upload_progress(app, "preparing", "Optimizing clip", None, None);
2610
2939
 
2611
2940
  let original = PreparedRecordingFile {
@@ -1 +1 @@
1
- {"version":3,"file":"create.d.ts","sourceRoot":"","sources":["../../src/cli/create.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"create.d.ts","sourceRoot":"","sources":["../../src/cli/create.ts"],"names":[],"mappings":"AAgBA,OAAO,EAAkB,mBAAmB,EAAE,MAAM,kBAAkB,CAAC;AAmBvE;;;;;GAKG;AACH,qBAAa,eAAgB,SAAQ,KAAK;IACxC,YAAY,OAAO,EAAE,MAAM,EAG1B;CACF;AA6BD,MAAM,WAAW,gBAAgB;IAC/B,iFAAiF;IACjF,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,iFAAiF;IACjF,UAAU,CAAC,EAAE,OAAO,CAAC;IACrB,0DAA0D;IAC1D,SAAS,CAAC,EAAE,OAAO,CAAC;IACpB;;;;OAIG;IACH,cAAc,CAAC,EAAE,OAAO,CAAC;CAC1B;AAED;;;;;;;;GAQG;AACH,wBAAsB,SAAS,CAC7B,IAAI,CAAC,EAAE,MAAM,EACb,IAAI,CAAC,EAAE,gBAAgB,GACtB,OAAO,CAAC,IAAI,CAAC,CAwEf;AA0OD,iBAAS,oCAAoC,CAAC,YAAY,EAAE,MAAM,GAAG,MAAM,CAa1E;AAiBD,iBAAe,qBAAqB,CAClC,SAAS,EAAE,MAAM,EACjB,IAAI,EAAE,MAAM,GACX,OAAO,CAAC,IAAI,CAAC,CA0Cf;AAiDD;;;;;;;GAOG;AACH,wBAAsB,iBAAiB,CACrC,IAAI,CAAC,EAAE,MAAM,EACb,IAAI,CAAC,EAAE,gBAAgB,GACtB,OAAO,CAAC,IAAI,CAAC,CA4Cf;AAmLD;;;;;GAKG;AACH,iBAAe,mBAAmB,CAChC,SAAS,EAAE,MAAM,EACjB,QAAQ,EAAE,MAAM,GACf,OAAO,CAAC,IAAI,CAAC,CA4Cf;AAoDD;;;;GAIG;AACH,iBAAe,wBAAwB,CACrC,aAAa,EAAE,MAAM,EAAE,EACvB,aAAa,EAAE,MAAM,GACpB,OAAO,CAAC,IAAI,CAAC,CA2Ef;AAED;;;GAGG;AACH,iBAAS,qBAAqB,CAC5B,IAAI,EAAE,MAAM,EACZ,SAAS,EAAE,MAAM,EACjB,YAAY,CAAC,EAAE,MAAM,GACpB,IAAI,CA4GN;AAwJD;;;;GAIG;AACH,wBAAgB,eAAe,CAC7B,QAAQ,EAAE,MAAM,GACf;IAAE,aAAa,EAAE,MAAM,CAAC;IAAC,iBAAiB,EAAE,MAAM,CAAA;CAAE,GAAG,IAAI,CAkB7D;AAED,OAAO,EAAE,mBAAmB,EAAE,CAAC;AAE/B,yCAAyC;AACzC,OAAO,EACL,qBAAqB,IAAI,sBAAsB,EAC/C,mBAAmB,IAAI,oBAAoB,EAC3C,wBAAwB,IAAI,yBAAyB,EACrD,qBAAqB,IAAI,sBAAsB,EAC/C,WAAW,IAAI,YAAY,EAC3B,kBAAkB,IAAI,mBAAmB,EACzC,eAAe,IAAI,gBAAgB,EACnC,kBAAkB,IAAI,mBAAmB,EACzC,wBAAwB,IAAI,yBAAyB,EACrD,4BAA4B,IAAI,6BAA6B,EAC7D,oBAAoB,IAAI,qBAAqB,EAC7C,8BAA8B,IAAI,+BAA+B,EACjE,oCAAoC,IAAI,qCAAqC,EAC7E,uBAAuB,IAAI,wBAAwB,EACnD,cAAc,IAAI,eAAe,GAClC,CAAC;AAcF,iBAAS,cAAc,CACrB,OAAO,EAAE,MAAM,EACf,OAAO,EAAE,MAAM,EACf,OAAO,GAAE;IAAE,iBAAiB,CAAC,EAAE,OAAO,CAAA;CAAO,GAC5C,MAAM,EAAE,CAQV;AAuID;;;;;GAKG;AACH,iBAAS,WAAW,IAAI,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CA+B7C;AAiCD,iBAAS,kBAAkB,CACzB,MAAM,EAAE,MAAM,EACd,IAAI,EAAE,MAAM,EACZ,YAAY,CAAC,EAAE,MAAM,GACpB,IAAI,CAsBN;AAyBD,iBAAS,wBAAwB,IAAI,MAAM,CAW1C;AAED,iBAAS,4BAA4B,IAAI,MAAM,CAO9C;AAcD;;;;;;;;;;;GAWG;AACH,iBAAS,8BAA8B,IAAI,MAAM,EAAE,CASlD;AAED;;qDAEqD;AACrD,iBAAS,oBAAoB,IAAI,MAAM,CAEtC;AAoID,iBAAS,kBAAkB,CACzB,MAAM,EAAE,MAAM,EACd,OAAO,EAAE,MAAM,EACf,IAAI,EAAE,YAAY,GAAG,WAAW,GAC/B,IAAI,CA8CN;AAkHD,iBAAS,eAAe,CAAC,GAAG,EAAE,MAAM,GAAG,IAAI,CAI1C;AAyED,iBAAS,uBAAuB,CAAC,IAAI,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE,MAAM,GAAG,OAAO,CA+BxE"}