@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.
- package/corpus/core/CHANGELOG.md +6 -0
- package/corpus/core/package.json +1 -1
- package/corpus/core/src/cli/create.ts +65 -15
- package/corpus/core/src/cli/sync-builder-starter-manifest.ts +261 -65
- package/corpus/templates/clips/actions/finalize-recording.ts +1 -1
- package/corpus/templates/clips/desktop/src/app.tsx +35 -19
- package/corpus/templates/clips/desktop/src/overlays/toolbar.tsx +38 -1
- package/corpus/templates/clips/desktop/src/styles.css +35 -0
- package/corpus/templates/clips/desktop/src-tauri/src/native_screen.rs +343 -14
- package/dist/cli/create.d.ts.map +1 -1
- package/dist/cli/create.js +53 -15
- package/dist/cli/create.js.map +1 -1
- package/dist/cli/sync-builder-starter-manifest.d.ts +36 -2
- package/dist/cli/sync-builder-starter-manifest.d.ts.map +1 -1
- package/dist/cli/sync-builder-starter-manifest.js +180 -37
- package/dist/cli/sync-builder-starter-manifest.js.map +1 -1
- package/dist/observability/routes.d.ts +5 -5
- package/dist/server/agent-engine-api-key-route.d.ts +2 -2
- package/package.json +1 -1
|
@@ -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
|
-
|
|
1377
|
-
"
|
|
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
|
-
|
|
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.
|
|
2122
|
-
//
|
|
2123
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
2166
|
-
|
|
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 {
|
package/dist/cli/create.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"create.d.ts","sourceRoot":"","sources":["../../src/cli/create.ts"],"names":[],"mappings":"AAgBA,OAAO,EAAkB,mBAAmB,EAAE,MAAM,kBAAkB,CAAC;
|
|
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"}
|