@design-twin/dtwin 0.0.0-stage → 0.1.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/LICENSE +21 -0
- package/README.md +959 -2
- package/figma-plugin/code.js +4207 -0
- package/figma-plugin/manifest.json +28 -0
- package/figma-plugin/ui.html +686 -0
- package/layer/cross-check.js +2746 -0
- package/layer/drift-lint.js +1526 -0
- package/layer/get-component.js +553 -0
- package/layer/map-bootstrap.js +1404 -0
- package/layer/map-validate.js +397 -0
- package/package.json +53 -4
- package/src/abort-reason.ts +12 -0
- package/src/asset-compare.ts +175 -0
- package/src/asset-owners.ts +280 -0
- package/src/atomic-write.ts +79 -0
- package/src/bridge-holder.ts +294 -0
- package/src/cli-commands.ts +427 -0
- package/src/client-presence.ts +146 -0
- package/src/clock.ts +45 -0
- package/src/commands.ts +561 -0
- package/src/daemon-auth.ts +174 -0
- package/src/daemon.ts +788 -0
- package/src/delivery.ts +85 -0
- package/src/design-system-layout.ts +232 -0
- package/src/doc-types.ts +1054 -0
- package/src/doctor.ts +602 -0
- package/src/errmsg.ts +14 -0
- package/src/export-reader.ts +409 -0
- package/src/export-verdict.ts +258 -0
- package/src/figma-mcp.ts +942 -0
- package/src/figma-pull.ts +1265 -0
- package/src/file-lock.ts +268 -0
- package/src/file-memo.ts +62 -0
- package/src/hash.ts +6 -0
- package/src/hex-color.ts +39 -0
- package/src/human-ms.ts +7 -0
- package/src/init.ts +424 -0
- package/src/instance-overrides.ts +7 -0
- package/src/json-file.ts +79 -0
- package/src/json-size.ts +108 -0
- package/src/json-util.ts +51 -0
- package/src/library-layout.ts +193 -0
- package/src/node-id.ts +69 -0
- package/src/pages-layout.ts +483 -0
- package/src/pending-requests.ts +187 -0
- package/src/plugin-frames.ts +32 -0
- package/src/png.ts +196 -0
- package/src/ports.ts +25 -0
- package/src/project-layout.ts +189 -0
- package/src/quick-keys.ts +68 -0
- package/src/read-json.ts +67 -0
- package/src/read-opts.ts +125 -0
- package/src/read-plan.ts +105 -0
- package/src/read-run.ts +119 -0
- package/src/route.ts +243 -0
- package/src/seed-components.ts +229 -0
- package/src/served-session.ts +347 -0
- package/src/server-core.ts +1093 -0
- package/src/settings.ts +156 -0
- package/src/source-stamp.ts +16 -0
- package/src/staleness.ts +63 -0
- package/src/stdio.ts +156 -0
- package/src/svg-normalize.ts +205 -0
- package/src/timeouts.ts +76 -0
- package/src/timings.ts +118 -0
- package/src/token-store.ts +261 -0
- package/src/variables-merge.ts +337 -0
- package/src/verbs.ts +140 -0
- package/src/write-out.ts +1255 -0
|
@@ -0,0 +1,686 @@
|
|
|
1
|
+
<!-- GENERATED from ui.template.html + src/relay.ts by build.ts — do not edit by hand. Run `bun run build`. -->
|
|
2
|
+
<style>
|
|
3
|
+
/* Colors come from Figma's own theme variables (injected because main.ts passes themeColors:true),
|
|
4
|
+
so the window follows the user's light/dark setting. Every var() carries the old light-theme
|
|
5
|
+
literal as its fallback — if the variables are ever absent the UI still renders, just light. */
|
|
6
|
+
body { font: 13px -apple-system, sans-serif; margin: 0; padding: 14px;
|
|
7
|
+
color: var(--figma-color-text, #222); background: var(--figma-color-bg, #fff); }
|
|
8
|
+
#status { margin-bottom: 12px; color: var(--figma-color-text-secondary, #555); min-height: 18px; }
|
|
9
|
+
#status.err { color: var(--figma-color-text-danger, #c0362c); }
|
|
10
|
+
button { display: block; width: 100%; margin: 6px 0; padding: 8px; border: 1px solid var(--figma-color-border-strong, #ccc);
|
|
11
|
+
border-radius: 6px; background: var(--figma-color-bg, #fff); color: var(--figma-color-text, #222);
|
|
12
|
+
cursor: pointer; font-size: 13px; }
|
|
13
|
+
button:disabled { opacity: 0.4; cursor: default; }
|
|
14
|
+
button:not(:disabled):hover { background: var(--figma-color-bg-hover, #f4f4f4); }
|
|
15
|
+
.primary { background: var(--figma-color-bg-brand, #0d99ff); color: var(--figma-color-text-onbrand, #fff);
|
|
16
|
+
border-color: var(--figma-color-bg-brand, #0d99ff); font-weight: 600; }
|
|
17
|
+
.primary:not(:disabled):hover { background: var(--figma-color-bg-brand-hover, #0a86e0); }
|
|
18
|
+
/* Keyboard focus must be visible in both themes — the browser default ring is near-invisible on the
|
|
19
|
+
dark background. */
|
|
20
|
+
button:focus-visible, input:focus-visible, summary:focus-visible {
|
|
21
|
+
outline: 2px solid var(--figma-color-border-selected, #0d99ff); outline-offset: 1px; }
|
|
22
|
+
.sep { height: 1px; background: var(--figma-color-border, #eee); margin: 12px 0; }
|
|
23
|
+
.label { font-size: 11px; text-transform: uppercase; letter-spacing: .04em; color: var(--figma-color-text-tertiary, #999); margin: 4px 0; }
|
|
24
|
+
label.label { display: block; }
|
|
25
|
+
.hint { margin-top: 12px; font-size: 11px; color: var(--figma-color-text-tertiary, #999); line-height: 1.45; }
|
|
26
|
+
.hint code, #bridge code { font-size: 11px; color: var(--figma-color-text-secondary, #555); }
|
|
27
|
+
.row { display: flex; gap: 6px; }
|
|
28
|
+
.row button { margin: 6px 0; }
|
|
29
|
+
input { flex: 1; box-sizing: border-box; padding: 8px; border: 1px solid var(--figma-color-border-strong, #ccc); border-radius: 6px;
|
|
30
|
+
font-size: 13px; color: var(--figma-color-text, #222); background: var(--figma-color-bg, #fff); }
|
|
31
|
+
input::placeholder { color: var(--figma-color-text-tertiary, #999); }
|
|
32
|
+
/* The connection section is collapsed by default: exporting by hand needs none of it, and a designer
|
|
33
|
+
who never opens a terminal should not be greeted by "bridge token". Its one-line state stays
|
|
34
|
+
visible in the summary either way. */
|
|
35
|
+
details#connect > summary { cursor: pointer; list-style: none; display: flex; align-items: center; gap: 6px;
|
|
36
|
+
font-size: 12px; color: var(--figma-color-text-secondary, #555); padding: 2px 0; border-radius: 4px; }
|
|
37
|
+
details#connect > summary::-webkit-details-marker { display: none; }
|
|
38
|
+
details#connect > summary::before { content: "▸"; font-size: 10px; color: var(--figma-color-text-tertiary, #999); }
|
|
39
|
+
details#connect[open] > summary::before { content: "▾"; }
|
|
40
|
+
#pill { margin-left: auto; font-size: 11px; padding: 1px 7px; border-radius: 9px;
|
|
41
|
+
background: var(--figma-color-bg-secondary, #f6f6f6); color: var(--figma-color-text-tertiary, #888); }
|
|
42
|
+
#pill.on { background: var(--figma-color-bg-success-tertiary, #e7f7ec); color: var(--figma-color-text-success, #1a7f3c); }
|
|
43
|
+
#pill.bad { background: var(--figma-color-bg-danger-tertiary, #fdecea); color: var(--figma-color-text-danger, #9b1c12); }
|
|
44
|
+
#bridge { font-size: 11px; margin-top: 10px; padding: 6px 8px; border-radius: 6px; line-height: 1.45;
|
|
45
|
+
background: var(--figma-color-bg-secondary, #f6f6f6); color: var(--figma-color-text-secondary, #888); }
|
|
46
|
+
#bridge.on { background: var(--figma-color-bg-success-tertiary, #e7f7ec); color: var(--figma-color-text-success, #1a7f3c); }
|
|
47
|
+
#bridge.bad { background: var(--figma-color-bg-danger-tertiary, #fdecea); color: var(--figma-color-text-danger, #9b1c12); }
|
|
48
|
+
/* `button { display: block }` above beats a bare [hidden], so the hide rule has to win explicitly —
|
|
49
|
+
otherwise the Cancel button is permanently visible. */
|
|
50
|
+
[hidden] { display: none !important; }
|
|
51
|
+
/* A PERSISTENT failure notice, deliberately not the #status line: status is overwritten by the very
|
|
52
|
+
next selection change or export, and "the browser delivered 0 of your 400 files" is precisely the
|
|
53
|
+
message that must still be on screen when you come back to the window. */
|
|
54
|
+
#banner { margin-bottom: 12px; padding: 8px; border: 1px solid var(--figma-color-border-danger, #f0b4ae); border-radius: 6px;
|
|
55
|
+
background: var(--figma-color-bg-danger-tertiary, #fdecea); color: var(--figma-color-text-danger, #9b1c12);
|
|
56
|
+
font-size: 12px; line-height: 1.4; }
|
|
57
|
+
#progress { height: 6px; margin-bottom: 12px; border-radius: 3px; background: var(--figma-color-bg-tertiary, #eee); overflow: hidden; }
|
|
58
|
+
#bar { height: 100%; width: 0; background: var(--figma-color-bg-brand, #0d99ff); }
|
|
59
|
+
/* Phases with no page count (the design-system catalog, per-asset ticks inside one huge frame) still
|
|
60
|
+
have to look ALIVE — a bar frozen at 0% reads as a hang. */
|
|
61
|
+
#progress.indet #bar { width: 30%; animation: indet 1.2s linear infinite; }
|
|
62
|
+
@keyframes indet { from { margin-left: -30%; } to { margin-left: 100%; } }
|
|
63
|
+
#cancel { border-color: var(--figma-color-border-danger-strong, #c0362c); color: var(--figma-color-text-danger, #c0362c); }
|
|
64
|
+
</style>
|
|
65
|
+
|
|
66
|
+
<!-- role=status / role=alert: a screen-reader user otherwise never hears that a long export finished
|
|
67
|
+
or that a download batch failed. -->
|
|
68
|
+
<div id="status" role="status" aria-live="polite">Loading…</div>
|
|
69
|
+
<div id="banner" role="alert" hidden></div>
|
|
70
|
+
<div id="progress" hidden><div id="bar"></div></div>
|
|
71
|
+
|
|
72
|
+
<div class="label">Export</div>
|
|
73
|
+
<button id="run-sel" disabled>Export current selection</button>
|
|
74
|
+
<button id="run-full" class="primary">Export design system + all page frames</button>
|
|
75
|
+
<div class="hint" style="margin-top:6px">Exports the file's design system and all frames on the current page. On a large page this can take several minutes. You can cancel it.</div>
|
|
76
|
+
<!-- Shown only while a run is walking the file — INCLUDING one a CLI/MCP client started, which the
|
|
77
|
+
designer never clicked for and would otherwise have no way to stop. -->
|
|
78
|
+
<button id="cancel" hidden>Cancel export</button>
|
|
79
|
+
|
|
80
|
+
<div class="sep"></div>
|
|
81
|
+
<div class="label">Downloads</div>
|
|
82
|
+
<div id="downloads"></div>
|
|
83
|
+
<button id="layers" disabled>Download layers (<span id="lcount">0</span>)</button>
|
|
84
|
+
<button id="assets" disabled>Download assets (<span id="acount">0</span>)</button>
|
|
85
|
+
|
|
86
|
+
<div class="hint">Save the downloads into your project's <code>design/</code> folder (assets into <code>design/assets/</code>).</div>
|
|
87
|
+
|
|
88
|
+
<div class="sep"></div>
|
|
89
|
+
<!-- Optional. Closed by default; opened automatically only when it needs the user (a rejected token). -->
|
|
90
|
+
<details id="connect">
|
|
91
|
+
<summary>Connect to Claude Code (optional) <span id="pill">not set up</span></summary>
|
|
92
|
+
<label class="label" for="token" style="margin-top: 10px">Bridge token</label>
|
|
93
|
+
<div class="row">
|
|
94
|
+
<input id="token" type="password" autocomplete="off" spellcheck="false" placeholder="paste the token here" />
|
|
95
|
+
<button id="save-token" style="flex: 0 0 60px">Save</button>
|
|
96
|
+
</div>
|
|
97
|
+
<div id="bridge" role="status" aria-live="polite">Not set up.</div>
|
|
98
|
+
<div class="hint">Lets the <code>dtwin</code> command line or the Design Twin MCP server pull this file directly, instead of you downloading it. It runs on your own computer only (localhost) — nothing is sent over the internet. Get the token by running <code>dtwin token show</code> in a terminal — or ask whoever set this up to send it to you. You paste it once.</div>
|
|
99
|
+
</details>
|
|
100
|
+
|
|
101
|
+
<script>
|
|
102
|
+
var DesignTwinRelay = (() => {
|
|
103
|
+
var __defProp = Object.defineProperty;
|
|
104
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
105
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
106
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
107
|
+
var __export = (target, all) => {
|
|
108
|
+
for (var name in all)
|
|
109
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
110
|
+
};
|
|
111
|
+
var __copyProps = (to, from, except, desc) => {
|
|
112
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
113
|
+
for (let key of __getOwnPropNames(from))
|
|
114
|
+
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
115
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
116
|
+
}
|
|
117
|
+
return to;
|
|
118
|
+
};
|
|
119
|
+
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
120
|
+
|
|
121
|
+
// src/relay.ts
|
|
122
|
+
var relay_exports = {};
|
|
123
|
+
__export(relay_exports, {
|
|
124
|
+
createRelay: () => createRelay,
|
|
125
|
+
helloFrame: () => helloFrame,
|
|
126
|
+
mainMessageFor: () => mainMessageFor,
|
|
127
|
+
progressFrame: () => progressFrame,
|
|
128
|
+
replyFrame: () => replyFrame
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
// ../bridge/src/json-util.ts
|
|
132
|
+
function isRecord(x) {
|
|
133
|
+
return typeof x === "object" && x !== null && !Array.isArray(x);
|
|
134
|
+
}
|
|
135
|
+
function ifDefined(key, v) {
|
|
136
|
+
const o = {};
|
|
137
|
+
if (v !== void 0) o[key] = v;
|
|
138
|
+
return o;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// src/relay.ts
|
|
142
|
+
var idStr = (idn, k) => {
|
|
143
|
+
const v = idn[k];
|
|
144
|
+
return typeof v === "string" && v ? v : null;
|
|
145
|
+
};
|
|
146
|
+
function helloFrame(identity) {
|
|
147
|
+
const idn = isRecord(identity) ? identity : {};
|
|
148
|
+
return {
|
|
149
|
+
type: "hello",
|
|
150
|
+
instanceId: idStr(idn, "instanceId"),
|
|
151
|
+
file: idStr(idn, "file"),
|
|
152
|
+
// Only a readable key: a plugin without the private API reports fileKeyAvailable:false (and fileKey null).
|
|
153
|
+
fileKey: idn.fileKeyAvailable === true ? idStr(idn, "fileKey") : null,
|
|
154
|
+
page: idStr(idn, "page"),
|
|
155
|
+
// The plugin's build version (build-options.ts), so the bridge can tell a stale plugin from a reloaded one.
|
|
156
|
+
pluginVersion: idStr(idn, "pluginVersion")
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
function replyFrame(m) {
|
|
160
|
+
var _a;
|
|
161
|
+
if (m.id === void 0) return null;
|
|
162
|
+
return m.ok ? { id: m.id, ok: true, result: m.result } : { id: m.id, ok: false, error: (_a = m.error) != null ? _a : "" };
|
|
163
|
+
}
|
|
164
|
+
function progressFrame(m) {
|
|
165
|
+
if (m.type === "run-begin") return m.source === "bridge" ? { type: "progress", phase: "start", ...ifDefined("requestId", m.requestId) } : null;
|
|
166
|
+
if (m.type !== "progress" || m.source !== "bridge") return null;
|
|
167
|
+
return {
|
|
168
|
+
type: "progress",
|
|
169
|
+
phase: m.phase,
|
|
170
|
+
..."requestId" in m ? ifDefined("requestId", m.requestId) : {},
|
|
171
|
+
..."page" in m ? ifDefined("page", m.page) : {},
|
|
172
|
+
..."nodes" in m ? ifDefined("nodes", m.nodes) : {},
|
|
173
|
+
..."assets" in m ? ifDefined("assets", m.assets) : {}
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
function mainMessageFor(data) {
|
|
177
|
+
let f;
|
|
178
|
+
try {
|
|
179
|
+
f = JSON.parse(String(data));
|
|
180
|
+
} catch {
|
|
181
|
+
return null;
|
|
182
|
+
}
|
|
183
|
+
if (!isRecord(f)) return null;
|
|
184
|
+
if (f.type === "cancel" && f.cmd === void 0) return typeof f.id === "string" ? { type: "cancel", id: f.id } : null;
|
|
185
|
+
return { type: "bridge", ...ifDefined("id", typeof f.id === "string" ? f.id : void 0), cmd: typeof f.cmd === "string" ? f.cmd : String(f.cmd), args: f.args };
|
|
186
|
+
}
|
|
187
|
+
function createRelay(ports) {
|
|
188
|
+
return {
|
|
189
|
+
fromMain(m) {
|
|
190
|
+
if (m.type === "identity") {
|
|
191
|
+
ports.toSocket(helloFrame(m.identity));
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
if (m.type === "bridge-result") {
|
|
195
|
+
const r = replyFrame(m);
|
|
196
|
+
if (r) ports.toSocket(r);
|
|
197
|
+
return;
|
|
198
|
+
}
|
|
199
|
+
if (m.type === "run-begin" || m.type === "progress") {
|
|
200
|
+
const p = progressFrame(m);
|
|
201
|
+
if (p) ports.toSocket(p);
|
|
202
|
+
}
|
|
203
|
+
},
|
|
204
|
+
fromSocket(data) {
|
|
205
|
+
const msg = mainMessageFor(data);
|
|
206
|
+
if (msg) ports.toMain(msg);
|
|
207
|
+
},
|
|
208
|
+
// Announce which FILE this is on every open, reconnects included: the bridge accepts several plugin instances
|
|
209
|
+
// at once (one per open file) and routes by identity. Only the main thread can read it (figma.* lives there).
|
|
210
|
+
// Never cached: if Figma restarts the plugin runtime the instanceId changes, and re-reading it on each open is
|
|
211
|
+
// what keeps the bridge's listing honest.
|
|
212
|
+
socketOpened() {
|
|
213
|
+
ports.toMain({ type: "get-identity" });
|
|
214
|
+
},
|
|
215
|
+
// The socket that asked for any bridge work is gone, and a reply carrying an id nobody on the next bridge
|
|
216
|
+
// knows is dropped — so finishing a bridge run is pure waste that queues every later command behind it.
|
|
217
|
+
// Abandon them all (UI-started runs are untouched).
|
|
218
|
+
socketClosed() {
|
|
219
|
+
ports.toMain({ type: "cancel", scope: "bridge" });
|
|
220
|
+
}
|
|
221
|
+
};
|
|
222
|
+
}
|
|
223
|
+
return __toCommonJS(relay_exports);
|
|
224
|
+
})();
|
|
225
|
+
// Everything is wrapped so a single init error can NEVER leave the UI silently frozen:
|
|
226
|
+
// - window.onerror surfaces any uncaught error as visible text in #status (no DevTools needed).
|
|
227
|
+
// - window.onmessage is registered immediately (catches early messages from code.js).
|
|
228
|
+
// - DOM-dependent wiring (button handlers, get-token) runs on DOMContentLoaded, never before the
|
|
229
|
+
// elements exist — the classic "script ran before the buttons" freeze.
|
|
230
|
+
(function () {
|
|
231
|
+
// The ports the bridge may listen on, in the order we try them. This list is NOT free-form: a
|
|
232
|
+
// published plugin can only open sockets to ports named in manifest.json's `allowedDomains`, and
|
|
233
|
+
// match patterns take no port wildcard, so every port here must also be listed there (and vice
|
|
234
|
+
// versa). Adding a fourth port means shipping a new plugin VERSION through review — which is why
|
|
235
|
+
// FIGMA_BRIDGE_PORT can no longer be an arbitrary number; see bridge/server-core.js.
|
|
236
|
+
var BRIDGE_PORTS = [8787, 8788, 8789];
|
|
237
|
+
var portIdx = 0; // which one the NEXT connect() attempt uses; advances on every failure
|
|
238
|
+
var assetsState = [];
|
|
239
|
+
var layerFilesState = [];
|
|
240
|
+
// A batch runs for items.length * 200ms — 80s for 400 assets — with the button still on screen.
|
|
241
|
+
// Two clicks used to start two interleaved batches writing the same status line and doubling the
|
|
242
|
+
// download rate back into the throttle the staggering exists to clear. One at a time.
|
|
243
|
+
var batchRunning = false;
|
|
244
|
+
var ws = null;
|
|
245
|
+
var bridgeToken = "";
|
|
246
|
+
var busy = false; // an export is in flight — block a second run (the main thread serializes too)
|
|
247
|
+
var lastSelCount = 0; // last-known selection count, so we restore run-sel state after a run ends
|
|
248
|
+
// The run currently walking the file ({source,label}), or null. Driven by run-begin/run-end from
|
|
249
|
+
// the main thread rather than by our own button clicks, so a BRIDGE-triggered export — which no
|
|
250
|
+
// one clicked here — puts the window into exactly the same visible state.
|
|
251
|
+
var running = null;
|
|
252
|
+
var cancelSent = false; // we asked to stop; don't let progress frames overwrite "Cancelling…"
|
|
253
|
+
// The bridge protocol's plugin half (figma-plugin/src/relay.ts, bundled in above by build.ts): it turns
|
|
254
|
+
// main-thread messages into socket frames and socket frames into main-thread messages. This window only
|
|
255
|
+
// hands it the socket (a frame for a socket that is not open is dropped) and the main thread.
|
|
256
|
+
var relay = DesignTwinRelay.createRelay({
|
|
257
|
+
toSocket: function (frame) { if (ws && ws.readyState === 1) ws.send(JSON.stringify(frame)); },
|
|
258
|
+
toMain: function (msg) { parent.postMessage({ pluginMessage: msg }, "*"); }
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
function $(id) { return document.getElementById(id); }
|
|
262
|
+
// A batch button is enabled exactly when it has files AND no batch is running. DERIVED from that
|
|
263
|
+
// state every time, never snapshotted-and-restored: a saved `disabled` flag goes stale the moment
|
|
264
|
+
// a new export lands mid-batch, and restoring it would re-disable a button that now has files (or
|
|
265
|
+
// enable one that no longer does). Same reason the manifest derives `skipped` instead of counting.
|
|
266
|
+
function syncBatchButtons() {
|
|
267
|
+
var lb = $("layers"); if (lb) lb.disabled = batchRunning || layerFilesState.length === 0;
|
|
268
|
+
var ab = $("assets"); if (ab) ab.disabled = batchRunning || assetsState.length === 0;
|
|
269
|
+
}
|
|
270
|
+
// Thrown value -> message string. Byte-for-byte the body of bridge/errmsg.js, restated here only
|
|
271
|
+
// because a sandboxed iframe cannot import the bundle — one copy in this file, not one per catch.
|
|
272
|
+
function errText(e) { return typeof e === "string" ? e : String((e && e.message) || e); }
|
|
273
|
+
function setStatus(text, isErr) {
|
|
274
|
+
var s = $("status");
|
|
275
|
+
if (!s) return;
|
|
276
|
+
s.textContent = text;
|
|
277
|
+
s.className = isErr ? "err" : "";
|
|
278
|
+
}
|
|
279
|
+
function setBusy(b) {
|
|
280
|
+
busy = b;
|
|
281
|
+
var rf = $("run-full"); if (rf) rf.disabled = b;
|
|
282
|
+
var rs = $("run-sel"); if (rs) rs.disabled = b || lastSelCount === 0;
|
|
283
|
+
}
|
|
284
|
+
// A persistent red notice. Pass "" to clear it — only a NEW export or a new batch does that, never
|
|
285
|
+
// an ordinary status update.
|
|
286
|
+
function setBanner(text) {
|
|
287
|
+
var b = $("banner");
|
|
288
|
+
if (!b) return;
|
|
289
|
+
b.textContent = text || "";
|
|
290
|
+
b.hidden = !text;
|
|
291
|
+
}
|
|
292
|
+
// 1204 -> "1,204". A node count is the main number on the progress line and unseparated six-digit
|
|
293
|
+
// counts are unreadable at a glance.
|
|
294
|
+
function fmt(n) { return String(n).replace(/\B(?=(\d{3})+(?!\d))/g, ","); }
|
|
295
|
+
// `frac` null => indeterminate (a phase with no page count). The bar is never hidden mid-run, so
|
|
296
|
+
// "no page info yet" must still look like motion rather than a stall.
|
|
297
|
+
function setProgress(frac) {
|
|
298
|
+
var p = $("progress"), bar = $("bar");
|
|
299
|
+
if (!p || !bar) return;
|
|
300
|
+
if (frac == null) { p.className = "indet"; bar.style.width = ""; }
|
|
301
|
+
else { p.className = ""; bar.style.width = Math.max(0, Math.min(100, Math.round(frac * 100))) + "%"; }
|
|
302
|
+
}
|
|
303
|
+
function runBegin(info) {
|
|
304
|
+
running = info;
|
|
305
|
+
cancelSent = false;
|
|
306
|
+
var c = $("cancel"); if (c) { c.hidden = false; c.disabled = false; c.textContent = "Cancel export"; }
|
|
307
|
+
var p = $("progress"); if (p) p.hidden = false;
|
|
308
|
+
setProgress(null);
|
|
309
|
+
setBusy(true);
|
|
310
|
+
setStatus(info.source === "bridge"
|
|
311
|
+
? "Export in progress — requested over the bridge (" + info.label + ")…"
|
|
312
|
+
: "Exporting…");
|
|
313
|
+
}
|
|
314
|
+
// Tear the run chrome down. Deliberately does NOT clear `cancelSent`: the main thread sends
|
|
315
|
+
// run-end BEFORE the error that the cancellation produced, and both need to know it was a cancel.
|
|
316
|
+
function runEnd() {
|
|
317
|
+
running = null;
|
|
318
|
+
var c = $("cancel"); if (c) c.hidden = true;
|
|
319
|
+
var p = $("progress"); if (p) { p.hidden = true; p.className = ""; }
|
|
320
|
+
setBusy(false);
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
// Any uncaught UI-script error becomes visible text in the plugin window.
|
|
324
|
+
window.onerror = function (msg, src, line) {
|
|
325
|
+
setStatus("UI error: " + msg + (line ? " (line " + line + ")" : ""), true);
|
|
326
|
+
return false;
|
|
327
|
+
};
|
|
328
|
+
|
|
329
|
+
function b64ToBytes(b64) {
|
|
330
|
+
var bin = atob(b64 || "");
|
|
331
|
+
var arr = new Uint8Array(bin.length);
|
|
332
|
+
for (var i = 0; i < bin.length; i++) arr[i] = bin.charCodeAt(i);
|
|
333
|
+
return arr;
|
|
334
|
+
}
|
|
335
|
+
function dl(name, blob) {
|
|
336
|
+
var a = document.createElement("a");
|
|
337
|
+
var url = URL.createObjectURL(blob);
|
|
338
|
+
a.href = url;
|
|
339
|
+
a.download = name;
|
|
340
|
+
a.click();
|
|
341
|
+
// Release the blob once the browser has started the download — otherwise every export leaks
|
|
342
|
+
// the (multi-MB) object URL for the plugin window's lifetime.
|
|
343
|
+
setTimeout(function () { URL.revokeObjectURL(url); }, 10000);
|
|
344
|
+
}
|
|
345
|
+
function send(type) {
|
|
346
|
+
setStatus("Exporting…");
|
|
347
|
+
parent.postMessage({ pluginMessage: { type: type } }, "*");
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
// ---- messages FROM the Figma main thread (code.js). Registered immediately. ----
|
|
351
|
+
window.onmessage = function (e) {
|
|
352
|
+
var m = e.data && e.data.pluginMessage;
|
|
353
|
+
if (!m) return;
|
|
354
|
+
relay.fromMain(m); // what the bridge must hear of it (hello, replies, a bridge run's progress); the DOM's below
|
|
355
|
+
|
|
356
|
+
if (m.type === "selection") {
|
|
357
|
+
lastSelCount = m.count;
|
|
358
|
+
var rs = $("run-sel");
|
|
359
|
+
if (rs && !busy) rs.disabled = m.count === 0;
|
|
360
|
+
// Figma fires selectionchange freely while an export runs (and collectNode selects the node it
|
|
361
|
+
// exported), so without this guard a selection event wipes the progress line mid-walk.
|
|
362
|
+
if (running) return;
|
|
363
|
+
setStatus(
|
|
364
|
+
m.count === 0
|
|
365
|
+
? "Select a frame for single-screen export, or use the blue button for the whole page."
|
|
366
|
+
: m.count + " selected" + (m.name ? ' — "' + m.name + '"' : "") + "."
|
|
367
|
+
);
|
|
368
|
+
return;
|
|
369
|
+
}
|
|
370
|
+
// ---- run lifecycle + progress (both manual and bridge-triggered runs) ----
|
|
371
|
+
if (m.type === "run-begin") {
|
|
372
|
+
runBegin(m);
|
|
373
|
+
return;
|
|
374
|
+
}
|
|
375
|
+
if (m.type === "run-end") {
|
|
376
|
+
var wasCancelled = cancelSent;
|
|
377
|
+
runEnd();
|
|
378
|
+
// Bridge-triggered runs never produce a `files`/`error` message here, so this is the ONLY
|
|
379
|
+
// place a designer learns their cancel took effect on one.
|
|
380
|
+
if (wasCancelled) setStatus("Export cancelled — nothing was delivered.", true);
|
|
381
|
+
// `abandoned` is set only when the run died because the BRIDGE gave up on the request that
|
|
382
|
+
// asked for it (its caller timed out, stalled out, or disconnected) — no one here clicked
|
|
383
|
+
// anything, so the button's sentence would be a lie. The designer's own cancel wins if both.
|
|
384
|
+
else if (m.abandoned) setStatus("Export cancelled — the caller went away.", true);
|
|
385
|
+
return;
|
|
386
|
+
}
|
|
387
|
+
if (m.type === "progress") {
|
|
388
|
+
// `queued`: a bridge run waiting behind the run that is executing (state.ts scheduleRun). It
|
|
389
|
+
// has not begun, so it is relayed (relay.ts) and nothing here changes — the window keeps showing
|
|
390
|
+
// the run that IS walking the file, or stays idle. A `start` here is a scene read that waited
|
|
391
|
+
// and has now begun: not a run of the window's own (no run-begin), so likewise relayed only.
|
|
392
|
+
if (m.phase === "queued" || m.phase === "start") return;
|
|
393
|
+
if (!running) runBegin(m); // a frame that outran its run-begin — never show progress with no chrome
|
|
394
|
+
if (cancelSent) return; // "Cancelling…" must not be overwritten by in-flight frames
|
|
395
|
+
var pg = m.page;
|
|
396
|
+
setProgress(pg && pg.of ? pg.index / pg.of : null);
|
|
397
|
+
var bits = [];
|
|
398
|
+
if (typeof m.nodes === "number" && m.nodes) bits.push(fmt(m.nodes) + " nodes");
|
|
399
|
+
if (typeof m.assets === "number" && m.assets) bits.push(fmt(m.assets) + " assets");
|
|
400
|
+
var detail = bits.length ? " (" + bits.join(", ") + ")" : "";
|
|
401
|
+
var who = m.source === "bridge" ? " [bridge: " + m.label + "]" : "";
|
|
402
|
+
var line;
|
|
403
|
+
if (m.phase === "design-system") line = "Building the design system" + (pg ? " — page " + pg.index + " of " + pg.of + ", " + pg.name : "") + detail + "…";
|
|
404
|
+
else if (pg) line = "Exporting page " + pg.index + " of " + pg.of + " — " + pg.name + detail + "…";
|
|
405
|
+
else line = "Exporting" + detail + "…";
|
|
406
|
+
setStatus(line + who);
|
|
407
|
+
return;
|
|
408
|
+
}
|
|
409
|
+
if (m.type === "cancel-ack") {
|
|
410
|
+
// Refused (nothing was running) — say so and put the window back, rather than leaving a
|
|
411
|
+
// "Cancelling…" that will never resolve.
|
|
412
|
+
if (!m.accepted) { cancelSent = false; runEnd(); setStatus("Nothing to cancel — the export had already finished."); }
|
|
413
|
+
return;
|
|
414
|
+
}
|
|
415
|
+
if (m.type === "error") {
|
|
416
|
+
var errWasCancel = cancelSent;
|
|
417
|
+
runEnd();
|
|
418
|
+
setStatus(errWasCancel ? "Export cancelled — nothing was delivered." : m.message, true);
|
|
419
|
+
return;
|
|
420
|
+
}
|
|
421
|
+
if (m.type === "files") {
|
|
422
|
+
runEnd();
|
|
423
|
+
setBanner(""); // a fresh successful export clears the previous batch's failure notice
|
|
424
|
+
// The warnings COUNT, surfaced next to the summary. The list itself is in the export's own
|
|
425
|
+
// manifest; showing only `summary` meant a run with 40 unexported icons read as a clean one.
|
|
426
|
+
setStatus("Ready: " + m.summary
|
|
427
|
+
+ (m.warnings ? " — " + fmt(m.warnings) + " warning" + (m.warnings === 1 ? "" : "s") + ", see manifest.warnings in the export" : ""));
|
|
428
|
+
var wrap = $("downloads");
|
|
429
|
+
if (wrap) {
|
|
430
|
+
wrap.innerHTML = "";
|
|
431
|
+
m.files.forEach(function (f) {
|
|
432
|
+
var row = document.createElement("div");
|
|
433
|
+
row.className = "row";
|
|
434
|
+
var d = document.createElement("button");
|
|
435
|
+
d.textContent = "Download " + f.name;
|
|
436
|
+
d.onclick = function () { dl(f.name, new Blob([f.content], { type: "application/json" })); };
|
|
437
|
+
row.appendChild(d);
|
|
438
|
+
if (f.copyable) {
|
|
439
|
+
var c = document.createElement("button");
|
|
440
|
+
c.textContent = "Copy";
|
|
441
|
+
c.style.flex = "0 0 70px";
|
|
442
|
+
c.onclick = function () { navigator.clipboard.writeText(f.content); };
|
|
443
|
+
row.appendChild(c);
|
|
444
|
+
}
|
|
445
|
+
wrap.appendChild(row);
|
|
446
|
+
});
|
|
447
|
+
}
|
|
448
|
+
assetsState = m.assets || [];
|
|
449
|
+
var ac = $("acount"); if (ac) ac.textContent = assetsState.length;
|
|
450
|
+
layerFilesState = m.layerFiles || [];
|
|
451
|
+
var lc = $("lcount"); if (lc) lc.textContent = layerFilesState.length;
|
|
452
|
+
syncBatchButtons();
|
|
453
|
+
return;
|
|
454
|
+
}
|
|
455
|
+
if (m.type === "token") {
|
|
456
|
+
bridgeToken = m.token || "";
|
|
457
|
+
var t = $("token"); if (t) t.value = bridgeToken;
|
|
458
|
+
connect();
|
|
459
|
+
return;
|
|
460
|
+
}
|
|
461
|
+
if (m.type === "identity") {
|
|
462
|
+
// The main thread answered "who am I" — relay.ts has sent it to the bridge as the `hello`.
|
|
463
|
+
var idn = m.identity || {};
|
|
464
|
+
if (idn.file) setBridge("on", "connected", "Connected to the bridge on this computer as " + JSON.stringify(idn.file) + ".");
|
|
465
|
+
return;
|
|
466
|
+
}
|
|
467
|
+
};
|
|
468
|
+
|
|
469
|
+
// ---- the localhost bridge (WebSocket client) ----
|
|
470
|
+
// One connection state, shown twice: a short `pill` in the (usually collapsed) section header and
|
|
471
|
+
// a full sentence inside it. `kind` is "on" | "bad" | "" (neutral). "bad" is reserved for states
|
|
472
|
+
// the USER has to fix — a bridge that simply isn't running right now is neutral, not an error:
|
|
473
|
+
// one-shot `dtwin` commands start and stop a bridge on every run, so "nothing listening" is the
|
|
474
|
+
// normal resting state between pulls.
|
|
475
|
+
function setBridge(kind, pill, text) {
|
|
476
|
+
var el = $("bridge"), p = $("pill");
|
|
477
|
+
if (el) { el.className = kind; el.textContent = text; }
|
|
478
|
+
if (p) { p.className = kind; p.textContent = pill; }
|
|
479
|
+
}
|
|
480
|
+
// Application close code the bridge uses for "the handshake was fine but the TOKEN is wrong"
|
|
481
|
+
// (bridge/server-core.js). A browser WebSocket can never read an HTTP 401 — a refused handshake is
|
|
482
|
+
// an opaque 1006, identical to "nothing is listening" — so the bridge admits a plugin with a bad
|
|
483
|
+
// token just long enough to say so with a code we CAN read.
|
|
484
|
+
var CLOSE_BAD_TOKEN = 4401;
|
|
485
|
+
var everOpened = false; // has ANY socket opened since the token was last saved?
|
|
486
|
+
var failsSinceOpen = 0; // consecutive failed attempts; >= ports.length means "tried them all"
|
|
487
|
+
// The last answer was "wrong token". Held until a socket opens or a new token is saved, so the
|
|
488
|
+
// background retry cannot replace the one instruction the user needs with "connecting…".
|
|
489
|
+
var tokenRejected = false;
|
|
490
|
+
// Reconnect discipline. Every handler below is guarded by `s === ws` so only the CURRENT socket
|
|
491
|
+
// can drive state, and reconnects go through a single shared timer. Without both, each retired
|
|
492
|
+
// socket kept its own live reconnect chain: saving the token closed a socket AND called connect()
|
|
493
|
+
// immediately, then a redial later the closed socket's onclose connected again — so sockets multiplied
|
|
494
|
+
// with every save (and with every stale-socket terminate() from the bridge).
|
|
495
|
+
var reconnectTimer = null;
|
|
496
|
+
// The redial delay, one port per attempt, so a window that keeps missing walks all of BRIDGE_PORTS
|
|
497
|
+
// in 3 s. A one-shot dtwin command opens its bridge at any moment and waits for this, so it is short;
|
|
498
|
+
// a refused dial to localhost costs next to nothing. The bridge reads it as timeouts.ts
|
|
499
|
+
// PLUGIN_REDIAL_MS (its settle window is built from it) — change both together.
|
|
500
|
+
var REDIAL_MS = 1000;
|
|
501
|
+
function scheduleReconnect(ms) {
|
|
502
|
+
if (reconnectTimer) return; // one pending reconnect at a time, ever
|
|
503
|
+
reconnectTimer = setTimeout(function () { reconnectTimer = null; connect(); }, ms || REDIAL_MS);
|
|
504
|
+
}
|
|
505
|
+
// Nothing answered. Which sentence depends on history: a bridge that WAS here and went away is the
|
|
506
|
+
// ordinary end of a one-shot `dtwin` pull; one that was never found (after every allowed port has
|
|
507
|
+
// been tried) more likely was never started.
|
|
508
|
+
function showOffline() {
|
|
509
|
+
if (everOpened) {
|
|
510
|
+
setBridge("", "waiting", "Disconnected — the last dtwin command finished, or Claude Code closed. Nothing to do: this reconnects by itself the next time one starts.");
|
|
511
|
+
} else if (failsSinceOpen >= BRIDGE_PORTS.length) {
|
|
512
|
+
setBridge("", "not running", "No bridge found on this computer (tried ports " + BRIDGE_PORTS.join(", ") + "). Start one — run a dtwin command such as dtwin --serve in a terminal, or open Claude Code in a project with the Design Twin MCP server enabled. This keeps retrying.");
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
function dropSocket() {
|
|
516
|
+
if (!ws) return;
|
|
517
|
+
var old = ws;
|
|
518
|
+
ws = null;
|
|
519
|
+
old.onopen = old.onclose = old.onerror = old.onmessage = null; // retire it silently
|
|
520
|
+
try { old.close(); } catch (e) {}
|
|
521
|
+
}
|
|
522
|
+
function connect() {
|
|
523
|
+
if (!bridgeToken) { setBridge("", "not set up", "Not set up. Paste the bridge token above and press Save."); return; }
|
|
524
|
+
if (ws && (ws.readyState === 0 || ws.readyState === 1)) return; // already CONNECTING/OPEN
|
|
525
|
+
// Try each allowed port in turn rather than only 8787. A bridge that lost the race for 8787 to
|
|
526
|
+
// some unrelated local process is otherwise invisible to the plugin, and the user has no way to
|
|
527
|
+
// point it elsewhere — the manifest, not a setting, decides what we may dial.
|
|
528
|
+
var port = BRIDGE_PORTS[portIdx % BRIDGE_PORTS.length];
|
|
529
|
+
portIdx++;
|
|
530
|
+
var s;
|
|
531
|
+
try {
|
|
532
|
+
s = new WebSocket("ws://localhost:" + port + "/?token=" + encodeURIComponent(bridgeToken));
|
|
533
|
+
} catch (e) { failsSinceOpen++; showOffline(); scheduleReconnect(); return; }
|
|
534
|
+
ws = s;
|
|
535
|
+
if (!tokenRejected && !everOpened && failsSinceOpen < BRIDGE_PORTS.length) setBridge("", "connecting…", "Looking for the bridge on this computer…");
|
|
536
|
+
s.onopen = function () {
|
|
537
|
+
if (s !== ws) return;
|
|
538
|
+
// Stick to whatever answered: the next reconnect should retry the LIVE bridge's port first,
|
|
539
|
+
// not resume walking the ladder past it.
|
|
540
|
+
portIdx = BRIDGE_PORTS.indexOf(port);
|
|
541
|
+
everOpened = true;
|
|
542
|
+
tokenRejected = false;
|
|
543
|
+
failsSinceOpen = 0;
|
|
544
|
+
setBridge("on", "connected", "Connected to the bridge on this computer (localhost:" + port + ").");
|
|
545
|
+
relay.socketOpened(); // asks the main thread who this file is; its answer goes out as the `hello`
|
|
546
|
+
};
|
|
547
|
+
s.onclose = function (ev) {
|
|
548
|
+
if (s !== ws) return;
|
|
549
|
+
ws = null;
|
|
550
|
+
// Every bridge run is abandoned (relay.ts) — also on CLOSE_BAD_TOKEN: nothing can collect the result
|
|
551
|
+
// there either. Not on onerror — it closes the socket, and this handler follows.
|
|
552
|
+
relay.socketClosed();
|
|
553
|
+
if (ev && ev.code === CLOSE_BAD_TOKEN) {
|
|
554
|
+
// A bridge IS there — stay on its port instead of walking on to the next one — and this is
|
|
555
|
+
// the one state only the user can fix, so open the section and say exactly what to do. Retry
|
|
556
|
+
// slowly: the same token will be refused again until it is re-pasted (Save reconnects at once).
|
|
557
|
+
portIdx = BRIDGE_PORTS.indexOf(port);
|
|
558
|
+
failsSinceOpen = 0;
|
|
559
|
+
tokenRejected = true;
|
|
560
|
+
setBridge("bad", "token rejected", "The bridge is running, but it rejected this token — it was probably replaced (dtwin --rotate-token) or this is a different computer's token. Run dtwin --show-token in a terminal, paste the result above and press Save.");
|
|
561
|
+
var d = $("connect"); if (d) d.open = true;
|
|
562
|
+
scheduleReconnect(15000);
|
|
563
|
+
return;
|
|
564
|
+
}
|
|
565
|
+
tokenRejected = false; // that bridge is gone; whatever answers next gets a fresh diagnosis
|
|
566
|
+
failsSinceOpen++;
|
|
567
|
+
showOffline();
|
|
568
|
+
scheduleReconnect();
|
|
569
|
+
};
|
|
570
|
+
s.onerror = function () { try { s.close(); } catch (e) {} };
|
|
571
|
+
s.onmessage = function (ev) {
|
|
572
|
+
if (s !== ws) return;
|
|
573
|
+
relay.fromSocket(ev.data); // a command or a cancel, for the main thread (relay.ts)
|
|
574
|
+
};
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
// ---- DOM-dependent wiring — runs only once the elements exist ----
|
|
578
|
+
function init() {
|
|
579
|
+
try {
|
|
580
|
+
var rs = $("run-sel"); if (rs) rs.onclick = function () { if (busy) return; setBusy(true); setStatus("Exporting…"); send("run-selection"); };
|
|
581
|
+
var rf = $("run-full"); if (rf) rf.onclick = function () { if (busy) return; setBusy(true); setStatus("Exporting…"); send("run-full"); };
|
|
582
|
+
var cb = $("cancel"); if (cb) cb.onclick = function () {
|
|
583
|
+
if (!running || cancelSent) return;
|
|
584
|
+
cancelSent = true;
|
|
585
|
+
cb.disabled = true;
|
|
586
|
+
cb.textContent = "Cancelling…";
|
|
587
|
+
// Honest about the latency: the walk only aborts at its safe points (each node, between
|
|
588
|
+
// pages and top-level frames, at each asset export), so the last exportAsync still finishes.
|
|
589
|
+
setStatus("Cancelling — the export stops at its next safe point…");
|
|
590
|
+
parent.postMessage({ pluginMessage: { type: "cancel" } }, "*");
|
|
591
|
+
};
|
|
592
|
+
var st = $("save-token"); if (st) st.onclick = function () {
|
|
593
|
+
var t = $("token"); bridgeToken = (t && t.value ? t.value : "").trim();
|
|
594
|
+
parent.postMessage({ pluginMessage: { type: "set-token", token: bridgeToken } }, "*");
|
|
595
|
+
dropSocket(); // retires handlers first, so the old socket can't schedule its own reconnect
|
|
596
|
+
if (reconnectTimer) { clearTimeout(reconnectTimer); reconnectTimer = null; } // a pending 15s bad-token retry must not delay this
|
|
597
|
+
everOpened = false; failsSinceOpen = 0; tokenRejected = false;
|
|
598
|
+
connect();
|
|
599
|
+
};
|
|
600
|
+
// Source images keep their ORIGINAL container format (png/jpg/webp/gif, detected from magic
|
|
601
|
+
// bytes in assets.ts), so the MIME must follow a.format rather than always claiming PNG.
|
|
602
|
+
var MIME = { svg: "image/svg+xml", png: "image/png", jpg: "image/jpeg", webp: "image/webp", gif: "image/gif" };
|
|
603
|
+
// Chromium (which Figma desktop embeds) throttles programmatic downloads to ~10 PER SECOND —
|
|
604
|
+
// a rate limit, not a total cap — and silently drops what exceeds it, so a `forEach(dl)` over a
|
|
605
|
+
// real export's dozens of layers or hundreds of assets delivered the first handful and looked
|
|
606
|
+
// like it delivered them all. 200ms spacing puts us at 5/s, comfortably under. Stagger them and
|
|
607
|
+
// report the actual count, so "done" means done. `label` names the batch in the status line;
|
|
608
|
+
// `make` builds each Blob lazily, one at a time, instead of materialising every asset's bytes
|
|
609
|
+
// up front.
|
|
610
|
+
// NOT fixed by pacing: Chrome's per-site "Automatic downloads" permission can block multi-file
|
|
611
|
+
// downloads outright at any rate. A blocked anchor click does not throw, so `failed` stays empty
|
|
612
|
+
// and the batch would otherwise announce a full delivery of zero files — the silent truncation
|
|
613
|
+
// this file refuses everywhere else. We cannot detect it, so the completion line names it.
|
|
614
|
+
// Re-entry is refused, and BOTH batch buttons go disabled for the duration (syncBatchButtons
|
|
615
|
+
// reads `batchRunning`) so it is visible why.
|
|
616
|
+
function dlBatch(items, label, name, make) {
|
|
617
|
+
if (!items.length) return;
|
|
618
|
+
if (batchRunning) { setStatus("A download batch is already running — wait for it to finish.", true); return; }
|
|
619
|
+
batchRunning = true;
|
|
620
|
+
setBanner(""); // this attempt's outcome replaces the last one's, never stacks on it
|
|
621
|
+
syncBatchButtons();
|
|
622
|
+
var done = function () {
|
|
623
|
+
batchRunning = false;
|
|
624
|
+
syncBatchButtons();
|
|
625
|
+
};
|
|
626
|
+
var i = 0;
|
|
627
|
+
var failed = [];
|
|
628
|
+
setStatus("Downloading " + items.length + " " + label + "… 0/" + items.length);
|
|
629
|
+
(function step() {
|
|
630
|
+
if (i >= items.length) {
|
|
631
|
+
done();
|
|
632
|
+
// Report the WHOLE outcome. Aborting the batch on the first failure — as this did —
|
|
633
|
+
// threw away every remaining item over one bad blob, and a partial delivery that
|
|
634
|
+
// announces itself as finished is the silent-truncation this file avoids everywhere
|
|
635
|
+
// else. One bad asset should cost you that asset, not the other 399.
|
|
636
|
+
var delivered = items.length - failed.length;
|
|
637
|
+
if (!failed.length) {
|
|
638
|
+
setStatus("Downloaded " + items.length + " " + label + " — if fewer arrived, allow “Automatic downloads” for figma.com and retry.");
|
|
639
|
+
} else if (delivered === 0) {
|
|
640
|
+
// ZERO delivered is a different event from "3 of 400 failed": nothing landed on disk,
|
|
641
|
+
// and the cause is almost always Chrome's per-site "Automatic downloads" permission
|
|
642
|
+
// rather than the blobs themselves. It gets the persistent banner — a status line here
|
|
643
|
+
// is gone the moment the next selection change or export writes over it, which is how
|
|
644
|
+
// this failed silently before.
|
|
645
|
+
setBanner("Downloaded 0 of " + items.length + " " + label + " — nothing was saved. "
|
|
646
|
+
+ "Allow “Automatic downloads” for figma.com in your browser/desktop-app settings, then click the button again. "
|
|
647
|
+
+ "First failure: " + failed[0]);
|
|
648
|
+
setStatus("Download failed — see the notice above.", true);
|
|
649
|
+
} else {
|
|
650
|
+
setBanner("Downloaded " + delivered + " of " + items.length + " " + label + " — " + failed.length + " failed: " + failed.join(", "));
|
|
651
|
+
setStatus("Downloaded " + delivered + "/" + items.length + " " + label + " — see the notice above.", true);
|
|
652
|
+
}
|
|
653
|
+
return;
|
|
654
|
+
}
|
|
655
|
+
var it = items[i++];
|
|
656
|
+
try { dl(name(it), make(it)); } catch (e) { failed.push(name(it) + " (" + errText(e) + ")"); }
|
|
657
|
+
setStatus("Downloading " + items.length + " " + label + "… " + i + "/" + items.length + (failed.length ? " (" + failed.length + " failed)" : ""));
|
|
658
|
+
setTimeout(step, 200); // ~200ms is the spacing that reliably clears Chromium's throttle
|
|
659
|
+
})();
|
|
660
|
+
}
|
|
661
|
+
var lb = $("layers"); if (lb) lb.onclick = function () {
|
|
662
|
+
dlBatch(layerFilesState, "layer file(s)", function (f) { return f.name; }, function (f) {
|
|
663
|
+
return new Blob([f.content], { type: "application/json" });
|
|
664
|
+
});
|
|
665
|
+
};
|
|
666
|
+
var as = $("assets"); if (as) as.onclick = function () {
|
|
667
|
+
// a.file is the name the plugin already wrote into the tree's `asset` path — use it verbatim
|
|
668
|
+
// rather than re-deriving the convention (this copy had already drifted: it sanitised the id
|
|
669
|
+
// but not the format).
|
|
670
|
+
dlBatch(assetsState, "asset(s)", function (a) { return a.file; }, function (a) {
|
|
671
|
+
var type = MIME[a.format] || "application/octet-stream";
|
|
672
|
+
return a.text != null ? new Blob([a.text], { type: type }) : new Blob([b64ToBytes(a.base64)], { type: type });
|
|
673
|
+
});
|
|
674
|
+
};
|
|
675
|
+
// Ask code.js for the stored token; connect() runs once it replies. Also proves the round-trip.
|
|
676
|
+
parent.postMessage({ pluginMessage: { type: "get-token" } }, "*");
|
|
677
|
+
setStatus("Ready — select a frame, or use the blue button.");
|
|
678
|
+
} catch (e) {
|
|
679
|
+
setStatus("UI init error: " + errText(e), true);
|
|
680
|
+
}
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
if (document.readyState === "loading") document.addEventListener("DOMContentLoaded", init);
|
|
684
|
+
else init();
|
|
685
|
+
})();
|
|
686
|
+
</script>
|