@kubohiroya/turbowarp-webrtc-qrcode-pairing 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/LICENSE +373 -0
  2. package/README.ja.md +122 -0
  3. package/README.md +414 -0
  4. package/dist/extension-manifest.json +318 -0
  5. package/dist/webrtc-qrcode-pairing.js +4064 -0
  6. package/docs/architecture.ja.md +57 -0
  7. package/docs/architecture.md +65 -0
  8. package/docs/design/qr-pairing/README.md +16 -0
  9. package/docs/design/qr-pairing/architecture.md +191 -0
  10. package/docs/design/qr-pairing/block-api.md +123 -0
  11. package/docs/design/qr-pairing/dataflow.md +202 -0
  12. package/docs/design/qr-pairing/extraction-plan.md +134 -0
  13. package/docs/design/qr-pairing/interfaces.ts +473 -0
  14. package/docs/design/qr-pairing/requirements.md +152 -0
  15. package/docs/design/qr-pairing/test-plan.md +126 -0
  16. package/docs/index.html +34 -0
  17. package/docs/initialization-issue.ja.md +34 -0
  18. package/docs/integration-guide.ja.md +245 -0
  19. package/docs/integration-guide.md +258 -0
  20. package/docs/migration.ja.md +110 -0
  21. package/docs/migration.md +115 -0
  22. package/package.json +90 -0
  23. package/schemas/extension-manifest.schema.json +49 -0
  24. package/src/block-definitions.json +264 -0
  25. package/src/clock.ts +18 -0
  26. package/src/config/feature-flags.ts +18 -0
  27. package/src/config/qr-config.ts +22 -0
  28. package/src/config.ts +12 -0
  29. package/src/errors.ts +51 -0
  30. package/src/extension-manifest.ts +149 -0
  31. package/src/extension.ts +281 -0
  32. package/src/globals.d.ts +54 -0
  33. package/src/index.ts +8 -0
  34. package/src/pairing/controller.ts +836 -0
  35. package/src/pairing/limits.ts +12 -0
  36. package/src/pairing/types.ts +96 -0
  37. package/src/ports/display.ts +136 -0
  38. package/src/ports/qr-scan.ts +151 -0
  39. package/src/ports/webrtc.ts +61 -0
  40. package/src/qr/courier.ts +267 -0
  41. package/src/qr/envelope.ts +0 -0
  42. package/src/qr/hash.ts +13 -0
  43. package/src/qr/limits.ts +44 -0
  44. package/src/qr/svg.ts +34 -0
  45. package/templates/README.ja.md +183 -0
  46. package/templates/README.md +198 -0
@@ -0,0 +1,264 @@
1
+ {
2
+ "extensionName": "TurboWarp-WebRTC-QRCode-Pairing",
3
+ "blocks": [
4
+ {
5
+ "opcode": "startOfferPairing",
6
+ "blockType": "COMMAND",
7
+ "text": "start offer pairing [SESSION] as [LOCAL_PEER] to [REMOTE_PEER]",
8
+ "description": "Creates a WebRTC offer for the integration machine and prepares its QR parts.",
9
+ "arguments": {
10
+ "SESSION": {"type": "STRING", "defaultValue": "pairing-1"},
11
+ "LOCAL_PEER": {"type": "STRING", "defaultValue": "hub"},
12
+ "REMOTE_PEER": {"type": "STRING", "defaultValue": "camera-1"}
13
+ }
14
+ },
15
+ {
16
+ "opcode": "startAnswerPairing",
17
+ "blockType": "COMMAND",
18
+ "text": "start answer pairing [SESSION] as [LOCAL_PEER]",
19
+ "description": "Waits for an offer on the camera machine. Leave the name empty to adopt the name the offer assigns.",
20
+ "arguments": {
21
+ "SESSION": {"type": "STRING", "defaultValue": "pairing-1"},
22
+ "LOCAL_PEER": {"type": "STRING", "defaultValue": ""}
23
+ }
24
+ },
25
+ {
26
+ "opcode": "ingestPairingQrText",
27
+ "blockType": "COMMAND",
28
+ "text": "receive pairing QR text [TEXT] for [SESSION]",
29
+ "description": "Accepts one decoded QR text. Repeated readings of the same part are ignored.",
30
+ "arguments": {
31
+ "TEXT": {"type": "STRING", "defaultValue": ""},
32
+ "SESSION": {"type": "STRING", "defaultValue": "pairing-1"}
33
+ }
34
+ },
35
+ {
36
+ "opcode": "scanPairingQrFromCamera",
37
+ "blockType": "COMMAND",
38
+ "text": "scan pairing QR for [SESSION] from camera [CAMERA_ID]",
39
+ "description": "Reads parts from the named camera until the exchange has every part it needs.",
40
+ "arguments": {
41
+ "SESSION": {"type": "STRING", "defaultValue": "pairing-1"},
42
+ "CAMERA_ID": {"type": "STRING", "defaultValue": "default"}
43
+ }
44
+ },
45
+ {
46
+ "opcode": "pairingReceivedParts",
47
+ "blockType": "REPORTER",
48
+ "text": "received parts of [SESSION]",
49
+ "description": "Returns how many distinct parts have been accepted.",
50
+ "arguments": {
51
+ "SESSION": {"type": "STRING", "defaultValue": "pairing-1"}
52
+ }
53
+ },
54
+ {
55
+ "opcode": "pairingRequiredParts",
56
+ "blockType": "REPORTER",
57
+ "text": "required parts of [SESSION]",
58
+ "description": "Returns how many parts the incoming message has, or zero before the first part arrives.",
59
+ "arguments": {
60
+ "SESSION": {"type": "STRING", "defaultValue": "pairing-1"}
61
+ }
62
+ },
63
+ {
64
+ "opcode": "pairingMissingParts",
65
+ "blockType": "REPORTER",
66
+ "text": "missing parts of [SESSION]",
67
+ "description": "Returns the one-based part numbers that have not been read yet, separated by commas.",
68
+ "arguments": {
69
+ "SESSION": {"type": "STRING", "defaultValue": "pairing-1"}
70
+ }
71
+ },
72
+ {
73
+ "opcode": "showPairingQrPart",
74
+ "blockType": "COMMAND",
75
+ "text": "show pairing QR part [INDEX] of [SESSION] on this sprite",
76
+ "description": "Shows the selected one-based part using a temporary sprite skin.",
77
+ "arguments": {
78
+ "INDEX": {"type": "NUMBER", "defaultValue": 1},
79
+ "SESSION": {"type": "STRING", "defaultValue": "pairing-1"}
80
+ }
81
+ },
82
+ {
83
+ "opcode": "showNextPairingQrPart",
84
+ "blockType": "COMMAND",
85
+ "text": "show next pairing QR part of [SESSION] on this sprite",
86
+ "description": "Shows the next part and wraps from the last part back to the first.",
87
+ "arguments": {
88
+ "SESSION": {"type": "STRING", "defaultValue": "pairing-1"}
89
+ }
90
+ },
91
+ {
92
+ "opcode": "pairingQrPartCount",
93
+ "blockType": "REPORTER",
94
+ "text": "pairing QR part count of [SESSION]",
95
+ "description": "Returns how many parts are prepared for display, or zero when none are.",
96
+ "arguments": {
97
+ "SESSION": {"type": "STRING", "defaultValue": "pairing-1"}
98
+ }
99
+ },
100
+ {
101
+ "opcode": "pairingQrCurrentPart",
102
+ "blockType": "REPORTER",
103
+ "text": "current pairing QR part of [SESSION]",
104
+ "description": "Returns the one-based part selected for display, or zero when none is selected.",
105
+ "arguments": {
106
+ "SESSION": {"type": "STRING", "defaultValue": "pairing-1"}
107
+ }
108
+ },
109
+ {
110
+ "opcode": "pairingQrPartSvg",
111
+ "blockType": "REPORTER",
112
+ "text": "pairing QR part [INDEX] of [SESSION] as SVG",
113
+ "description": "Returns the part as SVG markup so a project can display it its own way.",
114
+ "arguments": {
115
+ "INDEX": {"type": "NUMBER", "defaultValue": 1},
116
+ "SESSION": {"type": "STRING", "defaultValue": "pairing-1"}
117
+ }
118
+ },
119
+ {
120
+ "opcode": "pairingQrPartDataUri",
121
+ "blockType": "REPORTER",
122
+ "text": "pairing QR part [INDEX] of [SESSION] as data URI",
123
+ "description": "Returns the part as a base64 SVG data URI for costumes and HTML images.",
124
+ "arguments": {
125
+ "INDEX": {"type": "NUMBER", "defaultValue": 1},
126
+ "SESSION": {"type": "STRING", "defaultValue": "pairing-1"}
127
+ }
128
+ },
129
+ {
130
+ "opcode": "endPairingQrDisplay",
131
+ "blockType": "COMMAND",
132
+ "text": "end pairing QR display of [SESSION]",
133
+ "description": "Restores the sprites this session changed. The session itself stays open.",
134
+ "arguments": {
135
+ "SESSION": {"type": "STRING", "defaultValue": "pairing-1"}
136
+ }
137
+ },
138
+ {
139
+ "opcode": "pairingPhase",
140
+ "blockType": "REPORTER",
141
+ "text": "pairing phase of [SESSION]",
142
+ "description": "Returns the session phase, such as offer-ready, receiving, answer-ready, connecting, or connected.",
143
+ "arguments": {
144
+ "SESSION": {"type": "STRING", "defaultValue": "pairing-1"}
145
+ }
146
+ },
147
+ {
148
+ "opcode": "pairingConnectionState",
149
+ "blockType": "REPORTER",
150
+ "text": "pairing connection state of [SESSION]",
151
+ "description": "Returns the WebRTC connection state reported for this session's peer.",
152
+ "arguments": {
153
+ "SESSION": {"type": "STRING", "defaultValue": "pairing-1"}
154
+ }
155
+ },
156
+ {
157
+ "opcode": "isPairingConnected",
158
+ "blockType": "BOOLEAN",
159
+ "text": "pairing [SESSION] connected?",
160
+ "description": "Reports whether WebRTC has established the connection for this session.",
161
+ "arguments": {
162
+ "SESSION": {"type": "STRING", "defaultValue": "pairing-1"}
163
+ }
164
+ },
165
+ {
166
+ "opcode": "waitUntilPairingConnected",
167
+ "blockType": "COMMAND",
168
+ "text": "wait until pairing [SESSION] is connected",
169
+ "description": "Waits for the connection, or fails when the session is cancelled, times out, or fails.",
170
+ "arguments": {
171
+ "SESSION": {"type": "STRING", "defaultValue": "pairing-1"}
172
+ }
173
+ },
174
+ {
175
+ "opcode": "pairingError",
176
+ "blockType": "REPORTER",
177
+ "text": "pairing error code of [SESSION]",
178
+ "description": "Returns the latest error code, such as hash-mismatch or peer-mismatch, or an empty string.",
179
+ "arguments": {
180
+ "SESSION": {"type": "STRING", "defaultValue": "pairing-1"}
181
+ }
182
+ },
183
+ {
184
+ "opcode": "pairingErrorMessage",
185
+ "blockType": "REPORTER",
186
+ "text": "pairing error message of [SESSION]",
187
+ "description": "Returns the latest error message. Pairing codes and QR payloads are never included.",
188
+ "arguments": {
189
+ "SESSION": {"type": "STRING", "defaultValue": "pairing-1"}
190
+ }
191
+ },
192
+ {
193
+ "opcode": "pairingLocalPeer",
194
+ "blockType": "REPORTER",
195
+ "text": "local peer of [SESSION]",
196
+ "description": "Returns the name this machine uses for itself in this exchange.",
197
+ "arguments": {
198
+ "SESSION": {"type": "STRING", "defaultValue": "pairing-1"}
199
+ }
200
+ },
201
+ {
202
+ "opcode": "pairingRemotePeer",
203
+ "blockType": "REPORTER",
204
+ "text": "remote peer of [SESSION]",
205
+ "description": "Returns the name this machine uses for the other machine, which is also its WebRTC peer name.",
206
+ "arguments": {
207
+ "SESSION": {"type": "STRING", "defaultValue": "pairing-1"}
208
+ }
209
+ },
210
+ {
211
+ "opcode": "pairingExchangeId",
212
+ "blockType": "REPORTER",
213
+ "text": "exchange id of [SESSION]",
214
+ "description": "Returns the identifier of the current offer and answer exchange. A retry allocates a new one.",
215
+ "arguments": {
216
+ "SESSION": {"type": "STRING", "defaultValue": "pairing-1"}
217
+ }
218
+ },
219
+ {
220
+ "opcode": "pairingRemainingSeconds",
221
+ "blockType": "REPORTER",
222
+ "text": "remaining seconds of [SESSION]",
223
+ "description": "Returns the seconds left before the exchange times out.",
224
+ "arguments": {
225
+ "SESSION": {"type": "STRING", "defaultValue": "pairing-1"}
226
+ }
227
+ },
228
+ {
229
+ "opcode": "pairingSessions",
230
+ "blockType": "REPORTER",
231
+ "text": "pairing sessions",
232
+ "description": "Returns the open session names, separated by commas.",
233
+ "arguments": {}
234
+ },
235
+ {
236
+ "opcode": "cancelPairing",
237
+ "blockType": "COMMAND",
238
+ "text": "cancel pairing [SESSION]",
239
+ "description": "Cancels the exchange and releases displays, buffers, and unconnected peers. Established connections stay up.",
240
+ "arguments": {
241
+ "SESSION": {"type": "STRING", "defaultValue": "pairing-1"}
242
+ }
243
+ },
244
+ {
245
+ "opcode": "retryPairing",
246
+ "blockType": "COMMAND",
247
+ "text": "retry pairing [SESSION]",
248
+ "description": "Starts a new exchange for the same session. QR codes from the previous exchange stop being accepted.",
249
+ "arguments": {
250
+ "SESSION": {"type": "STRING", "defaultValue": "pairing-1"}
251
+ }
252
+ },
253
+ {
254
+ "opcode": "setPairingTimeout",
255
+ "blockType": "COMMAND",
256
+ "text": "set pairing timeout of [SESSION] to [SECONDS] seconds",
257
+ "description": "Sets how long the exchange waits, measured on this machine's own clock.",
258
+ "arguments": {
259
+ "SESSION": {"type": "STRING", "defaultValue": "pairing-1"},
260
+ "SECONDS": {"type": "NUMBER", "defaultValue": 600}
261
+ }
262
+ }
263
+ ]
264
+ }
package/src/clock.ts ADDED
@@ -0,0 +1,18 @@
1
+ /**
2
+ * Monotonic time source.
3
+ *
4
+ * Deadlines are measured as elapsed time on the device that owns the session.
5
+ * The two devices in a pairing are not time-synchronized, so nothing may depend
6
+ * on their wall clocks agreeing, and a wall clock that is corrected mid-session
7
+ * must not move a deadline.
8
+ */
9
+ export interface MonotonicClock {
10
+ nowMilliseconds(): number;
11
+ }
12
+
13
+ export const systemMonotonicClock: MonotonicClock = {
14
+ nowMilliseconds: () =>
15
+ typeof performance === 'object' && typeof performance.now === 'function'
16
+ ? performance.now()
17
+ : Date.now()
18
+ };
@@ -0,0 +1,18 @@
1
+ export interface QrPairingFeatureFlags {
2
+ readonly qrCodePairing: boolean;
3
+ }
4
+
5
+ interface FeatureFlagGlobal {
6
+ readonly __TWQP_FEATURE_FLAGS__?: Partial<QrPairingFeatureFlags>;
7
+ }
8
+
9
+ const overrides = (globalThis as FeatureFlagGlobal).__TWQP_FEATURE_FLAGS__;
10
+
11
+ /**
12
+ * Startup-fixed flags, read once and frozen. The QR pairing path stays opt-in
13
+ * until it is verified on hardware; turning it off is a route switch back to
14
+ * manual offer/answer exchange, not a disconnect of established connections.
15
+ */
16
+ export const featureFlags: QrPairingFeatureFlags = Object.freeze({
17
+ qrCodePairing: overrides?.qrCodePairing === true
18
+ });
@@ -0,0 +1,22 @@
1
+ import type {QrErrorCorrectionLevel} from '../qr/envelope.js';
2
+
3
+ interface QrConfigGlobal {
4
+ readonly __TWQP_QR_CONFIG__?: {
5
+ readonly errorCorrectionLevel?: QrErrorCorrectionLevel;
6
+ };
7
+ }
8
+
9
+ const configuredLevel = (globalThis as QrConfigGlobal).__TWQP_QR_CONFIG__?.errorCorrectionLevel;
10
+
11
+ /**
12
+ * Startup-fixed QR settings. M is the software-validated default; higher levels
13
+ * survive worse optical conditions but carry fewer characters per symbol, which
14
+ * raises the part count the operator has to carry.
15
+ */
16
+ export const qrConfig = Object.freeze({
17
+ errorCorrectionLevel: isLevel(configuredLevel) ? configuredLevel : ('M' as const)
18
+ });
19
+
20
+ function isLevel(value: unknown): value is QrErrorCorrectionLevel {
21
+ return value === 'L' || value === 'M' || value === 'Q' || value === 'H';
22
+ }
package/src/config.ts ADDED
@@ -0,0 +1,12 @@
1
+ export const extensionConfig = {
2
+ id: 'kubohiroyawebrtcqrcodepairing',
3
+ slug: 'webrtc-qrcode-pairing',
4
+ name: 'TurboWarp-WebRTC-QRCode-Pairing',
5
+ description: 'Exchanges WebRTC offers and answers by carrying them optically as QR codes.',
6
+ author: 'Hiroya Kubo',
7
+ license: 'MPL-2.0',
8
+ unsandboxed: true,
9
+ docsURI: 'https://kubohiroya.github.io/turbowarp-webrtc-qrcode-pairing/',
10
+ blockIconURI:
11
+ 'data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCA0OCA0OCI+PHJlY3QgeD0iNCIgeT0iOCIgd2lkdGg9IjE4IiBoZWlnaHQ9IjE0IiByeD0iMyIgZmlsbD0iIzRDOTdGRiIvPjxyZWN0IHg9IjI2IiB5PSI4IiB3aWR0aD0iMTgiIGhlaWdodD0iMTQiIHJ4PSIzIiBmaWxsPSIjNTlDMDU5Ii8+PHJlY3QgeD0iMTUiIHk9IjI2IiB3aWR0aD0iMTgiIGhlaWdodD0iMTQiIHJ4PSIzIiBmaWxsPSIjRkZBQjE5Ii8+PC9zdmc+'
12
+ } as const;
package/src/errors.ts ADDED
@@ -0,0 +1,51 @@
1
+ /**
2
+ * Error contract shared by the QR transport layer and the pairing domain.
3
+ *
4
+ * Messages must never contain SDP, ICE details, pairing codes, or QR payload
5
+ * fragments: callers surface them to project authors and diagnostics.
6
+ */
7
+ export type PairingErrorCode =
8
+ // Feature flag and external dependencies.
9
+ | 'feature-disabled'
10
+ | 'webrtc-capability-missing'
11
+ | 'qr-decoder-missing'
12
+ | 'camera-unavailable'
13
+ | 'renderer-unavailable'
14
+ // Transport format and integrity.
15
+ | 'invalid-json'
16
+ | 'unsupported-protocol'
17
+ | 'invalid-envelope'
18
+ | 'part-too-large'
19
+ | 'index-out-of-range'
20
+ | 'message-too-large'
21
+ | 'too-many-parts'
22
+ | 'message-mismatch'
23
+ | 'conflicting-part'
24
+ | 'missing-parts'
25
+ | 'length-mismatch'
26
+ | 'hash-mismatch'
27
+ // Exchange identity.
28
+ | 'unknown-session'
29
+ | 'stale-exchange'
30
+ | 'unexpected-kind'
31
+ | 'peer-mismatch'
32
+ | 'reply-mismatch'
33
+ | 'already-accepted'
34
+ // Lifecycle.
35
+ | 'session-limit'
36
+ | 'session-exists'
37
+ | 'no-session'
38
+ | 'invalid-argument'
39
+ | 'timeout'
40
+ | 'cancelled'
41
+ | 'webrtc-rejected';
42
+
43
+ export class QrPairingError extends Error {
44
+ public readonly code: PairingErrorCode;
45
+
46
+ public constructor(code: PairingErrorCode, message: string, options?: {cause?: unknown}) {
47
+ super(message, options);
48
+ this.name = 'QrPairingError';
49
+ this.code = code;
50
+ }
51
+ }
@@ -0,0 +1,149 @@
1
+ import type {Plugin} from 'vite';
2
+
3
+ export const EXTENSION_MANIFEST_FORMAT_VERSION = 1 as const;
4
+
5
+ export interface ExtensionManifestArgument {
6
+ id: string;
7
+ type: string;
8
+ menu?: string;
9
+ }
10
+
11
+ export interface ExtensionManifestBlock {
12
+ opcode: string;
13
+ blockType: string;
14
+ arguments: ExtensionManifestArgument[];
15
+ }
16
+
17
+ export interface ExtensionManifestMenu {
18
+ id: string;
19
+ acceptReporters: boolean;
20
+ }
21
+
22
+ export interface ExtensionManifest {
23
+ formatVersion: typeof EXTENSION_MANIFEST_FORMAT_VERSION;
24
+ id: string;
25
+ blocks: ExtensionManifestBlock[];
26
+ menus: ExtensionManifestMenu[];
27
+ }
28
+
29
+ export interface ExtensionManifestPluginOptions {
30
+ id: string;
31
+ definitions: unknown;
32
+ fileName?: string;
33
+ }
34
+
35
+ export function createExtensionManifest(id: string, definitions: unknown): ExtensionManifest {
36
+ if (!/^[a-z0-9]+$/.test(id)) {
37
+ throw new TypeError('Extension manifest ID must contain only lowercase letters and numbers.');
38
+ }
39
+
40
+ const source = requireRecord(definitions, 'Block definitions');
41
+ const sourceBlocks = source.blocks;
42
+ if (!Array.isArray(sourceBlocks)) {
43
+ throw new TypeError('Block definitions must contain a blocks array.');
44
+ }
45
+
46
+ const menus = normalizeMenus(source.menus);
47
+ const menuIds = new Set(menus.map((menu) => menu.id));
48
+ const seenOpcodes = new Set<string>();
49
+ const blocks = sourceBlocks.map((block, index) => {
50
+ const normalized = normalizeBlock(block, index, menuIds);
51
+ if (seenOpcodes.has(normalized.opcode)) {
52
+ throw new TypeError(`Duplicate block opcode: ${normalized.opcode}`);
53
+ }
54
+ seenOpcodes.add(normalized.opcode);
55
+ return normalized;
56
+ });
57
+
58
+ return {
59
+ formatVersion: EXTENSION_MANIFEST_FORMAT_VERSION,
60
+ id,
61
+ blocks: blocks.sort((left, right) => compareIds(left.opcode, right.opcode)),
62
+ menus
63
+ };
64
+ }
65
+
66
+ export function serializeExtensionManifest(id: string, definitions: unknown): string {
67
+ return `${JSON.stringify(createExtensionManifest(id, definitions), null, 2)}\n`;
68
+ }
69
+
70
+ export function extensionManifestPlugin(options: ExtensionManifestPluginOptions): Plugin {
71
+ return {
72
+ name: 'extension-api-manifest',
73
+ apply: 'build',
74
+ enforce: 'post',
75
+ generateBundle() {
76
+ this.emitFile({
77
+ type: 'asset',
78
+ fileName: options.fileName ?? 'extension-manifest.json',
79
+ source: serializeExtensionManifest(options.id, options.definitions)
80
+ });
81
+ }
82
+ };
83
+ }
84
+
85
+ function normalizeBlock(
86
+ value: unknown,
87
+ index: number,
88
+ menuIds: ReadonlySet<string>
89
+ ): ExtensionManifestBlock {
90
+ const block = requireRecord(value, `Block at index ${index}`);
91
+ const opcode = requireNonEmptyString(block.opcode, `Block at index ${index} opcode`);
92
+ const blockType = requireNonEmptyString(block.blockType, `Block ${opcode} blockType`);
93
+ const sourceArguments = block.arguments ?? {};
94
+ const argumentRecord = requireRecord(sourceArguments, `Block ${opcode} arguments`);
95
+ const argumentsList = Object.entries(argumentRecord).map(([argumentId, argument]) => {
96
+ requireNonEmptyString(argumentId, `Block ${opcode} argument ID`);
97
+ const definition = requireRecord(argument, `Block ${opcode} argument ${argumentId}`);
98
+ const type = requireNonEmptyString(
99
+ definition.type,
100
+ `Block ${opcode} argument ${argumentId} type`
101
+ );
102
+ const menu = definition.menu;
103
+ if (menu !== undefined && (typeof menu !== 'string' || !menuIds.has(menu))) {
104
+ throw new TypeError(`Block ${opcode} argument ${argumentId} references unknown menu: ${menu}`);
105
+ }
106
+ return menu === undefined ? {id: argumentId, type} : {id: argumentId, type, menu};
107
+ });
108
+
109
+ return {
110
+ opcode,
111
+ blockType,
112
+ arguments: argumentsList.sort((left, right) => compareIds(left.id, right.id))
113
+ };
114
+ }
115
+
116
+ function normalizeMenus(value: unknown): ExtensionManifestMenu[] {
117
+ const menuRecord = requireRecord(value ?? {}, 'Block definition menus');
118
+ return Object.entries(menuRecord)
119
+ .map(([id, menu]) => {
120
+ requireNonEmptyString(id, 'Menu ID');
121
+ const definition = requireRecord(menu, `Menu ${id}`);
122
+ const acceptReporters = definition.acceptReporters ?? false;
123
+ if (typeof acceptReporters !== 'boolean') {
124
+ throw new TypeError(`Menu ${id} acceptReporters must be a boolean.`);
125
+ }
126
+ return {id, acceptReporters};
127
+ })
128
+ .sort((left, right) => compareIds(left.id, right.id));
129
+ }
130
+
131
+ function requireRecord(value: unknown, label: string): Record<string, unknown> {
132
+ if (typeof value !== 'object' || value === null || Array.isArray(value)) {
133
+ throw new TypeError(`${label} must be an object.`);
134
+ }
135
+ return value as Record<string, unknown>;
136
+ }
137
+
138
+ function requireNonEmptyString(value: unknown, label: string): string {
139
+ if (typeof value !== 'string' || value.length === 0) {
140
+ throw new TypeError(`${label} must be a non-empty string.`);
141
+ }
142
+ return value;
143
+ }
144
+
145
+ function compareIds(left: string, right: string): number {
146
+ if (left < right) return -1;
147
+ if (left > right) return 1;
148
+ return 0;
149
+ }