@agent-native/core 0.136.3 → 0.136.5
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/README.md +1 -1
- package/corpus/templates/clips/.agents/skills/recording/SKILL.md +53 -0
- package/corpus/templates/clips/actions/import-loom-recording.ts +7 -0
- package/corpus/templates/clips/actions/lib/loom-import-job.ts +24 -4
- package/corpus/templates/clips/changelog/2026-08-03-restart-during-a-recording-now-immediately-starts-a-fresh-ta.md +6 -0
- package/corpus/templates/clips/desktop/src/app.tsx +81 -30
- package/corpus/templates/clips/desktop/src/lib/recorder.ts +436 -158
- package/corpus/templates/clips/server/lib/post-finalize-dispatch.ts +14 -1
- package/corpus/templates/clips/server/plugins/auth.ts +9 -0
- package/corpus/templates/clips/server/routes/api/_agent-native-background/post-finalize-worker.post.ts +13 -0
- package/dist/client/agent-page/AgentJobsTab.js +19 -4
- package/dist/client/agent-page/use-jobs.d.ts +10 -0
- package/dist/client/agent-page/use-jobs.js +21 -0
- package/dist/client/settings/SecretsSection.js +32 -8
- package/dist/collab/awareness.d.ts +2 -2
- package/dist/collab/struct-routes.d.ts +1 -1
- package/dist/file-upload/actions/upload-image.d.ts +1 -1
- package/dist/jobs/actions/run-automation-now.d.ts +6 -0
- package/dist/jobs/actions/run-automation-now.js +22 -0
- package/dist/jobs/background-automation-runner.d.ts +2 -0
- package/dist/jobs/background-automation-runner.js +22 -16
- package/dist/jobs/run-history.d.ts +17 -3
- package/dist/jobs/run-history.js +73 -7
- package/dist/jobs/run-now.d.ts +20 -0
- package/dist/jobs/run-now.js +99 -0
- package/dist/jobs/scheduler.d.ts +15 -0
- package/dist/jobs/scheduler.js +76 -12
- package/dist/localization/default-messages.d.ts +8 -0
- package/dist/localization/default-messages.js +8 -0
- package/dist/notifications/routes.d.ts +4 -4
- package/dist/observability/routes.d.ts +3 -3
- package/dist/provider-api/actions/custom-provider-registration.d.ts +12 -12
- package/dist/provider-api/actions/provider-api.d.ts +4 -4
- package/dist/provider-api/corpus-jobs.d.ts +2 -2
- package/dist/secrets/routes.d.ts +5 -5
- package/dist/secrets/routes.js +41 -17
- package/dist/server/action-discovery.js +4 -0
- package/dist/server/agent-chat-plugin.js +135 -68
- package/dist/server/agent-engine-api-key-route.d.ts +1 -1
- package/dist/server/email-actions.d.ts +6 -3
- package/dist/server/email-actions.js +17 -83
- package/dist/server/email-markdown.d.ts +4 -0
- package/dist/server/email-markdown.js +174 -0
- package/dist/server/transcribe-voice.d.ts +1 -1
- package/dist/templates/workspace-core/.agents/skills/automations/SKILL.md +1 -0
- package/dist/triggers/actions.d.ts +1 -1
- package/dist/triggers/actions.js +27 -5
- package/package.json +1 -1
- package/src/templates/workspace-core/.agents/skills/automations/SKILL.md +1 -0
|
@@ -229,6 +229,17 @@ export interface StartParams {
|
|
|
229
229
|
* or turns the camera off).
|
|
230
230
|
*/
|
|
231
231
|
preAcquiredCameraStream?: MediaStream | null;
|
|
232
|
+
/**
|
|
233
|
+
* Live screen capture handed over by the session this start is replacing
|
|
234
|
+
* (see `RestartHandoff`). Present only on a restart.
|
|
235
|
+
*
|
|
236
|
+
* Ownership is the OPPOSITE of `preAcquiredCameraStream`: the popover keeps
|
|
237
|
+
* the camera, but these streams belong to the recorder, so this session
|
|
238
|
+
* stops them on its own stop/cancel exactly as if it had acquired them.
|
|
239
|
+
*/
|
|
240
|
+
preAcquiredDisplayStream?: MediaStream | null;
|
|
241
|
+
/** Live microphone capture handed over on restart. See `preAcquiredDisplayStream`. */
|
|
242
|
+
preAcquiredAudioStream?: MediaStream | null;
|
|
232
243
|
}
|
|
233
244
|
|
|
234
245
|
const REWIND_CLIP_ORIGINS_KEY = "clips.rewindClipOrigins.v1";
|
|
@@ -286,11 +297,27 @@ export function forgetRewindClipOrigin(recordingId: string): void {
|
|
|
286
297
|
writeRewindClipOrigins(origins);
|
|
287
298
|
}
|
|
288
299
|
|
|
300
|
+
export const RESTART_CAPTURE_ENDED_MESSAGE =
|
|
301
|
+
"Screen sharing ended — start a new recording.";
|
|
302
|
+
|
|
303
|
+
/**
|
|
304
|
+
* Live capture streams a discarded session hands to its replacement so the
|
|
305
|
+
* retake never calls `getDisplayMedia` again — a Tauri event listener carries
|
|
306
|
+
* no user activation, so re-acquiring here would always fail.
|
|
307
|
+
*/
|
|
308
|
+
export interface RestartHandoff {
|
|
309
|
+
/** Null when the backend re-acquires capture natively (no user gesture needed). */
|
|
310
|
+
displayStream: MediaStream | null;
|
|
311
|
+
audioStream: MediaStream | null;
|
|
312
|
+
}
|
|
313
|
+
|
|
289
314
|
export interface RecorderHandle {
|
|
290
315
|
/** Stop the recording and resolve once the server has finalized. */
|
|
291
316
|
stop(): Promise<RecorderStopResult>;
|
|
292
317
|
/** Discard the recording without saving. */
|
|
293
318
|
cancel(): Promise<void>;
|
|
319
|
+
/** Discard this take but keep gesture-bound capture alive for an immediate retake. */
|
|
320
|
+
discardForRestart(): Promise<RestartHandoff>;
|
|
294
321
|
}
|
|
295
322
|
|
|
296
323
|
export interface RecorderStopResult {
|
|
@@ -2327,7 +2354,7 @@ function captureSuspensionReleaser(
|
|
|
2327
2354
|
};
|
|
2328
2355
|
}
|
|
2329
2356
|
|
|
2330
|
-
function recorderWithCaptureSuspension(
|
|
2357
|
+
export function recorderWithCaptureSuspension(
|
|
2331
2358
|
handle: RecorderHandle,
|
|
2332
2359
|
release: () => Promise<void>,
|
|
2333
2360
|
): RecorderHandle {
|
|
@@ -2337,14 +2364,23 @@ function recorderWithCaptureSuspension(
|
|
|
2337
2364
|
// the suspension after the physical recorder has actually torn down.
|
|
2338
2365
|
const stop = handle.stop.bind(handle);
|
|
2339
2366
|
const cancel = handle.cancel.bind(handle);
|
|
2367
|
+
const discardForRestart = handle.discardForRestart.bind(handle);
|
|
2340
2368
|
let stopPromise: Promise<RecorderStopResult> | null = null;
|
|
2341
2369
|
let cancelPromise: Promise<void> | null = null;
|
|
2370
|
+
let discardPromise: Promise<RestartHandoff> | null = null;
|
|
2342
2371
|
handle.stop = async () => {
|
|
2343
2372
|
if (stopPromise) return stopPromise;
|
|
2344
2373
|
if (cancelPromise) {
|
|
2345
2374
|
await cancelPromise;
|
|
2346
2375
|
throw new Error("Recording was already cancelled");
|
|
2347
2376
|
}
|
|
2377
|
+
// Without this a stop racing a restart reaches the backend's `stopped`
|
|
2378
|
+
// short-circuit, which answers with the discarded take's id — the caller
|
|
2379
|
+
// would publish an aborted recording as a finished one.
|
|
2380
|
+
if (discardPromise) {
|
|
2381
|
+
await discardPromise;
|
|
2382
|
+
throw new Error("Recording was already discarded for a restart");
|
|
2383
|
+
}
|
|
2348
2384
|
stopPromise = (async () => {
|
|
2349
2385
|
try {
|
|
2350
2386
|
return await stop();
|
|
@@ -2362,13 +2398,45 @@ function recorderWithCaptureSuspension(
|
|
|
2362
2398
|
}
|
|
2363
2399
|
cancelPromise = (async () => {
|
|
2364
2400
|
try {
|
|
2401
|
+
// Cancelling after a restart discard means the retake will never take
|
|
2402
|
+
// ownership of the capture handed to it, and nothing else would stop
|
|
2403
|
+
// it. The backend's own cancel decides what else a late cancel owes.
|
|
2404
|
+
const handoff = await discardPromise;
|
|
2365
2405
|
await cancel();
|
|
2406
|
+
[handoff?.displayStream, handoff?.audioStream].forEach((stream) =>
|
|
2407
|
+
stream?.getTracks().forEach((track) => track.stop()),
|
|
2408
|
+
);
|
|
2366
2409
|
} finally {
|
|
2367
2410
|
await release();
|
|
2368
2411
|
}
|
|
2369
2412
|
})();
|
|
2370
2413
|
return cancelPromise;
|
|
2371
2414
|
};
|
|
2415
|
+
handle.discardForRestart = async () => {
|
|
2416
|
+
if (discardPromise) return discardPromise;
|
|
2417
|
+
if (stopPromise) {
|
|
2418
|
+
await stopPromise;
|
|
2419
|
+
throw new Error("Recording was already stopped");
|
|
2420
|
+
}
|
|
2421
|
+
if (cancelPromise) {
|
|
2422
|
+
await cancelPromise;
|
|
2423
|
+
throw new Error("Recording was already cancelled");
|
|
2424
|
+
}
|
|
2425
|
+
discardPromise = (async () => {
|
|
2426
|
+
const handoff = await discardForRestart();
|
|
2427
|
+
// The discard succeeded and the caller now owns live capture. Letting a
|
|
2428
|
+
// failed lease release reject in its place would strand those tracks
|
|
2429
|
+
// with nobody holding a reference to stop them.
|
|
2430
|
+
await release().catch((err) =>
|
|
2431
|
+
console.error(
|
|
2432
|
+
"[clips-recorder] capture suspension release failed after restart discard:",
|
|
2433
|
+
err,
|
|
2434
|
+
),
|
|
2435
|
+
);
|
|
2436
|
+
return handoff;
|
|
2437
|
+
})();
|
|
2438
|
+
return discardPromise;
|
|
2439
|
+
};
|
|
2372
2440
|
return handle;
|
|
2373
2441
|
}
|
|
2374
2442
|
|
|
@@ -2569,6 +2637,7 @@ async function tryStartRewindFullscreenRecording(
|
|
|
2569
2637
|
let stopped = false;
|
|
2570
2638
|
let stopPromise: Promise<RecorderStopResult> | null = null;
|
|
2571
2639
|
let cancelPromise: Promise<void> | null = null;
|
|
2640
|
+
let discardPromise: Promise<RestartHandoff> | null = null;
|
|
2572
2641
|
let stateUnlistens: UnlistenFn[] = [];
|
|
2573
2642
|
let tickHandle: ReturnType<typeof setInterval> | null = null;
|
|
2574
2643
|
let pausedAt: number | null = null;
|
|
@@ -2601,9 +2670,56 @@ async function tryStartRewindFullscreenRecording(
|
|
|
2601
2670
|
stateUnlistens = [];
|
|
2602
2671
|
};
|
|
2603
2672
|
|
|
2673
|
+
// End the whole recording session. `hide_overlays` destroys the camera bubble
|
|
2674
|
+
// along with the recording chrome, and clearing the recording state releases
|
|
2675
|
+
// the popover's blur auto-hide — both wrong between the two takes of a
|
|
2676
|
+
// restart, which is why `discardTake` only runs this for a real cancel.
|
|
2677
|
+
const endSession = async () => {
|
|
2678
|
+
await invoke("hide_overlays").catch(() => {});
|
|
2679
|
+
await clearRecordingState();
|
|
2680
|
+
};
|
|
2681
|
+
|
|
2682
|
+
const discardTake = async (forRestart: boolean) => {
|
|
2683
|
+
stopped = true;
|
|
2684
|
+
cleanupUi();
|
|
2685
|
+
transcriptionAborted = true;
|
|
2686
|
+
const transcriptionTornDown = transcriptionCapture
|
|
2687
|
+
?.cancel()
|
|
2688
|
+
.catch((err) => {
|
|
2689
|
+
console.warn("[clips-recorder] transcription cancel failed:", err);
|
|
2690
|
+
});
|
|
2691
|
+
await invoke("rewind_clip_cancel").catch(() => {});
|
|
2692
|
+
audioCue.cleanup();
|
|
2693
|
+
if (forRestart) {
|
|
2694
|
+
await invoke("hide_recording_chrome").catch(() => {});
|
|
2695
|
+
} else {
|
|
2696
|
+
await endSession();
|
|
2697
|
+
}
|
|
2698
|
+
if (!localOnly && id) {
|
|
2699
|
+
forgetRewindClipOrigin(id);
|
|
2700
|
+
await cleanupCancelledRemoteRecording(params.serverUrl, id).catch(
|
|
2701
|
+
() => {},
|
|
2702
|
+
);
|
|
2703
|
+
}
|
|
2704
|
+
// The transcription engine is process-global, so this stop must land
|
|
2705
|
+
// before the replacement session starts it again.
|
|
2706
|
+
if (forRestart) await transcriptionTornDown;
|
|
2707
|
+
};
|
|
2708
|
+
|
|
2604
2709
|
const handle: RecorderHandle = {
|
|
2605
2710
|
async stop() {
|
|
2606
2711
|
if (stopPromise) return stopPromise;
|
|
2712
|
+
// This handle runs unwrapped (the Rewind producer holds no capture
|
|
2713
|
+
// suspension lease), so the terminal-transition guards live here.
|
|
2714
|
+
// Answering with `id` would publish a take that was already trashed.
|
|
2715
|
+
if (cancelPromise) {
|
|
2716
|
+
await cancelPromise;
|
|
2717
|
+
throw new Error("Recording was already cancelled");
|
|
2718
|
+
}
|
|
2719
|
+
if (discardPromise) {
|
|
2720
|
+
await discardPromise;
|
|
2721
|
+
throw new Error("Recording was already discarded for a restart");
|
|
2722
|
+
}
|
|
2607
2723
|
if (stopped) return { recordingId: id, viewUrl: `/r/${id}` };
|
|
2608
2724
|
stopPromise = (async () => {
|
|
2609
2725
|
stopped = true;
|
|
@@ -2727,27 +2843,34 @@ async function tryStartRewindFullscreenRecording(
|
|
|
2727
2843
|
},
|
|
2728
2844
|
async cancel() {
|
|
2729
2845
|
if (cancelPromise) return cancelPromise;
|
|
2846
|
+
// A cancel landing on an already-discarded take still has to end the
|
|
2847
|
+
// session — the restart skipped that half deliberately, so returning
|
|
2848
|
+
// here would leave the bubble up and the recording state active.
|
|
2849
|
+
if (discardPromise) {
|
|
2850
|
+
cancelPromise = discardPromise.then(endSession);
|
|
2851
|
+
return cancelPromise;
|
|
2852
|
+
}
|
|
2730
2853
|
if (stopped) return;
|
|
2731
|
-
cancelPromise = (
|
|
2732
|
-
stopped = true;
|
|
2733
|
-
cleanupUi();
|
|
2734
|
-
transcriptionAborted = true;
|
|
2735
|
-
void transcriptionCapture?.cancel().catch((err) => {
|
|
2736
|
-
console.warn("[clips-recorder] transcription cancel failed:", err);
|
|
2737
|
-
});
|
|
2738
|
-
await invoke("rewind_clip_cancel").catch(() => {});
|
|
2739
|
-
audioCue.cleanup();
|
|
2740
|
-
await invoke("hide_overlays").catch(() => {});
|
|
2741
|
-
await clearRecordingState();
|
|
2742
|
-
if (!localOnly && id) {
|
|
2743
|
-
forgetRewindClipOrigin(id);
|
|
2744
|
-
await cleanupCancelledRemoteRecording(params.serverUrl, id).catch(
|
|
2745
|
-
() => {},
|
|
2746
|
-
);
|
|
2747
|
-
}
|
|
2748
|
-
})();
|
|
2854
|
+
cancelPromise = discardTake(false);
|
|
2749
2855
|
return cancelPromise;
|
|
2750
2856
|
},
|
|
2857
|
+
async discardForRestart() {
|
|
2858
|
+
if (discardPromise) return discardPromise;
|
|
2859
|
+
if (cancelPromise) {
|
|
2860
|
+
await cancelPromise;
|
|
2861
|
+
throw new Error("Recording was already cancelled");
|
|
2862
|
+
}
|
|
2863
|
+
if (stopped) {
|
|
2864
|
+
throw new Error("Recording already finished — nothing to restart");
|
|
2865
|
+
}
|
|
2866
|
+
// The Rewind producer re-acquires capture natively, so the retake needs
|
|
2867
|
+
// nothing handed to it.
|
|
2868
|
+
discardPromise = discardTake(true).then(() => ({
|
|
2869
|
+
displayStream: null,
|
|
2870
|
+
audioStream: null,
|
|
2871
|
+
}));
|
|
2872
|
+
return discardPromise;
|
|
2873
|
+
},
|
|
2751
2874
|
};
|
|
2752
2875
|
|
|
2753
2876
|
pauseQueue = createPauseTransitionQueue({
|
|
@@ -3125,6 +3248,7 @@ async function startNativeFullscreenRecording(
|
|
|
3125
3248
|
let stopped = false;
|
|
3126
3249
|
let stopPromise: Promise<RecorderStopResult> | null = null;
|
|
3127
3250
|
let cancelPromise: Promise<void> | null = null;
|
|
3251
|
+
let discardPromise: Promise<RestartHandoff> | null = null;
|
|
3128
3252
|
let stateUnlistens: UnlistenFn[] = [];
|
|
3129
3253
|
let tickHandle: ReturnType<typeof setInterval> | null = null;
|
|
3130
3254
|
let segmentRotateHandle: ReturnType<typeof setInterval> | null = null;
|
|
@@ -3197,6 +3321,65 @@ async function startNativeFullscreenRecording(
|
|
|
3197
3321
|
}).catch(() => {});
|
|
3198
3322
|
}
|
|
3199
3323
|
|
|
3324
|
+
// End the whole recording session. `hide_overlays` destroys the camera
|
|
3325
|
+
// bubble window along with the countdown and toolbar, which is wrong between
|
|
3326
|
+
// the two takes of a restart — hence the split with `discardTake`.
|
|
3327
|
+
const endSession = async () => {
|
|
3328
|
+
await invoke("hide_overlays").catch(() => {});
|
|
3329
|
+
};
|
|
3330
|
+
|
|
3331
|
+
const discardTake = async (forRestart: boolean) => {
|
|
3332
|
+
stopped = true;
|
|
3333
|
+
clearSegmentRotator();
|
|
3334
|
+
pauseQueue?.dispose();
|
|
3335
|
+
if (tickHandle) {
|
|
3336
|
+
clearInterval(tickHandle);
|
|
3337
|
+
tickHandle = null;
|
|
3338
|
+
}
|
|
3339
|
+
stateUnlistens.forEach((u) => u());
|
|
3340
|
+
stateUnlistens = [];
|
|
3341
|
+
const transcriptionTornDown = transcriptionCapture
|
|
3342
|
+
?.cancel()
|
|
3343
|
+
.catch((err) => {
|
|
3344
|
+
console.warn(
|
|
3345
|
+
"[clips-recorder] native transcription cancel failed:",
|
|
3346
|
+
err,
|
|
3347
|
+
);
|
|
3348
|
+
});
|
|
3349
|
+
await localCameraExport?.cancel().catch(() => {});
|
|
3350
|
+
await invoke("native_fullscreen_recording_cancel").catch((err) =>
|
|
3351
|
+
console.warn("[clips-recorder] native fullscreen cancel failed:", err),
|
|
3352
|
+
);
|
|
3353
|
+
if (bubbleCaptureExcluded) {
|
|
3354
|
+
await invoke("set_bubble_capture_excluded", { excluded: false }).catch(
|
|
3355
|
+
() => {},
|
|
3356
|
+
);
|
|
3357
|
+
bubbleCaptureExcluded = false;
|
|
3358
|
+
}
|
|
3359
|
+
if (localOwnsCameraStream) {
|
|
3360
|
+
localCameraStream?.getTracks().forEach((track) => track.stop());
|
|
3361
|
+
}
|
|
3362
|
+
streamCleanups.forEach((cleanup) => cleanup());
|
|
3363
|
+
if (forRestart) {
|
|
3364
|
+
await invoke("hide_recording_chrome").catch(() => {});
|
|
3365
|
+
} else {
|
|
3366
|
+
await endSession();
|
|
3367
|
+
}
|
|
3368
|
+
if (!localOnly && id) {
|
|
3369
|
+
void cleanupCancelledRemoteRecording(params.serverUrl, id).catch(
|
|
3370
|
+
(err) => {
|
|
3371
|
+
console.warn(
|
|
3372
|
+
"[clips-recorder] cancelled recording cleanup failed:",
|
|
3373
|
+
err,
|
|
3374
|
+
);
|
|
3375
|
+
},
|
|
3376
|
+
);
|
|
3377
|
+
}
|
|
3378
|
+
// The transcription engine is process-global, so this stop must land
|
|
3379
|
+
// before the replacement session starts it again.
|
|
3380
|
+
if (forRestart) await transcriptionTornDown;
|
|
3381
|
+
};
|
|
3382
|
+
|
|
3200
3383
|
const handle: RecorderHandle = {
|
|
3201
3384
|
async stop() {
|
|
3202
3385
|
if (stopPromise) return stopPromise;
|
|
@@ -3427,54 +3610,35 @@ async function startNativeFullscreenRecording(
|
|
|
3427
3610
|
|
|
3428
3611
|
async cancel() {
|
|
3429
3612
|
if (cancelPromise) return cancelPromise;
|
|
3613
|
+
// A cancel landing on an already-discarded take still has to end the
|
|
3614
|
+
// session — the restart skipped that half deliberately, so returning
|
|
3615
|
+
// here would leave the camera bubble up.
|
|
3616
|
+
if (discardPromise) {
|
|
3617
|
+
cancelPromise = discardPromise.then(endSession);
|
|
3618
|
+
return cancelPromise;
|
|
3619
|
+
}
|
|
3430
3620
|
if (stopped) return;
|
|
3431
|
-
cancelPromise = (
|
|
3432
|
-
stopped = true;
|
|
3433
|
-
clearSegmentRotator();
|
|
3434
|
-
pauseQueue?.dispose();
|
|
3435
|
-
if (tickHandle) {
|
|
3436
|
-
clearInterval(tickHandle);
|
|
3437
|
-
tickHandle = null;
|
|
3438
|
-
}
|
|
3439
|
-
stateUnlistens.forEach((u) => u());
|
|
3440
|
-
stateUnlistens = [];
|
|
3441
|
-
void transcriptionCapture?.cancel().catch((err) => {
|
|
3442
|
-
console.warn(
|
|
3443
|
-
"[clips-recorder] native transcription cancel failed:",
|
|
3444
|
-
err,
|
|
3445
|
-
);
|
|
3446
|
-
});
|
|
3447
|
-
await localCameraExport?.cancel().catch(() => {});
|
|
3448
|
-
await invoke("native_fullscreen_recording_cancel").catch((err) =>
|
|
3449
|
-
console.warn(
|
|
3450
|
-
"[clips-recorder] native fullscreen cancel failed:",
|
|
3451
|
-
err,
|
|
3452
|
-
),
|
|
3453
|
-
);
|
|
3454
|
-
if (bubbleCaptureExcluded) {
|
|
3455
|
-
await invoke("set_bubble_capture_excluded", {
|
|
3456
|
-
excluded: false,
|
|
3457
|
-
}).catch(() => {});
|
|
3458
|
-
bubbleCaptureExcluded = false;
|
|
3459
|
-
}
|
|
3460
|
-
if (localOwnsCameraStream) {
|
|
3461
|
-
localCameraStream?.getTracks().forEach((track) => track.stop());
|
|
3462
|
-
}
|
|
3463
|
-
streamCleanups.forEach((cleanup) => cleanup());
|
|
3464
|
-
await invoke("hide_overlays").catch(() => {});
|
|
3465
|
-
if (!localOnly && id) {
|
|
3466
|
-
void cleanupCancelledRemoteRecording(params.serverUrl, id).catch(
|
|
3467
|
-
(err) => {
|
|
3468
|
-
console.warn(
|
|
3469
|
-
"[clips-recorder] cancelled recording cleanup failed:",
|
|
3470
|
-
err,
|
|
3471
|
-
);
|
|
3472
|
-
},
|
|
3473
|
-
);
|
|
3474
|
-
}
|
|
3475
|
-
})();
|
|
3621
|
+
cancelPromise = discardTake(false);
|
|
3476
3622
|
return cancelPromise;
|
|
3477
3623
|
},
|
|
3624
|
+
|
|
3625
|
+
async discardForRestart() {
|
|
3626
|
+
if (discardPromise) return discardPromise;
|
|
3627
|
+
if (cancelPromise) {
|
|
3628
|
+
await cancelPromise;
|
|
3629
|
+
throw new Error("Recording was already cancelled");
|
|
3630
|
+
}
|
|
3631
|
+
if (stopped) {
|
|
3632
|
+
throw new Error("Recording already finished — nothing to restart");
|
|
3633
|
+
}
|
|
3634
|
+
// ScreenCaptureKit is driven from Rust, so the retake re-acquires
|
|
3635
|
+
// capture natively without needing a user gesture.
|
|
3636
|
+
discardPromise = discardTake(true).then(() => ({
|
|
3637
|
+
displayStream: null,
|
|
3638
|
+
audioStream: null,
|
|
3639
|
+
}));
|
|
3640
|
+
return discardPromise;
|
|
3641
|
+
},
|
|
3478
3642
|
};
|
|
3479
3643
|
|
|
3480
3644
|
pauseQueue = createPauseTransitionQueue({
|
|
@@ -3667,12 +3831,58 @@ export async function startRecording(
|
|
|
3667
3831
|
}
|
|
3668
3832
|
}
|
|
3669
3833
|
|
|
3834
|
+
/**
|
|
3835
|
+
* Vet the capture a restart inherited. A handed-off track can die between the
|
|
3836
|
+
* restart click and this call — the user may have hit the OS "Stop sharing"
|
|
3837
|
+
* control, or unplugged the microphone.
|
|
3838
|
+
*
|
|
3839
|
+
* The two are not interchangeable. Screen capture cannot be re-acquired here
|
|
3840
|
+
* (this start carries no user activation), so an ended display share is fatal
|
|
3841
|
+
* and has to name itself; re-acquiring would surface an activation error that
|
|
3842
|
+
* blames the wrong thing. A microphone needs no gesture, so an ended one is
|
|
3843
|
+
* simply dropped and picked up again by the normal acquisition path.
|
|
3844
|
+
*/
|
|
3845
|
+
export function resolveRestartHandoff(
|
|
3846
|
+
params: StartParams,
|
|
3847
|
+
wantsScreen: boolean,
|
|
3848
|
+
wantsAudio: boolean,
|
|
3849
|
+
): RestartHandoff {
|
|
3850
|
+
const stopStream = (stream: MediaStream | null) =>
|
|
3851
|
+
stream?.getTracks().forEach((track) => track.stop());
|
|
3852
|
+
const isLive = (stream: MediaStream) =>
|
|
3853
|
+
stream.getTracks().length > 0 &&
|
|
3854
|
+
stream.getTracks().every((track) => track.readyState === "live");
|
|
3855
|
+
|
|
3856
|
+
let displayStream = params.preAcquiredDisplayStream ?? null;
|
|
3857
|
+
let audioStream = params.preAcquiredAudioStream ?? null;
|
|
3858
|
+
|
|
3859
|
+
// Nothing downstream will ever own capture this session did not ask for.
|
|
3860
|
+
if (displayStream && !wantsScreen) {
|
|
3861
|
+
stopStream(displayStream);
|
|
3862
|
+
displayStream = null;
|
|
3863
|
+
}
|
|
3864
|
+
if (audioStream && (!wantsAudio || !isLive(audioStream))) {
|
|
3865
|
+
stopStream(audioStream);
|
|
3866
|
+
audioStream = null;
|
|
3867
|
+
}
|
|
3868
|
+
|
|
3869
|
+
if (displayStream && !isLive(displayStream)) {
|
|
3870
|
+
stopStream(displayStream);
|
|
3871
|
+
stopStream(audioStream);
|
|
3872
|
+
throw new Error(RESTART_CAPTURE_ENDED_MESSAGE);
|
|
3873
|
+
}
|
|
3874
|
+
return { displayStream, audioStream };
|
|
3875
|
+
}
|
|
3876
|
+
|
|
3670
3877
|
async function startRecordingInner(
|
|
3671
3878
|
params: StartParams,
|
|
3672
3879
|
): Promise<RecorderHandle> {
|
|
3673
3880
|
const wantsScreen = params.mode !== "camera";
|
|
3674
3881
|
const wantsCamera = params.mode !== "screen" && params.cameraOn;
|
|
3675
3882
|
const wantsAudio = params.micOn;
|
|
3883
|
+
// Vetted before anything is acquired so an ended display share cannot strand
|
|
3884
|
+
// a capture-suspension lease behind it.
|
|
3885
|
+
const restartHandoff = resolveRestartHandoff(params, wantsScreen, wantsAudio);
|
|
3676
3886
|
const wantsSystemAudio = wantsScreen && params.systemAudioOn !== false;
|
|
3677
3887
|
const wantsRecordedAudio = wantsAudio || wantsSystemAudio;
|
|
3678
3888
|
const canTranscribeLocally =
|
|
@@ -3758,30 +3968,42 @@ async function startRecordingInner(
|
|
|
3758
3968
|
requiresMicrophone: wantsAudio,
|
|
3759
3969
|
});
|
|
3760
3970
|
|
|
3761
|
-
|
|
3762
|
-
|
|
3763
|
-
|
|
3764
|
-
|
|
3765
|
-
|
|
3766
|
-
|
|
3767
|
-
|
|
3768
|
-
|
|
3769
|
-
|
|
3770
|
-
|
|
3771
|
-
|
|
3772
|
-
|
|
3773
|
-
|
|
3774
|
-
|
|
3971
|
+
// A restart inherits live capture from the take it replaces, so the two
|
|
3972
|
+
// gesture-bound acquisitions are skipped entirely. See `RestartHandoff`.
|
|
3973
|
+
const resumedDisplayStream = restartHandoff.displayStream;
|
|
3974
|
+
const resumedAudioStream = restartHandoff.audioStream;
|
|
3975
|
+
if (resumedDisplayStream || resumedAudioStream) {
|
|
3976
|
+
console.log("[clips-recorder] reusing handed-off capture streams", {
|
|
3977
|
+
display: Boolean(resumedDisplayStream),
|
|
3978
|
+
audio: Boolean(resumedAudioStream),
|
|
3979
|
+
});
|
|
3980
|
+
}
|
|
3981
|
+
const displayStreamPromise: Promise<MediaStream> | null = resumedDisplayStream
|
|
3982
|
+
? Promise.resolve(resumedDisplayStream)
|
|
3983
|
+
: wantsScreen
|
|
3984
|
+
? (() => {
|
|
3985
|
+
if (!devSyntheticCapture) {
|
|
3986
|
+
// Do not pass displaySurface as an input constraint. Modern runtimes
|
|
3987
|
+
// can reject it with "Invalid constraint", and it cannot reliably
|
|
3988
|
+
// pre-filter the OS picker anyway; the selected track reports its
|
|
3989
|
+
// actual surface through getSettings() after capture starts.
|
|
3990
|
+
return navigator.mediaDevices.getDisplayMedia(
|
|
3991
|
+
buildDesktopDisplayMediaOptions({
|
|
3992
|
+
audio: wantsSystemAudio,
|
|
3993
|
+
frameRate: CLOUD_CAPTURE_FRAME_RATE,
|
|
3994
|
+
maxWidth: CLOUD_CAPTURE_MAX_WIDTH,
|
|
3995
|
+
maxHeight: CLOUD_CAPTURE_MAX_HEIGHT,
|
|
3996
|
+
}),
|
|
3997
|
+
);
|
|
3998
|
+
}
|
|
3999
|
+
console.warn(
|
|
4000
|
+
"[clips-recorder] using opt-in dev synthetic screen capture; remove localStorage clips:dev-synthetic-capture to use the native picker",
|
|
3775
4001
|
);
|
|
3776
|
-
|
|
3777
|
-
|
|
3778
|
-
|
|
3779
|
-
)
|
|
3780
|
-
|
|
3781
|
-
streamCleanups.push(syntheticDisplay.cleanup);
|
|
3782
|
-
return Promise.resolve(syntheticDisplay.stream);
|
|
3783
|
-
})()
|
|
3784
|
-
: null;
|
|
4002
|
+
const syntheticDisplay = createSyntheticScreenStream();
|
|
4003
|
+
streamCleanups.push(syntheticDisplay.cleanup);
|
|
4004
|
+
return Promise.resolve(syntheticDisplay.stream);
|
|
4005
|
+
})()
|
|
4006
|
+
: null;
|
|
3785
4007
|
// If the popover handed us a live camera stream from the pre-record
|
|
3786
4008
|
// preview we reuse it verbatim and SKIP getUserMedia — see the
|
|
3787
4009
|
// `preAcquiredCameraStream` field doc for the WebKit rationale. This
|
|
@@ -3800,9 +4022,11 @@ async function startRecordingInner(
|
|
|
3800
4022
|
wantsCamera && !reusedCameraStream
|
|
3801
4023
|
? getCameraStreamWithFallback(params.cameraId)
|
|
3802
4024
|
: null;
|
|
3803
|
-
const audioStreamPromise: Promise<MediaStream> | null =
|
|
3804
|
-
?
|
|
3805
|
-
:
|
|
4025
|
+
const audioStreamPromise: Promise<MediaStream> | null = resumedAudioStream
|
|
4026
|
+
? Promise.resolve(resumedAudioStream)
|
|
4027
|
+
: wantsAudio
|
|
4028
|
+
? getAudioStreamWithFallback(params.micId, params.micLabel)
|
|
4029
|
+
: null;
|
|
3806
4030
|
|
|
3807
4031
|
let captureSuspension: RewindCaptureSuspensionLease;
|
|
3808
4032
|
try {
|
|
@@ -4103,10 +4327,12 @@ async function startRecordingInner(
|
|
|
4103
4327
|
}
|
|
4104
4328
|
};
|
|
4105
4329
|
|
|
4106
|
-
const stopOwnedStreams = () => {
|
|
4107
|
-
|
|
4108
|
-
|
|
4109
|
-
|
|
4330
|
+
const stopOwnedStreams = (keepCaptureStreams = false) => {
|
|
4331
|
+
if (!keepCaptureStreams) {
|
|
4332
|
+
[displayStream, audioStream].forEach((stream) =>
|
|
4333
|
+
stream?.getTracks().forEach((track) => track.stop()),
|
|
4334
|
+
);
|
|
4335
|
+
}
|
|
4110
4336
|
streamCleanups.forEach((cleanup) => cleanup());
|
|
4111
4337
|
if (!popoverOwnsCamera) {
|
|
4112
4338
|
bubbleCameraStream?.getTracks().forEach((track) => track.stop());
|
|
@@ -4119,6 +4345,25 @@ async function startRecordingInner(
|
|
|
4119
4345
|
);
|
|
4120
4346
|
};
|
|
4121
4347
|
|
|
4348
|
+
const discardTake = async (
|
|
4349
|
+
keepCaptureStreams: boolean,
|
|
4350
|
+
): Promise<RestartHandoff> => {
|
|
4351
|
+
// Synthetic dev capture belongs to `streamCleanups`, which always runs
|
|
4352
|
+
// here, so it is never handed over — the retake recreates it.
|
|
4353
|
+
const handsOff = keepCaptureStreams && !devSyntheticCapture;
|
|
4354
|
+
stopped = true;
|
|
4355
|
+
if (tickHandle) clearInterval(tickHandle);
|
|
4356
|
+
stateUnlistens.forEach((unlisten) => unlisten());
|
|
4357
|
+
stateUnlistens = [];
|
|
4358
|
+
await localExport.cancel();
|
|
4359
|
+
detachCombinedStream();
|
|
4360
|
+
stopOwnedStreams(handsOff);
|
|
4361
|
+
await hideChrome();
|
|
4362
|
+
return handsOff
|
|
4363
|
+
? { displayStream, audioStream }
|
|
4364
|
+
: { displayStream: null, audioStream: null };
|
|
4365
|
+
};
|
|
4366
|
+
|
|
4122
4367
|
const handle: RecorderHandle = {
|
|
4123
4368
|
async stop() {
|
|
4124
4369
|
if (stopped) {
|
|
@@ -4157,14 +4402,14 @@ async function startRecordingInner(
|
|
|
4157
4402
|
|
|
4158
4403
|
async cancel() {
|
|
4159
4404
|
if (stopped) return;
|
|
4160
|
-
|
|
4161
|
-
|
|
4162
|
-
|
|
4163
|
-
|
|
4164
|
-
|
|
4165
|
-
|
|
4166
|
-
|
|
4167
|
-
|
|
4405
|
+
await discardTake(false);
|
|
4406
|
+
},
|
|
4407
|
+
|
|
4408
|
+
async discardForRestart() {
|
|
4409
|
+
if (stopped) {
|
|
4410
|
+
throw new Error("Recording already finished — nothing to restart");
|
|
4411
|
+
}
|
|
4412
|
+
return discardTake(true);
|
|
4168
4413
|
},
|
|
4169
4414
|
};
|
|
4170
4415
|
|
|
@@ -4885,68 +5130,101 @@ async function startRecordingInner(
|
|
|
4885
5130
|
}
|
|
4886
5131
|
};
|
|
4887
5132
|
|
|
4888
|
-
const
|
|
4889
|
-
|
|
4890
|
-
|
|
4891
|
-
|
|
4892
|
-
|
|
4893
|
-
|
|
4894
|
-
|
|
4895
|
-
|
|
4896
|
-
|
|
4897
|
-
|
|
5133
|
+
const discardTake = async (
|
|
5134
|
+
keepCaptureStreams: boolean,
|
|
5135
|
+
): Promise<RestartHandoff> => {
|
|
5136
|
+
// Synthetic dev capture is owned by `streamCleanups`, which always runs
|
|
5137
|
+
// here — so there is nothing to hand over, and the next session recreates
|
|
5138
|
+
// it. It needs no user gesture either.
|
|
5139
|
+
const handsOff = keepCaptureStreams && !devSyntheticCapture;
|
|
5140
|
+
stopped = true;
|
|
5141
|
+
if (tickHandle) clearInterval(tickHandle);
|
|
5142
|
+
stateUnlistens.forEach((u) => u());
|
|
5143
|
+
stateUnlistens = [];
|
|
5144
|
+
const transcriptionTornDown = transcriptionCapture
|
|
5145
|
+
?.cancel()
|
|
5146
|
+
.catch((err) => {
|
|
4898
5147
|
console.warn("[clips-recorder] transcription cancel failed:", err);
|
|
4899
5148
|
});
|
|
4900
|
-
|
|
4901
|
-
|
|
4902
|
-
|
|
4903
|
-
|
|
4904
|
-
|
|
4905
|
-
|
|
4906
|
-
|
|
4907
|
-
|
|
4908
|
-
|
|
4909
|
-
|
|
4910
|
-
|
|
4911
|
-
|
|
4912
|
-
|
|
4913
|
-
|
|
4914
|
-
|
|
4915
|
-
|
|
4916
|
-
|
|
4917
|
-
|
|
4918
|
-
|
|
4919
|
-
|
|
4920
|
-
//
|
|
4921
|
-
|
|
5149
|
+
// Remove MediaRecorder's data handler so any final `ondataavailable`
|
|
5150
|
+
// from the stop() below doesn't push a new Blob into `inflight`
|
|
5151
|
+
// after we've decided to discard everything.
|
|
5152
|
+
recorder.ondataavailable = null;
|
|
5153
|
+
try {
|
|
5154
|
+
if (recorder.state !== "inactive") recorder.stop();
|
|
5155
|
+
// coercion-ok: the take is already discarded — a MediaRecorder that refuses to stop has nothing left to report
|
|
5156
|
+
} catch {
|
|
5157
|
+
// ignore
|
|
5158
|
+
}
|
|
5159
|
+
// Drop stream track references — same rationale as in stop(). This
|
|
5160
|
+
// detaches only from the MediaStreams; the originating streams own the
|
|
5161
|
+
// tracks and we stop them below.
|
|
5162
|
+
try {
|
|
5163
|
+
uploadCombined
|
|
5164
|
+
.getTracks()
|
|
5165
|
+
.forEach((t) => uploadCombined.removeTrack(t));
|
|
5166
|
+
combined.getTracks().forEach((t) => combined.removeTrack(t));
|
|
5167
|
+
// coercion-ok: detaching tracks is bookkeeping; the streams below are stopped or handed over either way
|
|
5168
|
+
} catch {
|
|
5169
|
+
// ignore
|
|
5170
|
+
}
|
|
5171
|
+
// Stop the streams WE own. Camera stays alive when the popover
|
|
5172
|
+
// owns it (see stop() for the same split). On a restart the display and
|
|
5173
|
+
// mic streams also stay alive and become the next session's to own.
|
|
5174
|
+
if (!handsOff) {
|
|
4922
5175
|
[displayStream, audioStream].forEach((s) =>
|
|
4923
5176
|
s?.getTracks().forEach((t) => t.stop()),
|
|
4924
5177
|
);
|
|
4925
|
-
|
|
4926
|
-
|
|
4927
|
-
|
|
4928
|
-
|
|
4929
|
-
|
|
4930
|
-
|
|
4931
|
-
|
|
4932
|
-
|
|
4933
|
-
|
|
4934
|
-
|
|
4935
|
-
|
|
4936
|
-
|
|
4937
|
-
|
|
4938
|
-
|
|
4939
|
-
|
|
4940
|
-
|
|
4941
|
-
|
|
4942
|
-
|
|
4943
|
-
|
|
4944
|
-
|
|
4945
|
-
|
|
5178
|
+
}
|
|
5179
|
+
streamCleanups.forEach((cleanup) => cleanup());
|
|
5180
|
+
if (!popoverOwnsCamera) {
|
|
5181
|
+
bubbleCameraStream?.getTracks().forEach((t) => t.stop());
|
|
5182
|
+
}
|
|
5183
|
+
// Drop remaining in-flight chunk Blobs aggressively. Their fetches
|
|
5184
|
+
// will still settle (we don't AbortController them — dev server is
|
|
5185
|
+
// local and won't hang long) but we no longer hold references to the
|
|
5186
|
+
// Blobs via this Set. Combined with the `ondataavailable = null`
|
|
5187
|
+
// above, this guarantees no new Blobs latch on during the stop.
|
|
5188
|
+
inflight.clear();
|
|
5189
|
+
await invoke("hide_recording_chrome").catch(() => {});
|
|
5190
|
+
// Tell the server to abort the partial recording (drops chunks from
|
|
5191
|
+
// application_state, flips the recording row to 'failed'), then trash
|
|
5192
|
+
// it. This is best-effort background cleanup: redo/cancel must release
|
|
5193
|
+
// the desktop chrome immediately even if the server is slow or offline.
|
|
5194
|
+
if (id) {
|
|
5195
|
+
void cleanupCancelledRemoteRecording(params.serverUrl, id).catch(
|
|
5196
|
+
(err) => {
|
|
5197
|
+
console.warn("[clips-recorder] abort failed (non-fatal):", err);
|
|
5198
|
+
},
|
|
5199
|
+
);
|
|
5200
|
+
}
|
|
5201
|
+
await deleteBrowserRecordingBackup(id).catch((err) => {
|
|
5202
|
+
console.warn("[clips-recorder] local backup cleanup failed:", err);
|
|
5203
|
+
});
|
|
5204
|
+
// The transcription engine is process-global, so this stop must land
|
|
5205
|
+
// before the replacement session starts it again.
|
|
5206
|
+
if (keepCaptureStreams) await transcriptionTornDown;
|
|
5207
|
+
return handsOff
|
|
5208
|
+
? { displayStream, audioStream }
|
|
5209
|
+
: { displayStream: null, audioStream: null };
|
|
5210
|
+
};
|
|
5211
|
+
|
|
5212
|
+
const handle: RecorderHandle = {
|
|
5213
|
+
stop: singleFlight(performStop),
|
|
5214
|
+
|
|
5215
|
+
async cancel() {
|
|
5216
|
+
if (stopped) return;
|
|
5217
|
+
await discardTake(false);
|
|
5218
|
+
},
|
|
5219
|
+
|
|
5220
|
+
// A restart reuses the same capture. `getDisplayMedia` cannot run from
|
|
5221
|
+
// the Tauri event that carries the toolbar click, so the streams have to
|
|
5222
|
+
// survive the take that is being thrown away.
|
|
5223
|
+
async discardForRestart() {
|
|
5224
|
+
if (stopped) {
|
|
5225
|
+
throw new Error("Recording already finished — nothing to restart");
|
|
4946
5226
|
}
|
|
4947
|
-
|
|
4948
|
-
console.warn("[clips-recorder] local backup cleanup failed:", err);
|
|
4949
|
-
});
|
|
5227
|
+
return discardTake(true);
|
|
4950
5228
|
},
|
|
4951
5229
|
};
|
|
4952
5230
|
|