@1agh/maude 0.58.2 → 0.59.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/apps/studio/annotations-bindings.ts +83 -4
- package/apps/studio/annotations-layer.tsx +49 -15
- package/apps/studio/api.ts +6 -1
- package/apps/studio/bin/_fetch-asset.mjs +169 -5
- package/apps/studio/bin/_import-asset.mjs +90 -0
- package/apps/studio/bin/_import-figma.mjs +1775 -0
- package/apps/studio/bin/_perf-probe-safari.mjs +332 -0
- package/apps/studio/bin/_perf-probe.mjs +228 -0
- package/apps/studio/bin/_perf-shared.mjs +345 -0
- package/apps/studio/bin/_video-playwright.mjs +103 -7
- package/apps/studio/bin/import-figma.sh +47 -0
- package/apps/studio/bin/perf.sh +228 -0
- package/apps/studio/bin/read-annotations.mjs +11 -1
- package/apps/studio/bin/smoke.sh +49 -5
- package/apps/studio/bun.lock +16 -22
- package/apps/studio/canvas-edit.ts +29 -5
- package/apps/studio/canvas-lib.tsx +148 -6
- package/apps/studio/client/app.jsx +196 -38
- package/apps/studio/client/export-center.jsx +42 -4
- package/apps/studio/client/panels/CloudBar.jsx +92 -1
- package/apps/studio/client/panels/FigmaImportPanel.jsx +264 -0
- package/apps/studio/client/panels/GitPanel.jsx +26 -6
- package/apps/studio/client/panels/SettingsPanel.jsx +181 -0
- package/apps/studio/client/panels/SetupChecklist.jsx +26 -2
- package/apps/studio/client/panels/SyncPanel.jsx +229 -0
- package/apps/studio/client/panels/TimelinePanel.jsx +31 -3
- package/apps/studio/client/panels/timeline-comp-target.js +101 -0
- package/apps/studio/client/panels/timeline-parse.js +3 -3
- package/apps/studio/client/styles/3-shell-maude.css +37 -0
- package/apps/studio/client/styles/4-components.css +134 -0
- package/apps/studio/clip-ops.ts +93 -17
- package/apps/studio/cloud/endpoints.ts +78 -10
- package/apps/studio/cloud/renew.ts +183 -0
- package/apps/studio/context.ts +2 -1
- package/apps/studio/dist/client.bundle.js +1231 -1231
- package/apps/studio/dist/runtime/@remotion_media.js +56 -136
- package/apps/studio/dist/runtime/@remotion_player.js +18 -18
- package/apps/studio/dist/runtime/@remotion_transitions.js +9 -9
- package/apps/studio/dist/runtime/@remotion_transitions_clock-wipe.js +1 -1
- package/apps/studio/dist/runtime/remotion.js +12 -12
- package/apps/studio/dist/styles.css +1 -1
- package/apps/studio/exporters/_browser-bundles.ts +20 -6
- package/apps/studio/exporters/_runtime.ts +19 -0
- package/apps/studio/exporters/degraded.ts +92 -0
- package/apps/studio/exporters/index.ts +5 -0
- package/apps/studio/exporters/jobs.ts +19 -0
- package/apps/studio/exporters/unsupported-media.ts +170 -0
- package/apps/studio/exporters/video-encode-lib.ts +35 -6
- package/apps/studio/exporters/video-render-lib.ts +6 -0
- package/apps/studio/exporters/video.ts +72 -1
- package/apps/studio/figma/assets.test.ts +464 -0
- package/apps/studio/figma/assets.ts +452 -0
- package/apps/studio/figma/client.test.ts +395 -0
- package/apps/studio/figma/client.ts +513 -0
- package/apps/studio/figma/codegen-client.test.ts +276 -0
- package/apps/studio/figma/codegen-client.ts +509 -0
- package/apps/studio/figma/codegen-fonts.test.ts +103 -0
- package/apps/studio/figma/codegen-fonts.ts +195 -0
- package/apps/studio/figma/codegen-values.test.ts +179 -0
- package/apps/studio/figma/codegen-values.ts +270 -0
- package/apps/studio/figma/comments-to-strokes.test.ts +194 -0
- package/apps/studio/figma/comments-to-strokes.ts +173 -0
- package/apps/studio/figma/endpoints.ts +273 -0
- package/apps/studio/figma/fig-decode.test.ts +702 -0
- package/apps/studio/figma/fig-decode.ts +617 -0
- package/apps/studio/figma/fig-kiwi.ts +410 -0
- package/apps/studio/figma/fig-zip.ts +270 -0
- package/apps/studio/figma/from-codegen.test.ts +408 -0
- package/apps/studio/figma/from-codegen.ts +1103 -0
- package/apps/studio/figma/sanitize.test.ts +325 -0
- package/apps/studio/figma/sanitize.ts +407 -0
- package/apps/studio/figma/style-map.ts +352 -0
- package/apps/studio/figma/tailwind-map.test.ts +142 -0
- package/apps/studio/figma/tailwind-map.ts +545 -0
- package/apps/studio/figma/to-artboard.test.ts +808 -0
- package/apps/studio/figma/to-artboard.ts +701 -0
- package/apps/studio/figma/to-render.test.ts +180 -0
- package/apps/studio/figma/to-render.ts +328 -0
- package/apps/studio/figma/to-strokes-roundtrip.test.ts +152 -0
- package/apps/studio/figma/to-strokes.test.ts +705 -0
- package/apps/studio/figma/to-strokes.ts +749 -0
- package/apps/studio/figma/to-tokens.test.ts +321 -0
- package/apps/studio/figma/to-tokens.ts +305 -0
- package/apps/studio/figma/types.ts +544 -0
- package/apps/studio/figma/url.test.ts +167 -0
- package/apps/studio/figma/url.ts +160 -0
- package/apps/studio/http.ts +176 -0
- package/apps/studio/sync/asset-push.ts +432 -0
- package/apps/studio/sync/connection-state.ts +82 -3
- package/apps/studio/sync/hub-link.ts +63 -7
- package/apps/studio/sync/hubs-config.ts +31 -3
- package/apps/studio/sync/index.ts +286 -27
- package/apps/studio/sync/migrate-flat-fallback.ts +121 -0
- package/apps/studio/sync/presentation.ts +45 -1
- package/apps/studio/sync/status.ts +18 -0
- package/apps/studio/sync/supervisor.ts +5 -1
- package/apps/studio/sync/workspace-signin.ts +7 -3
- package/apps/studio/test/annotations-bindings.test.ts +150 -12
- package/apps/studio/test/canvas-create-api.test.ts +4 -1
- package/apps/studio/test/canvas-origin-gate.test.ts +17 -0
- package/apps/studio/test/capture-determinism-shape.test.ts +135 -0
- package/apps/studio/test/clip-addressing.test.ts +6 -1
- package/apps/studio/test/clip-ops.test.ts +5 -1
- package/apps/studio/test/cloud-endpoints.test.ts +96 -0
- package/apps/studio/test/cloud-renew.test.ts +205 -0
- package/apps/studio/test/cloud-shell-surfaces.test.ts +11 -2
- package/apps/studio/test/exporters/degraded-propagation.test.ts +123 -0
- package/apps/studio/test/exporters/unsupported-media.test.ts +123 -0
- package/apps/studio/test/fetch-asset-gate.test.ts +189 -0
- package/apps/studio/test/figma-explode.test.ts +438 -0
- package/apps/studio/test/figma-provenance.test.ts +108 -0
- package/apps/studio/test/figma-routes.test.ts +294 -0
- package/apps/studio/test/fixtures/perf-canvas.mjs +201 -0
- package/apps/studio/test/git-cloud-posture.test.ts +50 -0
- package/apps/studio/test/hub-link.test.ts +11 -0
- package/apps/studio/test/import-figma.test.ts +667 -0
- package/apps/studio/test/sync-asset-push.test.ts +567 -0
- package/apps/studio/test/sync-connection-state.test.ts +79 -0
- package/apps/studio/test/sync-hubs-config.test.ts +5 -0
- package/apps/studio/test/sync-migrate-flat-fallback.test.ts +98 -0
- package/apps/studio/test/sync-panel-surface.test.ts +90 -0
- package/apps/studio/test/sync-path-pull.test.ts +63 -0
- package/apps/studio/test/sync-presentation.test.ts +77 -0
- package/apps/studio/test/sync-runtime.test.ts +316 -1
- package/apps/studio/test/sync-status.test.ts +28 -0
- package/apps/studio/test/timeline-comp-target.test.ts +139 -0
- package/apps/studio/test/video-comp.test.ts +104 -2
- package/apps/studio/test/video-encode-lib.test.ts +63 -0
- package/apps/studio/test/workspace-containment.test.ts +1 -0
- package/apps/studio/use-artboard-drag.tsx +37 -3
- package/apps/studio/video-comp.tsx +121 -6
- package/apps/studio/whats-new.json +98 -0
- package/apps/studio/workspace-mode.ts +4 -0
- package/cli/commands/design.mjs +15 -0
- package/cli/commands/kg.mjs +8 -1
- package/cli/commands/kg.test.mjs +24 -0
- package/cli/lib/figma-codegen-reachability.test.mjs +104 -0
- package/cli/lib/figma-import-controls.test.mjs +70 -0
- package/package.json +8 -8
- package/plugins/flow/.claude-plugin/config.schema.json +3 -3
|
@@ -1399,6 +1399,27 @@
|
|
|
1399
1399
|
background: color-mix(in oklab, var(--u-status-error) 18%, transparent);
|
|
1400
1400
|
color: var(--u-status-error);
|
|
1401
1401
|
}
|
|
1402
|
+
/* A degraded export is `done` but not clean — it must never read as success.
|
|
1403
|
+
Overrides --done, so declaration order matters. */
|
|
1404
|
+
.st-export-pill--degraded {
|
|
1405
|
+
background: color-mix(in oklab, var(--u-status-warn, var(--u-accent)) 18%, transparent);
|
|
1406
|
+
color: var(--u-status-warn, var(--u-accent));
|
|
1407
|
+
}
|
|
1408
|
+
.st-export-degraded {
|
|
1409
|
+
font-size: 12px;
|
|
1410
|
+
line-height: 1.45;
|
|
1411
|
+
color: var(--u-status-warn, var(--u-accent));
|
|
1412
|
+
padding: 6px 8px;
|
|
1413
|
+
border-radius: var(--radius-sm, 6px);
|
|
1414
|
+
background: color-mix(in oklab, var(--u-status-warn, var(--u-accent)) 10%, transparent);
|
|
1415
|
+
}
|
|
1416
|
+
.st-export-degraded__fix {
|
|
1417
|
+
margin-top: 4px;
|
|
1418
|
+
color: var(--u-fg-2);
|
|
1419
|
+
font-family: var(--font-mono, ui-monospace, monospace);
|
|
1420
|
+
font-size: 11px;
|
|
1421
|
+
overflow-wrap: anywhere;
|
|
1422
|
+
}
|
|
1402
1423
|
.st-export-progress {
|
|
1403
1424
|
position: relative;
|
|
1404
1425
|
height: 4px;
|
|
@@ -2023,3 +2044,116 @@
|
|
|
2023
2044
|
color: var(--u-fg-0);
|
|
2024
2045
|
background: var(--u-bg-2);
|
|
2025
2046
|
}
|
|
2047
|
+
|
|
2048
|
+
/* ── Figma import panel (DDR-216 / T11) ─────────────────────────────────────
|
|
2049
|
+
* Rides the shared .st-scrim / .st-dialog shell (3-shell-maude.css). Tokens
|
|
2050
|
+
* only — no hardcoded colour, and no inline `style=` in the JSX either, because
|
|
2051
|
+
* the canvas CSP (`style-src 'self'`) drops inline styles SILENTLY. */
|
|
2052
|
+
.st-figma-dialog {
|
|
2053
|
+
width: min(560px, 92vw);
|
|
2054
|
+
padding-bottom: var(--space-4);
|
|
2055
|
+
}
|
|
2056
|
+
.st-figma-label {
|
|
2057
|
+
display: block;
|
|
2058
|
+
padding: var(--space-3) var(--space-5) 4px;
|
|
2059
|
+
font-size: var(--type-xs, 12px);
|
|
2060
|
+
color: var(--fg-2);
|
|
2061
|
+
}
|
|
2062
|
+
.st-figma-input {
|
|
2063
|
+
display: block;
|
|
2064
|
+
width: calc(100% - (2 * var(--space-5)));
|
|
2065
|
+
margin: 0 var(--space-5);
|
|
2066
|
+
padding: 8px 10px;
|
|
2067
|
+
border: 1px solid var(--border-default);
|
|
2068
|
+
border-radius: var(--radius-sm);
|
|
2069
|
+
background: var(--bg-1);
|
|
2070
|
+
color: var(--fg-0);
|
|
2071
|
+
font-family: var(--font-mono);
|
|
2072
|
+
font-size: var(--type-sm, 13px);
|
|
2073
|
+
}
|
|
2074
|
+
.st-figma-modes {
|
|
2075
|
+
border: 0;
|
|
2076
|
+
margin: 0;
|
|
2077
|
+
padding: 0 var(--space-5);
|
|
2078
|
+
}
|
|
2079
|
+
.st-figma-mode {
|
|
2080
|
+
display: flex;
|
|
2081
|
+
align-items: baseline;
|
|
2082
|
+
gap: 8px;
|
|
2083
|
+
padding: 4px 0;
|
|
2084
|
+
font-size: var(--type-sm, 13px);
|
|
2085
|
+
color: var(--fg-1);
|
|
2086
|
+
}
|
|
2087
|
+
.st-figma-mode-hint {
|
|
2088
|
+
color: var(--fg-3);
|
|
2089
|
+
font-size: var(--type-xs, 12px);
|
|
2090
|
+
}
|
|
2091
|
+
.st-figma-actions {
|
|
2092
|
+
display: flex;
|
|
2093
|
+
justify-content: flex-end;
|
|
2094
|
+
gap: 8px;
|
|
2095
|
+
padding: var(--space-4) var(--space-5) 0;
|
|
2096
|
+
}
|
|
2097
|
+
.st-figma-notice {
|
|
2098
|
+
margin: var(--space-3) var(--space-5) 0;
|
|
2099
|
+
padding: 8px 10px;
|
|
2100
|
+
border: 1px solid var(--border-subtle);
|
|
2101
|
+
border-radius: var(--radius-sm);
|
|
2102
|
+
background: var(--bg-2);
|
|
2103
|
+
color: var(--fg-2);
|
|
2104
|
+
font-size: var(--type-xs, 12px);
|
|
2105
|
+
}
|
|
2106
|
+
/* The third-party-content banner. Deliberately reads as a caution, not as a
|
|
2107
|
+
* provenance badge — an "imported from Figma" stamp otherwise makes the most
|
|
2108
|
+
* attacker-influenced artifact in the tree look like verified design work. */
|
|
2109
|
+
.st-figma-notice-warn {
|
|
2110
|
+
border-color: var(--status-warn, var(--border-default));
|
|
2111
|
+
color: var(--fg-1);
|
|
2112
|
+
}
|
|
2113
|
+
.st-figma-error {
|
|
2114
|
+
margin: var(--space-3) var(--space-5) 0;
|
|
2115
|
+
color: var(--status-danger, var(--fg-0));
|
|
2116
|
+
font-size: var(--type-xs, 12px);
|
|
2117
|
+
}
|
|
2118
|
+
.st-figma-summary {
|
|
2119
|
+
margin: var(--space-3) var(--space-5) 0;
|
|
2120
|
+
padding: 10px;
|
|
2121
|
+
border: 1px solid var(--border-subtle);
|
|
2122
|
+
border-radius: var(--radius-sm);
|
|
2123
|
+
background: var(--bg-2);
|
|
2124
|
+
font-size: var(--type-xs, 12px);
|
|
2125
|
+
color: var(--fg-2);
|
|
2126
|
+
}
|
|
2127
|
+
.st-figma-summary-hd {
|
|
2128
|
+
color: var(--fg-1);
|
|
2129
|
+
font-weight: 600;
|
|
2130
|
+
margin-bottom: 6px;
|
|
2131
|
+
}
|
|
2132
|
+
.st-figma-summary-row {
|
|
2133
|
+
margin: var(--space-2) var(--space-5) 0;
|
|
2134
|
+
font-size: var(--type-xs, 12px);
|
|
2135
|
+
color: var(--fg-2);
|
|
2136
|
+
}
|
|
2137
|
+
.st-figma-summary .st-figma-summary-row {
|
|
2138
|
+
margin: 4px 0 0;
|
|
2139
|
+
}
|
|
2140
|
+
.st-figma-summary-list {
|
|
2141
|
+
list-style: none;
|
|
2142
|
+
margin: 0;
|
|
2143
|
+
padding: 0;
|
|
2144
|
+
display: grid;
|
|
2145
|
+
gap: 4px;
|
|
2146
|
+
}
|
|
2147
|
+
.st-figma-summary-kind {
|
|
2148
|
+
color: var(--fg-1);
|
|
2149
|
+
}
|
|
2150
|
+
.st-figma-summary-count {
|
|
2151
|
+
font-family: var(--font-mono);
|
|
2152
|
+
color: var(--fg-2);
|
|
2153
|
+
}
|
|
2154
|
+
.st-figma-summary-ids {
|
|
2155
|
+
display: block;
|
|
2156
|
+
font-family: var(--font-mono);
|
|
2157
|
+
color: var(--fg-3);
|
|
2158
|
+
overflow-wrap: anywhere;
|
|
2159
|
+
}
|
package/apps/studio/clip-ops.ts
CHANGED
|
@@ -341,16 +341,24 @@ export function applyInsertClipAt(
|
|
|
341
341
|
}
|
|
342
342
|
const dur = Math.max(1, Math.round(opts.durationInFrames));
|
|
343
343
|
const srcTrim = (opts.src ?? '').trim();
|
|
344
|
-
const media =
|
|
344
|
+
const media: { tag: string; src: string; importName: string; specifier: string } | null =
|
|
345
345
|
opts.mediaTag && INSERTABLE_MEDIA.has(opts.mediaTag) && srcTrim
|
|
346
|
-
? { tag: opts.mediaTag, src: srcTrim }
|
|
346
|
+
? { tag: opts.mediaTag, src: srcTrim, importName: opts.mediaTag, specifier: 'remotion' }
|
|
347
347
|
: null;
|
|
348
348
|
if (media) {
|
|
349
349
|
assertContainedSrc(canvasAbsPath, media.src, artboardId ?? '');
|
|
350
|
-
//
|
|
351
|
-
//
|
|
352
|
-
//
|
|
353
|
-
|
|
350
|
+
// This used to rewrite <Video> → <OffthreadVideo> as "house spelling", to
|
|
351
|
+
// dodge a collision with a canvas component named `Video`. That trade was
|
|
352
|
+
// wrong in one direction nobody noticed: <OffthreadVideo> is rejected by
|
|
353
|
+
// @remotion/web-renderer, so the editor was silently converting the
|
|
354
|
+
// audio-capable element into the audio-killing one behind the author's
|
|
355
|
+
// back — every inserted clip exported muted (RCA
|
|
356
|
+
// issue-mp4-audio-export-html5audio-silent-degrade). The collision is real,
|
|
357
|
+
// so it is now solved where it belongs: in the import (see resolveMediaTag).
|
|
358
|
+
const resolved = resolveMediaTag(source, media.tag);
|
|
359
|
+
media.tag = resolved.emitTag;
|
|
360
|
+
media.importName = resolved.importName;
|
|
361
|
+
media.specifier = resolved.specifier;
|
|
354
362
|
}
|
|
355
363
|
|
|
356
364
|
const seqs = cc.clips.filter((c) => c.kind === 'sequence');
|
|
@@ -403,7 +411,7 @@ export function applyInsertClipAt(
|
|
|
403
411
|
);
|
|
404
412
|
ensureImportPublic(s2, source, 'TransitionSeries', '@remotion/transitions');
|
|
405
413
|
if (opts.placeholder) ensureNamedImport(s2, source, 'AIPlaceholder', '@maude/canvas-lib');
|
|
406
|
-
if (media) ensureNamedImport(s2, source, media.
|
|
414
|
+
if (media) ensureNamedImport(s2, source, media.importName, media.specifier);
|
|
407
415
|
const out2 = s2.toString();
|
|
408
416
|
assertParses(canvasAbsPath, out2, artboardId ?? '');
|
|
409
417
|
assertCompSemantics(canvasAbsPath, out2);
|
|
@@ -426,7 +434,7 @@ export function applyInsertClipAt(
|
|
|
426
434
|
? `\n${indent} <${media.tag} src="${escapeAttr(media.src)}" />\n${indent}`
|
|
427
435
|
: '';
|
|
428
436
|
if (opts.placeholder) ensureNamedImport(s, source, 'AIPlaceholder', '@maude/canvas-lib');
|
|
429
|
-
if (media) ensureNamedImport(s, source, media.
|
|
437
|
+
if (media) ensureNamedImport(s, source, media.importName, media.specifier);
|
|
430
438
|
const beatText = `<${seriesPrefix}.Sequence durationInFrames={${dur}}>${mediaText}</${seriesPrefix}.Sequence>`;
|
|
431
439
|
if (index === 0) {
|
|
432
440
|
// Head insert: B (T) before the first beat.
|
|
@@ -514,7 +522,7 @@ export function applyInsertClipAt(
|
|
|
514
522
|
? `\n${indent} <${media.tag} src="${escapeAttr(media.src)}" />\n${indent}`
|
|
515
523
|
: '';
|
|
516
524
|
if (opts.placeholder) ensureNamedImport(s, source, 'AIPlaceholder', '@maude/canvas-lib');
|
|
517
|
-
if (media) ensureNamedImport(s, source, media.
|
|
525
|
+
if (media) ensureNamedImport(s, source, media.importName, media.specifier);
|
|
518
526
|
if (titleText) ensureNamedImport(s, source, 'AbsoluteFill', 'remotion');
|
|
519
527
|
ensureNamedImport(s, source, 'Sequence', 'remotion');
|
|
520
528
|
const clipText = `\n${indent}<Sequence from={${from}} durationInFrames={${dur}}>${mediaText}</Sequence>`;
|
|
@@ -549,7 +557,7 @@ export function applyInsertClipAt(
|
|
|
549
557
|
s2.appendLeft(openStart, '<>');
|
|
550
558
|
s2.appendRight(insertAt, `${clipText}\n${indent}</>`);
|
|
551
559
|
if (opts.placeholder) ensureNamedImport(s2, source, 'AIPlaceholder', '@maude/canvas-lib');
|
|
552
|
-
if (media) ensureNamedImport(s2, source, media.
|
|
560
|
+
if (media) ensureNamedImport(s2, source, media.importName, media.specifier);
|
|
553
561
|
if (titleText) ensureNamedImport(s2, source, 'AbsoluteFill', 'remotion');
|
|
554
562
|
ensureNamedImport(s2, source, 'Sequence', 'remotion');
|
|
555
563
|
out = s2.toString();
|
|
@@ -675,7 +683,7 @@ function setTagAttr(
|
|
|
675
683
|
function directMediaTagStart(
|
|
676
684
|
source: string,
|
|
677
685
|
clip: ClipInfo,
|
|
678
|
-
tags = ['Video', 'OffthreadVideo', 'Audio', 'Img']
|
|
686
|
+
tags = ['Video', 'OffthreadVideo', 'Audio', 'Img', 'MaudeVideo', 'MaudeAudio', 'MaudeImg']
|
|
679
687
|
): number | null {
|
|
680
688
|
const span = source.slice(clip.start, clip.end);
|
|
681
689
|
const m = span.match(new RegExp(`<(${tags.join('|')})\\b`));
|
|
@@ -737,7 +745,13 @@ export function applySetPlaybackRate(
|
|
|
737
745
|
}
|
|
738
746
|
const { clips } = enumerateClips(canvasAbsPath, source, artboardId);
|
|
739
747
|
const { clip } = mustResolve(canvasAbsPath, clips, stableId, expectedHash);
|
|
740
|
-
const mediaStart = directMediaTagStart(source, clip, [
|
|
748
|
+
const mediaStart = directMediaTagStart(source, clip, [
|
|
749
|
+
'Video',
|
|
750
|
+
'OffthreadVideo',
|
|
751
|
+
'Audio',
|
|
752
|
+
'MaudeVideo',
|
|
753
|
+
'MaudeAudio',
|
|
754
|
+
]);
|
|
741
755
|
if (mediaStart == null) {
|
|
742
756
|
throw new CanvasEditError(
|
|
743
757
|
`clip "${stableId}" has no direct media element — speed applies to <Video>/<Audio> clips (shared/wrapper media would change every user)`,
|
|
@@ -780,7 +794,13 @@ export function applyTrimIn(
|
|
|
780
794
|
const delta = Math.round(deltaFrames);
|
|
781
795
|
const { clips } = enumerateClips(canvasAbsPath, source, artboardId);
|
|
782
796
|
const { clip } = mustResolve(canvasAbsPath, clips, stableId, expectedHash);
|
|
783
|
-
const mediaStart = directMediaTagStart(source, clip, [
|
|
797
|
+
const mediaStart = directMediaTagStart(source, clip, [
|
|
798
|
+
'Video',
|
|
799
|
+
'OffthreadVideo',
|
|
800
|
+
'Audio',
|
|
801
|
+
'MaudeVideo',
|
|
802
|
+
'MaudeAudio',
|
|
803
|
+
]);
|
|
784
804
|
if (mediaStart == null) {
|
|
785
805
|
throw new CanvasEditError(
|
|
786
806
|
`clip "${stableId}" has no direct media element — in-point trim applies to media clips`,
|
|
@@ -843,7 +863,13 @@ export function applyClipAudio(
|
|
|
843
863
|
): { source: string } {
|
|
844
864
|
const { clips } = enumerateClips(canvasAbsPath, source, artboardId);
|
|
845
865
|
const { clip } = mustResolve(canvasAbsPath, clips, stableId, expectedHash);
|
|
846
|
-
const mediaStart = directMediaTagStart(source, clip, [
|
|
866
|
+
const mediaStart = directMediaTagStart(source, clip, [
|
|
867
|
+
'Video',
|
|
868
|
+
'OffthreadVideo',
|
|
869
|
+
'Audio',
|
|
870
|
+
'MaudeVideo',
|
|
871
|
+
'MaudeAudio',
|
|
872
|
+
]);
|
|
847
873
|
if (mediaStart == null) {
|
|
848
874
|
throw new CanvasEditError(`clip "${stableId}" has no direct media element to set audio on`, {
|
|
849
875
|
canvas: canvasAbsPath,
|
|
@@ -882,7 +908,7 @@ export function applyDetachAudio(
|
|
|
882
908
|
): { source: string; audioStableId: string | null } {
|
|
883
909
|
const cc = enumerateClips(canvasAbsPath, source, artboardId);
|
|
884
910
|
const { clip } = mustResolve(canvasAbsPath, cc.clips, stableId, expectedHash);
|
|
885
|
-
const mediaStart = directMediaTagStart(source, clip, ['Video', 'OffthreadVideo']);
|
|
911
|
+
const mediaStart = directMediaTagStart(source, clip, ['Video', 'OffthreadVideo', 'MaudeVideo']);
|
|
886
912
|
if (mediaStart == null || !clip.mediaSrc) {
|
|
887
913
|
throw new CanvasEditError(
|
|
888
914
|
`clip "${stableId}" has no direct video media with a literal src to detach`,
|
|
@@ -1038,7 +1064,13 @@ export function applyClipGrade(
|
|
|
1038
1064
|
): { source: string; filter: string } {
|
|
1039
1065
|
const { clips } = enumerateClips(canvasAbsPath, source, artboardId);
|
|
1040
1066
|
const { clip } = mustResolve(canvasAbsPath, clips, stableId, expectedHash);
|
|
1041
|
-
const mediaStart = directMediaTagStart(source, clip, [
|
|
1067
|
+
const mediaStart = directMediaTagStart(source, clip, [
|
|
1068
|
+
'Video',
|
|
1069
|
+
'OffthreadVideo',
|
|
1070
|
+
'Img',
|
|
1071
|
+
'MaudeVideo',
|
|
1072
|
+
'MaudeImg',
|
|
1073
|
+
]);
|
|
1042
1074
|
if (mediaStart == null) {
|
|
1043
1075
|
throw new CanvasEditError(`clip "${stableId}" has no direct media element to grade`, {
|
|
1044
1076
|
canvas: canvasAbsPath,
|
|
@@ -1096,7 +1128,13 @@ export function applyClipFraming(
|
|
|
1096
1128
|
): { source: string } {
|
|
1097
1129
|
const { clips } = enumerateClips(canvasAbsPath, source, artboardId);
|
|
1098
1130
|
const { clip } = mustResolve(canvasAbsPath, clips, stableId, expectedHash);
|
|
1099
|
-
const mediaStart = directMediaTagStart(source, clip, [
|
|
1131
|
+
const mediaStart = directMediaTagStart(source, clip, [
|
|
1132
|
+
'Video',
|
|
1133
|
+
'OffthreadVideo',
|
|
1134
|
+
'Img',
|
|
1135
|
+
'MaudeVideo',
|
|
1136
|
+
'MaudeImg',
|
|
1137
|
+
]);
|
|
1100
1138
|
if (mediaStart == null) {
|
|
1101
1139
|
throw new CanvasEditError(`clip "${stableId}" has no direct media element to frame`, {
|
|
1102
1140
|
canvas: canvasAbsPath,
|
|
@@ -1410,6 +1448,44 @@ export function applySplitClip(
|
|
|
1410
1448
|
|
|
1411
1449
|
/** Ensure `name` is among the named imports from `specifier` (extends an
|
|
1412
1450
|
* existing import line, else adds one after the last import). */
|
|
1451
|
+
/**
|
|
1452
|
+
* Where an inserted media element must come from.
|
|
1453
|
+
*
|
|
1454
|
+
* `Video` and `Audio` MUST resolve to `@remotion/media` — those are the only
|
|
1455
|
+
* media elements `@remotion/web-renderer` can render, and importing them from
|
|
1456
|
+
* `remotion` (where `Audio` IS `Html5Audio`) makes the export drop its audio
|
|
1457
|
+
* track and fall back to a ~40× slower capture. RCA
|
|
1458
|
+
* `issue-mp4-audio-export-html5audio-silent-degrade`.
|
|
1459
|
+
*/
|
|
1460
|
+
const MEDIA_SPECIFIER: Record<string, string> = {
|
|
1461
|
+
Video: '@remotion/media',
|
|
1462
|
+
Audio: '@remotion/media',
|
|
1463
|
+
Img: 'remotion',
|
|
1464
|
+
OffthreadVideo: 'remotion',
|
|
1465
|
+
};
|
|
1466
|
+
|
|
1467
|
+
/**
|
|
1468
|
+
* Import an inserted media element from the right package, aliasing it when the
|
|
1469
|
+
* canvas already declares a component of that name.
|
|
1470
|
+
*
|
|
1471
|
+
* The collision is real — a canvas with its own `const Video = …` would have the
|
|
1472
|
+
* emitted `<Video>` resolve to itself. The old fix was to emit
|
|
1473
|
+
* `<OffthreadVideo>` instead, which silently cost the audio track. Aliasing
|
|
1474
|
+
* keeps the collision solved AND the audio intact. Returns the tag to emit.
|
|
1475
|
+
*/
|
|
1476
|
+
function resolveMediaTag(
|
|
1477
|
+
source: string,
|
|
1478
|
+
tag: string
|
|
1479
|
+
): { emitTag: string; importName: string; specifier: string } {
|
|
1480
|
+
const specifier = MEDIA_SPECIFIER[tag] ?? 'remotion';
|
|
1481
|
+
const declaresLocally = new RegExp(
|
|
1482
|
+
`(?:const|let|var|function|class)\\s+${tag}\\b|\\b${tag}\\s*[:=]\\s*\\(`
|
|
1483
|
+
).test(source);
|
|
1484
|
+
if (!declaresLocally) return { emitTag: tag, importName: tag, specifier };
|
|
1485
|
+
const alias = `Maude${tag}`;
|
|
1486
|
+
return { emitTag: alias, importName: `${tag} as ${alias}`, specifier };
|
|
1487
|
+
}
|
|
1488
|
+
|
|
1413
1489
|
function ensureNamedImport(s: MagicString, source: string, name: string, specifier: string): void {
|
|
1414
1490
|
const esc = specifier.replace(/[/]/g, '\\/');
|
|
1415
1491
|
const line = source.match(new RegExp(`import \\{([^}]*)\\} from ['"]${esc}['"]`));
|
|
@@ -15,11 +15,19 @@
|
|
|
15
15
|
// user token → saveHubCredential() + linkedHub in config.json —
|
|
16
16
|
// the exact state `maude design link` would have written by hand.
|
|
17
17
|
|
|
18
|
-
import {
|
|
18
|
+
import {
|
|
19
|
+
chmodSync,
|
|
20
|
+
existsSync,
|
|
21
|
+
mkdirSync,
|
|
22
|
+
readFileSync,
|
|
23
|
+
renameSync,
|
|
24
|
+
unlinkSync,
|
|
25
|
+
writeFileSync,
|
|
26
|
+
} from 'node:fs';
|
|
19
27
|
import { homedir } from 'node:os';
|
|
20
28
|
import { basename, dirname, join } from 'node:path';
|
|
21
29
|
|
|
22
|
-
import { saveHubCredential } from '../sync/hub-link.ts';
|
|
30
|
+
import { deleteHubCredential, saveHubCredential } from '../sync/hub-link.ts';
|
|
23
31
|
import { getHubToken, normalizeUrl } from '../sync/hubs-config.ts';
|
|
24
32
|
|
|
25
33
|
/**
|
|
@@ -116,11 +124,14 @@ interface Ctx {
|
|
|
116
124
|
* syncing after the next start.
|
|
117
125
|
*/
|
|
118
126
|
syncControl?: {
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
127
|
+
/** `null` = unlink: clear the runtime's in-memory link, cycle to solo. */
|
|
128
|
+
restart(
|
|
129
|
+
linkedHub?: {
|
|
130
|
+
url: string;
|
|
131
|
+
linkedAt: number;
|
|
132
|
+
syncTsx?: boolean;
|
|
133
|
+
} | null
|
|
134
|
+
): Promise<{ syncing: boolean; canvases: number; reason?: string; detail?: string }>;
|
|
124
135
|
};
|
|
125
136
|
}
|
|
126
137
|
|
|
@@ -319,6 +330,50 @@ export function createCloudEndpoints(ctx: Ctx) {
|
|
|
319
330
|
project: exchanged.body.project,
|
|
320
331
|
});
|
|
321
332
|
},
|
|
333
|
+
|
|
334
|
+
/**
|
|
335
|
+
* Detach THIS project from its workspace — the in-app `maude design
|
|
336
|
+
* unlink` (fix 7's Disconnect, sync RCA 2026-08-10). Drops the committed
|
|
337
|
+
* `linkedHub` AND the stored hub credential for that address (the CLI
|
|
338
|
+
* default), then cycles the runtime back to solo so syncing stops NOW
|
|
339
|
+
* rather than at the next restart. Idempotent: disconnecting an unlinked
|
|
340
|
+
* project answers ok, not an error.
|
|
341
|
+
*/
|
|
342
|
+
async detach(): Promise<CloudEndpointResult> {
|
|
343
|
+
const cfgPath = join(ctx.paths.designRoot, 'config.json');
|
|
344
|
+
let cfg: Record<string, unknown> = {};
|
|
345
|
+
try {
|
|
346
|
+
cfg = JSON.parse(readFileSync(cfgPath, 'utf8'));
|
|
347
|
+
} catch {
|
|
348
|
+
/* absent/malformed → nothing linked */
|
|
349
|
+
}
|
|
350
|
+
const linked = (cfg as { linkedHub?: { url?: unknown } }).linkedHub;
|
|
351
|
+
const url = typeof linked?.url === 'string' ? linked.url : null;
|
|
352
|
+
if (linked) {
|
|
353
|
+
delete cfg.linkedHub;
|
|
354
|
+
// Temp + rename (security review 2026-08-10, F5) — config.json is the
|
|
355
|
+
// SERVED project config (tokensCssRel, canvasGroups); a crash mid-write
|
|
356
|
+
// would corrupt it. Same atomic posture as `hub-link.ts`, but WITHOUT
|
|
357
|
+
// the credential store's 0o600 (this file is committed + world-readable
|
|
358
|
+
// by design), so it inherits the umask like the plain write it replaces.
|
|
359
|
+
const tmp = `${cfgPath}.${process.pid}.tmp`;
|
|
360
|
+
writeFileSync(tmp, `${JSON.stringify(cfg, null, 2)}\n`, 'utf8');
|
|
361
|
+
renameSync(tmp, cfgPath);
|
|
362
|
+
}
|
|
363
|
+
if (url) {
|
|
364
|
+
try {
|
|
365
|
+
deleteHubCredential(normalizeUrl(url));
|
|
366
|
+
} catch {
|
|
367
|
+
/* an unparseable stored URL has no credential entry to drop */
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
try {
|
|
371
|
+
await ctx.syncControl?.restart(null);
|
|
372
|
+
} catch {
|
|
373
|
+
/* best-effort — the link is gone on disk either way */
|
|
374
|
+
}
|
|
375
|
+
return { status: 200, json: { ok: true, detached: !!linked } };
|
|
376
|
+
},
|
|
322
377
|
};
|
|
323
378
|
|
|
324
379
|
/**
|
|
@@ -363,14 +418,27 @@ export function createCloudEndpoints(ctx: Ctx) {
|
|
|
363
418
|
}): Promise<CloudEndpointResult> {
|
|
364
419
|
// The cell is a different host than the control plane — one direct call.
|
|
365
420
|
let hubToken: string | null = null;
|
|
421
|
+
let hubTokenExpiresAt: number | undefined;
|
|
422
|
+
let vouchedRole: string | undefined;
|
|
366
423
|
try {
|
|
367
424
|
const res = await fetch(`${workspaceUrl}/auth/login`, {
|
|
368
425
|
method: 'POST',
|
|
369
426
|
headers: { 'content-type': 'application/json' },
|
|
370
427
|
body: JSON.stringify({ token: projectToken }),
|
|
371
428
|
});
|
|
372
|
-
const body = (await res.json().catch(() => ({}))) as CloudBody
|
|
373
|
-
|
|
429
|
+
const body = (await res.json().catch(() => ({}))) as CloudBody & {
|
|
430
|
+
expiresAt?: unknown;
|
|
431
|
+
user?: { role?: unknown };
|
|
432
|
+
};
|
|
433
|
+
if (res.ok && body?.token) {
|
|
434
|
+
hubToken = body.token;
|
|
435
|
+
// The cell reports when this session dies (≤ the 12 h project-token
|
|
436
|
+
// cap). Persisted so the sync runtime can renew BEFORE the deadline —
|
|
437
|
+
// discarding it here is what made every link silently expire.
|
|
438
|
+
if (typeof body.expiresAt === 'number' && Number.isFinite(body.expiresAt))
|
|
439
|
+
hubTokenExpiresAt = body.expiresAt;
|
|
440
|
+
if (typeof body.user?.role === 'string' && body.user.role) vouchedRole = body.user.role;
|
|
441
|
+
}
|
|
374
442
|
} catch {
|
|
375
443
|
/* handled below */
|
|
376
444
|
}
|
|
@@ -385,7 +453,7 @@ export function createCloudEndpoints(ctx: Ctx) {
|
|
|
385
453
|
}
|
|
386
454
|
|
|
387
455
|
const norm = normalizeUrl(workspaceUrl);
|
|
388
|
-
saveHubCredential(norm, hubToken);
|
|
456
|
+
saveHubCredential(norm, hubToken, vouchedRole, hubTokenExpiresAt);
|
|
389
457
|
|
|
390
458
|
// Project side — linkedHub in .design/config.json (committed; no token).
|
|
391
459
|
const cfgPath = join(ctx.paths.designRoot, 'config.json');
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
// Silent hub-credential renewal — the missing half of the cell-token model.
|
|
2
|
+
//
|
|
3
|
+
// The cell session token dies with the 12 h project token that minted it
|
|
4
|
+
// (Phase 23 B2 — deliberate: that cap IS the revocation window, and it stays).
|
|
5
|
+
// The account/device token next to it never expires. Nothing connected the
|
|
6
|
+
// two: the desktop held a forever credential and a 12 h credential side by
|
|
7
|
+
// side, and when the short one died the user was told to press Connect again —
|
|
8
|
+
// or, worse, told nothing (the alligators incident: 73 canvases permanently
|
|
9
|
+
// `connecting…`).
|
|
10
|
+
//
|
|
11
|
+
// This module replays the attach lane's credential tail WITHOUT the ceremony:
|
|
12
|
+
// GET /api/projects (account token) → which project owns this hub
|
|
13
|
+
// POST /projects/open (account token) → fresh 12 h project token
|
|
14
|
+
// POST <cell>/auth/login (project token) → fresh cell session
|
|
15
|
+
// saveHubCredential(…) → hubs.json, role preserved
|
|
16
|
+
//
|
|
17
|
+
// It deliberately does NOT touch `.design/config.json` (the link is already
|
|
18
|
+
// recorded; renewal is not a re-link) and does NOT cycle the sync runtime (the
|
|
19
|
+
// caller swaps the token in place). A renewal fails only on a REAL event —
|
|
20
|
+
// signed out, token revoked on the Account page, removed from the project —
|
|
21
|
+
// and that failure, not a clock, is what should reach the user.
|
|
22
|
+
//
|
|
23
|
+
// Trust posture: every hop talks only to the CONFIGURED cloud address or to
|
|
24
|
+
// the workspace URL the cloud's own /api/projects listing names for the
|
|
25
|
+
// matched project — never to an address read from repo-committed state.
|
|
26
|
+
|
|
27
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
28
|
+
import { homedir } from 'node:os';
|
|
29
|
+
import { join } from 'node:path';
|
|
30
|
+
|
|
31
|
+
import { saveHubCredential } from '../sync/hub-link.ts';
|
|
32
|
+
import { getHubRecord, normalizeUrl } from '../sync/hubs-config.ts';
|
|
33
|
+
|
|
34
|
+
export interface RenewedCredential {
|
|
35
|
+
token: string;
|
|
36
|
+
expiresAt: number | null;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export type RenewFailureReason =
|
|
40
|
+
| 'not-signed-in'
|
|
41
|
+
| 'account-revoked'
|
|
42
|
+
| 'no-matching-project'
|
|
43
|
+
| 'open-refused'
|
|
44
|
+
| 'cell-refused'
|
|
45
|
+
| 'save-failed'
|
|
46
|
+
| 'unreachable';
|
|
47
|
+
|
|
48
|
+
export type RenewResult =
|
|
49
|
+
| ({ ok: true } & RenewedCredential)
|
|
50
|
+
| { ok: false; reason: RenewFailureReason };
|
|
51
|
+
|
|
52
|
+
/** Mirrors cloud/endpoints.ts — resolved per call so tests/desktop can set it late. */
|
|
53
|
+
function cloudUrl(): string {
|
|
54
|
+
return (process.env.MAUDE_CLOUD_URL ?? 'https://cloud.maude.sh').replace(/\/+$/, '');
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function cloudConfigPath(): string {
|
|
58
|
+
return process.env.MAUDE_CLOUD_CONFIG ?? join(homedir(), '.config', 'maude', 'cloud.json');
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** The account credential, or null when this machine is signed out. */
|
|
62
|
+
function readAccountToken(): string | null {
|
|
63
|
+
const p = cloudConfigPath();
|
|
64
|
+
if (!existsSync(p)) return null;
|
|
65
|
+
try {
|
|
66
|
+
const parsed = JSON.parse(readFileSync(p, 'utf8'));
|
|
67
|
+
return parsed && typeof parsed.token === 'string' && parsed.token ? parsed.token : null;
|
|
68
|
+
} catch {
|
|
69
|
+
return null;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const FETCH_TIMEOUT_MS = 15_000;
|
|
74
|
+
|
|
75
|
+
async function jsonFetch(
|
|
76
|
+
doFetch: typeof fetch,
|
|
77
|
+
url: string,
|
|
78
|
+
init: RequestInit = {}
|
|
79
|
+
): Promise<{ status: number; body: Record<string, unknown> }> {
|
|
80
|
+
const ctl = new AbortController();
|
|
81
|
+
const timer = setTimeout(() => ctl.abort(), FETCH_TIMEOUT_MS);
|
|
82
|
+
try {
|
|
83
|
+
const res = await doFetch(url, { ...init, signal: ctl.signal });
|
|
84
|
+
const body = (await res.json().catch(() => ({}))) as Record<string, unknown>;
|
|
85
|
+
return { status: res.status, body };
|
|
86
|
+
} catch {
|
|
87
|
+
return { status: 0, body: {} };
|
|
88
|
+
} finally {
|
|
89
|
+
clearTimeout(timer);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Mint a fresh cell session for `hubUrl` from the signed-in Maude account.
|
|
95
|
+
*
|
|
96
|
+
* Single network path, no interaction. Callers treat any `ok:false` as "keep
|
|
97
|
+
* the old behaviour" — the stored (possibly dead) credential stays in place so
|
|
98
|
+
* a failed renewal never makes things worse than before it ran.
|
|
99
|
+
*/
|
|
100
|
+
export async function renewHubCredential(
|
|
101
|
+
hubUrl: string,
|
|
102
|
+
doFetch: typeof fetch = fetch
|
|
103
|
+
): Promise<RenewResult> {
|
|
104
|
+
const account = readAccountToken();
|
|
105
|
+
if (!account) return { ok: false, reason: 'not-signed-in' };
|
|
106
|
+
|
|
107
|
+
let norm: string;
|
|
108
|
+
try {
|
|
109
|
+
norm = normalizeUrl(hubUrl);
|
|
110
|
+
} catch {
|
|
111
|
+
return { ok: false, reason: 'no-matching-project' };
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// Which cloud project opens onto this hub? The listing is the only mapping
|
|
115
|
+
// that exists (the hub URL is derived from the project id at open time), and
|
|
116
|
+
// asking it fresh means a renamed/removed project fails here — the right
|
|
117
|
+
// place — instead of minting for a project this account no longer has.
|
|
118
|
+
const listed = await jsonFetch(doFetch, `${cloudUrl()}/api/projects`, {
|
|
119
|
+
headers: { authorization: `Bearer ${account}` },
|
|
120
|
+
});
|
|
121
|
+
if (listed.status === 401) return { ok: false, reason: 'account-revoked' };
|
|
122
|
+
if (listed.status !== 200) return { ok: false, reason: 'unreachable' };
|
|
123
|
+
const projects = Array.isArray(listed.body.projects) ? listed.body.projects : [];
|
|
124
|
+
const match = projects.find((p) => {
|
|
125
|
+
const url = (p as { url?: unknown })?.url;
|
|
126
|
+
if (typeof url !== 'string' || !url) return false;
|
|
127
|
+
try {
|
|
128
|
+
return normalizeUrl(url) === norm;
|
|
129
|
+
} catch {
|
|
130
|
+
return false;
|
|
131
|
+
}
|
|
132
|
+
}) as { id?: unknown; role?: unknown } | undefined;
|
|
133
|
+
// A viewer's Connect is a different lane (share-view); renewing an editor
|
|
134
|
+
// credential for one would mint capability the panel never offered.
|
|
135
|
+
if (!match || typeof match.id !== 'string' || match.role === 'viewer') {
|
|
136
|
+
return { ok: false, reason: 'no-matching-project' };
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const opened = await jsonFetch(doFetch, `${cloudUrl()}/projects/open`, {
|
|
140
|
+
method: 'POST',
|
|
141
|
+
headers: { authorization: `Bearer ${account}`, 'content-type': 'application/json' },
|
|
142
|
+
body: JSON.stringify({ project: match.id }),
|
|
143
|
+
});
|
|
144
|
+
if (opened.status === 0) return { ok: false, reason: 'unreachable' };
|
|
145
|
+
if (opened.status !== 200 || typeof opened.body.token !== 'string' || !opened.body.token) {
|
|
146
|
+
// This is where a removed member's renewal dies — exactly where it should.
|
|
147
|
+
return { ok: false, reason: 'open-refused' };
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const cellLogin = await jsonFetch(doFetch, `${norm}/auth/login`, {
|
|
151
|
+
method: 'POST',
|
|
152
|
+
headers: { 'content-type': 'application/json' },
|
|
153
|
+
body: JSON.stringify({ token: opened.body.token }),
|
|
154
|
+
});
|
|
155
|
+
if (cellLogin.status === 0) return { ok: false, reason: 'unreachable' };
|
|
156
|
+
if (
|
|
157
|
+
cellLogin.status !== 200 ||
|
|
158
|
+
typeof cellLogin.body.token !== 'string' ||
|
|
159
|
+
!cellLogin.body.token
|
|
160
|
+
) {
|
|
161
|
+
return { ok: false, reason: 'cell-refused' };
|
|
162
|
+
}
|
|
163
|
+
const expiresAt =
|
|
164
|
+
typeof cellLogin.body.expiresAt === 'number' && Number.isFinite(cellLogin.body.expiresAt)
|
|
165
|
+
? cellLogin.body.expiresAt
|
|
166
|
+
: null;
|
|
167
|
+
|
|
168
|
+
// The upsert replaces the whole record, so the role must be re-supplied. F5:
|
|
169
|
+
// prefer the role the cell JUST vouched (its /auth/login echoes `user.role`)
|
|
170
|
+
// over the stored one — this is the one moment the code learns the current
|
|
171
|
+
// role, and carrying the stale value forward would discard exactly that.
|
|
172
|
+
// Fall back to the prior role only when the cell didn't say (older cell).
|
|
173
|
+
const freshRoleRaw = (cellLogin.body.user as { role?: unknown } | undefined)?.role;
|
|
174
|
+
const freshRole = typeof freshRoleRaw === 'string' && freshRoleRaw ? freshRoleRaw : undefined;
|
|
175
|
+
const role = freshRole ?? getHubRecord(norm)?.role;
|
|
176
|
+
try {
|
|
177
|
+
saveHubCredential(norm, cellLogin.body.token, role, expiresAt ?? undefined);
|
|
178
|
+
} catch {
|
|
179
|
+
return { ok: false, reason: 'save-failed' };
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
return { ok: true, token: cellLogin.body.token, expiresAt };
|
|
183
|
+
}
|
package/apps/studio/context.ts
CHANGED
|
@@ -191,7 +191,8 @@ export interface Context {
|
|
|
191
191
|
* non-serving entry points construct a Context without one.
|
|
192
192
|
*/
|
|
193
193
|
syncControl?: {
|
|
194
|
-
|
|
194
|
+
/** `null` means UNLINK: clear the in-memory link and cycle back to solo. */
|
|
195
|
+
restart(linkedHub?: LinkedHub | null): Promise<{
|
|
195
196
|
syncing: boolean;
|
|
196
197
|
canvases: number;
|
|
197
198
|
reason?: string;
|