@agent-native/core 0.70.3 → 0.72.0
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 +2 -2
- package/corpus/core/CHANGELOG.md +51 -0
- package/corpus/core/docs/content/actions.md +23 -0
- package/corpus/core/docs/content/audit-log.md +111 -0
- package/corpus/core/docs/content/multi-app-workspace.md +2 -2
- package/corpus/core/docs/design/durable-agent-runs.md +458 -4
- package/corpus/core/package.json +2 -1
- package/corpus/core/src/action.ts +80 -1
- package/corpus/core/src/agent/durable-background.ts +192 -0
- package/corpus/core/src/agent/production-agent.ts +357 -13
- package/corpus/core/src/agent/run-manager.ts +66 -3
- package/corpus/core/src/agent/run-store.ts +143 -23
- package/corpus/core/src/agent/types.ts +20 -0
- package/corpus/core/src/application-state/store.ts +5 -0
- package/corpus/core/src/audit/actions/get-audit-event.ts +23 -0
- package/corpus/core/src/audit/actions/list-audit-events.ts +65 -0
- package/corpus/core/src/audit/cleanup-job.ts +100 -0
- package/corpus/core/src/audit/config.ts +91 -0
- package/corpus/core/src/audit/index.ts +43 -0
- package/corpus/core/src/audit/record.ts +143 -0
- package/corpus/core/src/audit/redact.ts +109 -0
- package/corpus/core/src/audit/store.ts +244 -0
- package/corpus/core/src/audit/types.ts +125 -0
- package/corpus/core/src/chat-threads/store.ts +10 -0
- package/corpus/core/src/client/AgentPanel.tsx +12 -9
- package/corpus/core/src/client/AssistantChat.tsx +6 -0
- package/corpus/core/src/client/blocks/library/FileTreeBlock.tsx +72 -14
- package/corpus/core/src/client/chat/repo-helpers.ts +38 -0
- package/corpus/core/src/db/client.ts +4 -0
- package/corpus/core/src/db/widen-columns.ts +75 -0
- package/corpus/core/src/deploy/build.ts +96 -0
- package/corpus/core/src/deploy/workspace-deploy.ts +121 -0
- package/corpus/core/src/mcp/build-server.ts +23 -13
- package/corpus/core/src/oauth-tokens/store.ts +6 -0
- package/corpus/core/src/provider-api/custom-registry.ts +8 -0
- package/corpus/core/src/resources/store.ts +10 -0
- package/corpus/core/src/scripts/runner.ts +6 -3
- package/corpus/core/src/server/action-discovery.ts +6 -0
- package/corpus/core/src/server/action-routes.ts +1 -0
- package/corpus/core/src/server/agent-chat-plugin.ts +133 -66
- package/corpus/core/src/server/auth.ts +5 -0
- package/corpus/core/src/server/core-routes-plugin.ts +14 -0
- package/corpus/core/src/server/security-headers.ts +9 -6
- package/corpus/core/src/settings/store.ts +6 -0
- package/corpus/core/src/templates/workspace-core/.agents/skills/audit-log/SKILL.md +93 -0
- package/corpus/core/src/usage/store.ts +6 -0
- package/corpus/templates/analytics/.agents/skills/dashboard-management/SKILL.md +19 -0
- package/corpus/templates/analytics/AGENTS.md +8 -0
- package/corpus/templates/analytics/actions/compose-dashboard.ts +317 -0
- package/corpus/templates/analytics/changelog/2026-06-23-build-large-first-party-analytics-dashboards-in-one-fast-cal.md +6 -0
- package/corpus/templates/analytics/server/lib/first-party-metric-catalog.ts +574 -0
- package/corpus/templates/calendar/app/components/calendar/GoogleConnectBanner.tsx +60 -6
- package/corpus/templates/calendar/changelog/2026-06-23-added-a-heads-up-explaining-google-s-app-not-verified-screen.md +6 -0
- package/corpus/templates/clips/app/components/recorder/recorder-engine.ts +20 -43
- package/corpus/templates/clips/app/components/recorder/recording-toolbar.tsx +5 -1
- package/corpus/templates/clips/app/lib/countdown-audio-cue.ts +3 -51
- package/corpus/templates/clips/changelog/2026-06-23-dragging-the-desktop-camera-bubble-now-glides-to-a-stop-at-t.md +6 -0
- package/corpus/templates/clips/changelog/2026-06-23-public-clips-now-play-inline-in-slack-connect-a-workspac.md +6 -0
- package/corpus/templates/clips/chrome-extension/PERMISSIONS.md +72 -0
- package/corpus/templates/clips/chrome-extension/public/manifest.json +1 -10
- package/corpus/templates/clips/chrome-extension/src/background.ts +163 -32
- package/corpus/templates/clips/chrome-extension/src/content-script.ts +301 -24
- package/corpus/templates/clips/chrome-extension/src/offscreen.ts +245 -67
- package/corpus/templates/clips/chrome-extension/src/overlay.css +77 -43
- package/corpus/templates/clips/chrome-extension/src/overlay.html +7 -1
- package/corpus/templates/clips/chrome-extension/src/overlay.ts +102 -38
- package/corpus/templates/clips/chrome-extension/src/popup.html +62 -5
- package/corpus/templates/clips/chrome-extension/src/popup.ts +275 -4
- package/corpus/templates/clips/chrome-extension/src/styles.css +34 -0
- package/corpus/templates/clips/chrome-extension/vite.config.ts +5 -0
- package/corpus/templates/clips/desktop/src/lib/audio-cue.ts +21 -16
- package/corpus/templates/clips/desktop/src/lib/recorder.ts +10 -17
- package/corpus/templates/clips/desktop/src/overlays/bubble.tsx +73 -23
- package/corpus/templates/clips/desktop/src/overlays/countdown.tsx +0 -6
- package/corpus/templates/clips/desktop/src/overlays/toolbar.tsx +56 -46
- package/corpus/templates/clips/desktop/src/styles.css +33 -5
- package/corpus/templates/clips/desktop/src-tauri/src/clips/mod.rs +113 -0
- package/corpus/templates/clips/desktop/src-tauri/src/lib.rs +3 -0
- package/corpus/templates/clips/server/lib/media-permissions.ts +5 -1
- package/corpus/templates/clips/shared/recording-audio.ts +62 -0
- package/corpus/templates/clips/shared/recording-core.ts +94 -0
- package/dist/action.d.ts +31 -0
- package/dist/action.d.ts.map +1 -1
- package/dist/action.js +45 -1
- package/dist/action.js.map +1 -1
- package/dist/agent/context-xray/schema.d.ts +1 -1
- package/dist/agent/durable-background.d.ts +60 -0
- package/dist/agent/durable-background.d.ts.map +1 -0
- package/dist/agent/durable-background.js +144 -0
- package/dist/agent/durable-background.js.map +1 -0
- package/dist/agent/observational-memory/schema.d.ts +1 -1
- package/dist/agent/production-agent.d.ts +20 -0
- package/dist/agent/production-agent.d.ts.map +1 -1
- package/dist/agent/production-agent.js +320 -14
- package/dist/agent/production-agent.js.map +1 -1
- package/dist/agent/run-manager.d.ts +48 -0
- package/dist/agent/run-manager.d.ts.map +1 -1
- package/dist/agent/run-manager.js +45 -3
- package/dist/agent/run-manager.js.map +1 -1
- package/dist/agent/run-store.d.ts +30 -1
- package/dist/agent/run-store.d.ts.map +1 -1
- package/dist/agent/run-store.js +138 -24
- package/dist/agent/run-store.js.map +1 -1
- package/dist/agent/types.d.ts +20 -0
- package/dist/agent/types.d.ts.map +1 -1
- package/dist/agent/types.js.map +1 -1
- package/dist/application-state/store.d.ts.map +1 -1
- package/dist/application-state/store.js +5 -0
- package/dist/application-state/store.js.map +1 -1
- package/dist/audit/actions/get-audit-event.d.ts +11 -0
- package/dist/audit/actions/get-audit-event.d.ts.map +1 -0
- package/dist/audit/actions/get-audit-event.js +22 -0
- package/dist/audit/actions/get-audit-event.js.map +1 -0
- package/dist/audit/actions/list-audit-events.d.ts +22 -0
- package/dist/audit/actions/list-audit-events.d.ts.map +1 -0
- package/dist/audit/actions/list-audit-events.js +61 -0
- package/dist/audit/actions/list-audit-events.js.map +1 -0
- package/dist/audit/cleanup-job.d.ts +12 -0
- package/dist/audit/cleanup-job.d.ts.map +1 -0
- package/dist/audit/cleanup-job.js +93 -0
- package/dist/audit/cleanup-job.js.map +1 -0
- package/dist/audit/config.d.ts +30 -0
- package/dist/audit/config.d.ts.map +1 -0
- package/dist/audit/config.js +65 -0
- package/dist/audit/config.js.map +1 -0
- package/dist/audit/index.d.ts +13 -0
- package/dist/audit/index.d.ts.map +1 -0
- package/dist/audit/index.js +6 -0
- package/dist/audit/index.js.map +1 -0
- package/dist/audit/record.d.ts +24 -0
- package/dist/audit/record.d.ts.map +1 -0
- package/dist/audit/record.js +106 -0
- package/dist/audit/record.js.map +1 -0
- package/dist/audit/redact.d.ts +28 -0
- package/dist/audit/redact.d.ts.map +1 -0
- package/dist/audit/redact.js +109 -0
- package/dist/audit/redact.js.map +1 -0
- package/dist/audit/store.d.ts +14 -0
- package/dist/audit/store.d.ts.map +1 -0
- package/dist/audit/store.js +219 -0
- package/dist/audit/store.js.map +1 -0
- package/dist/audit/types.d.ts +114 -0
- package/dist/audit/types.d.ts.map +1 -0
- package/dist/audit/types.js +15 -0
- package/dist/audit/types.js.map +1 -0
- package/dist/chat-threads/store.d.ts.map +1 -1
- package/dist/chat-threads/store.js +10 -0
- package/dist/chat-threads/store.js.map +1 -1
- package/dist/client/AgentPanel.d.ts.map +1 -1
- package/dist/client/AgentPanel.js +2 -2
- package/dist/client/AgentPanel.js.map +1 -1
- package/dist/client/AssistantChat.d.ts.map +1 -1
- package/dist/client/AssistantChat.js +6 -1
- package/dist/client/AssistantChat.js.map +1 -1
- package/dist/client/blocks/library/FileTreeBlock.d.ts.map +1 -1
- package/dist/client/blocks/library/FileTreeBlock.js +37 -4
- package/dist/client/blocks/library/FileTreeBlock.js.map +1 -1
- package/dist/client/chat/repo-helpers.d.ts +16 -0
- package/dist/client/chat/repo-helpers.d.ts.map +1 -1
- package/dist/client/chat/repo-helpers.js +40 -0
- package/dist/client/chat/repo-helpers.js.map +1 -1
- package/dist/db/client.d.ts.map +1 -1
- package/dist/db/client.js +3 -0
- package/dist/db/client.js.map +1 -1
- package/dist/db/widen-columns.d.ts +39 -0
- package/dist/db/widen-columns.d.ts.map +1 -0
- package/dist/db/widen-columns.js +73 -0
- package/dist/db/widen-columns.js.map +1 -0
- package/dist/deploy/build.d.ts +29 -0
- package/dist/deploy/build.d.ts.map +1 -1
- package/dist/deploy/build.js +85 -0
- package/dist/deploy/build.js.map +1 -1
- package/dist/deploy/workspace-deploy.d.ts.map +1 -1
- package/dist/deploy/workspace-deploy.js +108 -0
- package/dist/deploy/workspace-deploy.js.map +1 -1
- package/dist/mcp/build-server.d.ts.map +1 -1
- package/dist/mcp/build-server.js +24 -9
- package/dist/mcp/build-server.js.map +1 -1
- package/dist/oauth-tokens/store.d.ts.map +1 -1
- package/dist/oauth-tokens/store.js +6 -0
- package/dist/oauth-tokens/store.js.map +1 -1
- package/dist/provider-api/custom-registry.d.ts.map +1 -1
- package/dist/provider-api/custom-registry.js +8 -0
- package/dist/provider-api/custom-registry.js.map +1 -1
- package/dist/resources/store.d.ts.map +1 -1
- package/dist/resources/store.js +9 -0
- package/dist/resources/store.js.map +1 -1
- package/dist/scripts/runner.js +4 -3
- package/dist/scripts/runner.js.map +1 -1
- package/dist/server/action-discovery.d.ts.map +1 -1
- package/dist/server/action-discovery.js +6 -0
- package/dist/server/action-discovery.js.map +1 -1
- package/dist/server/action-routes.d.ts.map +1 -1
- package/dist/server/action-routes.js +1 -0
- package/dist/server/action-routes.js.map +1 -1
- package/dist/server/agent-chat-plugin.d.ts.map +1 -1
- package/dist/server/agent-chat-plugin.js +68 -13
- package/dist/server/agent-chat-plugin.js.map +1 -1
- package/dist/server/auth.d.ts.map +1 -1
- package/dist/server/auth.js +5 -0
- package/dist/server/auth.js.map +1 -1
- package/dist/server/core-routes-plugin.d.ts.map +1 -1
- package/dist/server/core-routes-plugin.js +13 -0
- package/dist/server/core-routes-plugin.js.map +1 -1
- package/dist/server/security-headers.d.ts +8 -5
- package/dist/server/security-headers.d.ts.map +1 -1
- package/dist/server/security-headers.js +9 -6
- package/dist/server/security-headers.js.map +1 -1
- package/dist/settings/store.d.ts.map +1 -1
- package/dist/settings/store.js +6 -0
- package/dist/settings/store.js.map +1 -1
- package/dist/templates/workspace-core/.agents/skills/audit-log/SKILL.md +93 -0
- package/dist/usage/store.d.ts.map +1 -1
- package/dist/usage/store.js +5 -0
- package/dist/usage/store.js.map +1 -1
- package/docs/content/actions.md +23 -0
- package/docs/content/audit-log.md +111 -0
- package/docs/content/multi-app-workspace.md +2 -2
- package/docs/design/durable-agent-runs.md +458 -4
- package/package.json +2 -1
- package/src/templates/workspace-core/.agents/skills/audit-log/SKILL.md +93 -0
|
@@ -606,40 +606,90 @@ export function Bubble() {
|
|
|
606
606
|
}, []);
|
|
607
607
|
|
|
608
608
|
// ---- explicit drag handler --------------------------------------------
|
|
609
|
-
//
|
|
610
|
-
//
|
|
611
|
-
//
|
|
612
|
-
//
|
|
613
|
-
//
|
|
614
|
-
//
|
|
615
|
-
// "customize drag behavior") and skips all of those footguns.
|
|
609
|
+
// Loom-style manual drag. We deliberately do NOT use Tauri's native
|
|
610
|
+
// `startDragging()` (or `data-tauri-drag-region`): with a native drag the OS
|
|
611
|
+
// window server owns the position, so the only way to keep the bubble on
|
|
612
|
+
// screen is to clamp AFTER each move and `set_position` it back — and the OS
|
|
613
|
+
// immediately shoves it back out toward the cursor on the next frame. That
|
|
614
|
+
// fight is exactly the jitter/snap-back we're killing here.
|
|
616
615
|
//
|
|
617
|
-
//
|
|
618
|
-
//
|
|
619
|
-
//
|
|
620
|
-
|
|
621
|
-
|
|
616
|
+
// Instead we drive the move ourselves. On pointer-down Rust snapshots the
|
|
617
|
+
// cursor + window anchor; each frame `bubble_drag_move` reads the live cursor,
|
|
618
|
+
// offsets the window by the cursor delta, and clamps BEFORE moving. The
|
|
619
|
+
// window therefore stops dead at the edge like a puck against a wall — the
|
|
620
|
+
// cursor keeps going, the bubble stays pinned, nothing to snap back from.
|
|
621
|
+
//
|
|
622
|
+
// Interactive children (close X, size dots) are marked `data-no-drag` so
|
|
623
|
+
// their clicks land on their onClick handlers instead of starting a drag.
|
|
624
|
+
const draggingRef = useRef(false);
|
|
625
|
+
const moveFrameRef = useRef<number | null>(null);
|
|
626
|
+
|
|
627
|
+
const handleBubblePointerDown = (e: React.PointerEvent) => {
|
|
628
|
+
// Only the left button initiates a drag.
|
|
622
629
|
if (e.button !== 0) return;
|
|
623
630
|
const target = e.target as HTMLElement;
|
|
624
|
-
//
|
|
625
|
-
// means we're over a real control, not the draggable surface.
|
|
631
|
+
// Any ancestor marked `data-no-drag` means we're over a real control.
|
|
626
632
|
if (target.closest("[data-no-drag]")) return;
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
633
|
+
e.preventDefault();
|
|
634
|
+
draggingRef.current = true;
|
|
635
|
+
// Pointer capture keeps pointermove/up flowing even after the cursor
|
|
636
|
+
// leaves the (small) bubble window — which it will the moment the bubble
|
|
637
|
+
// hits an edge and the cursor outruns it.
|
|
638
|
+
try {
|
|
639
|
+
(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
|
|
640
|
+
} catch {
|
|
641
|
+
// capture is best-effort
|
|
642
|
+
}
|
|
643
|
+
void invoke("bubble_drag_start").catch((err) => {
|
|
644
|
+
console.warn("[bubble] bubble_drag_start failed", err);
|
|
645
|
+
});
|
|
646
|
+
};
|
|
647
|
+
|
|
648
|
+
const handleBubblePointerMove = (e: React.PointerEvent) => {
|
|
649
|
+
if (!draggingRef.current) return;
|
|
650
|
+
// Coalesce a burst of pointermove events into at most one reposition per
|
|
651
|
+
// animation frame, no matter how fast the OS delivers them. Rust reads the
|
|
652
|
+
// live cursor itself, so the frame callback needs no coordinates.
|
|
653
|
+
if (moveFrameRef.current != null) return;
|
|
654
|
+
moveFrameRef.current = requestAnimationFrame(() => {
|
|
655
|
+
moveFrameRef.current = null;
|
|
656
|
+
if (!draggingRef.current) return;
|
|
657
|
+
void invoke("bubble_drag_move").catch(() => {
|
|
658
|
+
// Transient failures (e.g. window mid-teardown) are non-fatal; the
|
|
659
|
+
// next pointermove schedules another frame.
|
|
632
660
|
});
|
|
661
|
+
});
|
|
662
|
+
};
|
|
663
|
+
|
|
664
|
+
const endBubbleDrag = (e: React.PointerEvent) => {
|
|
665
|
+
if (!draggingRef.current) return;
|
|
666
|
+
draggingRef.current = false;
|
|
667
|
+
if (moveFrameRef.current != null) {
|
|
668
|
+
cancelAnimationFrame(moveFrameRef.current);
|
|
669
|
+
moveFrameRef.current = null;
|
|
670
|
+
}
|
|
671
|
+
try {
|
|
672
|
+
(e.currentTarget as HTMLElement).releasePointerCapture(e.pointerId);
|
|
673
|
+
} catch {
|
|
674
|
+
// already released
|
|
675
|
+
}
|
|
676
|
+
void invoke("bubble_drag_end").catch((err) => {
|
|
677
|
+
console.warn("[bubble] bubble_drag_end failed", err);
|
|
678
|
+
});
|
|
633
679
|
};
|
|
634
680
|
|
|
635
681
|
return (
|
|
636
|
-
// The ENTIRE wrapper
|
|
637
|
-
//
|
|
682
|
+
// The ENTIRE wrapper is the drag surface — pointer-down drives the manual
|
|
683
|
+
// clamp-before-move drag loop. No `startDragging()`, no
|
|
684
|
+
// `data-tauri-drag-region` — see `handleBubblePointerDown` above.
|
|
638
685
|
<div
|
|
639
686
|
className={`bubble-wrapper bubble-${size}`}
|
|
640
687
|
onMouseEnter={handleMouseEnter}
|
|
641
688
|
onMouseLeave={handleMouseLeave}
|
|
642
|
-
|
|
689
|
+
onPointerDown={handleBubblePointerDown}
|
|
690
|
+
onPointerMove={handleBubblePointerMove}
|
|
691
|
+
onPointerUp={endBubbleDrag}
|
|
692
|
+
onPointerCancel={endBubbleDrag}
|
|
643
693
|
data-path={activePath}
|
|
644
694
|
>
|
|
645
695
|
<div className="bubble-root">
|
|
@@ -676,7 +726,7 @@ export function Bubble() {
|
|
|
676
726
|
}
|
|
677
727
|
/>
|
|
678
728
|
{/* Close X — top-right of bubble, only visible on hover. Marked
|
|
679
|
-
`data-no-drag` so
|
|
729
|
+
`data-no-drag` so pointer-down here does NOT start a drag;
|
|
680
730
|
onClick fires normally. */}
|
|
681
731
|
<button
|
|
682
732
|
type="button"
|
|
@@ -46,12 +46,6 @@ export function Countdown() {
|
|
|
46
46
|
};
|
|
47
47
|
}, [closeWithEvent]);
|
|
48
48
|
|
|
49
|
-
useEffect(() => {
|
|
50
|
-
if (n === 1) {
|
|
51
|
-
emit("clips:countdown-one").catch(() => {});
|
|
52
|
-
}
|
|
53
|
-
}, [n]);
|
|
54
|
-
|
|
55
49
|
useEffect(() => {
|
|
56
50
|
if (n <= 0) {
|
|
57
51
|
closeWithEvent("clips:countdown-done");
|
|
@@ -14,7 +14,10 @@ import {
|
|
|
14
14
|
const OVERLAY_SHADOW_GUTTER = 18;
|
|
15
15
|
const TOOLBAR_CONTENT_WIDTH = 72;
|
|
16
16
|
const TOOLBAR_COLLAPSED_HEIGHT = 150;
|
|
17
|
-
|
|
17
|
+
// Collapsed content box (150 − 20 padding = 130) holds the centered primary
|
|
18
|
+
// zone; the expanded height must fit that fixed 130 zone + the 88px hover
|
|
19
|
+
// actions (+2 margin) + 20 vertical padding so nothing clips on hover.
|
|
20
|
+
const TOOLBAR_EXPANDED_HEIGHT = 240;
|
|
18
21
|
const TOOLBAR_WINDOW_WIDTH = TOOLBAR_CONTENT_WIDTH + OVERLAY_SHADOW_GUTTER * 2;
|
|
19
22
|
const TOOLBAR_COLLAPSED_WINDOW_HEIGHT =
|
|
20
23
|
TOOLBAR_COLLAPSED_HEIGHT + OVERLAY_SHADOW_GUTTER * 2;
|
|
@@ -225,51 +228,58 @@ export function Toolbar() {
|
|
|
225
228
|
onFocusCapture={() => resizeToolbarWindow(true)}
|
|
226
229
|
onBlurCapture={handleToolbarBlur}
|
|
227
230
|
>
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
231
|
+
{/* Primary controls live in a fixed-height zone so they stay pinned
|
|
232
|
+
to the same vertical position whether or not the pill is hovered.
|
|
233
|
+
Centering happens INSIDE this zone (not on the pill), so the
|
|
234
|
+
collapsed→expanded `justify-content` change can't nudge the Stop
|
|
235
|
+
button up — only the hover actions below grow into the new space. */}
|
|
236
|
+
<div className="toolbar-v-primary">
|
|
237
|
+
<button
|
|
238
|
+
className="toolbar-v-stop"
|
|
239
|
+
onClick={stop}
|
|
240
|
+
disabled={!!pendingAction || !enabled}
|
|
241
|
+
aria-label={
|
|
242
|
+
pendingAction === "stop" ? "Stopping recording" : "Stop recording"
|
|
243
|
+
}
|
|
244
|
+
title={
|
|
245
|
+
pendingAction === "stop"
|
|
246
|
+
? pendingActionLabel
|
|
247
|
+
: enabled
|
|
248
|
+
? "Stop recording"
|
|
249
|
+
: "Recording not started yet"
|
|
250
|
+
}
|
|
251
|
+
data-no-drag
|
|
252
|
+
>
|
|
253
|
+
{pendingAction === "stop" ? (
|
|
254
|
+
<IconLoader2 className="toolbar-v-spinner" size={18} />
|
|
255
|
+
) : (
|
|
256
|
+
<span className="toolbar-v-stop-square" />
|
|
257
|
+
)}
|
|
258
|
+
</button>
|
|
259
|
+
<div className="toolbar-v-time">{formatTime(elapsed)}</div>
|
|
260
|
+
<button
|
|
261
|
+
className="toolbar-v-pause"
|
|
262
|
+
onClick={togglePause}
|
|
263
|
+
disabled={!enabled || !!pendingAction}
|
|
264
|
+
aria-label={paused ? "Resume" : "Pause"}
|
|
265
|
+
title={
|
|
266
|
+
pendingAction
|
|
267
|
+
? pendingActionLabel
|
|
268
|
+
: enabled
|
|
269
|
+
? paused
|
|
270
|
+
? "Resume"
|
|
271
|
+
: "Pause"
|
|
272
|
+
: "Recording not started yet"
|
|
273
|
+
}
|
|
274
|
+
data-no-drag
|
|
275
|
+
>
|
|
276
|
+
{paused ? (
|
|
277
|
+
<IconPlayerPlayFilled size={18} />
|
|
278
|
+
) : (
|
|
279
|
+
<IconPlayerPauseFilled size={18} />
|
|
280
|
+
)}
|
|
281
|
+
</button>
|
|
282
|
+
</div>
|
|
273
283
|
<div
|
|
274
284
|
className="toolbar-v-hover-actions"
|
|
275
285
|
role="group"
|
|
@@ -2106,8 +2106,18 @@ body[data-clips-route="recording-pill"] #root {
|
|
|
2106
2106
|
font-weight: 800;
|
|
2107
2107
|
font-variant-numeric: tabular-nums;
|
|
2108
2108
|
color: #ffffff;
|
|
2109
|
-
|
|
2109
|
+
/*
|
|
2110
|
+
* Do NOT use -webkit-text-stroke here: it joins with a hard miter and no way
|
|
2111
|
+
* to set stroke-linejoin, so at this size/weight the sharp concave corner of
|
|
2112
|
+
* the "2" (where the curved top meets the flat bottom bar) sprouts star-shaped
|
|
2113
|
+
* miter spikes. We fake a soft dark edge with a 4-direction 1px shadow halo
|
|
2114
|
+
* instead, which has no join geometry and cannot spike.
|
|
2115
|
+
*/
|
|
2110
2116
|
text-shadow:
|
|
2117
|
+
1px 0 1px rgba(17, 24, 39, 0.22),
|
|
2118
|
+
-1px 0 1px rgba(17, 24, 39, 0.22),
|
|
2119
|
+
0 1px 1px rgba(17, 24, 39, 0.22),
|
|
2120
|
+
0 -1px 1px rgba(17, 24, 39, 0.22),
|
|
2111
2121
|
0 2px 4px rgba(0, 0, 0, 0.48),
|
|
2112
2122
|
0 12px 34px rgba(0, 0, 0, 0.36),
|
|
2113
2123
|
0 0 2px rgba(0, 0, 0, 0.72);
|
|
@@ -2420,8 +2430,12 @@ body[data-clips-route="recording-pill"] #root {
|
|
|
2420
2430
|
display: flex;
|
|
2421
2431
|
flex-direction: column;
|
|
2422
2432
|
align-items: center;
|
|
2423
|
-
|
|
2424
|
-
|
|
2433
|
+
/* Pin content to the top in BOTH states. The primary controls are
|
|
2434
|
+
centered inside their own fixed-height zone (.toolbar-v-primary), so
|
|
2435
|
+
the collapsed→expanded growth only adds room BELOW them. Without this,
|
|
2436
|
+
flipping justify-content center→flex-start on hover yanked the Stop
|
|
2437
|
+
button up toward the bar's top edge. */
|
|
2438
|
+
justify-content: flex-start;
|
|
2425
2439
|
/* Equal inset on all four sides from the pill edge to the buttons. */
|
|
2426
2440
|
padding: 10px;
|
|
2427
2441
|
overflow: hidden;
|
|
@@ -2440,8 +2454,9 @@ body[data-clips-route="recording-pill"] #root {
|
|
|
2440
2454
|
|
|
2441
2455
|
.toolbar-v:hover,
|
|
2442
2456
|
.toolbar-v:focus-within {
|
|
2443
|
-
|
|
2444
|
-
|
|
2457
|
+
/* Matches TOOLBAR_EXPANDED_HEIGHT in toolbar.tsx — the fixed 130px primary
|
|
2458
|
+
zone + 88px hover actions (+2 margin) + 20px vertical padding. */
|
|
2459
|
+
height: 240px;
|
|
2445
2460
|
border-radius: 26px;
|
|
2446
2461
|
}
|
|
2447
2462
|
|
|
@@ -2449,6 +2464,19 @@ body[data-clips-route="recording-pill"] #root {
|
|
|
2449
2464
|
cursor: grabbing;
|
|
2450
2465
|
}
|
|
2451
2466
|
|
|
2467
|
+
/* Fixed-height home for Stop / time / Pause. Its height equals the collapsed
|
|
2468
|
+
pill's content box (150px − 20px vertical padding = 130px), so the trio is
|
|
2469
|
+
centered exactly as before when collapsed and never shifts when the pill
|
|
2470
|
+
grows on hover — the extra height flows into .toolbar-v-hover-actions below. */
|
|
2471
|
+
.toolbar-v-primary {
|
|
2472
|
+
flex: 0 0 130px;
|
|
2473
|
+
display: flex;
|
|
2474
|
+
flex-direction: column;
|
|
2475
|
+
align-items: center;
|
|
2476
|
+
justify-content: center;
|
|
2477
|
+
gap: 10px;
|
|
2478
|
+
}
|
|
2479
|
+
|
|
2452
2480
|
.toolbar-v-stop {
|
|
2453
2481
|
width: 30px;
|
|
2454
2482
|
height: 30px;
|
|
@@ -5,6 +5,7 @@ use std::process::Command;
|
|
|
5
5
|
#[cfg(target_os = "macos")]
|
|
6
6
|
use std::process::Stdio;
|
|
7
7
|
use std::sync::atomic::{AtomicBool, Ordering};
|
|
8
|
+
use std::sync::{Mutex, OnceLock};
|
|
8
9
|
use std::thread;
|
|
9
10
|
use std::time::Duration;
|
|
10
11
|
use tauri::{
|
|
@@ -216,6 +217,36 @@ fn clamp_existing_bubble_window(app: &AppHandle, window: &WebviewWindow) {
|
|
|
216
217
|
}
|
|
217
218
|
}
|
|
218
219
|
|
|
220
|
+
/// True while the user is hand-dragging the bubble through the JS pointer
|
|
221
|
+
/// handlers (`bubble_drag_start` / `bubble_drag_move` / `bubble_drag_end`).
|
|
222
|
+
///
|
|
223
|
+
/// While this is set, the bubble's `Moved` event handler skips its own clamp.
|
|
224
|
+
/// That handler used to re-clamp on EVERY move — including the OS-native drag's
|
|
225
|
+
/// interpolated moves — calling `set_position` to snap the window back inside
|
|
226
|
+
/// the screen. During a drag the OS keeps shoving the window back out toward the
|
|
227
|
+
/// cursor, so the snap-back and the OS fought each frame and the bubble
|
|
228
|
+
/// visibly jittered. Now the drag-move command is the sole authority on
|
|
229
|
+
/// position during a drag and clamps every frame itself, so the handler must
|
|
230
|
+
/// yield to it.
|
|
231
|
+
static BUBBLE_DRAGGING: AtomicBool = AtomicBool::new(false);
|
|
232
|
+
|
|
233
|
+
/// Anchor captured at the start of a hand-drag: the global cursor position and
|
|
234
|
+
/// the bubble window's top-left, both in physical px with a desktop top-left
|
|
235
|
+
/// origin (the same space `cursor_position()` / `outer_position()` report in).
|
|
236
|
+
/// Each move computes `win_start + (cursor_now - cursor_start)` so the bubble
|
|
237
|
+
/// tracks the cursor 1:1, then clamps to the monitor BEFORE moving.
|
|
238
|
+
struct BubbleDragAnchor {
|
|
239
|
+
cursor_x: i32,
|
|
240
|
+
cursor_y: i32,
|
|
241
|
+
win_x: i32,
|
|
242
|
+
win_y: i32,
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
fn bubble_drag_anchor() -> &'static Mutex<Option<BubbleDragAnchor>> {
|
|
246
|
+
static ANCHOR: OnceLock<Mutex<Option<BubbleDragAnchor>>> = OnceLock::new();
|
|
247
|
+
ANCHOR.get_or_init(|| Mutex::new(None))
|
|
248
|
+
}
|
|
249
|
+
|
|
219
250
|
/// Path to the JSON blob that stores the last-known bubble position on disk.
|
|
220
251
|
/// Lives in the Tauri app-data dir (platform-specific — `~/Library/Application
|
|
221
252
|
/// Support/<bundle-id>/` on macOS). Returns None if the app-data dir cannot be
|
|
@@ -842,6 +873,13 @@ pub async fn show_bubble(app: AppHandle) -> Result<(), String> {
|
|
|
842
873
|
| tauri::WindowEvent::Resized(_)
|
|
843
874
|
| tauri::WindowEvent::ScaleFactorChanged { .. }
|
|
844
875
|
) {
|
|
876
|
+
// During a hand-drag the move command owns the position and clamps
|
|
877
|
+
// every frame; re-clamping here would race that loop and bring back
|
|
878
|
+
// the edge jitter, so yield until the drag ends (which runs a final
|
|
879
|
+
// clamp of its own).
|
|
880
|
+
if BUBBLE_DRAGGING.load(Ordering::SeqCst) {
|
|
881
|
+
return;
|
|
882
|
+
}
|
|
845
883
|
clamp_existing_bubble_window(&app_for_bounds, &win_for_bounds);
|
|
846
884
|
}
|
|
847
885
|
});
|
|
@@ -1689,6 +1727,81 @@ pub async fn save_bubble_position(app: AppHandle, x: i32, y: i32) -> Result<(),
|
|
|
1689
1727
|
Ok(())
|
|
1690
1728
|
}
|
|
1691
1729
|
|
|
1730
|
+
/// Begin a Loom-style hand-drag of the bubble. The JS pointer handler calls
|
|
1731
|
+
/// this on pointer-down. We snapshot the current cursor and window position as
|
|
1732
|
+
/// the drag anchor and flip `BUBBLE_DRAGGING` so the bounds handler yields to
|
|
1733
|
+
/// the drag loop.
|
|
1734
|
+
///
|
|
1735
|
+
/// We deliberately do NOT use Tauri's native `startDragging()`: the OS window
|
|
1736
|
+
/// server owns the position during a native drag, so clamping it to the screen
|
|
1737
|
+
/// edge means fighting the OS every frame (the jitter/snap-back the user saw).
|
|
1738
|
+
/// Driving the move ourselves lets us clamp BEFORE moving, so the bubble stops
|
|
1739
|
+
/// dead at the edge like a puck hitting a wall.
|
|
1740
|
+
#[tauri::command]
|
|
1741
|
+
pub async fn bubble_drag_start(app: AppHandle) -> Result<(), String> {
|
|
1742
|
+
let Some(window) = app.get_webview_window(BUBBLE_LABEL) else {
|
|
1743
|
+
return Ok(());
|
|
1744
|
+
};
|
|
1745
|
+
let (Ok(cursor), Ok(pos)) = (window.cursor_position(), window.outer_position()) else {
|
|
1746
|
+
return Ok(());
|
|
1747
|
+
};
|
|
1748
|
+
*bubble_drag_anchor().lock().unwrap_or_else(|e| e.into_inner()) = Some(BubbleDragAnchor {
|
|
1749
|
+
cursor_x: cursor.x.round() as i32,
|
|
1750
|
+
cursor_y: cursor.y.round() as i32,
|
|
1751
|
+
win_x: pos.x,
|
|
1752
|
+
win_y: pos.y,
|
|
1753
|
+
});
|
|
1754
|
+
BUBBLE_DRAGGING.store(true, Ordering::SeqCst);
|
|
1755
|
+
Ok(())
|
|
1756
|
+
}
|
|
1757
|
+
|
|
1758
|
+
/// Move the bubble to follow the cursor for the active hand-drag. The JS
|
|
1759
|
+
/// pointer handler calls this once per animation frame while dragging.
|
|
1760
|
+
///
|
|
1761
|
+
/// The new top-left is `win_start + (cursor_now - cursor_start)` — a 1:1
|
|
1762
|
+
/// follow in physical px — then clamped to the target monitor BEFORE the move.
|
|
1763
|
+
/// Because we clamp first, the window never overshoots the edge, so there is
|
|
1764
|
+
/// nothing to snap back from: the cursor can keep travelling past the edge
|
|
1765
|
+
/// while the bubble sits pinned against it. No-op if no drag is in progress.
|
|
1766
|
+
#[tauri::command]
|
|
1767
|
+
pub async fn bubble_drag_move(app: AppHandle) -> Result<(), String> {
|
|
1768
|
+
let Some(window) = app.get_webview_window(BUBBLE_LABEL) else {
|
|
1769
|
+
return Ok(());
|
|
1770
|
+
};
|
|
1771
|
+
let Ok(cursor) = window.cursor_position() else {
|
|
1772
|
+
return Ok(());
|
|
1773
|
+
};
|
|
1774
|
+
let target = {
|
|
1775
|
+
let guard = bubble_drag_anchor().lock().unwrap_or_else(|e| e.into_inner());
|
|
1776
|
+
let Some(anchor) = guard.as_ref() else {
|
|
1777
|
+
return Ok(());
|
|
1778
|
+
};
|
|
1779
|
+
(
|
|
1780
|
+
anchor.win_x + (cursor.x.round() as i32 - anchor.cursor_x),
|
|
1781
|
+
anchor.win_y + (cursor.y.round() as i32 - anchor.cursor_y),
|
|
1782
|
+
)
|
|
1783
|
+
};
|
|
1784
|
+
let Ok(size) = window.outer_size() else {
|
|
1785
|
+
return Ok(());
|
|
1786
|
+
};
|
|
1787
|
+
let (x, y) = clamp_bubble_window_position(&app, target.0, target.1, size.width, size.height);
|
|
1788
|
+
let _ = window.set_position(PhysicalPosition::new(x, y));
|
|
1789
|
+
Ok(())
|
|
1790
|
+
}
|
|
1791
|
+
|
|
1792
|
+
/// End the hand-drag: clear the anchor, drop the dragging flag, and run one
|
|
1793
|
+
/// final clamp so the resting position is guaranteed in-bounds. The JS
|
|
1794
|
+
/// `onMoved` listener persists the final spot via `save_bubble_position`.
|
|
1795
|
+
#[tauri::command]
|
|
1796
|
+
pub async fn bubble_drag_end(app: AppHandle) -> Result<(), String> {
|
|
1797
|
+
*bubble_drag_anchor().lock().unwrap_or_else(|e| e.into_inner()) = None;
|
|
1798
|
+
BUBBLE_DRAGGING.store(false, Ordering::SeqCst);
|
|
1799
|
+
if let Some(window) = app.get_webview_window(BUBBLE_LABEL) {
|
|
1800
|
+
clamp_existing_bubble_window(&app, &window);
|
|
1801
|
+
}
|
|
1802
|
+
Ok(())
|
|
1803
|
+
}
|
|
1804
|
+
|
|
1692
1805
|
#[tauri::command]
|
|
1693
1806
|
pub async fn show_popover(app: AppHandle) -> Result<(), String> {
|
|
1694
1807
|
if let Some(window) = app.get_webview_window("popover") {
|
|
@@ -87,6 +87,9 @@ pub fn run() {
|
|
|
87
87
|
clips::set_recording_state,
|
|
88
88
|
clips::reset_state,
|
|
89
89
|
clips::save_bubble_position,
|
|
90
|
+
clips::bubble_drag_start,
|
|
91
|
+
clips::bubble_drag_move,
|
|
92
|
+
clips::bubble_drag_end,
|
|
90
93
|
clips::set_bubble_size,
|
|
91
94
|
clips::load_bubble_size,
|
|
92
95
|
// config commands
|
|
@@ -1,5 +1,9 @@
|
|
|
1
|
+
// camera=* (not (self)) so the Clips browser extension's camera bubble — injected
|
|
2
|
+
// as a cross-origin iframe — can run on Clips pages too; (self) only allows the
|
|
3
|
+
// app's own same-origin camera. Display-capture stays same-origin. The browser
|
|
4
|
+
// still prompts for camera/mic permission per origin.
|
|
1
5
|
export const MEDIA_CAPTURE_PERMISSIONS_POLICY =
|
|
2
|
-
"camera
|
|
6
|
+
"camera=*, microphone=(self), display-capture=(self), geolocation=(), screen-wake-lock=()";
|
|
3
7
|
|
|
4
8
|
export function withMediaCapturePermissions(response: Response): Response {
|
|
5
9
|
const headers = new Headers(response.headers);
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The canonical Clips "ready" chime, shared by every recorder surface so they
|
|
3
|
+
* all sound identical:
|
|
4
|
+
* - the web app recorder (app/lib/countdown-audio-cue.ts)
|
|
5
|
+
* - the Chrome extension (chrome-extension/src/offscreen.ts)
|
|
6
|
+
* - mirrors the desktop app's start cue
|
|
7
|
+
*
|
|
8
|
+
* Framework-free Web Audio — safe to import anywhere; the functions only touch
|
|
9
|
+
* `window`/`AudioContext` when called, so importing the module is SSR-safe.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Schedule the "ready" chime on an already-running {@link AudioContext}: a soft
|
|
14
|
+
* rising two-note (D5 → A5) with a faint high shimmer (A6). Each voice has a
|
|
15
|
+
* fast attack and a smooth exponential tail so it reads as a gentle
|
|
16
|
+
* confirmation rather than a harsh beep. Kept under ~380ms so it lands cleanly
|
|
17
|
+
* just as capture begins. The caller owns the context lifecycle; resolves once
|
|
18
|
+
* the last voice has finished.
|
|
19
|
+
*/
|
|
20
|
+
export function scheduleReadyChime(ctx: AudioContext): Promise<void> {
|
|
21
|
+
return new Promise<void>((resolve) => {
|
|
22
|
+
const t0 = ctx.currentTime + 0.005;
|
|
23
|
+
const voices = [
|
|
24
|
+
{ freq: 587.33, at: 0.0, dur: 0.22, peak: 0.06 }, // D5
|
|
25
|
+
{ freq: 880.0, at: 0.06, dur: 0.26, peak: 0.075 }, // A5
|
|
26
|
+
{ freq: 1760.0, at: 0.065, dur: 0.14, peak: 0.02 }, // A6 shimmer
|
|
27
|
+
];
|
|
28
|
+
|
|
29
|
+
let lastStop = t0;
|
|
30
|
+
for (const voice of voices) {
|
|
31
|
+
const startAt = t0 + voice.at;
|
|
32
|
+
const stopAt = startAt + voice.dur;
|
|
33
|
+
lastStop = Math.max(lastStop, stopAt);
|
|
34
|
+
|
|
35
|
+
const oscillator = ctx.createOscillator();
|
|
36
|
+
oscillator.type = "sine";
|
|
37
|
+
oscillator.frequency.setValueAtTime(voice.freq, startAt);
|
|
38
|
+
|
|
39
|
+
const gain = ctx.createGain();
|
|
40
|
+
gain.gain.setValueAtTime(0.0001, startAt);
|
|
41
|
+
gain.gain.exponentialRampToValueAtTime(voice.peak, startAt + 0.012);
|
|
42
|
+
gain.gain.exponentialRampToValueAtTime(0.0001, stopAt);
|
|
43
|
+
|
|
44
|
+
oscillator.connect(gain);
|
|
45
|
+
gain.connect(ctx.destination);
|
|
46
|
+
|
|
47
|
+
oscillator.start(startAt);
|
|
48
|
+
oscillator.stop(stopAt + 0.02);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
let resolved = false;
|
|
52
|
+
const finish = (): void => {
|
|
53
|
+
if (resolved) return;
|
|
54
|
+
resolved = true;
|
|
55
|
+
resolve();
|
|
56
|
+
};
|
|
57
|
+
window.setTimeout(
|
|
58
|
+
finish,
|
|
59
|
+
Math.ceil((lastStop - ctx.currentTime) * 1000) + 60,
|
|
60
|
+
);
|
|
61
|
+
});
|
|
62
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Framework-agnostic recording primitives shared by BOTH recorders:
|
|
3
|
+
* - the web app recorder (app/components/recorder/recorder-engine.ts)
|
|
4
|
+
* - the Chrome extension (chrome-extension/src/offscreen.ts)
|
|
5
|
+
*
|
|
6
|
+
* Keep this free of React, chrome.*, Node-only APIs, and any DOM beyond the
|
|
7
|
+
* media types (MediaRecorder, URLSearchParams) so both build targets — the
|
|
8
|
+
* Vite app build and the extension's separate Vite build — can import it.
|
|
9
|
+
*
|
|
10
|
+
* The two recorders intentionally keep their own *orchestration* and *upload
|
|
11
|
+
* strategy* (the extension streams each timeslice chunk from an offscreen
|
|
12
|
+
* document that survives navigation; the web app assembles + slices on stop with
|
|
13
|
+
* retry/compression). What MUST stay identical is the on-the-wire contract with
|
|
14
|
+
* the server's chunk-upload route and the MediaRecorder codec choice — that's
|
|
15
|
+
* what lives here.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
/** MediaRecorder codecs to try, best-supported first.
|
|
19
|
+
*
|
|
20
|
+
* vp8 is listed before vp9 because some display-capture streams report vp9
|
|
21
|
+
* support but reject the encoder configuration; vp8 is the most broadly
|
|
22
|
+
* accepted. MP4/avc1 is the Safari/WebKit fallback. */
|
|
23
|
+
export function pickMimeTypeCandidates(): string[] {
|
|
24
|
+
return [
|
|
25
|
+
"video/webm;codecs=vp8,opus",
|
|
26
|
+
"video/webm;codecs=vp9,opus",
|
|
27
|
+
"video/webm;codecs=vp8",
|
|
28
|
+
"video/webm;codecs=vp9",
|
|
29
|
+
"video/webm",
|
|
30
|
+
"video/mp4;codecs=avc1",
|
|
31
|
+
"video/mp4",
|
|
32
|
+
];
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** First supported codec from {@link pickMimeTypeCandidates}, or "" to let the
|
|
36
|
+
* browser pick its own default (callers should treat "" as "omit mimeType"). */
|
|
37
|
+
export function pickMimeType(): string {
|
|
38
|
+
if (typeof MediaRecorder === "undefined") return "video/webm";
|
|
39
|
+
for (const type of pickMimeTypeCandidates()) {
|
|
40
|
+
try {
|
|
41
|
+
if (MediaRecorder.isTypeSupported(type)) return type;
|
|
42
|
+
} catch {
|
|
43
|
+
// isTypeSupported can throw on some builds — treat as unsupported.
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
return "";
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Query params understood by the chunk-upload route
|
|
50
|
+
* (`/api/uploads/:id/chunk`). This is the on-the-wire contract — the route in
|
|
51
|
+
* `server/routes/api/uploads/[recordingId]/chunk.post.ts` reads exactly these. */
|
|
52
|
+
export type ChunkUploadParams = {
|
|
53
|
+
index: number;
|
|
54
|
+
total?: number;
|
|
55
|
+
isFinal?: boolean;
|
|
56
|
+
mimeType?: string;
|
|
57
|
+
durationMs?: number;
|
|
58
|
+
width?: number;
|
|
59
|
+
height?: number;
|
|
60
|
+
hasAudio?: boolean;
|
|
61
|
+
hasCamera?: boolean;
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
/** Encode {@link ChunkUploadParams} as the chunk-upload query string. Booleans
|
|
65
|
+
* become `1`/`0` and `durationMs` is rounded — matching what the route parses. */
|
|
66
|
+
export function chunkUploadQuery(params: ChunkUploadParams): string {
|
|
67
|
+
const q = new URLSearchParams();
|
|
68
|
+
q.set("index", String(params.index));
|
|
69
|
+
if (params.total !== undefined) q.set("total", String(params.total));
|
|
70
|
+
q.set("isFinal", params.isFinal ? "1" : "0");
|
|
71
|
+
if (params.mimeType) q.set("mimeType", params.mimeType);
|
|
72
|
+
if (params.durationMs !== undefined) {
|
|
73
|
+
q.set("durationMs", String(Math.round(params.durationMs)));
|
|
74
|
+
}
|
|
75
|
+
if (params.width !== undefined) q.set("width", String(params.width));
|
|
76
|
+
if (params.height !== undefined) q.set("height", String(params.height));
|
|
77
|
+
if (params.hasAudio !== undefined) {
|
|
78
|
+
q.set("hasAudio", params.hasAudio ? "1" : "0");
|
|
79
|
+
}
|
|
80
|
+
if (params.hasCamera !== undefined) {
|
|
81
|
+
q.set("hasCamera", params.hasCamera ? "1" : "0");
|
|
82
|
+
}
|
|
83
|
+
return q.toString();
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Full chunk-upload URL: `<chunkBaseUrl>?<encoded params>`. `chunkBaseUrl` is
|
|
87
|
+
* the per-recording endpoint (e.g. `/api/uploads/<id>/chunk` or its absolute
|
|
88
|
+
* form) with no existing query string. */
|
|
89
|
+
export function chunkUploadUrl(
|
|
90
|
+
chunkBaseUrl: string,
|
|
91
|
+
params: ChunkUploadParams,
|
|
92
|
+
): string {
|
|
93
|
+
return `${chunkBaseUrl}?${chunkUploadQuery(params)}`;
|
|
94
|
+
}
|