ais6-app 0.1.0 → 0.2.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/README.md +10 -1
- package/README.zh-CN.md +9 -1
- package/bin/ais6-app.mjs +30 -1
- package/package.json +5 -3
- package/src/create.mjs +64 -0
- package/src/index.mjs +1 -0
- package/template/LICENSE +21 -0
- package/template/README.md +40 -0
- package/template/README.zh-CN.md +37 -0
- package/template/_gitignore +4 -0
- package/template/biome.json +8 -0
- package/template/index.html +14 -0
- package/template/package.json +27 -0
- package/template/public/ais6-bridge.js +711 -0
- package/template/public/app.manifest.json +34 -0
- package/template/public/kit.css +431 -0
- package/template/src/bridge/bridge.ts +89 -0
- package/template/src/bridge/types.ts +100 -0
- package/template/src/main.tsx +105 -0
- package/template/src/strings/en.ts +15 -0
- package/template/src/strings/index.ts +23 -0
- package/template/src/strings/zh.ts +13 -0
- package/template/src/styles/app.css +42 -0
- package/template/src/ui/App.tsx +34 -0
- package/template/tests/unit/strings.test.ts +36 -0
- package/template/tsconfig.json +22 -0
- package/template/vite.config.ts +9 -0
- package/template/vitest.config.ts +5 -0
|
@@ -0,0 +1,711 @@
|
|
|
1
|
+
// AI System 6 app bridge — client, protocol 1.
|
|
2
|
+
//
|
|
3
|
+
// The one file an extracted app uses to talk to the desk. It is vendored by
|
|
4
|
+
// each app repository and served by the desk at /apps/_kit/ais6-bridge.js; no
|
|
5
|
+
// app writes postMessage by hand. Contract: bridge contract v1, section 3 of
|
|
6
|
+
// the desk's app extraction plan.
|
|
7
|
+
//
|
|
8
|
+
// Inside the desk the app is a same-origin iframe. The client says `hello` to
|
|
9
|
+
// its parent once; the desk answers `welcome` and hands over a MessagePort, and
|
|
10
|
+
// every later message travels on that port. Outside a desk (the app's own page,
|
|
11
|
+
// its development server) the same calls are answered by a standalone adapter:
|
|
12
|
+
// localStorage-backed state, the system colour scheme, files as downloads.
|
|
13
|
+
//
|
|
14
|
+
// This file is the single place an app artifact may name localStorage,
|
|
15
|
+
// sessionStorage or `parent` (tooling/apps/boundary-census.mjs allow-lists it).
|
|
16
|
+
(function installAis6Bridge(root) {
|
|
17
|
+
"use strict";
|
|
18
|
+
|
|
19
|
+
const PROTOCOL = 1;
|
|
20
|
+
const ENVELOPE = 1;
|
|
21
|
+
const HELLO_TIMEOUT_MS = 10000;
|
|
22
|
+
const REQUEST_TIMEOUT_MS = 15000;
|
|
23
|
+
const SNAPSHOT_IDLE_MS = 1000;
|
|
24
|
+
const SNAPSHOT_MAX_EDGE = 1600;
|
|
25
|
+
const ERROR_CODES = Object.freeze([
|
|
26
|
+
"unknown-method", "not-permitted", "read-only", "not-found", "conflict",
|
|
27
|
+
"invalid", "timeout", "cancelled", "unavailable", "internal",
|
|
28
|
+
]);
|
|
29
|
+
|
|
30
|
+
class BridgeError extends Error {
|
|
31
|
+
constructor(code, message) {
|
|
32
|
+
super(message || code);
|
|
33
|
+
this.name = "BridgeError";
|
|
34
|
+
this.code = ERROR_CODES.includes(code) ? code : "internal";
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const errorPayload = (error) => ({
|
|
39
|
+
code: error instanceof BridgeError ? error.code : (ERROR_CODES.includes(error?.code) ? error.code : "internal"),
|
|
40
|
+
message: String(error?.message || error || "internal"),
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
// ---------------------------------------------------------------------------
|
|
44
|
+
// Port endpoint: the request/response/event machinery both modes share.
|
|
45
|
+
// `send(envelope, transfer)` delivers one message to the other side;
|
|
46
|
+
// `dispatch(envelope)` is called for every message that arrives.
|
|
47
|
+
|
|
48
|
+
function createEndpoint(send, prefix) {
|
|
49
|
+
let nextId = 0;
|
|
50
|
+
let closed = false;
|
|
51
|
+
const pending = new Map();
|
|
52
|
+
const handlers = new Map();
|
|
53
|
+
const listeners = new Map();
|
|
54
|
+
|
|
55
|
+
function settle(id, fn) {
|
|
56
|
+
const entry = pending.get(id);
|
|
57
|
+
if (!entry) return;
|
|
58
|
+
pending.delete(id);
|
|
59
|
+
clearTimeout(entry.timer);
|
|
60
|
+
fn(entry);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function call(method, params = {}, options = {}) {
|
|
64
|
+
if (closed) return Promise.reject(new BridgeError("unavailable", "bridge closed"));
|
|
65
|
+
nextId += 1;
|
|
66
|
+
const id = `${prefix}${nextId}`;
|
|
67
|
+
const timeout = Number.isFinite(options.timeout) ? options.timeout : REQUEST_TIMEOUT_MS;
|
|
68
|
+
return new Promise((resolve, reject) => {
|
|
69
|
+
const timer = setTimeout(() => settle(id, (entry) => entry.reject(new BridgeError("timeout", `${method} timed out`))), timeout);
|
|
70
|
+
pending.set(id, { resolve, reject, timer });
|
|
71
|
+
try {
|
|
72
|
+
send({ v: ENVELOPE, id, kind: "request", method, params }, options.transfer || []);
|
|
73
|
+
} catch (error) {
|
|
74
|
+
settle(id, (entry) => entry.reject(new BridgeError("invalid", String(error?.message || error))));
|
|
75
|
+
}
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function emit(method, params = {}, transfer = []) {
|
|
80
|
+
if (closed) return false;
|
|
81
|
+
try {
|
|
82
|
+
send({ v: ENVELOPE, kind: "event", method, params }, transfer);
|
|
83
|
+
return true;
|
|
84
|
+
} catch (error) {
|
|
85
|
+
return false;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
async function answer(message) {
|
|
90
|
+
const handler = handlers.get(message.method);
|
|
91
|
+
if (!handler) {
|
|
92
|
+
send({ v: ENVELOPE, id: message.id, kind: "response", ok: false, error: { code: "unknown-method", message: message.method } }, []);
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
try {
|
|
96
|
+
const result = await handler(message.params || {});
|
|
97
|
+
const transfer = result && result.__transfer ? result.__transfer : [];
|
|
98
|
+
if (result && result.__transfer) delete result.__transfer;
|
|
99
|
+
send({ v: ENVELOPE, id: message.id, kind: "response", ok: true, result: result ?? {} }, transfer);
|
|
100
|
+
} catch (error) {
|
|
101
|
+
send({ v: ENVELOPE, id: message.id, kind: "response", ok: false, error: errorPayload(error) }, []);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function dispatch(message) {
|
|
106
|
+
if (closed || !message || typeof message !== "object" || message.v !== ENVELOPE) return;
|
|
107
|
+
if (message.kind === "response") {
|
|
108
|
+
settle(message.id, (entry) => {
|
|
109
|
+
if (message.ok) entry.resolve(message.result ?? {});
|
|
110
|
+
else entry.reject(new BridgeError(message.error?.code, message.error?.message));
|
|
111
|
+
});
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
if (message.kind === "request" && typeof message.method === "string") {
|
|
115
|
+
answer(message);
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
if (message.kind === "event" && typeof message.method === "string") {
|
|
119
|
+
(listeners.get(message.method) || []).slice().forEach((listener) => {
|
|
120
|
+
try { listener(message.params || {}); } catch (error) { /* one listener cannot break the others */ }
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function close() {
|
|
126
|
+
closed = true;
|
|
127
|
+
pending.forEach((entry) => {
|
|
128
|
+
clearTimeout(entry.timer);
|
|
129
|
+
entry.reject(new BridgeError("cancelled", "bridge closed"));
|
|
130
|
+
});
|
|
131
|
+
pending.clear();
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
return {
|
|
135
|
+
call,
|
|
136
|
+
emit,
|
|
137
|
+
dispatch,
|
|
138
|
+
close,
|
|
139
|
+
handle(method, fn) { handlers.set(method, fn); },
|
|
140
|
+
on(method, fn) {
|
|
141
|
+
if (!listeners.has(method)) listeners.set(method, []);
|
|
142
|
+
listeners.get(method).push(fn);
|
|
143
|
+
return () => listeners.set(method, (listeners.get(method) || []).filter((item) => item !== fn));
|
|
144
|
+
},
|
|
145
|
+
get closed() { return closed; },
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// ---------------------------------------------------------------------------
|
|
150
|
+
// Appearance: the desk hands over a closed token list and the kit stylesheet.
|
|
151
|
+
|
|
152
|
+
function applyAppearance(appearance) {
|
|
153
|
+
const doc = root.document;
|
|
154
|
+
if (!doc || !appearance) return;
|
|
155
|
+
const html = doc.documentElement;
|
|
156
|
+
html.dataset.colorMode = appearance.colorMode === "dark" ? "dark" : "light";
|
|
157
|
+
html.dataset.touch = appearance.touch ? "true" : "false";
|
|
158
|
+
html.dataset.reducedMotion = appearance.reducedMotion ? "true" : "false";
|
|
159
|
+
if (appearance.id) html.dataset.appearance = String(appearance.id);
|
|
160
|
+
Object.entries(appearance.tokens || {}).forEach(([name, value]) => {
|
|
161
|
+
if (/^--kit-[a-z0-9-]+$/.test(name)) html.style.setProperty(name, String(value));
|
|
162
|
+
});
|
|
163
|
+
// The desk's era fonts: its @font-face rules, nothing else.
|
|
164
|
+
if (Array.isArray(appearance.fontFaces)) {
|
|
165
|
+
let fonts = doc.querySelector("style[data-ais6-fonts]");
|
|
166
|
+
if (!fonts) {
|
|
167
|
+
fonts = doc.createElement("style");
|
|
168
|
+
fonts.dataset.ais6Fonts = "";
|
|
169
|
+
(doc.head || html).appendChild(fonts);
|
|
170
|
+
}
|
|
171
|
+
const faces = appearance.fontFaces.map(String).filter((rule) => /^@font-face\s*\{[^{}]*\}$/.test(rule.trim()));
|
|
172
|
+
if (fonts.textContent !== faces.join("\n")) fonts.textContent = faces.join("\n");
|
|
173
|
+
}
|
|
174
|
+
if (appearance.kitUrl) {
|
|
175
|
+
let link = doc.querySelector("link[data-ais6-kit]");
|
|
176
|
+
if (!link) {
|
|
177
|
+
link = doc.createElement("link");
|
|
178
|
+
link.rel = "stylesheet";
|
|
179
|
+
link.dataset.ais6Kit = "";
|
|
180
|
+
(doc.head || html).appendChild(link);
|
|
181
|
+
}
|
|
182
|
+
if (link.getAttribute("href") !== appearance.kitUrl) link.setAttribute("href", appearance.kitUrl);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// ---------------------------------------------------------------------------
|
|
187
|
+
// Snapshot producers (section 3.7). Both return an ImageBitmap or null and
|
|
188
|
+
// never throw; the desk falls back to its plain effect when nothing arrives.
|
|
189
|
+
|
|
190
|
+
function snapshotScale(width, height) {
|
|
191
|
+
const ratio = Math.min(2, Math.max(1, Number(root.devicePixelRatio) || 1));
|
|
192
|
+
return Math.min(ratio, SNAPSHOT_MAX_EDGE / Math.max(width, height, 1));
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// What is on screen: the target's box clipped to the frame's viewport. A
|
|
196
|
+
// page taller than its window is pictured as the window shows it.
|
|
197
|
+
function visibleBox(target) {
|
|
198
|
+
const rect = target.getBoundingClientRect();
|
|
199
|
+
const left = Math.max(0, rect.left);
|
|
200
|
+
const top = Math.max(0, rect.top);
|
|
201
|
+
const right = Math.min(root.innerWidth || rect.right, rect.right);
|
|
202
|
+
const bottom = Math.min(root.innerHeight || rect.bottom, rect.bottom);
|
|
203
|
+
return { rect, left, top, width: Math.max(0, Math.round(right - left)), height: Math.max(0, Math.round(bottom - top)) };
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function surfaceFor(width, height) {
|
|
207
|
+
if (typeof root.OffscreenCanvas === "function") return new root.OffscreenCanvas(width, height);
|
|
208
|
+
const canvas = root.document.createElement("canvas");
|
|
209
|
+
canvas.width = width;
|
|
210
|
+
canvas.height = height;
|
|
211
|
+
return canvas;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
async function canvasSnapshot(target) {
|
|
215
|
+
try {
|
|
216
|
+
const box = visibleBox(target);
|
|
217
|
+
if (box.width <= 0 || box.height <= 0) return null;
|
|
218
|
+
const scale = snapshotScale(box.width, box.height);
|
|
219
|
+
const width = Math.round(box.width * scale);
|
|
220
|
+
const height = Math.round(box.height * scale);
|
|
221
|
+
const surface = surfaceFor(width, height);
|
|
222
|
+
const ctx = surface.getContext("2d");
|
|
223
|
+
const background = root.getComputedStyle(target).backgroundColor;
|
|
224
|
+
ctx.fillStyle = background && background !== "rgba(0, 0, 0, 0)" ? background : "#fff";
|
|
225
|
+
ctx.fillRect(0, 0, width, height);
|
|
226
|
+
const canvases = target.tagName === "CANVAS" ? [target] : [...target.querySelectorAll("canvas")];
|
|
227
|
+
canvases.forEach((canvas) => {
|
|
228
|
+
const at = canvas.getBoundingClientRect();
|
|
229
|
+
if (at.width <= 0 || at.height <= 0) return;
|
|
230
|
+
ctx.drawImage(canvas, (at.left - box.left) * scale, (at.top - box.top) * scale, at.width * scale, at.height * scale);
|
|
231
|
+
});
|
|
232
|
+
const bitmap = await root.createImageBitmap(surface);
|
|
233
|
+
return { bitmap, width: box.width, height: box.height, scale };
|
|
234
|
+
} catch (error) {
|
|
235
|
+
return null;
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// Painting and layout properties copied onto the clone. Everything a
|
|
240
|
+
// foreignObject cannot reach through the cascade must be on the node itself.
|
|
241
|
+
const DOM_PROPS = [
|
|
242
|
+
"display", "position", "left", "top", "right", "bottom", "z-index", "width", "height",
|
|
243
|
+
"min-width", "min-height", "max-width", "max-height", "box-sizing",
|
|
244
|
+
"flex-direction", "flex-wrap", "flex-grow", "flex-shrink", "flex-basis", "order",
|
|
245
|
+
"align-items", "align-self", "align-content", "justify-content", "justify-items", "justify-self",
|
|
246
|
+
"gap", "row-gap", "column-gap", "grid-template-columns", "grid-template-rows", "grid-template-areas",
|
|
247
|
+
"grid-auto-flow", "grid-column", "grid-row", "grid-auto-columns", "grid-auto-rows",
|
|
248
|
+
"background-color", "background-image", "background-size", "background-position", "background-repeat",
|
|
249
|
+
"color", "border-top", "border-right", "border-bottom", "border-left", "border-radius",
|
|
250
|
+
"outline", "outline-offset", "box-shadow", "opacity", "visibility", "overflow-x", "overflow-y", "clip-path",
|
|
251
|
+
"padding-top", "padding-right", "padding-bottom", "padding-left",
|
|
252
|
+
"margin-top", "margin-right", "margin-bottom", "margin-left",
|
|
253
|
+
"font-family", "font-size", "font-weight", "font-style", "font-variant", "line-height",
|
|
254
|
+
"letter-spacing", "text-align", "text-decoration", "text-transform", "text-shadow",
|
|
255
|
+
"white-space", "word-break", "overflow-wrap", "text-overflow", "vertical-align",
|
|
256
|
+
"transform", "transform-origin", "filter", "object-fit", "object-position", "list-style",
|
|
257
|
+
"appearance", "-webkit-appearance", "fill", "stroke", "stroke-width", "color-scheme",
|
|
258
|
+
"-webkit-font-smoothing", "image-rendering",
|
|
259
|
+
];
|
|
260
|
+
|
|
261
|
+
function inlineStyles(live, copy) {
|
|
262
|
+
const cs = root.getComputedStyle(live);
|
|
263
|
+
copy.setAttribute("style", `${DOM_PROPS.map((name) => {
|
|
264
|
+
const value = cs.getPropertyValue(name);
|
|
265
|
+
return value ? `${name}:${value}` : "";
|
|
266
|
+
}).filter(Boolean).join(";")};animation:none;transition:none;`);
|
|
267
|
+
if (live.tagName === "TEXTAREA") copy.textContent = live.value;
|
|
268
|
+
if (live.tagName === "INPUT") {
|
|
269
|
+
copy.setAttribute("value", live.type === "password" ? "" : live.value);
|
|
270
|
+
if (live.checked) copy.setAttribute("checked", ""); else copy.removeAttribute("checked");
|
|
271
|
+
}
|
|
272
|
+
if (live.tagName === "OPTION") {
|
|
273
|
+
if (live.selected) copy.setAttribute("selected", ""); else copy.removeAttribute("selected");
|
|
274
|
+
}
|
|
275
|
+
const liveKids = [...live.children];
|
|
276
|
+
const copyKids = [...copy.children];
|
|
277
|
+
liveKids.forEach((child, index) => { if (copyKids[index]) inlineStyles(child, copyKids[index]); });
|
|
278
|
+
const x = live.scrollLeft || 0;
|
|
279
|
+
const y = live.scrollTop || 0;
|
|
280
|
+
if (x || y) {
|
|
281
|
+
copy.style.setProperty("overflow", "hidden");
|
|
282
|
+
copyKids.forEach((child) => child.style.setProperty("translate", `${-x}px ${-y}px`));
|
|
283
|
+
}
|
|
284
|
+
materializePseudo(live, copy, "::before");
|
|
285
|
+
materializePseudo(live, copy, "::after");
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
// ::before and ::after are not in a clone. Drawn controls lean on them (the
|
|
289
|
+
// select harness's arrow), so each becomes a span carrying its computed
|
|
290
|
+
// style and its literal content.
|
|
291
|
+
const NO_PSEUDO = /^(?:INPUT|SELECT|TEXTAREA|OPTION|IMG|CANVAS|IFRAME|VIDEO|AUDIO|BR|HR|svg)$/i;
|
|
292
|
+
function materializePseudo(live, copy, pseudo) {
|
|
293
|
+
if (NO_PSEUDO.test(live.tagName)) return;
|
|
294
|
+
const ps = root.getComputedStyle(live, pseudo);
|
|
295
|
+
const content = ps.getPropertyValue("content");
|
|
296
|
+
if (!content || content === "none" || content === "normal" || ps.getPropertyValue("display") === "none") return;
|
|
297
|
+
const span = root.document.createElement("span");
|
|
298
|
+
span.setAttribute("style", `${DOM_PROPS.map((name) => {
|
|
299
|
+
const value = ps.getPropertyValue(name);
|
|
300
|
+
return value ? `${name}:${value}` : "";
|
|
301
|
+
}).filter(Boolean).join(";")};animation:none;transition:none;`);
|
|
302
|
+
const literal = /^"(.*)"$/.exec(content);
|
|
303
|
+
span.textContent = literal ? literal[1].replace(/\\(.)/g, "$1") : "";
|
|
304
|
+
if (pseudo === "::before") copy.insertBefore(span, copy.firstChild);
|
|
305
|
+
else copy.appendChild(span);
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
const dataUrls = new Map();
|
|
309
|
+
function sameOriginDataUrl(url) {
|
|
310
|
+
let absolute;
|
|
311
|
+
try { absolute = new URL(url, root.document.baseURI); } catch (error) { return Promise.resolve(null); }
|
|
312
|
+
if (absolute.origin !== root.location.origin) return Promise.resolve(null);
|
|
313
|
+
if (!dataUrls.has(absolute.href)) {
|
|
314
|
+
dataUrls.set(absolute.href, root.fetch(absolute.href)
|
|
315
|
+
.then((response) => (response.ok ? response.blob() : null))
|
|
316
|
+
.then((blob) => (blob ? new Promise((resolve) => {
|
|
317
|
+
const reader = new root.FileReader();
|
|
318
|
+
reader.onload = () => resolve(typeof reader.result === "string" ? reader.result : null);
|
|
319
|
+
reader.onerror = () => resolve(null);
|
|
320
|
+
reader.readAsDataURL(blob);
|
|
321
|
+
}) : null))
|
|
322
|
+
.catch(() => null));
|
|
323
|
+
}
|
|
324
|
+
return dataUrls.get(absolute.href);
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
async function embedImages(live, copy) {
|
|
328
|
+
const jobs = [];
|
|
329
|
+
const liveArt = [...live.querySelectorAll("img, image, canvas")];
|
|
330
|
+
const copyArt = [...copy.querySelectorAll("img, image, canvas")];
|
|
331
|
+
liveArt.forEach((node, index) => {
|
|
332
|
+
const target = copyArt[index];
|
|
333
|
+
if (!target) return;
|
|
334
|
+
if (node.tagName === "CANVAS") {
|
|
335
|
+
// A canvas clones blank; its pixels travel as an image.
|
|
336
|
+
try {
|
|
337
|
+
const img = root.document.createElement("img");
|
|
338
|
+
img.setAttribute("src", node.toDataURL());
|
|
339
|
+
img.setAttribute("style", target.getAttribute("style") || "");
|
|
340
|
+
target.replaceWith(img);
|
|
341
|
+
} catch (error) { /* a tainted canvas stays blank */ }
|
|
342
|
+
return;
|
|
343
|
+
}
|
|
344
|
+
const raw = node.tagName === "IMG" ? node.currentSrc || node.src : node.getAttribute("href");
|
|
345
|
+
if (!raw || raw.startsWith("data:")) return;
|
|
346
|
+
jobs.push(sameOriginDataUrl(raw).then((data) => {
|
|
347
|
+
if (!data) return;
|
|
348
|
+
if (target.tagName === "IMG") target.setAttribute("src", data);
|
|
349
|
+
else target.setAttribute("href", data);
|
|
350
|
+
}));
|
|
351
|
+
});
|
|
352
|
+
[copy, ...copy.querySelectorAll("*")].forEach((node) => {
|
|
353
|
+
const background = node.style?.getPropertyValue("background-image") || "";
|
|
354
|
+
const urls = [...background.matchAll(/url\("?([^")]+)"?\)/g)].map((match) => match[1]).filter((url) => !url.startsWith("data:"));
|
|
355
|
+
if (!urls.length) return;
|
|
356
|
+
jobs.push(Promise.all(urls.map(sameOriginDataUrl)).then((data) => {
|
|
357
|
+
let next = node.style.getPropertyValue("background-image");
|
|
358
|
+
urls.forEach((url, index) => { if (data[index]) next = next.split(url).join(data[index]); });
|
|
359
|
+
node.style.setProperty("background-image", next);
|
|
360
|
+
}));
|
|
361
|
+
});
|
|
362
|
+
await Promise.race([Promise.all(jobs), new Promise((resolve) => setTimeout(resolve, 600))]);
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
function loadImage(url) {
|
|
366
|
+
return new Promise((resolve, reject) => {
|
|
367
|
+
const img = new root.Image();
|
|
368
|
+
img.onload = () => resolve(img);
|
|
369
|
+
img.onerror = () => reject(new Error("image failed"));
|
|
370
|
+
img.src = url;
|
|
371
|
+
});
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
async function domSnapshot(target) {
|
|
375
|
+
try {
|
|
376
|
+
const box = visibleBox(target);
|
|
377
|
+
if (box.width <= 0 || box.height <= 0) return null;
|
|
378
|
+
const { width, height, rect } = box;
|
|
379
|
+
const scale = snapshotScale(width, height);
|
|
380
|
+
const copy = target.cloneNode(true);
|
|
381
|
+
inlineStyles(target, copy);
|
|
382
|
+
copy.querySelectorAll("script, iframe, video, audio, object, embed").forEach((node) => node.remove());
|
|
383
|
+
// The whole target, shifted so the visible part lands in the picture.
|
|
384
|
+
Object.assign(copy.style, {
|
|
385
|
+
position: "absolute", left: `${Math.round(rect.left - box.left)}px`, top: `${Math.round(rect.top - box.top)}px`,
|
|
386
|
+
margin: "0px", transform: "none", translate: "none",
|
|
387
|
+
width: `${Math.round(rect.width)}px`, height: `${Math.round(rect.height)}px`, boxSizing: "border-box",
|
|
388
|
+
});
|
|
389
|
+
await embedImages(target, copy);
|
|
390
|
+
const serialized = new root.XMLSerializer().serializeToString(copy);
|
|
391
|
+
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="${Math.round(width * scale)}" height="${Math.round(height * scale)}">`
|
|
392
|
+
+ `<foreignObject x="0" y="0" width="${width}" height="${height}" transform="scale(${scale})">`
|
|
393
|
+
+ `<div xmlns="http://www.w3.org/1999/xhtml" style="position:relative;width:${width}px;height:${height}px;overflow:hidden;">${serialized}</div>`
|
|
394
|
+
+ "</foreignObject></svg>";
|
|
395
|
+
// A data: URL, not blob:. Chromium treats a foreignObject SVG loaded from
|
|
396
|
+
// a blob URL as cross-origin and taints the canvas.
|
|
397
|
+
const img = await loadImage(`data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`);
|
|
398
|
+
const surface = surfaceFor(Math.round(width * scale), Math.round(height * scale));
|
|
399
|
+
const ctx = surface.getContext("2d");
|
|
400
|
+
const background = root.getComputedStyle(target).backgroundColor;
|
|
401
|
+
ctx.fillStyle = background && background !== "rgba(0, 0, 0, 0)" ? background : "#fff";
|
|
402
|
+
ctx.fillRect(0, 0, surface.width, surface.height);
|
|
403
|
+
ctx.drawImage(img, 0, 0);
|
|
404
|
+
const bitmap = await root.createImageBitmap(surface);
|
|
405
|
+
return { bitmap, width, height, scale };
|
|
406
|
+
} catch (error) {
|
|
407
|
+
return null;
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
// ---------------------------------------------------------------------------
|
|
412
|
+
// Hosted mode.
|
|
413
|
+
|
|
414
|
+
function hostedBridge(port, welcome, options) {
|
|
415
|
+
const endpoint = createEndpoint((message, transfer) => port.postMessage(message, transfer), "a");
|
|
416
|
+
port.onmessage = (event) => endpoint.dispatch(event.data);
|
|
417
|
+
if (typeof port.start === "function") port.start();
|
|
418
|
+
|
|
419
|
+
let snapshotConfig = null;
|
|
420
|
+
let snapshotTimer = 0;
|
|
421
|
+
let snapshotObserver = null;
|
|
422
|
+
const granted = new Set(welcome.capabilities || []);
|
|
423
|
+
|
|
424
|
+
async function produceSnapshot() {
|
|
425
|
+
if (!snapshotConfig) return null;
|
|
426
|
+
const target = snapshotConfig.target || root.document?.body || null;
|
|
427
|
+
if (typeof snapshotConfig.producer === "function") return snapshotConfig.producer(target);
|
|
428
|
+
if (!target) return null;
|
|
429
|
+
return snapshotConfig.producer === "canvas" ? canvasSnapshot(target) : domSnapshot(target);
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
async function sendSnapshot() {
|
|
433
|
+
if (!snapshotConfig || !granted.has("snapshot")) return false;
|
|
434
|
+
const picture = await produceSnapshot();
|
|
435
|
+
if (!picture?.bitmap) return false;
|
|
436
|
+
return endpoint.emit("window.snapshot", picture, [picture.bitmap]);
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
function scheduleSnapshot() {
|
|
440
|
+
if (!snapshotConfig || snapshotTimer) return;
|
|
441
|
+
snapshotTimer = setTimeout(() => {
|
|
442
|
+
snapshotTimer = 0;
|
|
443
|
+
const idle = root.requestIdleCallback || ((fn) => setTimeout(fn, 0));
|
|
444
|
+
idle(() => { sendSnapshot(); });
|
|
445
|
+
}, SNAPSHOT_IDLE_MS);
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
endpoint.handle("menu.command", () => ({ handled: false }));
|
|
449
|
+
endpoint.handle("lifecycle.suspend", async () => { await sendSnapshot(); return {}; });
|
|
450
|
+
endpoint.handle("lifecycle.resume", () => ({}));
|
|
451
|
+
endpoint.handle("lifecycle.dispose", () => ({}));
|
|
452
|
+
endpoint.handle("intent.deliver", () => ({ ok: false, reason: "no-handler" }));
|
|
453
|
+
endpoint.handle("window.snapshotRequest", async () => {
|
|
454
|
+
const picture = await produceSnapshot();
|
|
455
|
+
if (!picture?.bitmap) throw new BridgeError("unavailable", "no snapshot producer");
|
|
456
|
+
return { ...picture, __transfer: [picture.bitmap] };
|
|
457
|
+
});
|
|
458
|
+
|
|
459
|
+
if (options.applyAppearance !== false) {
|
|
460
|
+
applyAppearance(welcome.appearance);
|
|
461
|
+
endpoint.on("appearance.changed", (appearance) => applyAppearance(appearance));
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
// Desk shortcuts. A modified key the app did not consume goes to the desk,
|
|
465
|
+
// so ⌘W and the menu shortcuts keep working while the frame has focus.
|
|
466
|
+
const onKeyDown = (event) => {
|
|
467
|
+
if (event.defaultPrevented || !(event.metaKey || event.ctrlKey)) return;
|
|
468
|
+
endpoint.emit("input.shortcut", {
|
|
469
|
+
key: event.key, code: event.code,
|
|
470
|
+
metaKey: !!event.metaKey, ctrlKey: !!event.ctrlKey, altKey: !!event.altKey, shiftKey: !!event.shiftKey,
|
|
471
|
+
});
|
|
472
|
+
};
|
|
473
|
+
const onPointerDown = () => endpoint.emit("window.activate", {});
|
|
474
|
+
if (typeof root.addEventListener === "function") {
|
|
475
|
+
root.addEventListener("keydown", onKeyDown);
|
|
476
|
+
root.addEventListener("pointerdown", onPointerDown, true);
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
const bridge = {
|
|
480
|
+
standalone: false,
|
|
481
|
+
welcome,
|
|
482
|
+
call: endpoint.call,
|
|
483
|
+
emit: endpoint.emit,
|
|
484
|
+
on: endpoint.on,
|
|
485
|
+
handle: endpoint.handle,
|
|
486
|
+
ready() {
|
|
487
|
+
const sent = endpoint.emit("app.ready", {});
|
|
488
|
+
if (snapshotConfig) setTimeout(() => sendSnapshot(), 0);
|
|
489
|
+
return sent;
|
|
490
|
+
},
|
|
491
|
+
status(params) { return endpoint.emit("app.status", params || {}); },
|
|
492
|
+
menuState(params) { return endpoint.emit("menu.state", params || {}); },
|
|
493
|
+
setTitle(title) { return endpoint.emit("window.setTitle", { title: String(title || "") }); },
|
|
494
|
+
// Snapshots: `producer` is "canvas", "dom" or a function returning
|
|
495
|
+
// { bitmap, width, height, scale }. Pictures are taken off the
|
|
496
|
+
// interaction path: after first paint, on suspend, and at most once a
|
|
497
|
+
// second after a visible change.
|
|
498
|
+
enableSnapshots(config = {}) {
|
|
499
|
+
snapshotConfig = { producer: config.producer || "dom", target: config.target || null };
|
|
500
|
+
const target = snapshotConfig.target || root.document?.body;
|
|
501
|
+
if (target && typeof root.MutationObserver === "function") {
|
|
502
|
+
snapshotObserver?.disconnect();
|
|
503
|
+
snapshotObserver = new root.MutationObserver(scheduleSnapshot);
|
|
504
|
+
snapshotObserver.observe(target, { attributes: true, childList: true, subtree: true, characterData: true });
|
|
505
|
+
}
|
|
506
|
+
return bridge;
|
|
507
|
+
},
|
|
508
|
+
snapshotNow: sendSnapshot,
|
|
509
|
+
close() {
|
|
510
|
+
snapshotObserver?.disconnect();
|
|
511
|
+
clearTimeout(snapshotTimer);
|
|
512
|
+
if (typeof root.removeEventListener === "function") {
|
|
513
|
+
root.removeEventListener("keydown", onKeyDown);
|
|
514
|
+
root.removeEventListener("pointerdown", onPointerDown, true);
|
|
515
|
+
}
|
|
516
|
+
endpoint.close();
|
|
517
|
+
try { port.close(); } catch (error) { /* already closed */ }
|
|
518
|
+
},
|
|
519
|
+
};
|
|
520
|
+
return bridge;
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
// ---------------------------------------------------------------------------
|
|
524
|
+
// Standalone adapter: the app's own page, outside any desk.
|
|
525
|
+
|
|
526
|
+
function standaloneBridge(options) {
|
|
527
|
+
const appId = String(options.appId || "app");
|
|
528
|
+
const storage = (() => { try { return root.localStorage; } catch (error) { return null; } })();
|
|
529
|
+
const prefix = `ais6:${appId}:`;
|
|
530
|
+
const media = (query) => { try { return !!root.matchMedia?.(query).matches; } catch (error) { return false; } };
|
|
531
|
+
const params = (() => { try { return new URL(root.location.href).searchParams; } catch (error) { return new URLSearchParams(); } })();
|
|
532
|
+
const language = params.get("lang") === "en" || (!params.get("lang") && !/^zh/i.test(root.navigator?.language || "")) ? "en" : "zh";
|
|
533
|
+
const welcome = Object.freeze({
|
|
534
|
+
protocol: PROTOCOL,
|
|
535
|
+
appId,
|
|
536
|
+
window: String(options.window || ""),
|
|
537
|
+
version: String(options.version || ""),
|
|
538
|
+
language,
|
|
539
|
+
appearance: Object.freeze({
|
|
540
|
+
id: "standalone",
|
|
541
|
+
colorMode: media("(prefers-color-scheme: dark)") ? "dark" : "light",
|
|
542
|
+
touch: media("(pointer: coarse)"),
|
|
543
|
+
reducedMotion: media("(prefers-reduced-motion: reduce)"),
|
|
544
|
+
tokens: Object.freeze({}),
|
|
545
|
+
kitUrl: String(options.kitUrl || ""),
|
|
546
|
+
}),
|
|
547
|
+
capabilities: Object.freeze(["state", "files"]),
|
|
548
|
+
readOnly: false,
|
|
549
|
+
profile: "desktop",
|
|
550
|
+
phone: 0,
|
|
551
|
+
launch: null,
|
|
552
|
+
standalone: true,
|
|
553
|
+
});
|
|
554
|
+
const listeners = new Map();
|
|
555
|
+
let fileCount = 0;
|
|
556
|
+
|
|
557
|
+
function download(name, mime, body, bytes) {
|
|
558
|
+
const doc = root.document;
|
|
559
|
+
if (!doc) throw new BridgeError("unavailable", "no document");
|
|
560
|
+
const blob = new root.Blob([bytes || String(body ?? "")], { type: mime || "application/octet-stream" });
|
|
561
|
+
const url = root.URL.createObjectURL(blob);
|
|
562
|
+
const link = doc.createElement("a");
|
|
563
|
+
link.href = url;
|
|
564
|
+
link.download = String(name || "download");
|
|
565
|
+
doc.body.appendChild(link);
|
|
566
|
+
link.click();
|
|
567
|
+
link.remove();
|
|
568
|
+
setTimeout(() => root.URL.revokeObjectURL(url), 1000);
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
const methods = {
|
|
572
|
+
"state.get": ({ key }) => {
|
|
573
|
+
const raw = storage?.getItem(prefix + String(key));
|
|
574
|
+
return { value: raw == null ? null : JSON.parse(raw) };
|
|
575
|
+
},
|
|
576
|
+
"state.set": ({ key, value }) => {
|
|
577
|
+
if (!storage) throw new BridgeError("unavailable", "no storage");
|
|
578
|
+
try { storage.setItem(prefix + String(key), JSON.stringify(value)); } catch (error) { throw new BridgeError("unavailable", "quota"); }
|
|
579
|
+
return {};
|
|
580
|
+
},
|
|
581
|
+
"state.remove": ({ key }) => { storage?.removeItem(prefix + String(key)); return {}; },
|
|
582
|
+
"state.keys": ({ prefix: wanted = "" } = {}) => {
|
|
583
|
+
const keys = [];
|
|
584
|
+
for (let index = 0; storage && index < storage.length; index += 1) {
|
|
585
|
+
const name = storage.key(index);
|
|
586
|
+
if (name?.startsWith(prefix) && name.slice(prefix.length).startsWith(wanted)) keys.push(name.slice(prefix.length));
|
|
587
|
+
}
|
|
588
|
+
return { keys: keys.sort() };
|
|
589
|
+
},
|
|
590
|
+
"files.export": ({ name, mime, body, bytes }) => { download(name, mime, body, bytes); return {}; },
|
|
591
|
+
"files.create": ({ name, body, bytes }) => {
|
|
592
|
+
download(name, "text/plain;charset=utf-8", body, bytes);
|
|
593
|
+
fileCount += 1;
|
|
594
|
+
return { id: `standalone-${fileCount}`, hash: "" };
|
|
595
|
+
},
|
|
596
|
+
// No desk, no desk appearance to visit and no verification gate: the
|
|
597
|
+
// page's own server answers or it does not.
|
|
598
|
+
"appearance.visit": () => ({ theme: null }),
|
|
599
|
+
"access.session": () => ({ profile: "standalone", verified: true }),
|
|
600
|
+
"access.verify": () => ({ verified: true }),
|
|
601
|
+
};
|
|
602
|
+
|
|
603
|
+
function call(method, params = {}) {
|
|
604
|
+
const fn = methods[method];
|
|
605
|
+
if (!fn) {
|
|
606
|
+
const known = /^(state|files|receipts|intent|model|menu|window|app|lifecycle|input|appearance|access)\./.test(method);
|
|
607
|
+
return Promise.reject(new BridgeError(known ? "unavailable" : "unknown-method", method));
|
|
608
|
+
}
|
|
609
|
+
try {
|
|
610
|
+
return Promise.resolve(fn(params || {}));
|
|
611
|
+
} catch (error) {
|
|
612
|
+
return Promise.reject(error instanceof BridgeError ? error : new BridgeError("internal", String(error?.message || error)));
|
|
613
|
+
}
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
if (options.applyAppearance !== false) applyAppearance(welcome.appearance);
|
|
617
|
+
|
|
618
|
+
const bridge = {
|
|
619
|
+
standalone: true,
|
|
620
|
+
welcome,
|
|
621
|
+
call,
|
|
622
|
+
emit: () => true,
|
|
623
|
+
on(method, fn) {
|
|
624
|
+
if (!listeners.has(method)) listeners.set(method, []);
|
|
625
|
+
listeners.get(method).push(fn);
|
|
626
|
+
return () => listeners.set(method, (listeners.get(method) || []).filter((item) => item !== fn));
|
|
627
|
+
},
|
|
628
|
+
handle: () => {},
|
|
629
|
+
ready: () => true,
|
|
630
|
+
status: () => true,
|
|
631
|
+
menuState: () => true,
|
|
632
|
+
setTitle(title) { if (root.document) root.document.title = String(title || ""); return true; },
|
|
633
|
+
enableSnapshots: () => bridge,
|
|
634
|
+
snapshotNow: () => Promise.resolve(false),
|
|
635
|
+
close: () => {},
|
|
636
|
+
};
|
|
637
|
+
return bridge;
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
// ---------------------------------------------------------------------------
|
|
641
|
+
// connect(): the only entry point.
|
|
642
|
+
|
|
643
|
+
function reloadOnceForVersion(appId) {
|
|
644
|
+
const key = `ais6-version-reload:${appId}`;
|
|
645
|
+
try {
|
|
646
|
+
if (root.sessionStorage.getItem(key)) {
|
|
647
|
+
root.sessionStorage.removeItem(key);
|
|
648
|
+
return false;
|
|
649
|
+
}
|
|
650
|
+
root.sessionStorage.setItem(key, "1");
|
|
651
|
+
root.location.reload();
|
|
652
|
+
return true;
|
|
653
|
+
} catch (error) {
|
|
654
|
+
return false;
|
|
655
|
+
}
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
function connect(options = {}) {
|
|
659
|
+
const appId = String(options.appId || "");
|
|
660
|
+
const windowName = String(options.window || "");
|
|
661
|
+
const parent = root.parent;
|
|
662
|
+
if (!parent || parent === root) return Promise.resolve(standaloneBridge(options));
|
|
663
|
+
return new Promise((resolve, reject) => {
|
|
664
|
+
let timer = 0;
|
|
665
|
+
const onMessage = (event) => {
|
|
666
|
+
if (event.source !== parent || event.origin !== root.location.origin) return;
|
|
667
|
+
const data = event.data;
|
|
668
|
+
if (!data || (data.ais6 !== "welcome" && data.ais6 !== "refused")) return;
|
|
669
|
+
root.removeEventListener("message", onMessage);
|
|
670
|
+
clearTimeout(timer);
|
|
671
|
+
if (data.ais6 === "refused") {
|
|
672
|
+
reject(new BridgeError(data.code || "not-permitted", data.message || "refused"));
|
|
673
|
+
return;
|
|
674
|
+
}
|
|
675
|
+
const port = event.ports?.[0];
|
|
676
|
+
const welcome = data.welcome;
|
|
677
|
+
if (!port || !welcome) {
|
|
678
|
+
reject(new BridgeError("internal", "welcome without a port"));
|
|
679
|
+
return;
|
|
680
|
+
}
|
|
681
|
+
if (options.version && welcome.version && String(options.version) !== String(welcome.version)) {
|
|
682
|
+
if (reloadOnceForVersion(appId)) return;
|
|
683
|
+
const failed = hostedBridge(port, welcome, { applyAppearance: false });
|
|
684
|
+
failed.status({ host: "fail", key: "version-mismatch" });
|
|
685
|
+
failed.close();
|
|
686
|
+
reject(new BridgeError("invalid", `version ${options.version} is not the pinned ${welcome.version}`));
|
|
687
|
+
return;
|
|
688
|
+
}
|
|
689
|
+
resolve(hostedBridge(port, Object.freeze(welcome), options));
|
|
690
|
+
};
|
|
691
|
+
root.addEventListener("message", onMessage);
|
|
692
|
+
timer = setTimeout(() => {
|
|
693
|
+
root.removeEventListener("message", onMessage);
|
|
694
|
+
reject(new BridgeError("timeout", "no welcome from the desk"));
|
|
695
|
+
}, Number.isFinite(options.timeout) ? options.timeout : HELLO_TIMEOUT_MS);
|
|
696
|
+
parent.postMessage({ ais6: "hello", protocol: PROTOCOL, appId, window: windowName }, root.location.origin);
|
|
697
|
+
});
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
root.AIS6Bridge = Object.freeze({
|
|
701
|
+
protocol: PROTOCOL,
|
|
702
|
+
errorCodes: ERROR_CODES,
|
|
703
|
+
BridgeError,
|
|
704
|
+
connect,
|
|
705
|
+
applyAppearance,
|
|
706
|
+
producers: Object.freeze({ canvas: canvasSnapshot, dom: domSnapshot }),
|
|
707
|
+
// Exposed for the desk's host and the contracts: the same envelope
|
|
708
|
+
// machinery on both sides of the port.
|
|
709
|
+
createEndpoint,
|
|
710
|
+
});
|
|
711
|
+
})(typeof window !== "undefined" ? window : globalThis);
|