@enbox/browser 0.3.43 → 0.3.45

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 (48) hide show
  1. package/dist/browser.mjs +161 -155
  2. package/dist/browser.mjs.map +4 -4
  3. package/dist/esm/browser-connect-handler.js +17 -20
  4. package/dist/esm/browser-connect-handler.js.map +1 -1
  5. package/dist/esm/dweb-connect-client.js +1 -1
  6. package/dist/esm/dweb-connect-client.js.map +1 -1
  7. package/dist/esm/index.js +4 -1
  8. package/dist/esm/index.js.map +1 -1
  9. package/dist/esm/relay-connect-runner.js +128 -0
  10. package/dist/esm/relay-connect-runner.js.map +1 -0
  11. package/dist/esm/ui/connect-modal.js +990 -0
  12. package/dist/esm/ui/connect-modal.js.map +1 -0
  13. package/dist/esm/ui/qr.js +468 -0
  14. package/dist/esm/ui/qr.js.map +1 -0
  15. package/dist/esm/ui/wallet-icon.js +55 -0
  16. package/dist/esm/ui/wallet-icon.js.map +1 -0
  17. package/dist/esm/ui/wallet-well-known.js +61 -0
  18. package/dist/esm/ui/wallet-well-known.js.map +1 -0
  19. package/dist/types/browser-connect-handler.d.ts +32 -7
  20. package/dist/types/browser-connect-handler.d.ts.map +1 -1
  21. package/dist/types/dweb-connect-client.d.ts +3 -1
  22. package/dist/types/dweb-connect-client.d.ts.map +1 -1
  23. package/dist/types/index.d.ts +8 -2
  24. package/dist/types/index.d.ts.map +1 -1
  25. package/dist/types/relay-connect-runner.d.ts +82 -0
  26. package/dist/types/relay-connect-runner.d.ts.map +1 -0
  27. package/dist/types/ui/connect-modal.d.ts +108 -0
  28. package/dist/types/ui/connect-modal.d.ts.map +1 -0
  29. package/dist/types/ui/qr.d.ts +55 -0
  30. package/dist/types/ui/qr.d.ts.map +1 -0
  31. package/dist/types/ui/wallet-icon.d.ts +13 -0
  32. package/dist/types/ui/wallet-icon.d.ts.map +1 -0
  33. package/dist/types/ui/wallet-well-known.d.ts +33 -0
  34. package/dist/types/ui/wallet-well-known.d.ts.map +1 -0
  35. package/package.json +7 -5
  36. package/src/browser-connect-handler.ts +51 -21
  37. package/src/dweb-connect-client.ts +1 -1
  38. package/src/index.ts +8 -2
  39. package/src/relay-connect-runner.ts +209 -0
  40. package/src/ui/connect-modal.ts +1201 -0
  41. package/src/ui/qr.ts +549 -0
  42. package/src/ui/wallet-icon.ts +63 -0
  43. package/src/ui/wallet-well-known.ts +57 -0
  44. package/dist/esm/ui/wallet-selector.js +0 -585
  45. package/dist/esm/ui/wallet-selector.js.map +0 -1
  46. package/dist/types/ui/wallet-selector.d.ts +0 -44
  47. package/dist/types/ui/wallet-selector.d.ts.map +0 -1
  48. package/src/ui/wallet-selector.ts +0 -658
package/src/ui/qr.ts ADDED
@@ -0,0 +1,549 @@
1
+ /**
2
+ * Minimal QR code encoder — dependency-free, byte mode, ECC level M,
3
+ * versions 1–10 with automatic version selection and standard mask
4
+ * evaluation (ISO/IEC 18004 penalty rules N1–N4).
5
+ *
6
+ * Scope is deliberately narrow: it exists to render wallet-connect URIs in
7
+ * the connect modal without adding a third-party runtime dependency. The
8
+ * encoding pipeline follows the standard exactly — segment → codewords →
9
+ * Reed-Solomon blocks → interleave → placement → mask — and the test suite
10
+ * verifies full module-matrix equality against the `qrcode` npm package
11
+ * (dev-only oracle) across versions and mask patterns.
12
+ *
13
+ * @module
14
+ */
15
+
16
+ /** An encoded QR symbol: `modules[y][x]` is `true` for dark modules. */
17
+ export interface QrCode {
18
+ /** Symbol width/height in modules (17 + 4×version). */
19
+ size: number;
20
+
21
+ /** Dark/light module matrix, row-major. */
22
+ modules: boolean[][];
23
+
24
+ /** Symbol version (1–10). */
25
+ version: number;
26
+
27
+ /** Applied mask pattern (0–7). */
28
+ mask: number;
29
+ }
30
+
31
+ /** Error-correction level M codewords per block, indexed by version (1–10). */
32
+ const ECC_PER_BLOCK = [-1, 10, 16, 26, 18, 24, 16, 18, 22, 22, 26];
33
+
34
+ /** Error-correction level M block counts, indexed by version (1–10). */
35
+ const NUM_BLOCKS = [-1, 1, 1, 1, 2, 2, 4, 4, 4, 5, 5];
36
+
37
+ /** Total codewords in the symbol, indexed by version (1–10). */
38
+ const TOTAL_CODEWORDS = [-1, 26, 44, 70, 100, 134, 172, 196, 242, 292, 346];
39
+
40
+ /** Format-info error-correction bits for level M (`00` per the standard). */
41
+ const FORMAT_ECL_M = 0b00;
42
+
43
+ const MAX_VERSION = 10;
44
+
45
+ /** Data codeword capacity for level M at `version`. */
46
+ function dataCodewords(version: number): number {
47
+ return TOTAL_CODEWORDS[version] - ECC_PER_BLOCK[version] * NUM_BLOCKS[version];
48
+ }
49
+
50
+ /**
51
+ * Encodes UTF-8 text as a byte-mode QR symbol at ECC level M.
52
+ *
53
+ * @param text - The payload; encoded as UTF-8 bytes.
54
+ * @param forcedMask - Optional mask override (0–7); defaults to the
55
+ * penalty-minimising mask per the standard.
56
+ * @returns The encoded symbol.
57
+ * @throws If the payload does not fit in a version-10 level-M symbol.
58
+ */
59
+ export function encodeQr(text: string, forcedMask?: number): QrCode {
60
+ const data = new TextEncoder().encode(text);
61
+
62
+ // ── Version selection: smallest version that fits the segment. ──
63
+ let version = 0;
64
+ for (let candidate = 1; candidate <= MAX_VERSION; candidate++) {
65
+ const capacityBits = dataCodewords(candidate) * 8;
66
+ const headerBits = 4 + (candidate <= 9 ? 8 : 16);
67
+ if (headerBits + data.length * 8 <= capacityBits) {
68
+ version = candidate;
69
+ break;
70
+ }
71
+ }
72
+ if (version === 0) {
73
+ throw new Error(`[@enbox/browser] QR payload too long (${data.length} bytes exceeds version ${MAX_VERSION}-M capacity).`);
74
+ }
75
+
76
+ // ── Segment bits: mode 0100, char count, data, terminator, padding. ──
77
+ const bits: number[] = [];
78
+ const pushBits = (value: number, length: number): void => {
79
+ for (let i = length - 1; i >= 0; i--) {
80
+ bits.push((value >>> i) & 1);
81
+ }
82
+ };
83
+
84
+ pushBits(0b0100, 4);
85
+ pushBits(data.length, version <= 9 ? 8 : 16);
86
+ for (const byte of data) {
87
+ pushBits(byte, 8);
88
+ }
89
+
90
+ const capacityBits = dataCodewords(version) * 8;
91
+ pushBits(0, Math.min(4, capacityBits - bits.length)); // terminator
92
+ pushBits(0, (8 - (bits.length % 8)) % 8); // byte align
93
+
94
+ const padBytes = [0xEC, 0x11];
95
+ for (let i = 0; bits.length < capacityBits; i++) {
96
+ pushBits(padBytes[i % 2], 8);
97
+ }
98
+
99
+ const codewords = new Uint8Array(capacityBits / 8);
100
+ for (let i = 0; i < bits.length; i++) {
101
+ codewords[i >> 3] |= bits[i] << (7 - (i & 7));
102
+ }
103
+
104
+ // ── Reed-Solomon blocks + interleave. ──
105
+ const allCodewords = buildInterleavedCodewords(codewords, version);
106
+
107
+ // ── Module placement. ──
108
+ const size = 17 + 4 * version;
109
+ const modules: boolean[][] = Array.from({ length: size }, () => new Array<boolean>(size).fill(false));
110
+ const isFunction: boolean[][] = Array.from({ length: size }, () => new Array<boolean>(size).fill(false));
111
+
112
+ drawFunctionPatterns(modules, isFunction, version);
113
+ drawCodewords(modules, isFunction, allCodewords);
114
+
115
+ // ── Mask selection: apply each mask, score, keep the best. ──
116
+ let mask = forcedMask ?? -1;
117
+ if (mask === -1) {
118
+ let bestPenalty = Infinity;
119
+ for (let candidate = 0; candidate < 8; candidate++) {
120
+ applyMask(modules, isFunction, candidate);
121
+ drawFormatBits(modules, isFunction, candidate);
122
+ const penalty = computePenalty(modules);
123
+ if (penalty < bestPenalty) {
124
+ bestPenalty = penalty;
125
+ mask = candidate;
126
+ }
127
+ applyMask(modules, isFunction, candidate); // undo (XOR is involutive)
128
+ }
129
+ }
130
+
131
+ applyMask(modules, isFunction, mask);
132
+ drawFormatBits(modules, isFunction, mask);
133
+
134
+ return { size, modules, version, mask };
135
+ }
136
+
137
+ /** Splits data codewords into level-M blocks, computes ECC, interleaves. */
138
+ function buildInterleavedCodewords(data: Uint8Array, version: number): Uint8Array {
139
+ const numBlocks = NUM_BLOCKS[version];
140
+ const eccLen = ECC_PER_BLOCK[version];
141
+ const totalCodewords = TOTAL_CODEWORDS[version];
142
+
143
+ const numShortBlocks = numBlocks - (totalCodewords % numBlocks);
144
+ const shortBlockLen = Math.floor(totalCodewords / numBlocks);
145
+
146
+ const blocks: Uint8Array[] = [];
147
+ const eccBlocks: Uint8Array[] = [];
148
+ const rsDivisor = reedSolomonDivisor(eccLen);
149
+
150
+ let offset = 0;
151
+ for (let i = 0; i < numBlocks; i++) {
152
+ const dataLen = shortBlockLen - eccLen + (i < numShortBlocks ? 0 : 1);
153
+ const block = data.slice(offset, offset + dataLen);
154
+ offset += dataLen;
155
+ blocks.push(block);
156
+ eccBlocks.push(reedSolomonRemainder(block, rsDivisor));
157
+ }
158
+
159
+ const result = new Uint8Array(totalCodewords);
160
+ let index = 0;
161
+ const maxDataLen = shortBlockLen - eccLen + 1;
162
+ for (let i = 0; i < maxDataLen; i++) {
163
+ for (let b = 0; b < numBlocks; b++) {
164
+ if (i < blocks[b].length) {
165
+ result[index++] = blocks[b][i];
166
+ }
167
+ }
168
+ }
169
+ for (let i = 0; i < eccLen; i++) {
170
+ for (let b = 0; b < numBlocks; b++) {
171
+ result[index++] = eccBlocks[b][i];
172
+ }
173
+ }
174
+
175
+ return result;
176
+ }
177
+
178
+ // ── GF(256) Reed-Solomon (polynomial 0x11D) ─────────────────────
179
+
180
+ function gfMultiply(x: number, y: number): number {
181
+ let z = 0;
182
+ for (let i = 7; i >= 0; i--) {
183
+ z = (z << 1) ^ ((z >>> 7) * 0x11D);
184
+ z ^= ((y >>> i) & 1) * x;
185
+ }
186
+ return z & 0xFF;
187
+ }
188
+
189
+ /** Generator polynomial coefficients for `degree` ECC codewords. */
190
+ function reedSolomonDivisor(degree: number): Uint8Array {
191
+ const result = new Uint8Array(degree);
192
+ result[degree - 1] = 1;
193
+
194
+ let root = 1;
195
+ for (let i = 0; i < degree; i++) {
196
+ for (let j = 0; j < degree; j++) {
197
+ result[j] = gfMultiply(result[j], root);
198
+ if (j + 1 < degree) {
199
+ result[j] ^= result[j + 1];
200
+ }
201
+ }
202
+ root = gfMultiply(root, 0x02);
203
+ }
204
+ return result;
205
+ }
206
+
207
+ function reedSolomonRemainder(data: Uint8Array, divisor: Uint8Array): Uint8Array {
208
+ const result = new Uint8Array(divisor.length);
209
+ for (const byte of data) {
210
+ const factor = byte ^ result[0];
211
+ result.copyWithin(0, 1);
212
+ result[result.length - 1] = 0;
213
+ for (let i = 0; i < divisor.length; i++) {
214
+ result[i] ^= gfMultiply(divisor[i], factor);
215
+ }
216
+ }
217
+ return result;
218
+ }
219
+
220
+ // ── Function patterns ───────────────────────────────────────────
221
+
222
+ /** Alignment pattern centre coordinates, indexed by version (1–10). */
223
+ const ALIGNMENT_POSITIONS: number[][] = [
224
+ [], [],
225
+ [6, 18], [6, 22], [6, 26], [6, 30], [6, 34],
226
+ [6, 22, 38], [6, 24, 42], [6, 26, 46], [6, 28, 50],
227
+ ];
228
+
229
+ function setFunctionModule(
230
+ modules: boolean[][],
231
+ isFunction: boolean[][],
232
+ x: number,
233
+ y: number,
234
+ dark: boolean,
235
+ ): void {
236
+ modules[y][x] = dark;
237
+ isFunction[y][x] = true;
238
+ }
239
+
240
+ function drawFunctionPatterns(modules: boolean[][], isFunction: boolean[][], version: number): void {
241
+ const size = modules.length;
242
+
243
+ // Timing patterns.
244
+ for (let i = 0; i < size; i++) {
245
+ setFunctionModule(modules, isFunction, 6, i, i % 2 === 0);
246
+ setFunctionModule(modules, isFunction, i, 6, i % 2 === 0);
247
+ }
248
+
249
+ // Finder patterns (with separators) at three corners.
250
+ drawFinderPattern(modules, isFunction, 3, 3);
251
+ drawFinderPattern(modules, isFunction, size - 4, 3);
252
+ drawFinderPattern(modules, isFunction, 3, size - 4);
253
+
254
+ // Alignment patterns: all pairs except the three finder corners.
255
+ const positions = ALIGNMENT_POSITIONS[version];
256
+ for (let i = 0; i < positions.length; i++) {
257
+ for (let j = 0; j < positions.length; j++) {
258
+ const skip =
259
+ (i === 0 && j === 0) ||
260
+ (i === 0 && j === positions.length - 1) ||
261
+ (i === positions.length - 1 && j === 0);
262
+ if (!skip) {
263
+ drawAlignmentPattern(modules, isFunction, positions[i], positions[j]);
264
+ }
265
+ }
266
+ }
267
+
268
+ // Format-info reserved areas (drawn with mask later, but the modules must
269
+ // be marked as function modules before codeword placement).
270
+ drawFormatBits(modules, isFunction, 0);
271
+
272
+ // Dark module.
273
+ setFunctionModule(modules, isFunction, 8, size - 8, true);
274
+
275
+ // Version information (versions ≥ 7).
276
+ if (version >= 7) {
277
+ drawVersionBits(modules, isFunction, version);
278
+ }
279
+ }
280
+
281
+ function drawFinderPattern(modules: boolean[][], isFunction: boolean[][], cx: number, cy: number): void {
282
+ const size = modules.length;
283
+ for (let dy = -4; dy <= 4; dy++) {
284
+ for (let dx = -4; dx <= 4; dx++) {
285
+ const x = cx + dx;
286
+ const y = cy + dy;
287
+ if (x < 0 || x >= size || y < 0 || y >= size) {
288
+ continue;
289
+ }
290
+ const distance = Math.max(Math.abs(dx), Math.abs(dy));
291
+ setFunctionModule(modules, isFunction, x, y, distance !== 2 && distance !== 4);
292
+ }
293
+ }
294
+ }
295
+
296
+ function drawAlignmentPattern(modules: boolean[][], isFunction: boolean[][], cx: number, cy: number): void {
297
+ for (let dy = -2; dy <= 2; dy++) {
298
+ for (let dx = -2; dx <= 2; dx++) {
299
+ setFunctionModule(modules, isFunction, cx + dx, cy + dy, Math.max(Math.abs(dx), Math.abs(dy)) !== 1);
300
+ }
301
+ }
302
+ }
303
+
304
+ /** 15-bit format info: 5 data bits BCH(15,5)-expanded, XORed with 0x5412. */
305
+ function drawFormatBits(modules: boolean[][], isFunction: boolean[][], mask: number): void {
306
+ const size = modules.length;
307
+
308
+ const data = (FORMAT_ECL_M << 3) | mask;
309
+ let rem = data;
310
+ for (let i = 0; i < 10; i++) {
311
+ rem = (rem << 1) ^ ((rem >>> 9) * 0x537);
312
+ }
313
+ const bits = ((data << 10) | rem) ^ 0x5412;
314
+
315
+ // Around the top-left finder.
316
+ for (let i = 0; i <= 5; i++) {
317
+ setFunctionModule(modules, isFunction, 8, i, ((bits >>> i) & 1) !== 0);
318
+ }
319
+ setFunctionModule(modules, isFunction, 8, 7, ((bits >>> 6) & 1) !== 0);
320
+ setFunctionModule(modules, isFunction, 8, 8, ((bits >>> 7) & 1) !== 0);
321
+ setFunctionModule(modules, isFunction, 7, 8, ((bits >>> 8) & 1) !== 0);
322
+ for (let i = 9; i < 15; i++) {
323
+ setFunctionModule(modules, isFunction, 14 - i, 8, ((bits >>> i) & 1) !== 0);
324
+ }
325
+
326
+ // Second copy: below the top-right finder and beside the bottom-left one.
327
+ for (let i = 0; i < 8; i++) {
328
+ setFunctionModule(modules, isFunction, size - 1 - i, 8, ((bits >>> i) & 1) !== 0);
329
+ }
330
+ for (let i = 8; i < 15; i++) {
331
+ setFunctionModule(modules, isFunction, 8, size - 15 + i, ((bits >>> i) & 1) !== 0);
332
+ }
333
+ }
334
+
335
+ /** 18-bit version info (versions ≥ 7): 6 data bits + BCH(18,6) remainder. */
336
+ function drawVersionBits(modules: boolean[][], isFunction: boolean[][], version: number): void {
337
+ const size = modules.length;
338
+
339
+ let rem = version;
340
+ for (let i = 0; i < 12; i++) {
341
+ rem = (rem << 1) ^ ((rem >>> 11) * 0x1F25);
342
+ }
343
+ const bits = (version << 12) | rem;
344
+
345
+ for (let i = 0; i < 18; i++) {
346
+ const bit = ((bits >>> i) & 1) !== 0;
347
+ const a = size - 11 + (i % 3);
348
+ const b = Math.floor(i / 3);
349
+ setFunctionModule(modules, isFunction, a, b, bit);
350
+ setFunctionModule(modules, isFunction, b, a, bit);
351
+ }
352
+ }
353
+
354
+ /** Zig-zag codeword placement over non-function modules. */
355
+ function drawCodewords(modules: boolean[][], isFunction: boolean[][], codewords: Uint8Array): void {
356
+ const size = modules.length;
357
+ let bitIndex = 0;
358
+
359
+ for (let right = size - 1; right >= 1; right -= 2) {
360
+ if (right === 6) {
361
+ right = 5;
362
+ }
363
+ for (let vertical = 0; vertical < size; vertical++) {
364
+ for (let j = 0; j < 2; j++) {
365
+ const x = right - j;
366
+ const upward = ((right + 1) & 2) === 0;
367
+ const y = upward ? size - 1 - vertical : vertical;
368
+ if (!isFunction[y][x] && bitIndex < codewords.length * 8) {
369
+ modules[y][x] = ((codewords[bitIndex >> 3] >>> (7 - (bitIndex & 7))) & 1) !== 0;
370
+ bitIndex++;
371
+ }
372
+ }
373
+ }
374
+ }
375
+ }
376
+
377
+ /** XORs the mask pattern over non-function modules (involutive). */
378
+ function applyMask(modules: boolean[][], isFunction: boolean[][], mask: number): void {
379
+ const size = modules.length;
380
+ for (let y = 0; y < size; y++) {
381
+ for (let x = 0; x < size; x++) {
382
+ if (isFunction[y][x]) {
383
+ continue;
384
+ }
385
+ let invert: boolean;
386
+ switch (mask) {
387
+ case 0: invert = (x + y) % 2 === 0; break;
388
+ case 1: invert = y % 2 === 0; break;
389
+ case 2: invert = x % 3 === 0; break;
390
+ case 3: invert = (x + y) % 3 === 0; break;
391
+ case 4: invert = (Math.floor(x / 3) + Math.floor(y / 2)) % 2 === 0; break;
392
+ case 5: invert = ((x * y) % 2) + ((x * y) % 3) === 0; break;
393
+ case 6: invert = (((x * y) % 2) + ((x * y) % 3)) % 2 === 0; break;
394
+ default: invert = (((x + y) % 2) + ((x * y) % 3)) % 2 === 0; break;
395
+ }
396
+ if (invert) {
397
+ modules[y][x] = !modules[y][x];
398
+ }
399
+ }
400
+ }
401
+ }
402
+
403
+ // ── Mask penalty scoring (rules N1–N4) ──────────────────────────
404
+
405
+ function computePenalty(modules: boolean[][]): number {
406
+ const size = modules.length;
407
+ let penalty = 0;
408
+
409
+ // N1: runs of ≥5 same-colour modules in a row/column.
410
+ for (let y = 0; y < size; y++) {
411
+ let runColor = false;
412
+ let runLength = 0;
413
+ for (let x = 0; x < size; x++) {
414
+ if (x === 0 || modules[y][x] !== runColor) {
415
+ runColor = modules[y][x];
416
+ runLength = 1;
417
+ } else {
418
+ runLength++;
419
+ if (runLength === 5) {
420
+ penalty += 3;
421
+ } else if (runLength > 5) {
422
+ penalty += 1;
423
+ }
424
+ }
425
+ }
426
+ }
427
+ for (let x = 0; x < size; x++) {
428
+ let runColor = false;
429
+ let runLength = 0;
430
+ for (let y = 0; y < size; y++) {
431
+ if (y === 0 || modules[y][x] !== runColor) {
432
+ runColor = modules[y][x];
433
+ runLength = 1;
434
+ } else {
435
+ runLength++;
436
+ if (runLength === 5) {
437
+ penalty += 3;
438
+ } else if (runLength > 5) {
439
+ penalty += 1;
440
+ }
441
+ }
442
+ }
443
+ }
444
+
445
+ // N2: 2×2 blocks of the same colour.
446
+ for (let y = 0; y < size - 1; y++) {
447
+ for (let x = 0; x < size - 1; x++) {
448
+ const c = modules[y][x];
449
+ if (c === modules[y][x + 1] && c === modules[y + 1][x] && c === modules[y + 1][x + 1]) {
450
+ penalty += 3;
451
+ }
452
+ }
453
+ }
454
+
455
+ // N3: finder-like 1:1:3:1:1 patterns with 4-module light flank.
456
+ const hasFinderPattern = (line: boolean[], start: number): number => {
457
+ const core = [true, false, true, true, true, false, true];
458
+ const matchesCore = core.every((value, index) => line[start + index] === value);
459
+ if (!matchesCore) {
460
+ return 0;
461
+ }
462
+ const lightBefore = start >= 4 && line.slice(start - 4, start).every((v) => !v);
463
+ const lightAfter = start + 11 <= line.length && line.slice(start + 7, start + 11).every((v) => !v);
464
+ return (lightBefore ? 40 : 0) + (lightAfter ? 40 : 0);
465
+ };
466
+
467
+ for (let y = 0; y < size; y++) {
468
+ const row = modules[y];
469
+ const column = modules.map((r) => r[y]);
470
+ for (let x = 0; x + 7 <= size; x++) {
471
+ penalty += hasFinderPattern(row, x);
472
+ penalty += hasFinderPattern(column, x);
473
+ }
474
+ }
475
+
476
+ // N4: dark-module proportion deviation from 50%, in 5% steps.
477
+ let dark = 0;
478
+ for (const row of modules) {
479
+ for (const module of row) {
480
+ if (module) {
481
+ dark++;
482
+ }
483
+ }
484
+ }
485
+ const total = size * size;
486
+ const k = Math.ceil(Math.abs(dark * 20 - total * 10) / total) - 1;
487
+ penalty += k * 10;
488
+
489
+ return penalty;
490
+ }
491
+
492
+ // ── SVG rendering ───────────────────────────────────────────────
493
+
494
+ /** Options for {@link qrToSvg}. */
495
+ export interface QrSvgOptions {
496
+ /** Dark module colour. @default '#000000' */
497
+ dark?: string;
498
+
499
+ /** Light/background colour; `'transparent'` is allowed. @default '#ffffff' */
500
+ light?: string;
501
+
502
+ /** Quiet-zone width in modules on each side. @default 2 */
503
+ quietZone?: number;
504
+ }
505
+
506
+ const SVG_NS = 'http://www.w3.org/2000/svg';
507
+
508
+ /**
509
+ * Renders an encoded symbol as a crisp, scalable `<svg>` element. One
510
+ * `<path>` carries all dark modules so the output stays compact.
511
+ *
512
+ * Built with `createElementNS`/`setAttribute` rather than markup strings so
513
+ * caller-supplied colours can never be interpreted as HTML (CodeQL
514
+ * js/html-constructed-from-input). Append the returned element directly;
515
+ * never serialize it back through `innerHTML`.
516
+ */
517
+ export function qrToSvg(qr: QrCode, options: QrSvgOptions = {}): SVGSVGElement {
518
+ const dark = options.dark ?? '#000000';
519
+ const light = options.light ?? '#ffffff';
520
+ const quietZone = options.quietZone ?? 2;
521
+ const dimension = qr.size + quietZone * 2;
522
+
523
+ let path = '';
524
+ for (let y = 0; y < qr.size; y++) {
525
+ for (let x = 0; x < qr.size; x++) {
526
+ if (qr.modules[y][x]) {
527
+ path += `M${x + quietZone} ${y + quietZone}h1v1h-1z`;
528
+ }
529
+ }
530
+ }
531
+
532
+ const svg = document.createElementNS(SVG_NS, 'svg');
533
+ svg.setAttribute('viewBox', `0 0 ${dimension} ${dimension}`);
534
+ svg.setAttribute('shape-rendering', 'crispEdges');
535
+ svg.setAttribute('role', 'img');
536
+
537
+ const rect = document.createElementNS(SVG_NS, 'rect');
538
+ rect.setAttribute('width', String(dimension));
539
+ rect.setAttribute('height', String(dimension));
540
+ rect.setAttribute('fill', light);
541
+ svg.appendChild(rect);
542
+
543
+ const modules = document.createElementNS(SVG_NS, 'path');
544
+ modules.setAttribute('d', path);
545
+ modules.setAttribute('fill', dark);
546
+ svg.appendChild(modules);
547
+
548
+ return svg;
549
+ }
@@ -0,0 +1,63 @@
1
+ /**
2
+ * Wallet icon builder shared by the connect modal's wallet switcher.
3
+ *
4
+ * Prefers the wallet's own favicon and degrades to a letter badge. We never
5
+ * call a third-party favicon proxy: those 404 for many origins and leak the
6
+ * wallet URL to the proxy.
7
+ *
8
+ * @module
9
+ */
10
+
11
+ import type { WalletOption } from '../browser-connect-handler.js';
12
+
13
+ /** Build a wallet icon element (own favicon with a letter-badge fallback). */
14
+ export function buildWalletIcon(wallet: WalletOption): HTMLElement {
15
+ const wrap = document.createElement('span');
16
+ wrap.className = 'wallet-icon';
17
+
18
+ const badge = document.createElement('span');
19
+ badge.className = 'wallet-badge';
20
+ badge.textContent = wallet.name.charAt(0).toUpperCase();
21
+ wrap.appendChild(badge);
22
+
23
+ const sources = iconCandidates(wallet);
24
+ if (sources.length === 0) {
25
+ return wrap;
26
+ }
27
+
28
+ const img = document.createElement('img');
29
+ img.className = 'wallet-img';
30
+ img.alt = '';
31
+ img.width = 28;
32
+ img.height = 28;
33
+
34
+ let index = 0;
35
+ img.addEventListener('error', () => {
36
+ index += 1;
37
+ if (index < sources.length) {
38
+ img.src = sources[index];
39
+ } else {
40
+ img.remove(); // fall back to the letter badge
41
+ }
42
+ });
43
+
44
+ img.src = sources[index];
45
+ wrap.appendChild(img);
46
+ return wrap;
47
+ }
48
+
49
+ /** Ordered favicon URLs to try for a wallet, most specific first. */
50
+ function iconCandidates(wallet: WalletOption): string[] {
51
+ if (wallet.icon !== undefined) {
52
+ return [wallet.icon];
53
+ }
54
+
55
+ let origin: string;
56
+ try {
57
+ origin = new URL(wallet.url).origin;
58
+ } catch {
59
+ return [];
60
+ }
61
+
62
+ return [`${origin}/favicon.svg`, `${origin}/favicon.ico`, `${origin}/favicon.png`];
63
+ }
@@ -0,0 +1,57 @@
1
+ /**
2
+ * Wallet discovery via the `/.well-known/enbox-connect` document.
3
+ *
4
+ * Shared by the connect modal (relay discovery + custom-URL validation)
5
+ * and exported for dapps rolling their own wallet selection UI.
6
+ *
7
+ * @module
8
+ */
9
+
10
+ /** Path of the wallet's connect discovery document, relative to its origin. */
11
+ export const WALLET_WELL_KNOWN_PATH = '/.well-known/enbox-connect';
12
+
13
+ /** Timeout applied to the well-known validation fetch. */
14
+ const WELL_KNOWN_FETCH_TIMEOUT_MS = 6_000;
15
+
16
+ /** The wallet's `/.well-known/enbox-connect` discovery document. */
17
+ export interface WalletWellKnownDocument {
18
+ /** Base URL of the relay that brokers this wallet's remote connects. */
19
+ connectServerUrl: string;
20
+ }
21
+
22
+ /**
23
+ * Fetch a wallet's `/.well-known/enbox-connect` discovery document.
24
+ *
25
+ * Resolves the parsed document when it is reachable and names a
26
+ * `connectServerUrl`; resolves `undefined` for unreachable origins, non-2xx
27
+ * responses, CORS failures, and malformed documents.
28
+ */
29
+ export async function fetchWalletWellKnown(origin: string): Promise<WalletWellKnownDocument | undefined> {
30
+ try {
31
+ const wellKnownUrl = new URL(WALLET_WELL_KNOWN_PATH, origin).toString();
32
+ const response = await fetch(wellKnownUrl, { signal: AbortSignal.timeout(WELL_KNOWN_FETCH_TIMEOUT_MS) });
33
+ if (!response.ok) {
34
+ return undefined;
35
+ }
36
+
37
+ const payload = await response.json() as { connectServerUrl?: unknown } | null;
38
+ if (typeof payload?.connectServerUrl !== 'string') {
39
+ return undefined;
40
+ }
41
+ return { connectServerUrl: payload.connectServerUrl };
42
+ } catch {
43
+ return undefined;
44
+ }
45
+ }
46
+
47
+ /**
48
+ * Fetch a wallet's `/.well-known/enbox-connect` discovery document to confirm
49
+ * the origin hosts an Enbox-compatible wallet.
50
+ *
51
+ * Resolves `true` only when the document is reachable and names a
52
+ * `connectServerUrl`. Unreachable origins, non-2xx responses, CORS failures,
53
+ * and malformed documents resolve `false`.
54
+ */
55
+ export async function probeWalletWellKnown(origin: string): Promise<boolean> {
56
+ return (await fetchWalletWellKnown(origin)) !== undefined;
57
+ }