@floegence/redevplugin-ui 0.3.2 → 0.4.1
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/dist/contracts.gen.d.ts +50 -50
- package/dist/contracts.gen.js +50 -50
- package/dist/errors.d.ts +3 -3
- package/dist/errors.js +1 -0
- package/dist/opaque-surface-policy.gen.d.ts +12 -2
- package/dist/opaque-surface-policy.gen.js +19 -2
- package/dist/plugin.d.ts +1 -1
- package/dist/surface.d.ts +57 -4
- package/dist/surface.js +864 -35
- package/package.json +1 -1
package/dist/surface.js
CHANGED
|
@@ -3,7 +3,7 @@ import { pluginUIProtocolVersion } from "./contracts.gen.js";
|
|
|
3
3
|
import { opaqueSurfaceAllowedTags, opaqueSurfaceGlobalAttributes, opaqueSurfaceRenderLimits, opaqueSurfaceSafeInputTypes, opaqueSurfaceTagAttributes, } from "./opaque-surface-policy.gen.js";
|
|
4
4
|
import { defaultFetch, hasAllowedKeys, hasExactKeys, isRecord, readHostEnvelope, } from "./http.js";
|
|
5
5
|
import { defaultPluginSurfaceScope, registerPluginSurface, } from "./surface-scope.js";
|
|
6
|
-
export const opaqueSurfaceDocumentSchemaVersion = "redevplugin.opaque_surface_document.
|
|
6
|
+
export const opaqueSurfaceDocumentSchemaVersion = "redevplugin.opaque_surface_document.v2";
|
|
7
7
|
export const pluginRiskPlanSchemaVersion = "redevplugin.capability.risk_plan.v1";
|
|
8
8
|
const opaquePluginBridgeGlobalKey = "__redevpluginWorkerBridgeV2";
|
|
9
9
|
const maxPendingPluginBridgeRequests = 256;
|
|
@@ -30,6 +30,7 @@ const streamCredentialInvalidatingErrorCodes = new Set([
|
|
|
30
30
|
const maxOpaqueSurfaceLazyAssets = 128;
|
|
31
31
|
const maxOpaqueSurfaceLazyBytes = 32 * 1024 * 1024;
|
|
32
32
|
const maxConcurrentAssetReads = 4;
|
|
33
|
+
const maxSurfaceQuiesceMs = 1500;
|
|
33
34
|
const pluginBridgeErrorCodeSet = new Set(pluginBridgeErrorCodes);
|
|
34
35
|
const hostCapabilityIDPattern = new RegExp("^[A-Za-z0-9][A-Za-z0-9._-]*$");
|
|
35
36
|
const canonicalSemverPattern = new RegExp("^(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)(?:-(?:(?:0|[1-9][0-9]*|[0-9A-Za-z-]*[A-Za-z-][0-9A-Za-z-]*)(?:\\.(?:0|[1-9][0-9]*|[0-9A-Za-z-]*[A-Za-z-][0-9A-Za-z-]*))*))?(?:\\+[0-9A-Za-z-]+(?:\\.[0-9A-Za-z-]+)*)?$");
|
|
@@ -42,6 +43,7 @@ export class PluginBridgeClient {
|
|
|
42
43
|
#port;
|
|
43
44
|
#pending = new Map();
|
|
44
45
|
#actionHandlers = new Map();
|
|
46
|
+
#canvasInputHandlers = new Map();
|
|
45
47
|
#lifecycleHandlers = new Set();
|
|
46
48
|
#ready = false;
|
|
47
49
|
#readyPromise;
|
|
@@ -49,7 +51,7 @@ export class PluginBridgeClient {
|
|
|
49
51
|
#rejectReady;
|
|
50
52
|
#disposed = false;
|
|
51
53
|
#onMessage = (event) => {
|
|
52
|
-
this.#handleMessage(event);
|
|
54
|
+
void this.#handleMessage(event);
|
|
53
55
|
};
|
|
54
56
|
constructor(options = {}) {
|
|
55
57
|
const claimed = options.port
|
|
@@ -125,6 +127,44 @@ export class PluginBridgeClient {
|
|
|
125
127
|
tree,
|
|
126
128
|
});
|
|
127
129
|
}
|
|
130
|
+
openCanvas(canvasId) {
|
|
131
|
+
this.#assertActive();
|
|
132
|
+
if (!validUIIdentifier(canvasId)) {
|
|
133
|
+
throw new PluginBridgeError("PLUGIN_INVALID_REQUEST", "Plugin canvas identifier is invalid");
|
|
134
|
+
}
|
|
135
|
+
const id = this.#requestID("canvas");
|
|
136
|
+
return this.#request(id, {
|
|
137
|
+
type: "redevplugin.ui.canvas.open",
|
|
138
|
+
id,
|
|
139
|
+
canvas_id: canvasId,
|
|
140
|
+
}, "canvas", canvasId);
|
|
141
|
+
}
|
|
142
|
+
updateCanvasAccessibility(canvasId, state) {
|
|
143
|
+
this.#assertActive();
|
|
144
|
+
if (!validUIIdentifier(canvasId) || !validCanvasAccessibilityState(state)) {
|
|
145
|
+
throw new PluginBridgeError("PLUGIN_INVALID_REQUEST", "Plugin canvas accessibility state is invalid");
|
|
146
|
+
}
|
|
147
|
+
const id = this.#requestID("canvas");
|
|
148
|
+
return this.#request(id, {
|
|
149
|
+
type: "redevplugin.ui.canvas.accessibility",
|
|
150
|
+
id,
|
|
151
|
+
canvas_id: canvasId,
|
|
152
|
+
label: state.label,
|
|
153
|
+
description: state.description,
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
loadImageAsset(assetId) {
|
|
157
|
+
this.#assertActive();
|
|
158
|
+
if (!validUIIdentifier(assetId)) {
|
|
159
|
+
throw new PluginBridgeError("PLUGIN_INVALID_REQUEST", "Plugin image asset identifier is invalid");
|
|
160
|
+
}
|
|
161
|
+
const id = this.#requestID("asset");
|
|
162
|
+
return this.#request(id, {
|
|
163
|
+
type: "redevplugin.ui.asset.image.open",
|
|
164
|
+
id,
|
|
165
|
+
asset_id: assetId,
|
|
166
|
+
}, "asset", assetId);
|
|
167
|
+
}
|
|
128
168
|
onAction(action, handler) {
|
|
129
169
|
this.#assertActive();
|
|
130
170
|
if (!validActionID(action) || typeof handler !== "function") {
|
|
@@ -139,6 +179,20 @@ export class PluginBridgeClient {
|
|
|
139
179
|
this.#actionHandlers.delete(action);
|
|
140
180
|
};
|
|
141
181
|
}
|
|
182
|
+
onCanvasInput(canvasId, handler) {
|
|
183
|
+
this.#assertActive();
|
|
184
|
+
if (!validUIIdentifier(canvasId) || typeof handler !== "function") {
|
|
185
|
+
throw new PluginBridgeError("PLUGIN_INVALID_REQUEST", "Plugin canvas input subscription is invalid");
|
|
186
|
+
}
|
|
187
|
+
const handlers = this.#canvasInputHandlers.get(canvasId) ?? new Set();
|
|
188
|
+
handlers.add(handler);
|
|
189
|
+
this.#canvasInputHandlers.set(canvasId, handlers);
|
|
190
|
+
return () => {
|
|
191
|
+
handlers.delete(handler);
|
|
192
|
+
if (handlers.size === 0)
|
|
193
|
+
this.#canvasInputHandlers.delete(canvasId);
|
|
194
|
+
};
|
|
195
|
+
}
|
|
142
196
|
onLifecycle(handler) {
|
|
143
197
|
this.#assertActive();
|
|
144
198
|
this.#lifecycleHandlers.add(handler);
|
|
@@ -160,10 +214,11 @@ export class PluginBridgeClient {
|
|
|
160
214
|
}
|
|
161
215
|
this.#pending.clear();
|
|
162
216
|
this.#actionHandlers.clear();
|
|
217
|
+
this.#canvasInputHandlers.clear();
|
|
163
218
|
this.#lifecycleHandlers.clear();
|
|
164
219
|
this.#port.close();
|
|
165
220
|
}
|
|
166
|
-
#request(id, message) {
|
|
221
|
+
#request(id, message, kind = "json", identifier) {
|
|
167
222
|
let normalizedMessage;
|
|
168
223
|
try {
|
|
169
224
|
normalizedMessage = normalizePluginJSONObject(message);
|
|
@@ -193,6 +248,8 @@ export class PluginBridgeClient {
|
|
|
193
248
|
resolve: (value) => resolve(value),
|
|
194
249
|
reject,
|
|
195
250
|
timer,
|
|
251
|
+
kind,
|
|
252
|
+
identifier,
|
|
196
253
|
});
|
|
197
254
|
});
|
|
198
255
|
try {
|
|
@@ -208,10 +265,44 @@ export class PluginBridgeClient {
|
|
|
208
265
|
}
|
|
209
266
|
return result;
|
|
210
267
|
}
|
|
211
|
-
#handleMessage(event) {
|
|
212
|
-
if (this.#disposed
|
|
268
|
+
async #handleMessage(event) {
|
|
269
|
+
if (this.#disposed)
|
|
213
270
|
return;
|
|
214
271
|
const data = event.data;
|
|
272
|
+
if (isCanvasReadyCandidate(data)) {
|
|
273
|
+
const pending = this.#pending.get(data.id);
|
|
274
|
+
if (!pending)
|
|
275
|
+
return;
|
|
276
|
+
this.#pending.delete(data.id);
|
|
277
|
+
clearTimeout(pending.timer);
|
|
278
|
+
if (pending.kind !== "canvas" || pending.identifier !== data.canvas_id || !isCanvasReadyMessage(data)) {
|
|
279
|
+
pending.reject(new PluginBridgeError("PLUGIN_CONTRACT_MISMATCH", `Plugin canvas response ${data.id} is invalid`));
|
|
280
|
+
return;
|
|
281
|
+
}
|
|
282
|
+
pending.resolve({
|
|
283
|
+
canvas: data.canvas,
|
|
284
|
+
canvasId: data.canvas_id,
|
|
285
|
+
cssWidth: data.css_width,
|
|
286
|
+
cssHeight: data.css_height,
|
|
287
|
+
devicePixelRatio: data.device_pixel_ratio,
|
|
288
|
+
});
|
|
289
|
+
return;
|
|
290
|
+
}
|
|
291
|
+
if (isImageReadyCandidate(data)) {
|
|
292
|
+
const pending = this.#pending.get(data.id);
|
|
293
|
+
if (!pending)
|
|
294
|
+
return;
|
|
295
|
+
this.#pending.delete(data.id);
|
|
296
|
+
clearTimeout(pending.timer);
|
|
297
|
+
if (pending.kind !== "asset" || pending.identifier !== data.asset_id || !isImageReadyMessage(data)) {
|
|
298
|
+
pending.reject(new PluginBridgeError("PLUGIN_CONTRACT_MISMATCH", `Plugin image response ${data.id} is invalid`));
|
|
299
|
+
return;
|
|
300
|
+
}
|
|
301
|
+
pending.resolve(data.image);
|
|
302
|
+
return;
|
|
303
|
+
}
|
|
304
|
+
if (!messageWithinLimit(data))
|
|
305
|
+
return;
|
|
215
306
|
if (isBridgeResponseCandidate(data)) {
|
|
216
307
|
const pending = this.#pending.get(data.id);
|
|
217
308
|
if (!pending)
|
|
@@ -222,7 +313,10 @@ export class PluginBridgeClient {
|
|
|
222
313
|
pending.reject(new PluginBridgeError("PLUGIN_CONTRACT_MISMATCH", `Plugin bridge response ${data.id} is invalid`));
|
|
223
314
|
return;
|
|
224
315
|
}
|
|
225
|
-
if (data.ok)
|
|
316
|
+
if (data.ok && pending.kind !== "json") {
|
|
317
|
+
pending.reject(new PluginBridgeError("PLUGIN_CONTRACT_MISMATCH", `Plugin transfer response ${data.id} is invalid`));
|
|
318
|
+
}
|
|
319
|
+
else if (data.ok)
|
|
226
320
|
pending.resolve(data.data);
|
|
227
321
|
else
|
|
228
322
|
pending.reject(new PluginBridgeError(data.error_code, data.error, undefined, data.error_details));
|
|
@@ -233,8 +327,20 @@ export class PluginBridgeClient {
|
|
|
233
327
|
this.#ready = true;
|
|
234
328
|
this.#resolveReady();
|
|
235
329
|
}
|
|
236
|
-
|
|
237
|
-
|
|
330
|
+
await Promise.allSettled(Array.from(this.#lifecycleHandlers, async (handler) => {
|
|
331
|
+
try {
|
|
332
|
+
await handler(data.event);
|
|
333
|
+
}
|
|
334
|
+
catch {
|
|
335
|
+
// A plugin lifecycle observer cannot block bounded surface teardown.
|
|
336
|
+
}
|
|
337
|
+
}));
|
|
338
|
+
if (data.quiesce_id) {
|
|
339
|
+
this.#port.postMessage({
|
|
340
|
+
type: "redevplugin.bridge.lifecycle_ack",
|
|
341
|
+
quiesce_id: data.quiesce_id,
|
|
342
|
+
});
|
|
343
|
+
}
|
|
238
344
|
if (data.event.type === "dispose")
|
|
239
345
|
this.dispose();
|
|
240
346
|
return;
|
|
@@ -242,6 +348,12 @@ export class PluginBridgeClient {
|
|
|
242
348
|
if (isActionMessage(data)) {
|
|
243
349
|
for (const handler of this.#actionHandlers.get(data.action) ?? [])
|
|
244
350
|
handler(data);
|
|
351
|
+
return;
|
|
352
|
+
}
|
|
353
|
+
const canvasInput = publicCanvasInputMessage(data);
|
|
354
|
+
if (canvasInput) {
|
|
355
|
+
for (const handler of this.#canvasInputHandlers.get(canvasInput.canvasId) ?? [])
|
|
356
|
+
handler(canvasInput.event);
|
|
245
357
|
}
|
|
246
358
|
}
|
|
247
359
|
#requestID(prefix) {
|
|
@@ -412,6 +524,15 @@ export function createOpaquePluginBootstrapHTML(options = {}) {
|
|
|
412
524
|
const maxTextLength = ${opaqueSurfaceRenderLimits.max_text_length};
|
|
413
525
|
const maxAttributeValueLength = ${opaqueSurfaceRenderLimits.max_attribute_value_length};
|
|
414
526
|
const maxFormFields = ${opaqueSurfaceRenderLimits.max_form_fields};
|
|
527
|
+
const maxCanvasCount = ${opaqueSurfaceRenderLimits.max_canvas_count};
|
|
528
|
+
const maxCanvasDimension = ${opaqueSurfaceRenderLimits.max_canvas_dimension};
|
|
529
|
+
const maxCanvasTotalPixels = ${opaqueSurfaceRenderLimits.max_canvas_total_pixels};
|
|
530
|
+
const maxCanvasPointerEventsPerSecond = ${opaqueSurfaceRenderLimits.max_canvas_pointer_events_per_second};
|
|
531
|
+
const maxImageCount = ${opaqueSurfaceRenderLimits.max_image_count};
|
|
532
|
+
const maxImageDimension = ${opaqueSurfaceRenderLimits.max_image_dimension};
|
|
533
|
+
const maxImageTotalPixels = ${opaqueSurfaceRenderLimits.max_image_total_pixels};
|
|
534
|
+
const workerHeartbeatIntervalMs = ${opaqueSurfaceRenderLimits.worker_heartbeat_interval_ms};
|
|
535
|
+
const workerHeartbeatTimeoutMs = ${opaqueSurfaceRenderLimits.worker_heartbeat_timeout_ms};
|
|
415
536
|
const maxCriticalDocumentBytes = ${8 * 1024 * 1024};
|
|
416
537
|
const maxPrivateAssetBase64Length = ${Math.ceil((32 * 1024 * 1024) / 3) * 4};
|
|
417
538
|
const maxLazyAssetCount = ${maxOpaqueSurfaceLazyAssets};
|
|
@@ -458,6 +579,7 @@ export function createOpaquePluginBootstrapHTML(options = {}) {
|
|
|
458
579
|
catch { return false; }
|
|
459
580
|
};
|
|
460
581
|
const validIdentifier = (value) => typeof value === "string" && /^[A-Za-z0-9._:-]{1,128}$/.test(value);
|
|
582
|
+
const validResourceIdentifier = (value) => typeof value === "string" && /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/.test(value);
|
|
461
583
|
const validOpaqueHandle = (value, prefix) => typeof value === "string" && value.startsWith(prefix + "_") && /^[A-Za-z0-9_-]{8,160}$/.test(value);
|
|
462
584
|
const validDigest = (value) => typeof value === "string" && /^sha256:[a-f0-9]{64}$/.test(value);
|
|
463
585
|
const validPath = (value) => typeof value === "string" && value.length > 0 && value.length <= 512 && !value.startsWith("/") && !value.includes("\\\\") && !value.split("/").some((part) => !part || part === "." || part === "..");
|
|
@@ -467,8 +589,11 @@ export function createOpaquePluginBootstrapHTML(options = {}) {
|
|
|
467
589
|
if (!globalAttributes.has(lower) && !lower.startsWith("aria-") && !(tagAttributes[tag] && tagAttributes[tag].has(lower))) return false;
|
|
468
590
|
if (!["string", "number", "boolean"].includes(typeof value)) return false;
|
|
469
591
|
if (lower === "data-redevplugin-action" && !validIdentifier(String(value))) return false;
|
|
592
|
+
if (lower === "data-redevplugin-escape-action" && !validIdentifier(String(value))) return false;
|
|
470
593
|
if (lower === "data-redevplugin-asset-binding" && !validOpaqueHandle(String(value), "asset")) return false;
|
|
471
594
|
if (lower === "data-redevplugin-asset-attr" && !["src", "poster"].includes(String(value))) return false;
|
|
595
|
+
if (lower === "data-redevplugin-canvas" && (tag !== "canvas" || !validResourceIdentifier(String(value)))) return false;
|
|
596
|
+
if (tag === "canvas" && (lower === "width" || lower === "height") && (!/^[1-9][0-9]{0,4}$/.test(String(value)) || Number(value) > maxCanvasDimension)) return false;
|
|
472
597
|
if (tag === "input" && lower === "type" && !safeInputTypes.has(String(value).trim().toLowerCase() || "text")) return false;
|
|
473
598
|
return String(value).length <= maxAttributeValueLength;
|
|
474
599
|
};
|
|
@@ -492,8 +617,8 @@ export function createOpaquePluginBootstrapHTML(options = {}) {
|
|
|
492
617
|
};
|
|
493
618
|
const validStyle = (value) => exactKeys(value, ["path", "sha256", "content"]) && validPath(value.path) && validDigest(value.sha256) && typeof value.content === "string" && value.content.length <= 2097152;
|
|
494
619
|
const validWorker = (value) => exactKeys(value, ["path", "sha256", "type", "content"]) && validPath(value.path) && validDigest(value.sha256) && value.type === "classic" && typeof value.content === "string" && value.content.length <= 4194304;
|
|
495
|
-
const validAsset = (value) => exactKeys(value, ["binding_id", "path", "sha256", "size", "content_type"]) && validOpaqueHandle(value.binding_id, "asset") && validPath(value.path) && validDigest(value.sha256) && Number.isSafeInteger(value.size) && value.size >= 0 && value.size <= maxLazyAssetBytes && typeof value.content_type === "string" && value.content_type.length > 0 && value.content_type.length <= 256;
|
|
496
|
-
const validDocument = (value) => isRecord(value) && Object.keys(value).every((key) => ["schema_version", "entry_path", "entry_sha256", "title", "language", "direction", "body_html", "styles", "worker", "assets", "critical_bytes"].includes(key)) && value.schema_version === documentSchema && validPath(value.entry_path) && validDigest(value.entry_sha256) && (value.title === undefined || (typeof value.title === "string" && value.title.length <= 256)) && (value.language === undefined || (typeof value.language === "string" && value.language.length <= 64)) && (value.direction === undefined || ["ltr", "rtl", "auto"].includes(value.direction)) && typeof value.body_html === "string" && value.body_html.length <= 4194304 && Array.isArray(value.styles) && value.styles.every(validStyle) && validWorker(value.worker) && Array.isArray(value.assets) && value.assets.length <= maxLazyAssetCount && value.assets.every(validAsset) && new Set(value.assets.map((asset) => asset.binding_id)).size === value.assets.length && new Set(value.assets.map((asset) => asset.path)).size === value.assets.length && value.assets.reduce((total, asset) => total + asset.size, 0) <= maxLazyAssetBytes && Number.isSafeInteger(value.critical_bytes) && value.critical_bytes >= 0 && value.critical_bytes <= maxCriticalDocumentBytes;
|
|
620
|
+
const validAsset = (value) => exactKeys(value, ["binding_id", "logical_ids", "path", "sha256", "size", "content_type"]) && validOpaqueHandle(value.binding_id, "asset") && Array.isArray(value.logical_ids) && value.logical_ids.length > 0 && value.logical_ids.length <= 16 && value.logical_ids.every(validResourceIdentifier) && new Set(value.logical_ids).size === value.logical_ids.length && validPath(value.path) && validDigest(value.sha256) && Number.isSafeInteger(value.size) && value.size >= 0 && value.size <= maxLazyAssetBytes && typeof value.content_type === "string" && value.content_type.length > 0 && value.content_type.length <= 256;
|
|
621
|
+
const validDocument = (value) => isRecord(value) && Object.keys(value).every((key) => ["schema_version", "entry_path", "entry_sha256", "title", "language", "direction", "body_html", "styles", "worker", "assets", "critical_bytes"].includes(key)) && value.schema_version === documentSchema && validPath(value.entry_path) && validDigest(value.entry_sha256) && (value.title === undefined || (typeof value.title === "string" && value.title.length <= 256)) && (value.language === undefined || (typeof value.language === "string" && value.language.length <= 64)) && (value.direction === undefined || ["ltr", "rtl", "auto"].includes(value.direction)) && typeof value.body_html === "string" && value.body_html.length <= 4194304 && Array.isArray(value.styles) && value.styles.every(validStyle) && validWorker(value.worker) && Array.isArray(value.assets) && value.assets.length <= maxLazyAssetCount && value.assets.every(validAsset) && new Set(value.assets.map((asset) => asset.binding_id)).size === value.assets.length && new Set(value.assets.map((asset) => asset.path)).size === value.assets.length && new Set(value.assets.flatMap((asset) => asset.logical_ids)).size === value.assets.reduce((total, asset) => total + asset.logical_ids.length, 0) && value.assets.reduce((total, asset) => total + asset.size, 0) <= maxLazyAssetBytes && Number.isSafeInteger(value.critical_bytes) && value.critical_bytes >= 0 && value.critical_bytes <= maxCriticalDocumentBytes;
|
|
497
622
|
let accepted = false;
|
|
498
623
|
let initialized = false;
|
|
499
624
|
let parentPort;
|
|
@@ -504,15 +629,31 @@ export function createOpaquePluginBootstrapHTML(options = {}) {
|
|
|
504
629
|
let surfaceHandle = "";
|
|
505
630
|
let currentDocument;
|
|
506
631
|
let workerReady = false;
|
|
632
|
+
let workerHeartbeatSequence = 0;
|
|
633
|
+
let workerHeartbeatPendingID;
|
|
634
|
+
let workerHeartbeatTimer;
|
|
635
|
+
let workerHeartbeatTimeout;
|
|
636
|
+
let pendingQuiesceID;
|
|
507
637
|
const pendingWorkerRequests = new Set();
|
|
508
|
-
const requestSequence = { rpc: 0, stream: 0, render: 0, operation: 0 };
|
|
638
|
+
const requestSequence = { rpc: 0, stream: 0, render: 0, operation: 0, canvas: 0, asset: 0 };
|
|
509
639
|
let renderWindowStartedAt = 0;
|
|
510
640
|
let renderCount = 0;
|
|
641
|
+
let lastAutofocusIdentity = "";
|
|
511
642
|
const pendingAssets = new Map();
|
|
512
643
|
const queuedAssets = [];
|
|
513
644
|
let activeAssetReads = 0;
|
|
514
645
|
let assetSequence = 0;
|
|
515
646
|
const blobURLs = new Set();
|
|
647
|
+
const assetByLogicalID = new Map();
|
|
648
|
+
const verifiedAssetBytes = new Map();
|
|
649
|
+
const imageAssetDimensions = new Map();
|
|
650
|
+
const imageAssetTypes = new Map();
|
|
651
|
+
const pendingImageRequests = new Map();
|
|
652
|
+
const activeImageDecodes = new Set();
|
|
653
|
+
const imageReservations = new Map();
|
|
654
|
+
let retainedImageCount = 0;
|
|
655
|
+
let retainedImagePixels = 0;
|
|
656
|
+
const canvasRuntimes = new Map();
|
|
516
657
|
|
|
517
658
|
const sendParent = (message) => {
|
|
518
659
|
if (parentPort && withinLimit(message)) parentPort.postMessage(message);
|
|
@@ -520,6 +661,9 @@ export function createOpaquePluginBootstrapHTML(options = {}) {
|
|
|
520
661
|
const sendWorker = (message) => {
|
|
521
662
|
if (workerPort && withinLimit(message)) workerPort.postMessage(message);
|
|
522
663
|
};
|
|
664
|
+
const sendWorkerTransfer = (message, transfer) => {
|
|
665
|
+
if (workerPort) workerPort.postMessage(message, transfer);
|
|
666
|
+
};
|
|
523
667
|
const fail = (message) => {
|
|
524
668
|
document.documentElement.lang = "en";
|
|
525
669
|
document.body.replaceChildren();
|
|
@@ -531,6 +675,14 @@ export function createOpaquePluginBootstrapHTML(options = {}) {
|
|
|
531
675
|
disposeRuntime();
|
|
532
676
|
};
|
|
533
677
|
const disposeRuntime = () => {
|
|
678
|
+
if (workerHeartbeatTimer) clearTimeout(workerHeartbeatTimer);
|
|
679
|
+
if (workerHeartbeatTimeout) clearTimeout(workerHeartbeatTimeout);
|
|
680
|
+
workerHeartbeatTimer = undefined;
|
|
681
|
+
workerHeartbeatTimeout = undefined;
|
|
682
|
+
workerHeartbeatPendingID = undefined;
|
|
683
|
+
workerReady = false;
|
|
684
|
+
for (const runtime of canvasRuntimes.values()) runtime.dispose();
|
|
685
|
+
canvasRuntimes.clear();
|
|
534
686
|
if (workerControlPort) {
|
|
535
687
|
workerControlPort.close();
|
|
536
688
|
workerControlPort = undefined;
|
|
@@ -550,14 +702,44 @@ export function createOpaquePluginBootstrapHTML(options = {}) {
|
|
|
550
702
|
pendingAssets.clear();
|
|
551
703
|
queuedAssets.length = 0;
|
|
552
704
|
activeAssetReads = 0;
|
|
705
|
+
pendingImageRequests.clear();
|
|
706
|
+
activeImageDecodes.clear();
|
|
707
|
+
imageReservations.clear();
|
|
708
|
+
imageAssetDimensions.clear();
|
|
709
|
+
imageAssetTypes.clear();
|
|
710
|
+
retainedImageCount = 0;
|
|
711
|
+
retainedImagePixels = 0;
|
|
712
|
+
verifiedAssetBytes.clear();
|
|
713
|
+
assetByLogicalID.clear();
|
|
714
|
+
};
|
|
715
|
+
const validateCanvasIdentifiers = (root) => {
|
|
716
|
+
const identifiers = new Set();
|
|
717
|
+
let totalPixels = 0;
|
|
718
|
+
for (const canvas of root.querySelectorAll("canvas[data-redevplugin-canvas]")) {
|
|
719
|
+
const identifier = canvas.getAttribute("data-redevplugin-canvas");
|
|
720
|
+
if (!validResourceIdentifier(identifier) || identifiers.has(identifier)) throw new Error("plugin canvas identifiers are invalid or ambiguous");
|
|
721
|
+
identifiers.add(identifier);
|
|
722
|
+
if (identifiers.size > maxCanvasCount) throw new Error("plugin document exceeds the canvas count limit");
|
|
723
|
+
const width = Number(canvas.getAttribute("width") || 300);
|
|
724
|
+
const height = Number(canvas.getAttribute("height") || 150);
|
|
725
|
+
if (!Number.isSafeInteger(width) || !Number.isSafeInteger(height) || width <= 0 || height <= 0 || width > maxCanvasDimension || height > maxCanvasDimension) {
|
|
726
|
+
throw new Error("plugin canvas dimensions are invalid");
|
|
727
|
+
}
|
|
728
|
+
totalPixels += width * height;
|
|
729
|
+
if (totalPixels > maxCanvasTotalPixels) throw new Error("plugin document exceeds the canvas pixel budget");
|
|
730
|
+
}
|
|
553
731
|
};
|
|
554
732
|
const applyStaticDocument = (surfaceDocument) => {
|
|
733
|
+
for (const asset of surfaceDocument.assets) {
|
|
734
|
+
for (const logicalID of asset.logical_ids) assetByLogicalID.set(logicalID, asset);
|
|
735
|
+
}
|
|
555
736
|
document.title = typeof surfaceDocument.title === "string" ? surfaceDocument.title.slice(0, 256) : "Plugin";
|
|
556
737
|
document.documentElement.lang = typeof surfaceDocument.language === "string" ? surfaceDocument.language.slice(0, 64) : "en";
|
|
557
738
|
if (["ltr", "rtl", "auto"].includes(surfaceDocument.direction)) document.documentElement.dir = surfaceDocument.direction;
|
|
558
739
|
const template = document.createElement("template");
|
|
559
740
|
template.innerHTML = surfaceDocument.body_html;
|
|
560
741
|
if (!validateDOMTree(template.content)) throw new Error("static plugin document failed renderer validation");
|
|
742
|
+
validateCanvasIdentifiers(template.content);
|
|
561
743
|
document.body.replaceChildren(template.content.cloneNode(true));
|
|
562
744
|
for (const styleAsset of surfaceDocument.styles) {
|
|
563
745
|
const style = document.createElement("style");
|
|
@@ -582,6 +764,10 @@ export function createOpaquePluginBootstrapHTML(options = {}) {
|
|
|
582
764
|
if (!isRecord(value.attributes) || Object.keys(value.attributes).length > maxAttributesPerElement) throw new Error("plugin render attributes are invalid");
|
|
583
765
|
for (const [name, attributeValue] of Object.entries(value.attributes)) {
|
|
584
766
|
if (!validAttribute(tag, name, attributeValue)) throw new Error("plugin render attribute is not allowed");
|
|
767
|
+
if (name.toLowerCase() === "autofocus") {
|
|
768
|
+
if (attributeValue !== false) element.setAttribute("data-redevplugin-renderer-autofocus", "");
|
|
769
|
+
continue;
|
|
770
|
+
}
|
|
585
771
|
if (typeof attributeValue === "boolean") {
|
|
586
772
|
if (attributeValue) element.setAttribute(name, "");
|
|
587
773
|
} else {
|
|
@@ -595,22 +781,88 @@ export function createOpaquePluginBootstrapHTML(options = {}) {
|
|
|
595
781
|
}
|
|
596
782
|
return element;
|
|
597
783
|
};
|
|
784
|
+
const renderElementIdentity = (element) => ({
|
|
785
|
+
tag: element.tagName,
|
|
786
|
+
id: element.getAttribute("id") || "",
|
|
787
|
+
name: element.getAttribute("name") || "",
|
|
788
|
+
action: element.getAttribute("data-redevplugin-action") || "",
|
|
789
|
+
escapeAction: element.getAttribute("data-redevplugin-escape-action") || "",
|
|
790
|
+
ariaLabel: element.getAttribute("aria-label") || "",
|
|
791
|
+
value: element.tagName === "BUTTON" || element.tagName === "OPTION" ? element.getAttribute("value") || "" : "",
|
|
792
|
+
});
|
|
793
|
+
const sameRenderElementIdentity = (left, right) => left.tag === right.tag && left.id === right.id && left.name === right.name && left.action === right.action && left.escapeAction === right.escapeAction && left.ariaLabel === right.ariaLabel && left.value === right.value;
|
|
794
|
+
const captureRenderState = () => {
|
|
795
|
+
const active = document.activeElement instanceof Element && document.body.contains(document.activeElement) ? document.activeElement : undefined;
|
|
796
|
+
let selection;
|
|
797
|
+
if (active instanceof HTMLInputElement || active instanceof HTMLTextAreaElement) {
|
|
798
|
+
selection = { start: active.selectionStart, end: active.selectionEnd, direction: active.selectionDirection };
|
|
799
|
+
}
|
|
800
|
+
return {
|
|
801
|
+
activeIdentity: active ? renderElementIdentity(active) : undefined,
|
|
802
|
+
selection,
|
|
803
|
+
activeScrollTop: active instanceof HTMLElement ? active.scrollTop : 0,
|
|
804
|
+
activeScrollLeft: active instanceof HTMLElement ? active.scrollLeft : 0,
|
|
805
|
+
documentScrollTop: document.documentElement.scrollTop,
|
|
806
|
+
documentScrollLeft: document.documentElement.scrollLeft,
|
|
807
|
+
bodyScrollTop: document.body.scrollTop,
|
|
808
|
+
bodyScrollLeft: document.body.scrollLeft,
|
|
809
|
+
};
|
|
810
|
+
};
|
|
811
|
+
const restoreRenderState = (snapshot) => {
|
|
812
|
+
const autofocus = document.body.querySelector("[data-redevplugin-renderer-autofocus]");
|
|
813
|
+
const autofocusIdentity = autofocus instanceof Element ? JSON.stringify(renderElementIdentity(autofocus)) : "";
|
|
814
|
+
if (autofocus instanceof Element) autofocus.removeAttribute("data-redevplugin-renderer-autofocus");
|
|
815
|
+
let target;
|
|
816
|
+
if (autofocus instanceof HTMLElement && !autofocus.hasAttribute("disabled") && autofocusIdentity !== lastAutofocusIdentity) {
|
|
817
|
+
target = autofocus;
|
|
818
|
+
lastAutofocusIdentity = autofocusIdentity;
|
|
819
|
+
} else {
|
|
820
|
+
if (!autofocusIdentity) lastAutofocusIdentity = "";
|
|
821
|
+
if (snapshot.activeIdentity) {
|
|
822
|
+
for (const candidate of document.body.querySelectorAll(snapshot.activeIdentity.tag.toLowerCase())) {
|
|
823
|
+
if (sameRenderElementIdentity(renderElementIdentity(candidate), snapshot.activeIdentity)) {
|
|
824
|
+
target = candidate;
|
|
825
|
+
break;
|
|
826
|
+
}
|
|
827
|
+
}
|
|
828
|
+
}
|
|
829
|
+
}
|
|
830
|
+
if (target instanceof HTMLElement && !target.hasAttribute("disabled")) {
|
|
831
|
+
try { target.focus({ preventScroll: true }); } catch { target.focus(); }
|
|
832
|
+
target.scrollTop = snapshot.activeScrollTop;
|
|
833
|
+
target.scrollLeft = snapshot.activeScrollLeft;
|
|
834
|
+
if (snapshot.selection && (target instanceof HTMLInputElement || target instanceof HTMLTextAreaElement)) {
|
|
835
|
+
const length = target.value.length;
|
|
836
|
+
const start = Math.min(snapshot.selection.start ?? length, length);
|
|
837
|
+
const end = Math.min(snapshot.selection.end ?? start, length);
|
|
838
|
+
try { target.setSelectionRange(start, end, snapshot.selection.direction || "none"); } catch {}
|
|
839
|
+
}
|
|
840
|
+
}
|
|
841
|
+
document.documentElement.scrollTop = snapshot.documentScrollTop;
|
|
842
|
+
document.documentElement.scrollLeft = snapshot.documentScrollLeft;
|
|
843
|
+
document.body.scrollTop = snapshot.bodyScrollTop;
|
|
844
|
+
document.body.scrollLeft = snapshot.bodyScrollLeft;
|
|
845
|
+
};
|
|
598
846
|
const applyWorkerRender = (tree) => {
|
|
847
|
+
if (canvasRuntimes.size > 0) throw new Error("plugin render cannot replace an active transferred canvas");
|
|
599
848
|
const values = Array.isArray(tree) ? tree : [tree];
|
|
600
849
|
const fragment = document.createDocumentFragment();
|
|
601
850
|
const state = { nodes: 0 };
|
|
602
851
|
for (const value of values) fragment.append(buildWorkerNode(value, state, 1));
|
|
852
|
+
validateCanvasIdentifiers(fragment);
|
|
853
|
+
const renderState = captureRenderState();
|
|
603
854
|
document.body.replaceChildren(fragment);
|
|
855
|
+
restoreRenderState(renderState);
|
|
604
856
|
};
|
|
605
|
-
const actionPayload = (event, element) => {
|
|
606
|
-
const payload = { type: "redevplugin.ui.action", action: element.getAttribute("data-redevplugin-action"), event:
|
|
857
|
+
const actionPayload = (event, element, eventType = event.type) => {
|
|
858
|
+
const payload = { type: "redevplugin.ui.action", action: element.getAttribute("data-redevplugin-action"), event: eventType };
|
|
607
859
|
const target = event.target;
|
|
608
860
|
if (target && typeof target.value === "string") payload.value = target.value.slice(0, maxTextLength);
|
|
609
861
|
if (target && typeof target.checked === "boolean") payload.checked = target.checked;
|
|
610
|
-
if (
|
|
862
|
+
if (eventType === "submit" && element.tagName === "FORM") {
|
|
611
863
|
const values = {};
|
|
612
864
|
let count = 0;
|
|
613
|
-
for (const [name, value] of new FormData(
|
|
865
|
+
for (const [name, value] of new FormData(element)) {
|
|
614
866
|
if (typeof value !== "string" || !validIdentifier(name) || count >= maxFormFields) continue;
|
|
615
867
|
values[name] = value.slice(0, maxTextLength);
|
|
616
868
|
count += 1;
|
|
@@ -621,16 +873,34 @@ export function createOpaquePluginBootstrapHTML(options = {}) {
|
|
|
621
873
|
};
|
|
622
874
|
const handleAction = (event) => {
|
|
623
875
|
if (!["click", "input", "change", "submit"].includes(event.type)) return;
|
|
624
|
-
if (event.type === "submit") event.preventDefault();
|
|
625
876
|
const origin = event.target instanceof Element ? event.target : null;
|
|
626
877
|
const element = origin ? origin.closest("[data-redevplugin-action]") : null;
|
|
627
878
|
if (!element || !document.body.contains(element)) return;
|
|
628
|
-
event.
|
|
879
|
+
if (element.tagName === "FORM" && event.type !== "submit") {
|
|
880
|
+
const submitButton = origin ? origin.closest("button") : null;
|
|
881
|
+
const buttonType = submitButton && element.contains(submitButton) ? (submitButton.getAttribute("type") || "submit").toLowerCase() : "";
|
|
882
|
+
if (event.type === "click" && buttonType === "submit") {
|
|
883
|
+
event.preventDefault();
|
|
884
|
+
sendWorker(actionPayload(event, element, "submit"));
|
|
885
|
+
}
|
|
886
|
+
return;
|
|
887
|
+
}
|
|
888
|
+
if (event.type === "submit" || (event.type === "click" && element.tagName === "BUTTON")) event.preventDefault();
|
|
629
889
|
const action = element.getAttribute("data-redevplugin-action");
|
|
630
890
|
if (!validIdentifier(action)) return;
|
|
631
891
|
sendWorker(actionPayload(event, element));
|
|
632
892
|
};
|
|
633
893
|
for (const eventType of ["click", "input", "change", "submit"]) document.addEventListener(eventType, handleAction, true);
|
|
894
|
+
document.addEventListener("keydown", (event) => {
|
|
895
|
+
if (event.key !== "Escape" || event.defaultPrevented || event.repeat) return;
|
|
896
|
+
const origin = event.target instanceof Element ? event.target : document.activeElement instanceof Element ? document.activeElement : null;
|
|
897
|
+
const owner = origin ? origin.closest("[data-redevplugin-escape-action]") : null;
|
|
898
|
+
const action = owner?.getAttribute("data-redevplugin-escape-action");
|
|
899
|
+
if (!owner || !document.body.contains(owner) || !validIdentifier(action)) return;
|
|
900
|
+
event.preventDefault();
|
|
901
|
+
event.stopPropagation();
|
|
902
|
+
sendWorker({ type: "redevplugin.ui.action", action, event: "escape" });
|
|
903
|
+
}, true);
|
|
634
904
|
const pumpAssets = () => {
|
|
635
905
|
while (activeAssetReads < maxConcurrentAssetReads && queuedAssets.length > 0) {
|
|
636
906
|
const asset = queuedAssets.shift();
|
|
@@ -644,6 +914,129 @@ export function createOpaquePluginBootstrapHTML(options = {}) {
|
|
|
644
914
|
queuedAssets.push(...currentDocument.assets);
|
|
645
915
|
pumpAssets();
|
|
646
916
|
};
|
|
917
|
+
const failTransferRequest = (id, error) => {
|
|
918
|
+
pendingImageRequests.delete(id);
|
|
919
|
+
activeImageDecodes.delete(id);
|
|
920
|
+
const reservedPixels = imageReservations.get(id);
|
|
921
|
+
if (reservedPixels !== undefined) {
|
|
922
|
+
imageReservations.delete(id);
|
|
923
|
+
retainedImageCount = Math.max(0, retainedImageCount - 1);
|
|
924
|
+
retainedImagePixels = Math.max(0, retainedImagePixels - reservedPixels);
|
|
925
|
+
}
|
|
926
|
+
completeWorkerRequest(id);
|
|
927
|
+
sendWorker({ type: "redevplugin.bridge.response", id, ok: false, error_code: "PLUGIN_CONTRACT_MISMATCH", error: String(error).slice(0, 512) });
|
|
928
|
+
};
|
|
929
|
+
const imageType = (contentType) => String(contentType).split(";", 1)[0].trim().toLowerCase();
|
|
930
|
+
const bytesMatch = (bytes, offset, values) => values.every((value, index) => bytes[offset + index] === value);
|
|
931
|
+
const asciiMatches = (bytes, offset, value) => Array.from(value).every((character, index) => bytes[offset + index] === character.charCodeAt(0));
|
|
932
|
+
const detectRasterImageType = (bytes) => {
|
|
933
|
+
if (bytes.length >= 8 && bytesMatch(bytes, 0, [137, 80, 78, 71, 13, 10, 26, 10])) return "image/png";
|
|
934
|
+
if (bytes.length >= 2 && bytesMatch(bytes, 0, [255, 216])) return "image/jpeg";
|
|
935
|
+
if (bytes.length >= 6 && (asciiMatches(bytes, 0, "GIF87a") || asciiMatches(bytes, 0, "GIF89a"))) return "image/gif";
|
|
936
|
+
if (bytes.length >= 12 && asciiMatches(bytes, 0, "RIFF") && asciiMatches(bytes, 8, "WEBP")) return "image/webp";
|
|
937
|
+
return "";
|
|
938
|
+
};
|
|
939
|
+
const uint16BE = (bytes, offset) => (bytes[offset] << 8) | bytes[offset + 1];
|
|
940
|
+
const uint16LE = (bytes, offset) => bytes[offset] | (bytes[offset + 1] << 8);
|
|
941
|
+
const uint24LE = (bytes, offset) => bytes[offset] | (bytes[offset + 1] << 8) | (bytes[offset + 2] << 16);
|
|
942
|
+
const uint32BE = (bytes, offset) => ((bytes[offset] * 0x1000000) + (bytes[offset + 1] << 16) + (bytes[offset + 2] << 8) + bytes[offset + 3]) >>> 0;
|
|
943
|
+
const readImageDimensions = (bytes, contentType) => {
|
|
944
|
+
const type = imageType(contentType);
|
|
945
|
+
let width = 0;
|
|
946
|
+
let height = 0;
|
|
947
|
+
if (type === "image/png") {
|
|
948
|
+
if (bytes.length < 24 || !bytesMatch(bytes, 0, [137, 80, 78, 71, 13, 10, 26, 10]) || !asciiMatches(bytes, 12, "IHDR")) throw new Error("PNG dimensions are invalid");
|
|
949
|
+
width = uint32BE(bytes, 16);
|
|
950
|
+
height = uint32BE(bytes, 20);
|
|
951
|
+
} else if (type === "image/gif") {
|
|
952
|
+
if (bytes.length < 10 || (!asciiMatches(bytes, 0, "GIF87a") && !asciiMatches(bytes, 0, "GIF89a"))) throw new Error("GIF dimensions are invalid");
|
|
953
|
+
width = uint16LE(bytes, 6);
|
|
954
|
+
height = uint16LE(bytes, 8);
|
|
955
|
+
} else if (type === "image/jpeg") {
|
|
956
|
+
if (bytes.length < 4 || !bytesMatch(bytes, 0, [255, 216])) throw new Error("JPEG dimensions are invalid");
|
|
957
|
+
let offset = 2;
|
|
958
|
+
const startOfFrame = new Set([192, 193, 194, 195, 197, 198, 199, 201, 202, 203, 205, 206, 207]);
|
|
959
|
+
while (offset + 4 <= bytes.length) {
|
|
960
|
+
if (bytes[offset] !== 255) throw new Error("JPEG marker sequence is invalid");
|
|
961
|
+
while (offset < bytes.length && bytes[offset] === 255) offset += 1;
|
|
962
|
+
const marker = bytes[offset++];
|
|
963
|
+
if (marker === 217 || marker === 218) break;
|
|
964
|
+
if (marker === 1 || (marker >= 208 && marker <= 215)) continue;
|
|
965
|
+
if (offset + 2 > bytes.length) break;
|
|
966
|
+
const length = uint16BE(bytes, offset);
|
|
967
|
+
if (length < 2 || offset + length > bytes.length) throw new Error("JPEG segment length is invalid");
|
|
968
|
+
if (startOfFrame.has(marker)) {
|
|
969
|
+
if (length < 7) throw new Error("JPEG frame dimensions are invalid");
|
|
970
|
+
height = uint16BE(bytes, offset + 3);
|
|
971
|
+
width = uint16BE(bytes, offset + 5);
|
|
972
|
+
break;
|
|
973
|
+
}
|
|
974
|
+
offset += length;
|
|
975
|
+
}
|
|
976
|
+
} else if (type === "image/webp") {
|
|
977
|
+
if (bytes.length < 30 || !asciiMatches(bytes, 0, "RIFF") || !asciiMatches(bytes, 8, "WEBP")) throw new Error("WebP dimensions are invalid");
|
|
978
|
+
if (asciiMatches(bytes, 12, "VP8X")) {
|
|
979
|
+
width = uint24LE(bytes, 24) + 1;
|
|
980
|
+
height = uint24LE(bytes, 27) + 1;
|
|
981
|
+
} else if (asciiMatches(bytes, 12, "VP8L") && bytes[20] === 47) {
|
|
982
|
+
width = 1 + bytes[21] + ((bytes[22] & 63) << 8);
|
|
983
|
+
height = 1 + (bytes[22] >> 6) + (bytes[23] << 2) + ((bytes[24] & 15) << 10);
|
|
984
|
+
} else if (asciiMatches(bytes, 12, "VP8 ") && bytesMatch(bytes, 23, [157, 1, 42])) {
|
|
985
|
+
width = uint16LE(bytes, 26) & 0x3fff;
|
|
986
|
+
height = uint16LE(bytes, 28) & 0x3fff;
|
|
987
|
+
}
|
|
988
|
+
} else {
|
|
989
|
+
throw new Error("plugin raster image type is unsupported");
|
|
990
|
+
}
|
|
991
|
+
if (!Number.isSafeInteger(width) || !Number.isSafeInteger(height) || width <= 0 || height <= 0 || width > maxImageDimension || height > maxImageDimension) {
|
|
992
|
+
throw new Error("decoded image dimensions exceed the renderer budget");
|
|
993
|
+
}
|
|
994
|
+
return { width, height, pixels: width * height };
|
|
995
|
+
};
|
|
996
|
+
const retainImagePixels = (pixels) => {
|
|
997
|
+
if (!Number.isSafeInteger(pixels) || pixels <= 0 || retainedImageCount >= maxImageCount || retainedImagePixels + pixels > maxImageTotalPixels) {
|
|
998
|
+
throw new Error("decoded image assets exceed the renderer pixel budget");
|
|
999
|
+
}
|
|
1000
|
+
retainedImageCount += 1;
|
|
1001
|
+
retainedImagePixels += pixels;
|
|
1002
|
+
};
|
|
1003
|
+
const deliverImageAsset = async (id, assetID, asset) => {
|
|
1004
|
+
if (!pendingImageRequests.has(id)) pendingImageRequests.set(id, { assetID, asset });
|
|
1005
|
+
const bytes = verifiedAssetBytes.get(asset.binding_id);
|
|
1006
|
+
if (!bytes) {
|
|
1007
|
+
return;
|
|
1008
|
+
}
|
|
1009
|
+
if (activeImageDecodes.has(id)) return;
|
|
1010
|
+
activeImageDecodes.add(id);
|
|
1011
|
+
let image;
|
|
1012
|
+
try {
|
|
1013
|
+
const rasterType = imageAssetTypes.get(asset.binding_id) || imageType(asset.content_type);
|
|
1014
|
+
const dimensions = imageAssetDimensions.get(asset.binding_id) || readImageDimensions(bytes, rasterType);
|
|
1015
|
+
retainImagePixels(dimensions.pixels);
|
|
1016
|
+
imageReservations.set(id, dimensions.pixels);
|
|
1017
|
+
image = await createImageBitmap(new Blob([bytes], { type: rasterType }));
|
|
1018
|
+
if (!pendingWorkerRequests.has(id) || !pendingImageRequests.has(id)) {
|
|
1019
|
+
image.close();
|
|
1020
|
+
const reservedPixels = imageReservations.get(id);
|
|
1021
|
+
if (reservedPixels !== undefined) {
|
|
1022
|
+
imageReservations.delete(id);
|
|
1023
|
+
retainedImageCount = Math.max(0, retainedImageCount - 1);
|
|
1024
|
+
retainedImagePixels = Math.max(0, retainedImagePixels - reservedPixels);
|
|
1025
|
+
}
|
|
1026
|
+
activeImageDecodes.delete(id);
|
|
1027
|
+
return;
|
|
1028
|
+
}
|
|
1029
|
+
if (!image || image.width !== dimensions.width || image.height !== dimensions.height) throw new Error("decoded image dimensions changed after validation");
|
|
1030
|
+
pendingImageRequests.delete(id);
|
|
1031
|
+
activeImageDecodes.delete(id);
|
|
1032
|
+
sendWorkerTransfer({ type: "redevplugin.ui.asset.image.ready", id, asset_id: assetID, image, width: image.width, height: image.height }, [image]);
|
|
1033
|
+
imageReservations.delete(id);
|
|
1034
|
+
completeWorkerRequest(id);
|
|
1035
|
+
} catch (error) {
|
|
1036
|
+
try { image && image.close(); } catch {}
|
|
1037
|
+
failTransferRequest(id, error && error.message || "plugin image asset could not be decoded");
|
|
1038
|
+
}
|
|
1039
|
+
};
|
|
647
1040
|
const applyAsset = async (asset, contentBase64) => {
|
|
648
1041
|
if (contentBase64.length !== Math.ceil(asset.size / 3) * 4) throw new Error("plugin asset encoded size mismatch");
|
|
649
1042
|
const binary = atob(contentBase64);
|
|
@@ -653,7 +1046,17 @@ export function createOpaquePluginBootstrapHTML(options = {}) {
|
|
|
653
1046
|
const digestBytes = new Uint8Array(await crypto.subtle.digest("SHA-256", bytes));
|
|
654
1047
|
const actualDigest = "sha256:" + Array.from(digestBytes, (byte) => byte.toString(16).padStart(2, "0")).join("");
|
|
655
1048
|
if (actualDigest !== asset.sha256) throw new Error("plugin asset bytes failed SHA-256 verification");
|
|
656
|
-
const
|
|
1049
|
+
const declaredType = imageType(asset.content_type);
|
|
1050
|
+
const rasterType = detectRasterImageType(bytes);
|
|
1051
|
+
if (rasterType || (declaredType.startsWith("image/") && declaredType !== "image/svg+xml")) {
|
|
1052
|
+
const validatedType = rasterType || declaredType;
|
|
1053
|
+
const dimensions = readImageDimensions(bytes, validatedType);
|
|
1054
|
+
retainImagePixels(dimensions.pixels);
|
|
1055
|
+
imageAssetDimensions.set(asset.binding_id, dimensions);
|
|
1056
|
+
imageAssetTypes.set(asset.binding_id, validatedType);
|
|
1057
|
+
}
|
|
1058
|
+
verifiedAssetBytes.set(asset.binding_id, bytes);
|
|
1059
|
+
const url = URL.createObjectURL(new Blob([bytes], { type: rasterType || asset.content_type }));
|
|
657
1060
|
blobURLs.add(url);
|
|
658
1061
|
document.documentElement.style.setProperty("--redevplugin-asset-" + asset.binding_id, 'url("' + url.replaceAll('"', '%22') + '")');
|
|
659
1062
|
for (const element of document.querySelectorAll('[data-redevplugin-asset-binding="' + asset.binding_id + '"]')) {
|
|
@@ -662,6 +1065,9 @@ export function createOpaquePluginBootstrapHTML(options = {}) {
|
|
|
662
1065
|
element.removeAttribute("data-redevplugin-asset-binding");
|
|
663
1066
|
element.removeAttribute("data-redevplugin-asset-attr");
|
|
664
1067
|
}
|
|
1068
|
+
for (const [id, pending] of pendingImageRequests) {
|
|
1069
|
+
if (pending.asset.binding_id === asset.binding_id) void deliverImageAsset(id, pending.assetID, pending.asset);
|
|
1070
|
+
}
|
|
665
1071
|
};
|
|
666
1072
|
const workerRuntime = () => [
|
|
667
1073
|
'{',
|
|
@@ -677,10 +1083,17 @@ export function createOpaquePluginBootstrapHTML(options = {}) {
|
|
|
677
1083
|
'const __rpControlPost = __rpControlPort.postMessage.bind(__rpControlPort);',
|
|
678
1084
|
'const __rpControlStart = __rpControlPort.start.bind(__rpControlPort);',
|
|
679
1085
|
'const __rpControlClose = __rpControlPort.close.bind(__rpControlPort);',
|
|
1086
|
+
'const __rpControlAddEventListener = __rpControlPort.addEventListener.bind(__rpControlPort);',
|
|
680
1087
|
'const __rpGetPrototypeOf = Object.getPrototypeOf;',
|
|
681
1088
|
'const __rpGetOwnPropertyDescriptor = Object.getOwnPropertyDescriptor;',
|
|
682
1089
|
'const __rpDefineProperty = Object.defineProperty;',
|
|
683
1090
|
'const __rpHasOwn = Object.prototype.hasOwnProperty;',
|
|
1091
|
+
'const __rpObjectKeys = Object.keys;',
|
|
1092
|
+
'const __rpApply = Reflect.apply;',
|
|
1093
|
+
'const __rpNumberIsSafeInteger = Number.isSafeInteger.bind(Number);',
|
|
1094
|
+
'const __rpOffscreenCanvas = globalThis.OffscreenCanvas;',
|
|
1095
|
+
'const __rpCanvasPrototype = __rpOffscreenCanvas && __rpOffscreenCanvas.prototype;',
|
|
1096
|
+
'const __rpTrackedCanvases = [];',
|
|
684
1097
|
'const __rpBlocked = () => { throw new TypeError("API is unavailable in the ReDevPlugin worker sandbox"); };',
|
|
685
1098
|
'const __rpSealDescriptor = (owner, name, value) => {',
|
|
686
1099
|
' const descriptor = __rpGetOwnPropertyDescriptor(owner, name);',
|
|
@@ -711,9 +1124,42 @@ export function createOpaquePluginBootstrapHTML(options = {}) {
|
|
|
711
1124
|
' }',
|
|
712
1125
|
' if (!found) throw new TypeError("MessagePort method is unavailable: " + name);',
|
|
713
1126
|
'};',
|
|
1127
|
+
'const __rpTrackCanvas = (canvas) => {',
|
|
1128
|
+
' for (let index = 0; index < __rpTrackedCanvases.length; index += 1) if (__rpTrackedCanvases[index] === canvas) return;',
|
|
1129
|
+
' if (__rpTrackedCanvases.length >= ' + JSON.stringify(maxCanvasCount) + ') throw new RangeError("plugin canvas count exceeds the worker budget");',
|
|
1130
|
+
' __rpTrackedCanvases.push(canvas);',
|
|
1131
|
+
'};',
|
|
1132
|
+
'const __rpInstallCanvasBudget = () => {',
|
|
1133
|
+
' if (!__rpCanvasPrototype) return;',
|
|
1134
|
+
' const widthDescriptor = __rpGetOwnPropertyDescriptor(__rpCanvasPrototype, "width");',
|
|
1135
|
+
' const heightDescriptor = __rpGetOwnPropertyDescriptor(__rpCanvasPrototype, "height");',
|
|
1136
|
+
' const contextDescriptor = __rpGetOwnPropertyDescriptor(__rpCanvasPrototype, "getContext");',
|
|
1137
|
+
' if (!widthDescriptor || !heightDescriptor || typeof widthDescriptor.get !== "function" || typeof widthDescriptor.set !== "function" || typeof heightDescriptor.get !== "function" || typeof heightDescriptor.set !== "function" || !contextDescriptor || typeof contextDescriptor.value !== "function") throw new TypeError("OffscreenCanvas descriptors are unavailable");',
|
|
1138
|
+
' const readWidth = (canvas) => __rpApply(widthDescriptor.get, canvas, []);',
|
|
1139
|
+
' const readHeight = (canvas) => __rpApply(heightDescriptor.get, canvas, []);',
|
|
1140
|
+
' const validateResize = (canvas, axis, value) => {',
|
|
1141
|
+
' if (!__rpNumberIsSafeInteger(value) || value <= 0 || value > ' + JSON.stringify(maxCanvasDimension) + ') throw new RangeError("plugin canvas resize exceeds the worker dimension budget");',
|
|
1142
|
+
' __rpTrackCanvas(canvas);',
|
|
1143
|
+
' let pixels = 0;',
|
|
1144
|
+
' for (let index = 0; index < __rpTrackedCanvases.length; index += 1) {',
|
|
1145
|
+
' const current = __rpTrackedCanvases[index];',
|
|
1146
|
+
' const width = current === canvas && axis === "width" ? value : readWidth(current);',
|
|
1147
|
+
' const height = current === canvas && axis === "height" ? value : readHeight(current);',
|
|
1148
|
+
' pixels += width * height;',
|
|
1149
|
+
' if (pixels > ' + JSON.stringify(maxCanvasTotalPixels) + ') throw new RangeError("plugin canvas resize exceeds the worker pixel budget");',
|
|
1150
|
+
' }',
|
|
1151
|
+
' };',
|
|
1152
|
+
' __rpDefineProperty(__rpCanvasPrototype, "width", { configurable: false, enumerable: Boolean(widthDescriptor.enumerable), get() { return readWidth(this); }, set(value) { validateResize(this, "width", value); __rpApply(widthDescriptor.set, this, [value]); } });',
|
|
1153
|
+
' __rpDefineProperty(__rpCanvasPrototype, "height", { configurable: false, enumerable: Boolean(heightDescriptor.enumerable), get() { return readHeight(this); }, set(value) { validateResize(this, "height", value); __rpApply(heightDescriptor.set, this, [value]); } });',
|
|
1154
|
+
' __rpDefineProperty(__rpCanvasPrototype, "getContext", { configurable: false, enumerable: Boolean(contextDescriptor.enumerable), writable: false, value(type, ...options) { if (type !== "2d") throw new TypeError("only 2d canvas contexts are available"); __rpTrackCanvas(this); return __rpApply(contextDescriptor.value, this, [type, ...options]); } });',
|
|
1155
|
+
' __rpSealDescriptor(__rpCanvasPrototype, "constructor", undefined);',
|
|
1156
|
+
' __rpSealDescriptor(__rpCanvasPrototype, "convertToBlob", __rpBlocked);',
|
|
1157
|
+
' __rpSealDescriptor(__rpCanvasPrototype, "transferToImageBitmap", __rpBlocked);',
|
|
1158
|
+
'};',
|
|
714
1159
|
'try {',
|
|
715
1160
|
' for (const name of ["postMessage", "start", "close", "addEventListener", "removeEventListener"]) __rpSealMessagePortMethod(name);',
|
|
716
|
-
'
|
|
1161
|
+
' __rpInstallCanvasBudget();',
|
|
1162
|
+
' for (const [name, value] of Object.entries({fetch:__rpBlocked,XMLHttpRequest:undefined,WebSocket:undefined,EventSource:undefined,WebTransport:undefined,Worker:undefined,SharedWorker:undefined,indexedDB:undefined,caches:undefined,RTCPeerConnection:undefined,webkitRTCPeerConnection:undefined,BroadcastChannel:undefined,importScripts:undefined,postMessage:undefined,eval:undefined,Function:undefined,Blob:undefined,File:undefined,FileReader:undefined,FileReaderSync:undefined,OffscreenCanvas:undefined,ImageBitmap:undefined,createImageBitmap:undefined})) __rpSealChain(globalThis, name, value);',
|
|
717
1163
|
' for (const [name, value] of Object.entries({storage:undefined,sendBeacon:undefined,serviceWorker:undefined})) __rpSealChain(navigator, name, value);',
|
|
718
1164
|
' const __rpFunctionPrototypes = [__rpGetPrototypeOf(function() {}), __rpGetPrototypeOf(async function() {}), __rpGetPrototypeOf(function*() {}), __rpGetPrototypeOf(async function*() {})];',
|
|
719
1165
|
' for (const prototype of __rpFunctionPrototypes) __rpSealDescriptor(prototype, "constructor", undefined);',
|
|
@@ -738,6 +1184,14 @@ export function createOpaquePluginBootstrapHTML(options = {}) {
|
|
|
738
1184
|
'Object.defineProperty(globalThis, ' + JSON.stringify(workerGlobalKey) + ', { configurable: false, enumerable: false, writable: false, value: __rpBridge });',
|
|
739
1185
|
'__rpControlStart();',
|
|
740
1186
|
'__rpPort.start();',
|
|
1187
|
+
'const __rpControlListener = (event) => {',
|
|
1188
|
+
' const message = event.data;',
|
|
1189
|
+
' if (!message || typeof message !== "object" || Array.isArray(message)) return;',
|
|
1190
|
+
' const keys = __rpObjectKeys(message).sort();',
|
|
1191
|
+
' if (keys.length !== 2 || keys[0] !== "ping_id" || keys[1] !== "type" || message.type !== "redevplugin.worker.ping" || typeof message.ping_id !== "string" || !/^ping_[1-9][0-9]{0,15}$/.test(message.ping_id)) return;',
|
|
1192
|
+
' __rpControlPost({ type: "redevplugin.worker.pong", ping_id: message.ping_id });',
|
|
1193
|
+
'};',
|
|
1194
|
+
'__rpControlAddEventListener("message", __rpControlListener);',
|
|
741
1195
|
'let __rpFailed = false;',
|
|
742
1196
|
'const __rpReportFailure = (error) => { if (__rpFailed) return; __rpFailed = true; __rpControlPost({ type: "redevplugin.worker.error", error: String(error && error.message || error).slice(0, 512) }); };',
|
|
743
1197
|
'addEventListener("error", (event) => __rpReportFailure(event.error || event.message || "plugin worker failed"), { capture: true });',
|
|
@@ -775,7 +1229,7 @@ export function createOpaquePluginBootstrapHTML(options = {}) {
|
|
|
775
1229
|
const validCall = (value) => exactKeys(value, ["type", "request"]) && value.type === "redevplugin.bridge.call" && isRecord(value.request) && Object.keys(value.request).every((key) => ["id", "method", "params"].includes(key)) && typeof value.request.id === "string" && value.request.id.length <= 128 && typeof value.request.method === "string" && /^[A-Za-z0-9._:-]{1,256}$/.test(value.request.method) && (value.request.params === undefined || isRecord(value.request.params));
|
|
776
1230
|
const requestID = (value, expectedKind) => {
|
|
777
1231
|
if (typeof value !== "string") return undefined;
|
|
778
|
-
const match = /^(rpc|stream|render|operation)_([1-9][0-9]{0,15})$/.exec(value);
|
|
1232
|
+
const match = /^(rpc|stream|render|operation|canvas|asset)_([1-9][0-9]{0,15})$/.exec(value);
|
|
779
1233
|
if (!match || match[1] !== expectedKind) return undefined;
|
|
780
1234
|
const sequence = Number(match[2]);
|
|
781
1235
|
return Number.isSafeInteger(sequence) ? { kind: match[1], sequence } : undefined;
|
|
@@ -796,6 +1250,175 @@ export function createOpaquePluginBootstrapHTML(options = {}) {
|
|
|
796
1250
|
return true;
|
|
797
1251
|
};
|
|
798
1252
|
const rejectWorkerRequest = (id, error) => sendWorker({ type: "redevplugin.bridge.response", id, ok: false, error_code: "PLUGIN_INVALID_REQUEST", error });
|
|
1253
|
+
const findCanvas = (canvasID) => {
|
|
1254
|
+
for (const element of document.querySelectorAll("canvas[data-redevplugin-canvas]")) {
|
|
1255
|
+
if (element.getAttribute("data-redevplugin-canvas") === canvasID) return element;
|
|
1256
|
+
}
|
|
1257
|
+
return undefined;
|
|
1258
|
+
};
|
|
1259
|
+
const updateCanvasAccessibility = (id, canvasID, label, description) => {
|
|
1260
|
+
const runtime = canvasRuntimes.get(canvasID);
|
|
1261
|
+
if (!runtime || typeof label !== "string" || label.length < 1 || label.length > 256 || typeof description !== "string" || description.length > 1024) {
|
|
1262
|
+
completeWorkerRequest(id);
|
|
1263
|
+
return rejectWorkerRequest(id, "plugin canvas accessibility state is invalid");
|
|
1264
|
+
}
|
|
1265
|
+
if (!runtime.description) {
|
|
1266
|
+
const status = document.createElement("span");
|
|
1267
|
+
status.id = "redevplugin-canvas-description-" + canvasID.replace(/[^A-Za-z0-9_-]/g, "-");
|
|
1268
|
+
status.style.position = "absolute";
|
|
1269
|
+
status.style.width = "1px";
|
|
1270
|
+
status.style.height = "1px";
|
|
1271
|
+
status.style.padding = "0";
|
|
1272
|
+
status.style.margin = "-1px";
|
|
1273
|
+
status.style.overflow = "hidden";
|
|
1274
|
+
status.style.clip = "rect(0, 0, 0, 0)";
|
|
1275
|
+
status.style.whiteSpace = "nowrap";
|
|
1276
|
+
status.style.border = "0";
|
|
1277
|
+
runtime.canvas.insertAdjacentElement("afterend", status);
|
|
1278
|
+
runtime.canvas.setAttribute("aria-describedby", status.id);
|
|
1279
|
+
runtime.description = status;
|
|
1280
|
+
}
|
|
1281
|
+
runtime.canvas.setAttribute("aria-label", label);
|
|
1282
|
+
runtime.description.textContent = description;
|
|
1283
|
+
completeWorkerRequest(id);
|
|
1284
|
+
sendWorker({ type: "redevplugin.bridge.response", id, ok: true });
|
|
1285
|
+
};
|
|
1286
|
+
const canvasMetrics = (canvas) => {
|
|
1287
|
+
const rect = canvas.getBoundingClientRect();
|
|
1288
|
+
const cssWidth = Math.ceil(rect.width || canvas.width || 1);
|
|
1289
|
+
const cssHeight = Math.ceil(rect.height || canvas.height || 1);
|
|
1290
|
+
const devicePixelRatio = Math.min(4, Math.max(0.5, Number(globalThis.devicePixelRatio) || 1));
|
|
1291
|
+
if (!Number.isSafeInteger(cssWidth) || !Number.isSafeInteger(cssHeight) || cssWidth <= 0 || cssHeight <= 0 || cssWidth > maxCanvasDimension || cssHeight > maxCanvasDimension) {
|
|
1292
|
+
throw new Error("plugin canvas dimensions exceed the renderer budget");
|
|
1293
|
+
}
|
|
1294
|
+
const pixelCount = Math.ceil(cssWidth * devicePixelRatio) * Math.ceil(cssHeight * devicePixelRatio);
|
|
1295
|
+
return { cssWidth, cssHeight, devicePixelRatio, pixelCount };
|
|
1296
|
+
};
|
|
1297
|
+
const openCanvas = (id, canvasID) => {
|
|
1298
|
+
const canvas = findCanvas(canvasID);
|
|
1299
|
+
if (!canvas || canvasRuntimes.has(canvasID) || typeof canvas.transferControlToOffscreen !== "function" || typeof ResizeObserver !== "function") {
|
|
1300
|
+
completeWorkerRequest(id);
|
|
1301
|
+
return rejectWorkerRequest(id, "plugin canvas is missing, already transferred, or unsupported");
|
|
1302
|
+
}
|
|
1303
|
+
if (canvas.tabIndex < 0) canvas.tabIndex = 0;
|
|
1304
|
+
let pointerWindowStartedAt = 0;
|
|
1305
|
+
let pointerEvents = 0;
|
|
1306
|
+
const listeners = [];
|
|
1307
|
+
const listen = (type, handler) => {
|
|
1308
|
+
canvas.addEventListener(type, handler, { passive: false });
|
|
1309
|
+
listeners.push([type, handler]);
|
|
1310
|
+
};
|
|
1311
|
+
const sendInput = (event) => sendWorker({ type: "redevplugin.ui.canvas.input", canvas_id: canvasID, event });
|
|
1312
|
+
const sendResize = () => {
|
|
1313
|
+
const metrics = canvasMetrics(canvas);
|
|
1314
|
+
const runtime = canvasRuntimes.get(canvasID);
|
|
1315
|
+
let totalPixels = metrics.pixelCount;
|
|
1316
|
+
for (const [identifier, active] of canvasRuntimes) {
|
|
1317
|
+
if (identifier !== canvasID) totalPixels += active.pixelCount;
|
|
1318
|
+
}
|
|
1319
|
+
if (totalPixels > maxCanvasTotalPixels) throw new Error("plugin canvases exceed the renderer pixel budget");
|
|
1320
|
+
if (runtime) runtime.pixelCount = metrics.pixelCount;
|
|
1321
|
+
sendInput({ type: "resize", css_width: metrics.cssWidth, css_height: metrics.cssHeight, device_pixel_ratio: metrics.devicePixelRatio });
|
|
1322
|
+
return metrics;
|
|
1323
|
+
};
|
|
1324
|
+
const handlePointer = (event) => {
|
|
1325
|
+
const now = performance.now();
|
|
1326
|
+
if (now - pointerWindowStartedAt >= 1000) { pointerWindowStartedAt = now; pointerEvents = 0; }
|
|
1327
|
+
if (event.type === "pointermove") {
|
|
1328
|
+
if (pointerEvents >= maxCanvasPointerEventsPerSecond) return;
|
|
1329
|
+
pointerEvents += 1;
|
|
1330
|
+
}
|
|
1331
|
+
event.preventDefault();
|
|
1332
|
+
if (event.type === "pointerdown") {
|
|
1333
|
+
try { canvas.focus({ preventScroll: true }); } catch { canvas.focus(); }
|
|
1334
|
+
try { canvas.setPointerCapture(event.pointerId); } catch {}
|
|
1335
|
+
}
|
|
1336
|
+
if (event.type === "pointerup" || event.type === "pointercancel") {
|
|
1337
|
+
try { canvas.releasePointerCapture(event.pointerId); } catch {}
|
|
1338
|
+
}
|
|
1339
|
+
const rect = canvas.getBoundingClientRect();
|
|
1340
|
+
sendInput({
|
|
1341
|
+
type: "pointer",
|
|
1342
|
+
event: event.type,
|
|
1343
|
+
pointer_id: Math.max(0, Number.isSafeInteger(event.pointerId) ? event.pointerId : 0),
|
|
1344
|
+
pointer_type: ["mouse", "pen", "touch"].includes(event.pointerType) ? event.pointerType : "unknown",
|
|
1345
|
+
buttons: Math.min(31, Math.max(0, Number.isSafeInteger(event.buttons) ? event.buttons : 0)),
|
|
1346
|
+
button: Math.min(4, Math.max(-1, Number.isSafeInteger(event.button) ? event.button : -1)),
|
|
1347
|
+
x: Math.min(32768, Math.max(-16384, event.clientX - rect.left)),
|
|
1348
|
+
y: Math.min(32768, Math.max(-16384, event.clientY - rect.top)),
|
|
1349
|
+
pressure: Math.min(1, Math.max(0, Number.isFinite(event.pressure) ? event.pressure : 0)),
|
|
1350
|
+
});
|
|
1351
|
+
};
|
|
1352
|
+
const handleKey = (event) => {
|
|
1353
|
+
event.preventDefault();
|
|
1354
|
+
sendInput({
|
|
1355
|
+
type: "key",
|
|
1356
|
+
event: event.type,
|
|
1357
|
+
code: String(event.code || "").slice(0, 64),
|
|
1358
|
+
key: String(event.key || "").slice(0, 64),
|
|
1359
|
+
repeat: Boolean(event.repeat),
|
|
1360
|
+
alt_key: Boolean(event.altKey),
|
|
1361
|
+
ctrl_key: Boolean(event.ctrlKey),
|
|
1362
|
+
meta_key: Boolean(event.metaKey),
|
|
1363
|
+
shift_key: Boolean(event.shiftKey),
|
|
1364
|
+
});
|
|
1365
|
+
};
|
|
1366
|
+
for (const type of ["pointerdown", "pointermove", "pointerup", "pointercancel"]) listen(type, handlePointer);
|
|
1367
|
+
for (const type of ["keydown", "keyup"]) listen(type, handleKey);
|
|
1368
|
+
listen("focus", () => sendInput({ type: "focus" }));
|
|
1369
|
+
listen("blur", () => sendInput({ type: "blur" }));
|
|
1370
|
+
const observer = new ResizeObserver(() => {
|
|
1371
|
+
try { sendResize(); }
|
|
1372
|
+
catch (error) { fail(error && error.message || "plugin canvas resize exceeded renderer limits"); }
|
|
1373
|
+
});
|
|
1374
|
+
observer.observe(canvas);
|
|
1375
|
+
const runtime = { canvas, description: undefined, dispose: undefined, pixelCount: 0 };
|
|
1376
|
+
const dispose = () => {
|
|
1377
|
+
observer.disconnect();
|
|
1378
|
+
for (const [type, handler] of listeners) canvas.removeEventListener(type, handler);
|
|
1379
|
+
runtime.description?.remove();
|
|
1380
|
+
};
|
|
1381
|
+
runtime.dispose = dispose;
|
|
1382
|
+
try {
|
|
1383
|
+
const offscreen = canvas.transferControlToOffscreen();
|
|
1384
|
+
const metrics = canvasMetrics(canvas);
|
|
1385
|
+
let totalPixels = metrics.pixelCount;
|
|
1386
|
+
for (const runtime of canvasRuntimes.values()) totalPixels += runtime.pixelCount;
|
|
1387
|
+
if (canvasRuntimes.size >= maxCanvasCount || totalPixels > maxCanvasTotalPixels) throw new Error("plugin canvas resources exceed renderer limits");
|
|
1388
|
+
runtime.pixelCount = metrics.pixelCount;
|
|
1389
|
+
canvasRuntimes.set(canvasID, runtime);
|
|
1390
|
+
completeWorkerRequest(id);
|
|
1391
|
+
sendWorkerTransfer({ type: "redevplugin.ui.canvas.ready", id, canvas_id: canvasID, canvas: offscreen, css_width: metrics.cssWidth, css_height: metrics.cssHeight, device_pixel_ratio: metrics.devicePixelRatio }, [offscreen]);
|
|
1392
|
+
sendResize();
|
|
1393
|
+
} catch (error) {
|
|
1394
|
+
dispose();
|
|
1395
|
+
completeWorkerRequest(id);
|
|
1396
|
+
rejectWorkerRequest(id, String(error && error.message || "plugin canvas transfer failed").slice(0, 512));
|
|
1397
|
+
}
|
|
1398
|
+
};
|
|
1399
|
+
const scheduleWorkerHeartbeat = () => {
|
|
1400
|
+
if (workerHeartbeatTimer) clearTimeout(workerHeartbeatTimer);
|
|
1401
|
+
workerHeartbeatTimer = undefined;
|
|
1402
|
+
if (!workerReady || !workerControlPort || workerHeartbeatPendingID) return;
|
|
1403
|
+
workerHeartbeatTimer = setTimeout(() => {
|
|
1404
|
+
workerHeartbeatTimer = undefined;
|
|
1405
|
+
if (!workerReady || !workerControlPort || workerHeartbeatPendingID) return;
|
|
1406
|
+
const pingID = "ping_" + (++workerHeartbeatSequence);
|
|
1407
|
+
workerHeartbeatPendingID = pingID;
|
|
1408
|
+
try {
|
|
1409
|
+
workerControlPort.postMessage({ type: "redevplugin.worker.ping", ping_id: pingID });
|
|
1410
|
+
} catch {
|
|
1411
|
+
fail("plugin worker heartbeat could not be sent");
|
|
1412
|
+
return;
|
|
1413
|
+
}
|
|
1414
|
+
workerHeartbeatTimeout = setTimeout(() => {
|
|
1415
|
+
workerHeartbeatTimeout = undefined;
|
|
1416
|
+
if (workerHeartbeatPendingID !== pingID) return;
|
|
1417
|
+
workerHeartbeatPendingID = undefined;
|
|
1418
|
+
fail("plugin worker heartbeat timed out");
|
|
1419
|
+
}, workerHeartbeatTimeoutMs);
|
|
1420
|
+
}, workerHeartbeatIntervalMs);
|
|
1421
|
+
};
|
|
799
1422
|
const onWorkerControlMessage = (event) => {
|
|
800
1423
|
if (!withinLimit(event.data)) return;
|
|
801
1424
|
const message = event.data;
|
|
@@ -803,6 +1426,14 @@ export function createOpaquePluginBootstrapHTML(options = {}) {
|
|
|
803
1426
|
if (workerReady) return;
|
|
804
1427
|
workerReady = true;
|
|
805
1428
|
sendParent({ type: "redevplugin.surface.worker_ready" });
|
|
1429
|
+
scheduleWorkerHeartbeat();
|
|
1430
|
+
return;
|
|
1431
|
+
}
|
|
1432
|
+
if (exactKeys(message, ["type", "ping_id"]) && message.type === "redevplugin.worker.pong" && message.ping_id === workerHeartbeatPendingID) {
|
|
1433
|
+
if (workerHeartbeatTimeout) clearTimeout(workerHeartbeatTimeout);
|
|
1434
|
+
workerHeartbeatTimeout = undefined;
|
|
1435
|
+
workerHeartbeatPendingID = undefined;
|
|
1436
|
+
scheduleWorkerHeartbeat();
|
|
806
1437
|
return;
|
|
807
1438
|
}
|
|
808
1439
|
if (exactKeys(message, ["type", "error"]) && message.type === "redevplugin.worker.error" && typeof message.error === "string") {
|
|
@@ -813,6 +1444,12 @@ export function createOpaquePluginBootstrapHTML(options = {}) {
|
|
|
813
1444
|
const onWorkerMessage = (event) => {
|
|
814
1445
|
if (!withinLimit(event.data)) return;
|
|
815
1446
|
const message = event.data;
|
|
1447
|
+
if (exactKeys(message, ["type", "quiesce_id"]) && message.type === "redevplugin.bridge.lifecycle_ack" && validOpaqueHandle(message.quiesce_id, "quiesce") && message.quiesce_id === pendingQuiesceID) {
|
|
1448
|
+
pendingQuiesceID = undefined;
|
|
1449
|
+
sendParent({ type: "redevplugin.surface.quiesce_ack", quiesce_id: message.quiesce_id });
|
|
1450
|
+
disposeRuntime();
|
|
1451
|
+
return;
|
|
1452
|
+
}
|
|
816
1453
|
if (validCall(message)) {
|
|
817
1454
|
if (!acceptWorkerRequest(message.request.id, "rpc")) return rejectWorkerRequest(message.request.id, "duplicate, replayed, or excessive plugin request");
|
|
818
1455
|
sendParent(message);
|
|
@@ -830,6 +1467,26 @@ export function createOpaquePluginBootstrapHTML(options = {}) {
|
|
|
830
1467
|
sendParent(message);
|
|
831
1468
|
return;
|
|
832
1469
|
}
|
|
1470
|
+
if (exactKeys(message, ["type", "id", "canvas_id"]) && message.type === "redevplugin.ui.canvas.open" && typeof message.id === "string" && validResourceIdentifier(message.canvas_id)) {
|
|
1471
|
+
if (!acceptWorkerRequest(message.id, "canvas")) return rejectWorkerRequest(message.id, "duplicate, replayed, or excessive plugin request");
|
|
1472
|
+
openCanvas(message.id, message.canvas_id);
|
|
1473
|
+
return;
|
|
1474
|
+
}
|
|
1475
|
+
if (exactKeys(message, ["type", "id", "canvas_id", "label", "description"]) && message.type === "redevplugin.ui.canvas.accessibility" && typeof message.id === "string" && validResourceIdentifier(message.canvas_id)) {
|
|
1476
|
+
if (!acceptWorkerRequest(message.id, "canvas")) return rejectWorkerRequest(message.id, "duplicate, replayed, or excessive plugin request");
|
|
1477
|
+
updateCanvasAccessibility(message.id, message.canvas_id, message.label, message.description);
|
|
1478
|
+
return;
|
|
1479
|
+
}
|
|
1480
|
+
if (exactKeys(message, ["type", "id", "asset_id"]) && message.type === "redevplugin.ui.asset.image.open" && typeof message.id === "string" && validResourceIdentifier(message.asset_id)) {
|
|
1481
|
+
if (!acceptWorkerRequest(message.id, "asset")) return rejectWorkerRequest(message.id, "duplicate, replayed, or excessive plugin request");
|
|
1482
|
+
const asset = assetByLogicalID.get(message.asset_id);
|
|
1483
|
+
if (!asset || !String(asset.content_type).toLowerCase().startsWith("image/") || String(asset.content_type).toLowerCase() === "image/svg+xml") {
|
|
1484
|
+
completeWorkerRequest(message.id);
|
|
1485
|
+
return rejectWorkerRequest(message.id, "plugin image asset is not declared");
|
|
1486
|
+
}
|
|
1487
|
+
void deliverImageAsset(message.id, message.asset_id, asset);
|
|
1488
|
+
return;
|
|
1489
|
+
}
|
|
833
1490
|
if (exactKeys(message, ["type", "id", "tree"]) && message.type === "redevplugin.ui.render" && typeof message.id === "string") {
|
|
834
1491
|
if (!acceptWorkerRequest(message.id, "render")) return rejectWorkerRequest(message.id, "duplicate, replayed, or excessive plugin request");
|
|
835
1492
|
if (!renderRateAllowed()) {
|
|
@@ -848,6 +1505,11 @@ export function createOpaquePluginBootstrapHTML(options = {}) {
|
|
|
848
1505
|
}
|
|
849
1506
|
if (exactKeys(message, ["type", "id"]) && message.type === "redevplugin.bridge.cancel" && typeof message.id === "string") {
|
|
850
1507
|
if (!pendingWorkerRequests.has(message.id)) return rejectWorkerRequest(message.id, "plugin request is not pending");
|
|
1508
|
+
if (pendingImageRequests.has(message.id)) {
|
|
1509
|
+
pendingImageRequests.delete(message.id);
|
|
1510
|
+
completeWorkerRequest(message.id);
|
|
1511
|
+
return;
|
|
1512
|
+
}
|
|
851
1513
|
completeWorkerRequest(message.id);
|
|
852
1514
|
sendParent(message);
|
|
853
1515
|
}
|
|
@@ -872,9 +1534,10 @@ export function createOpaquePluginBootstrapHTML(options = {}) {
|
|
|
872
1534
|
sendWorker(message);
|
|
873
1535
|
return;
|
|
874
1536
|
}
|
|
875
|
-
if (message && message.type === "redevplugin.bridge.lifecycle" && isRecord(message.event) && ["ready", "visible", "hidden", "dispose"].includes(message.event.type)) {
|
|
1537
|
+
if (message && message.type === "redevplugin.bridge.lifecycle" && isRecord(message.event) && ["ready", "visible", "hidden", "dispose"].includes(message.event.type) && Object.keys(message).every((key) => ["type", "event", "quiesce_id"].includes(key)) && (message.quiesce_id === undefined || (message.event.type === "dispose" && validOpaqueHandle(message.quiesce_id, "quiesce")))) {
|
|
1538
|
+
pendingQuiesceID = message.quiesce_id;
|
|
876
1539
|
sendWorker(message);
|
|
877
|
-
if (message.event.type === "dispose") disposeRuntime();
|
|
1540
|
+
if (message.event.type === "dispose" && !message.quiesce_id) disposeRuntime();
|
|
878
1541
|
return;
|
|
879
1542
|
}
|
|
880
1543
|
if (exactKeys(message, ["type", "request_id", "binding_id", "ok", "path", "sha256", "content_type", "content_base64"]) && message.type === "redevplugin.surface.asset.response" && typeof message.content_base64 === "string" && message.content_base64.length <= maxPrivateAssetBase64Length) {
|
|
@@ -944,8 +1607,10 @@ export class PluginSurfaceHost {
|
|
|
944
1607
|
#transportIdle;
|
|
945
1608
|
#port;
|
|
946
1609
|
#openSignals;
|
|
1610
|
+
#quiesce;
|
|
947
1611
|
#initialFrameLoad;
|
|
948
1612
|
#frameLoaded = false;
|
|
1613
|
+
#closePromise;
|
|
949
1614
|
#revokePromise;
|
|
950
1615
|
#unregisterSurfaceScope;
|
|
951
1616
|
#opened = false;
|
|
@@ -1085,7 +1750,22 @@ export class PluginSurfaceHost {
|
|
|
1085
1750
|
this.#assertReady();
|
|
1086
1751
|
this.#postToRenderer({ type: "redevplugin.bridge.lifecycle", event });
|
|
1087
1752
|
}
|
|
1088
|
-
|
|
1753
|
+
close() {
|
|
1754
|
+
if (this.#disposed)
|
|
1755
|
+
return Promise.resolve();
|
|
1756
|
+
if (this.#closePromise)
|
|
1757
|
+
return this.#closePromise;
|
|
1758
|
+
this.#closePromise = this.#closeSurface();
|
|
1759
|
+
return this.#closePromise;
|
|
1760
|
+
}
|
|
1761
|
+
dispose() {
|
|
1762
|
+
if (this.#disposed)
|
|
1763
|
+
return;
|
|
1764
|
+
void this.#bestEffortRevokeSurface(true);
|
|
1765
|
+
this.#disposeLocal();
|
|
1766
|
+
}
|
|
1767
|
+
async #closeSurface() {
|
|
1768
|
+
await this.#quiesceSurface();
|
|
1089
1769
|
if (this.#disposed)
|
|
1090
1770
|
return;
|
|
1091
1771
|
const revoke = this.#revokeSurface(false);
|
|
@@ -1097,12 +1777,6 @@ export class PluginSurfaceHost {
|
|
|
1097
1777
|
throw toBridgeError(error, "PLUGIN_BRIDGE_DISPOSED");
|
|
1098
1778
|
}
|
|
1099
1779
|
}
|
|
1100
|
-
dispose() {
|
|
1101
|
-
if (this.#disposed)
|
|
1102
|
-
return;
|
|
1103
|
-
void this.#bestEffortRevokeSurface(true);
|
|
1104
|
-
this.#disposeLocal();
|
|
1105
|
-
}
|
|
1106
1780
|
#disposeLocal() {
|
|
1107
1781
|
if (this.#disposed)
|
|
1108
1782
|
return;
|
|
@@ -1123,6 +1797,8 @@ export class PluginSurfaceHost {
|
|
|
1123
1797
|
this.#openSignals?.workerReady.reject(disposedError);
|
|
1124
1798
|
this.#openSignals?.portAcknowledged.reject(disposedError);
|
|
1125
1799
|
this.#initialFrameLoad?.reject(disposedError);
|
|
1800
|
+
this.#quiesce?.acknowledged.reject(disposedError);
|
|
1801
|
+
this.#quiesce = undefined;
|
|
1126
1802
|
this.#gatewayToken = undefined;
|
|
1127
1803
|
this.#leaseExpiresAtMs = 0;
|
|
1128
1804
|
this.#assetSession = undefined;
|
|
@@ -1161,6 +1837,10 @@ export class PluginSurfaceHost {
|
|
|
1161
1837
|
this.#openSignals?.workerReady.resolve();
|
|
1162
1838
|
return;
|
|
1163
1839
|
}
|
|
1840
|
+
if (hasExactKeys(data, ["type", "quiesce_id"]) && data.type === "redevplugin.surface.quiesce_ack" && validOpaqueHandle(data.quiesce_id, "quiesce") && data.quiesce_id === this.#quiesce?.id) {
|
|
1841
|
+
this.#quiesce.acknowledged.resolve();
|
|
1842
|
+
return;
|
|
1843
|
+
}
|
|
1164
1844
|
if (hasExactKeys(data, ["type", "error"]) && data.type === "redevplugin.surface.error" && typeof data.error === "string") {
|
|
1165
1845
|
const error = new PluginBridgeError("PLUGIN_BRIDGE_HANDSHAKE_FAILED", data.error);
|
|
1166
1846
|
await this.#failSurface(error);
|
|
@@ -1612,6 +2292,31 @@ export class PluginSurfaceHost {
|
|
|
1612
2292
|
// The server may already have revoked the surface after a failed prepare.
|
|
1613
2293
|
}
|
|
1614
2294
|
}
|
|
2295
|
+
async #quiesceSurface() {
|
|
2296
|
+
if (!this.#ready || !this.#port || this.#quiesce)
|
|
2297
|
+
return;
|
|
2298
|
+
const quiesce = {
|
|
2299
|
+
id: randomOpaqueHandle("quiesce"),
|
|
2300
|
+
acknowledged: deferred(),
|
|
2301
|
+
};
|
|
2302
|
+
void quiesce.acknowledged.promise.catch(() => undefined);
|
|
2303
|
+
this.#quiesce = quiesce;
|
|
2304
|
+
try {
|
|
2305
|
+
this.#postToRenderer({
|
|
2306
|
+
type: "redevplugin.bridge.lifecycle",
|
|
2307
|
+
event: { type: "dispose" },
|
|
2308
|
+
quiesce_id: quiesce.id,
|
|
2309
|
+
});
|
|
2310
|
+
await withTimeout(quiesce.acknowledged.promise, Math.min(this.#requestTimeoutMs, maxSurfaceQuiesceMs), "Plugin surface quiesce timed out");
|
|
2311
|
+
}
|
|
2312
|
+
catch {
|
|
2313
|
+
// A non-responsive plugin cannot block session revocation and iframe teardown.
|
|
2314
|
+
}
|
|
2315
|
+
finally {
|
|
2316
|
+
if (this.#quiesce === quiesce)
|
|
2317
|
+
this.#quiesce = undefined;
|
|
2318
|
+
}
|
|
2319
|
+
}
|
|
1615
2320
|
async #failSurface(error) {
|
|
1616
2321
|
if (this.#disposed)
|
|
1617
2322
|
return;
|
|
@@ -1624,7 +2329,12 @@ export class PluginSurfaceHost {
|
|
|
1624
2329
|
await revoke;
|
|
1625
2330
|
}
|
|
1626
2331
|
#postResponse(id, data) {
|
|
1627
|
-
|
|
2332
|
+
const response = removeUndefined({ type: "redevplugin.bridge.response", id, ok: true, data });
|
|
2333
|
+
if (!messageWithinLimit(response)) {
|
|
2334
|
+
this.#postError(id, "PLUGIN_JSON_LIMIT_EXCEEDED", "Plugin response exceeds the bridge message limit");
|
|
2335
|
+
return;
|
|
2336
|
+
}
|
|
2337
|
+
this.#postToRenderer(response);
|
|
1628
2338
|
}
|
|
1629
2339
|
#postError(id, errorCode, error, details) {
|
|
1630
2340
|
const errorDetails = details === undefined ? undefined : normalizePluginJSONObject(details);
|
|
@@ -1832,13 +2542,18 @@ function isOpaqueSurfaceDocument(value) {
|
|
|
1832
2542
|
if (!Array.isArray(value.assets) || value.assets.length > maxOpaqueSurfaceLazyAssets)
|
|
1833
2543
|
return false;
|
|
1834
2544
|
let lazyBytes = 0;
|
|
2545
|
+
const logicalIDs = new Set();
|
|
1835
2546
|
for (const asset of value.assets) {
|
|
1836
|
-
if (!hasExactKeys(asset, ["binding_id", "path", "sha256", "size", "content_type"]) ||
|
|
2547
|
+
if (!hasExactKeys(asset, ["binding_id", "logical_ids", "path", "sha256", "size", "content_type"]) ||
|
|
1837
2548
|
!validOpaqueHandle(asset.binding_id, "asset") || !validPackagePath(asset.path) || !validSHA256(asset.sha256) ||
|
|
2549
|
+
!Array.isArray(asset.logical_ids) || asset.logical_ids.length < 1 || asset.logical_ids.length > 16 ||
|
|
2550
|
+
asset.logical_ids.some((logicalID) => !validUIIdentifier(logicalID) || logicalIDs.has(logicalID)) ||
|
|
1838
2551
|
!Number.isSafeInteger(asset.size) || Number(asset.size) < 0 || Number(asset.size) > maxOpaqueSurfaceLazyBytes ||
|
|
1839
2552
|
typeof asset.content_type !== "string" || asset.content_type.length < 1 || asset.content_type.length > 256) {
|
|
1840
2553
|
return false;
|
|
1841
2554
|
}
|
|
2555
|
+
for (const logicalID of asset.logical_ids)
|
|
2556
|
+
logicalIDs.add(logicalID);
|
|
1842
2557
|
lazyBytes += Number(asset.size);
|
|
1843
2558
|
if (!Number.isSafeInteger(lazyBytes) || lazyBytes > maxOpaqueSurfaceLazyBytes)
|
|
1844
2559
|
return false;
|
|
@@ -1905,6 +2620,8 @@ function isBridgeResponse(value) {
|
|
|
1905
2620
|
}
|
|
1906
2621
|
if (value.error_code === "PLUGIN_CAPABILITY_ERROR")
|
|
1907
2622
|
return isCapabilityBusinessErrorDetails(value.error_details);
|
|
2623
|
+
if (value.error_code === "PLUGIN_WORKER_ERROR")
|
|
2624
|
+
return isWorkerErrorDetails(value.error_details);
|
|
1908
2625
|
if (value.error_details === undefined)
|
|
1909
2626
|
return true;
|
|
1910
2627
|
try {
|
|
@@ -1914,6 +2631,17 @@ function isBridgeResponse(value) {
|
|
|
1914
2631
|
return false;
|
|
1915
2632
|
}
|
|
1916
2633
|
}
|
|
2634
|
+
function validCanvasAccessibilityState(value) {
|
|
2635
|
+
return hasExactKeys(value, ["label", "description"]) &&
|
|
2636
|
+
typeof value.label === "string" && value.label.length > 0 && value.label.length <= 256 &&
|
|
2637
|
+
typeof value.description === "string" && value.description.length <= 1024;
|
|
2638
|
+
}
|
|
2639
|
+
function isWorkerErrorDetails(value) {
|
|
2640
|
+
return hasExactKeys(value, ["worker_error_code", "worker_error_message", "worker_error_origin"]) &&
|
|
2641
|
+
typeof value.worker_error_code === "string" && businessErrorCodePattern.test(value.worker_error_code) &&
|
|
2642
|
+
typeof value.worker_error_message === "string" && value.worker_error_message.length > 0 && value.worker_error_message.length <= 4096 &&
|
|
2643
|
+
(value.worker_error_origin === "runtime" || value.worker_error_origin === "hostcall" || value.worker_error_origin === "plugin");
|
|
2644
|
+
}
|
|
1917
2645
|
function isCapabilityBusinessErrorDetails(value) {
|
|
1918
2646
|
if (!hasAllowedKeys(value, [
|
|
1919
2647
|
"capability_id",
|
|
@@ -1940,20 +2668,117 @@ function isCapabilityBusinessErrorDetails(value) {
|
|
|
1940
2668
|
}
|
|
1941
2669
|
}
|
|
1942
2670
|
function isLifecycleMessage(value) {
|
|
1943
|
-
return
|
|
2671
|
+
return hasAllowedKeys(value, ["type", "event", "quiesce_id"]) &&
|
|
1944
2672
|
value.type === "redevplugin.bridge.lifecycle" &&
|
|
1945
2673
|
hasExactKeys(value.event, ["type"]) &&
|
|
1946
|
-
(value.event.type === "ready" || value.event.type === "visible" || value.event.type === "hidden" || value.event.type === "dispose")
|
|
2674
|
+
(value.event.type === "ready" || value.event.type === "visible" || value.event.type === "hidden" || value.event.type === "dispose") &&
|
|
2675
|
+
(value.quiesce_id === undefined || (value.event.type === "dispose" && validOpaqueHandle(value.quiesce_id, "quiesce")));
|
|
1947
2676
|
}
|
|
1948
2677
|
function isActionMessage(value) {
|
|
1949
2678
|
return hasAllowedKeys(value, ["type", "action", "event", "value", "checked", "form_data"]) &&
|
|
1950
2679
|
value.type === "redevplugin.ui.action" &&
|
|
1951
2680
|
validActionID(value.action) &&
|
|
1952
|
-
(value.event === "click" || value.event === "input" || value.event === "change" || value.event === "submit") &&
|
|
2681
|
+
(value.event === "click" || value.event === "input" || value.event === "change" || value.event === "submit" || value.event === "escape") &&
|
|
1953
2682
|
(value.value == null || (typeof value.value === "string" && value.value.length <= 65536)) &&
|
|
1954
2683
|
(value.checked == null || typeof value.checked === "boolean") &&
|
|
1955
2684
|
(value.form_data == null || (isRecord(value.form_data) && Object.entries(value.form_data).every(([key, item]) => validActionID(key) && typeof item === "string" && item.length <= 65536)));
|
|
1956
2685
|
}
|
|
2686
|
+
function isCanvasReadyCandidate(value) {
|
|
2687
|
+
return isRecord(value) && value.type === "redevplugin.ui.canvas.ready" && typeof value.id === "string" && typeof value.canvas_id === "string";
|
|
2688
|
+
}
|
|
2689
|
+
function isCanvasReadyMessage(value) {
|
|
2690
|
+
return hasExactKeys(value, ["type", "id", "canvas_id", "canvas", "css_width", "css_height", "device_pixel_ratio"]) &&
|
|
2691
|
+
value.type === "redevplugin.ui.canvas.ready" && validBridgeRequestID(value.id, "canvas") && validUIIdentifier(value.canvas_id) &&
|
|
2692
|
+
isOffscreenCanvasLike(value.canvas) && validSurfaceDimension(value.css_width) && validSurfaceDimension(value.css_height) &&
|
|
2693
|
+
validDevicePixelRatio(value.device_pixel_ratio);
|
|
2694
|
+
}
|
|
2695
|
+
function isImageReadyCandidate(value) {
|
|
2696
|
+
return isRecord(value) && value.type === "redevplugin.ui.asset.image.ready" && typeof value.id === "string" && typeof value.asset_id === "string";
|
|
2697
|
+
}
|
|
2698
|
+
function isImageReadyMessage(value) {
|
|
2699
|
+
return hasExactKeys(value, ["type", "id", "asset_id", "image", "width", "height"]) &&
|
|
2700
|
+
value.type === "redevplugin.ui.asset.image.ready" && validBridgeRequestID(value.id, "asset") && validUIIdentifier(value.asset_id) &&
|
|
2701
|
+
isImageBitmapLike(value.image) && Number.isInteger(value.width) && Number(value.width) > 0 && Number(value.width) <= opaqueSurfaceRenderLimits.max_image_dimension &&
|
|
2702
|
+
Number.isInteger(value.height) && Number(value.height) > 0 && Number(value.height) <= opaqueSurfaceRenderLimits.max_image_dimension &&
|
|
2703
|
+
value.image.width === value.width && value.image.height === value.height;
|
|
2704
|
+
}
|
|
2705
|
+
function publicCanvasInputMessage(value) {
|
|
2706
|
+
if (!hasExactKeys(value, ["type", "canvas_id", "event"]) || value.type !== "redevplugin.ui.canvas.input" ||
|
|
2707
|
+
!validUIIdentifier(value.canvas_id) || !isRecord(value.event))
|
|
2708
|
+
return undefined;
|
|
2709
|
+
const event = value.event;
|
|
2710
|
+
if (hasExactKeys(event, ["type"]) && (event.type === "focus" || event.type === "blur")) {
|
|
2711
|
+
return { canvasId: value.canvas_id, event: { type: event.type } };
|
|
2712
|
+
}
|
|
2713
|
+
if (hasExactKeys(event, ["type", "css_width", "css_height", "device_pixel_ratio"]) && event.type === "resize" &&
|
|
2714
|
+
validSurfaceDimension(event.css_width) && validSurfaceDimension(event.css_height) && validDevicePixelRatio(event.device_pixel_ratio)) {
|
|
2715
|
+
return {
|
|
2716
|
+
canvasId: value.canvas_id,
|
|
2717
|
+
event: { type: "resize", cssWidth: event.css_width, cssHeight: event.css_height, devicePixelRatio: event.device_pixel_ratio },
|
|
2718
|
+
};
|
|
2719
|
+
}
|
|
2720
|
+
if (hasExactKeys(event, ["type", "event", "code", "key", "repeat", "alt_key", "ctrl_key", "meta_key", "shift_key"]) &&
|
|
2721
|
+
event.type === "key" && (event.event === "keydown" || event.event === "keyup") &&
|
|
2722
|
+
typeof event.code === "string" && event.code.length <= 64 && typeof event.key === "string" && event.key.length <= 64 &&
|
|
2723
|
+
typeof event.repeat === "boolean" && typeof event.alt_key === "boolean" && typeof event.ctrl_key === "boolean" &&
|
|
2724
|
+
typeof event.meta_key === "boolean" && typeof event.shift_key === "boolean") {
|
|
2725
|
+
return {
|
|
2726
|
+
canvasId: value.canvas_id,
|
|
2727
|
+
event: {
|
|
2728
|
+
type: "key",
|
|
2729
|
+
event: event.event,
|
|
2730
|
+
code: event.code,
|
|
2731
|
+
key: event.key,
|
|
2732
|
+
repeat: event.repeat,
|
|
2733
|
+
altKey: event.alt_key,
|
|
2734
|
+
ctrlKey: event.ctrl_key,
|
|
2735
|
+
metaKey: event.meta_key,
|
|
2736
|
+
shiftKey: event.shift_key,
|
|
2737
|
+
},
|
|
2738
|
+
};
|
|
2739
|
+
}
|
|
2740
|
+
if (hasExactKeys(event, ["type", "event", "pointer_id", "pointer_type", "buttons", "button", "x", "y", "pressure"]) &&
|
|
2741
|
+
event.type === "pointer" && ["pointerdown", "pointermove", "pointerup", "pointercancel"].includes(String(event.event)) &&
|
|
2742
|
+
Number.isInteger(event.pointer_id) && Number(event.pointer_id) >= 0 &&
|
|
2743
|
+
["mouse", "pen", "touch", "unknown"].includes(String(event.pointer_type)) &&
|
|
2744
|
+
Number.isInteger(event.buttons) && Number(event.buttons) >= 0 && Number(event.buttons) <= 31 &&
|
|
2745
|
+
Number.isInteger(event.button) && Number(event.button) >= -1 && Number(event.button) <= 4 &&
|
|
2746
|
+
validCanvasCoordinate(event.x) && validCanvasCoordinate(event.y) &&
|
|
2747
|
+
typeof event.pressure === "number" && Number.isFinite(event.pressure) && event.pressure >= 0 && event.pressure <= 1) {
|
|
2748
|
+
return {
|
|
2749
|
+
canvasId: value.canvas_id,
|
|
2750
|
+
event: {
|
|
2751
|
+
type: "pointer",
|
|
2752
|
+
event: event.event,
|
|
2753
|
+
pointerId: Number(event.pointer_id),
|
|
2754
|
+
pointerType: event.pointer_type,
|
|
2755
|
+
buttons: Number(event.buttons),
|
|
2756
|
+
button: Number(event.button),
|
|
2757
|
+
x: event.x,
|
|
2758
|
+
y: event.y,
|
|
2759
|
+
pressure: event.pressure,
|
|
2760
|
+
},
|
|
2761
|
+
};
|
|
2762
|
+
}
|
|
2763
|
+
return undefined;
|
|
2764
|
+
}
|
|
2765
|
+
function isOffscreenCanvasLike(value) {
|
|
2766
|
+
return isRecord(value) && Number.isInteger(value.width) && Number(value.width) > 0 && Number(value.width) <= opaqueSurfaceRenderLimits.max_canvas_dimension &&
|
|
2767
|
+
Number.isInteger(value.height) && Number(value.height) > 0 && Number(value.height) <= opaqueSurfaceRenderLimits.max_canvas_dimension && typeof value.getContext === "function";
|
|
2768
|
+
}
|
|
2769
|
+
function isImageBitmapLike(value) {
|
|
2770
|
+
return isRecord(value) && Number.isInteger(value.width) && Number(value.width) > 0 && Number(value.width) <= opaqueSurfaceRenderLimits.max_image_dimension &&
|
|
2771
|
+
Number.isInteger(value.height) && Number(value.height) > 0 && Number(value.height) <= opaqueSurfaceRenderLimits.max_image_dimension && typeof value.close === "function";
|
|
2772
|
+
}
|
|
2773
|
+
function validSurfaceDimension(value) {
|
|
2774
|
+
return typeof value === "number" && Number.isFinite(value) && value > 0 && value <= opaqueSurfaceRenderLimits.max_canvas_dimension;
|
|
2775
|
+
}
|
|
2776
|
+
function validDevicePixelRatio(value) {
|
|
2777
|
+
return typeof value === "number" && Number.isFinite(value) && value >= 0.5 && value <= 4;
|
|
2778
|
+
}
|
|
2779
|
+
function validCanvasCoordinate(value) {
|
|
2780
|
+
return typeof value === "number" && Number.isFinite(value) && value >= -16384 && value <= 32768;
|
|
2781
|
+
}
|
|
1957
2782
|
function isMessagePortLike(value) {
|
|
1958
2783
|
return isRecord(value) &&
|
|
1959
2784
|
typeof value.postMessage === "function" &&
|
|
@@ -2033,8 +2858,9 @@ function validRPCParams(value) {
|
|
|
2033
2858
|
}
|
|
2034
2859
|
const pluginMethodPattern = new RegExp("^[-A-Za-z0-9._:]{1,256}$");
|
|
2035
2860
|
const pluginActionPattern = new RegExp("^[-A-Za-z0-9._:]{1,128}$");
|
|
2861
|
+
const pluginUIIdentifierPattern = new RegExp("^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$");
|
|
2036
2862
|
const opaqueHandlePattern = new RegExp("^[-A-Za-z0-9_]{8,160}$");
|
|
2037
|
-
const bridgeRequestIDPattern = /^(rpc|stream|render|operation)_([1-9][0-9]{0,15})$/;
|
|
2863
|
+
const bridgeRequestIDPattern = /^(rpc|stream|render|operation|canvas|asset)_([1-9][0-9]{0,15})$/;
|
|
2038
2864
|
function validBridgeRequestID(value, expectedKind) {
|
|
2039
2865
|
if (typeof value !== "string")
|
|
2040
2866
|
return false;
|
|
@@ -2049,6 +2875,9 @@ function validMethod(value) {
|
|
|
2049
2875
|
function validActionID(value) {
|
|
2050
2876
|
return typeof value === "string" && pluginActionPattern.test(value);
|
|
2051
2877
|
}
|
|
2878
|
+
function validUIIdentifier(value) {
|
|
2879
|
+
return typeof value === "string" && pluginUIIdentifierPattern.test(value);
|
|
2880
|
+
}
|
|
2052
2881
|
function validOpaqueHandle(value, prefix) {
|
|
2053
2882
|
return typeof value === "string" && value.startsWith(`${prefix}_`) && opaqueHandlePattern.test(value);
|
|
2054
2883
|
}
|