arcane-os 0.59.0 → 0.60.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.
@@ -0,0 +1,582 @@
1
+ import {inflate} from 'node:zlib';
2
+ import {promisify} from 'node:util';
3
+
4
+ const inflateImage = promisify(inflate);
5
+ const PNG_SIGNATURE = Buffer.from(
6
+ [137, 80, 78, 71, 13, 10, 26, 10]
7
+ );
8
+
9
+ function unsupported(message) {
10
+ const error = new Error(message);
11
+ error.code = 'ARCANE_WINDOWS_ICON_UNSUPPORTED';
12
+ return error;
13
+ }
14
+
15
+ function paeth(left, above, upperLeft) {
16
+ const prediction = left + above - upperLeft;
17
+ const a = Math.abs(prediction - left);
18
+ const b = Math.abs(prediction - above);
19
+ const c = Math.abs(prediction - upperLeft);
20
+ return a <= b && a <= c ? left : b <= c ? above : upperLeft;
21
+ }
22
+
23
+ async function readPng(source) {
24
+ if (!source.subarray(0, 8).equals(PNG_SIGNATURE)) throw new Error('The selected PNG has no PNG signature.');
25
+ let header;
26
+ let palette;
27
+ let transparency;
28
+ const compressed = [];
29
+ let ended = false;
30
+ for (let offset = 8; offset < source.length;) {
31
+ const length = source.readUInt32BE(offset);
32
+ const type = source.toString('ascii', offset + 4, offset + 8);
33
+ const end = offset + 12 + length;
34
+ if (end > source.length) throw new Error(`The selected PNG has an incomplete ${type} chunk.`);
35
+ const data = source.subarray(offset + 8, offset + 8 + length);
36
+ if (type === 'IHDR') header = data;
37
+ else if (type === 'PLTE') palette = data;
38
+ else if (type === 'tRNS') transparency = data;
39
+ else if (type === 'IDAT') compressed.push(data);
40
+ else if (type === 'IEND') {
41
+ ended = true;
42
+ break;
43
+ }
44
+ offset = end;
45
+ }
46
+ if (!header || !ended) throw new Error('The selected PNG is missing its image header or end marker.');
47
+ const width = header.readUInt32BE(0);
48
+ const height = header.readUInt32BE(4);
49
+ const depth = header[8];
50
+ const type = header[9];
51
+ const channelCounts = {0: 1, 2: 3, 3: 1, 4: 2, 6: 4};
52
+ const channels = channelCounts[type];
53
+ const depths = {
54
+ 0: [1, 2, 4, 8, 16],
55
+ 2: [8, 16],
56
+ 3: [1, 2, 4, 8],
57
+ 4: [8, 16],
58
+ 6: [8, 16]
59
+ };
60
+ if (!width || !height || !channels || !depths[type].includes(depth)
61
+ || header[10] !== 0 || header[11] !== 0 || ![0, 1].includes(header[12])) {
62
+ throw new Error('The selected PNG image header uses an unsupported PNG encoding.');
63
+ }
64
+ if (type === 3 && !palette) throw new Error('The selected indexed PNG has no palette.');
65
+ const scanlines = await inflateImage(
66
+ Buffer.concat(compressed)
67
+ );
68
+ const pixels = Buffer.alloc(width * height * 4);
69
+ const passes = header[12] === 0 ? [
70
+ [0, 0, 1, 1]
71
+ ] : [
72
+ [0, 0, 8, 8],
73
+ [4, 0, 8, 8],
74
+ [0, 4, 4, 8],
75
+ [2, 0, 4, 4],
76
+ [0, 2, 2, 4],
77
+ [1, 0, 2, 2],
78
+ [0, 1, 1, 2]
79
+ ];
80
+ let position = 0;
81
+ for (const [startX, startY, stepX, stepY] of passes) {
82
+ const columns = Math.max(
83
+ 0,
84
+ Math.ceil((width - startX) / stepX)
85
+ );
86
+ const rows = Math.max(
87
+ 0,
88
+ Math.ceil((height - startY) / stepY)
89
+ );
90
+ if (!columns || !rows) continue;
91
+ const stride = Math.ceil(columns * channels * depth / 8);
92
+ const filterStride = Math.max(
93
+ 1,
94
+ Math.ceil(channels * depth / 8)
95
+ );
96
+ let previous = Buffer.alloc(stride);
97
+ for (let row = 0; row < rows; row++) {
98
+ const filter = scanlines[position++];
99
+ if (filter > 4 || position + stride > scanlines.length) throw new Error('The selected PNG has an incomplete or invalid scanline.');
100
+ const current = Buffer.from(
101
+ scanlines.subarray(position, position + stride)
102
+ );
103
+ position += stride;
104
+ for (let column = 0; column < stride; column++) {
105
+ const left = column >= filterStride ? current[column - filterStride] : 0;
106
+ const above = previous[column];
107
+ const upperLeft = column >= filterStride ? previous[column - filterStride] : 0;
108
+ let predictor = 0;
109
+ if (filter === 1) predictor = left;
110
+ else if (filter === 2) predictor = above;
111
+ else if (filter === 3) predictor = Math.floor((left + above) / 2);
112
+ else if (filter === 4) predictor = paeth(left, above, upperLeft);
113
+ current[column] = (current[column] + predictor) & 255;
114
+ }
115
+ function sample(index) {
116
+ if (depth === 16) return current.readUInt16BE(index * 2);
117
+ if (depth === 8) return current[index];
118
+ const bit = index * depth;
119
+ return (current[Math.floor(bit / 8)] >> (8 - depth - bit % 8)) & ((1 << depth) - 1);
120
+ }
121
+ function channel(value) { return Math.round(value * 255 / (2 ** depth - 1)); }
122
+ for (let column = 0; column < columns; column++) {
123
+ const sampleOffset = column * channels;
124
+ const first = sample(sampleOffset);
125
+ const output = ((startY + row * stepY) * width + startX + column * stepX) * 4;
126
+ let red;
127
+ let green;
128
+ let blue;
129
+ let alpha = 255;
130
+ if (type === 3) {
131
+ if (first * 3 + 2 >= palette.length) throw new Error('The selected PNG references a missing palette entry.');
132
+ red = palette[first * 3];
133
+ green = palette[first * 3 + 1];
134
+ blue = palette[first * 3 + 2];
135
+ alpha = transparency?.[first] ?? 255;
136
+ } else if (type === 0 || type === 4) {
137
+ red = channel(first);
138
+ green = red;
139
+ blue = red;
140
+ if (type === 4) {
141
+ alpha = channel(
142
+ sample(sampleOffset + 1)
143
+ );
144
+ }
145
+ else if (transparency && first === transparency.readUInt16BE(0)) alpha = 0;
146
+ } else {
147
+ const second = sample(sampleOffset + 1);
148
+ const third = sample(sampleOffset + 2);
149
+ red = channel(first);
150
+ green = channel(second);
151
+ blue = channel(third);
152
+ if (type === 6) {
153
+ alpha = channel(
154
+ sample(sampleOffset + 3)
155
+ );
156
+ }
157
+ else if (transparency && first === transparency.readUInt16BE(0)
158
+ && second === transparency.readUInt16BE(2) && third === transparency.readUInt16BE(4)) alpha = 0;
159
+ }
160
+ pixels[output] = red;
161
+ pixels[output + 1] = green;
162
+ pixels[output + 2] = blue;
163
+ pixels[output + 3] = alpha;
164
+ }
165
+ previous = current;
166
+ }
167
+ }
168
+ return {width, height, pixels};
169
+ }
170
+
171
+ function bitmapIcon({width, height, pixels}, dimension) {
172
+ const maskStride = Math.ceil(dimension / 32) * 4;
173
+ const bitmap = Buffer.alloc(40 + dimension * dimension * 4 + maskStride * dimension);
174
+ bitmap.writeUInt32LE(40, 0);
175
+ bitmap.writeInt32LE(dimension, 4);
176
+ bitmap.writeInt32LE(dimension * 2, 8);
177
+ bitmap.writeUInt16LE(1, 12);
178
+ bitmap.writeUInt16LE(32, 14);
179
+ const scale = Math.min(dimension / width, dimension / height);
180
+ const drawWidth = width * scale;
181
+ const drawHeight = height * scale;
182
+ const left = (dimension - drawWidth) / 2;
183
+ const top = (dimension - drawHeight) / 2;
184
+ for (let y = 0; y < dimension; y++) {
185
+ for (let x = 0; x < dimension; x++) {
186
+ const output = 40 + ((dimension - 1 - y) * dimension + x) * 4;
187
+ if (x + 0.5 >= left && x + 0.5 < left + drawWidth && y + 0.5 >= top && y + 0.5 < top + drawHeight) {
188
+ const sourceX = Math.max(
189
+ 0,
190
+ Math.min(width - 1, (x + 0.5 - left) / scale - 0.5)
191
+ );
192
+ const sourceY = Math.max(
193
+ 0,
194
+ Math.min(height - 1, (y + 0.5 - top) / scale - 0.5)
195
+ );
196
+ const x0 = Math.floor(sourceX);
197
+ const y0 = Math.floor(sourceY);
198
+ const x1 = Math.min(width - 1, x0 + 1);
199
+ const y1 = Math.min(height - 1, y0 + 1);
200
+ const dx = sourceX - x0;
201
+ const dy = sourceY - y0;
202
+ const samples = [
203
+ [x0, y0, (1 - dx) * (1 - dy)],
204
+ [x1, y0, dx * (1 - dy)],
205
+ [x0, y1, (1 - dx) * dy],
206
+ [x1, y1, dx * dy]
207
+ ];
208
+ const color = [0, 0, 0];
209
+ let alpha = 0;
210
+ for (const [sx, sy, weight] of samples) {
211
+ const input = (sy * width + sx) * 4;
212
+ const contribution = pixels[input + 3] * weight;
213
+ alpha += contribution;
214
+ for (let component = 0; component < 3; component++) color[component] += pixels[input + component] * contribution;
215
+ }
216
+ if (alpha) {
217
+ bitmap[output] = Math.round(color[2] / alpha);
218
+ bitmap[output + 1] = Math.round(color[1] / alpha);
219
+ bitmap[output + 2] = Math.round(color[0] / alpha);
220
+ bitmap[output + 3] = Math.round(alpha);
221
+ }
222
+ }
223
+ if (bitmap[output + 3] === 0) {
224
+ bitmap[40 + dimension * dimension * 4 + (dimension - 1 - y) * maskStride + Math.floor(x / 8)] |= 128 >> (x % 8);
225
+ }
226
+ }
227
+ }
228
+ return {width: dimension, height: dimension, colors: 0, planes: 1, depth: 32, data: bitmap};
229
+ }
230
+
231
+ function readIco(source) {
232
+ if (source.readUInt16LE(0) !== 0 || source.readUInt16LE(2) !== 1) throw new Error('The selected ICO has no icon directory.');
233
+ const images = [];
234
+ const count = source.readUInt16LE(4);
235
+ if (!count) throw new Error('The selected ICO contains no images.');
236
+ for (let index = 0; index < count; index++) {
237
+ const entry = 6 + index * 16;
238
+ const length = source.readUInt32LE(entry + 8);
239
+ const offset = source.readUInt32LE(entry + 12);
240
+ if (!length || offset + length > source.length) throw new Error('The selected ICO contains an incomplete image.');
241
+ images.push(
242
+ {
243
+ width: source[entry] || 256,
244
+ height: source[entry + 1] || 256,
245
+ colors: source[entry + 2],
246
+ planes: source.readUInt16LE(entry + 4),
247
+ depth: source.readUInt16LE(entry + 6),
248
+ data: source.subarray(offset, offset + length)
249
+ }
250
+ );
251
+ }
252
+ return images;
253
+ }
254
+
255
+ function iconFile(images) {
256
+ const directory = Buffer.alloc(6 + images.length * 16);
257
+ directory.writeUInt16LE(1, 2);
258
+ directory.writeUInt16LE(images.length, 4);
259
+ let offset = directory.length;
260
+ for (const [index, image] of images.entries()) {
261
+ const entry = 6 + index * 16;
262
+ directory[entry] = image.width === 256 ? 0 : image.width;
263
+ directory[entry + 1] = image.height === 256 ? 0 : image.height;
264
+ directory[entry + 2] = image.colors;
265
+ directory.writeUInt16LE(image.planes, entry + 4);
266
+ directory.writeUInt16LE(image.depth, entry + 6);
267
+ directory.writeUInt32LE(image.data.length, entry + 8);
268
+ directory.writeUInt32LE(offset, entry + 12);
269
+ offset += image.data.length;
270
+ }
271
+ const payloads = images.map(
272
+ function imageContent(image) { return image.data; }
273
+ );
274
+ return Buffer.concat(
275
+ [directory, ...payloads]
276
+ );
277
+ }
278
+
279
+ function align(value, alignment) { return Math.ceil(value / alignment) * alignment; }
280
+
281
+ function readExecutable(source) {
282
+ if (source.readUInt16LE(0) !== 0x5a4d) throw unsupported('The selected Windows launcher is not a PE executable.');
283
+ const pe = source.readUInt32LE(60);
284
+ if (source.readUInt32LE(pe) !== 0x4550) throw unsupported('The selected Windows launcher has no PE signature.');
285
+ const optional = pe + 24;
286
+ const magic = source.readUInt16LE(optional);
287
+ if (![0x10b, 0x20b].includes(magic)) throw unsupported('The selected Windows launcher uses an unsupported PE optional header.');
288
+ const directories = optional + (magic === 0x20b ? 112 : 96);
289
+ const count = source.readUInt16LE(pe + 6);
290
+ const sectionTable = optional + source.readUInt16LE(pe + 20);
291
+ // The PE header declares both the directory count and the extent in which
292
+ // those entries exist. Do not interpret a following section as a directory.
293
+ if (directories + 24 > sectionTable || source.readUInt32LE(directories - 4) < 3) {
294
+ throw unsupported('The selected Windows launcher has no declared PE resource directory slot.');
295
+ }
296
+ const sections = [];
297
+ for (let index = 0; index < count; index++) {
298
+ const offset = sectionTable + index * 40;
299
+ sections.push(
300
+ {
301
+ offset,
302
+ virtualSize: source.readUInt32LE(offset + 8),
303
+ address: source.readUInt32LE(offset + 12),
304
+ rawSize: source.readUInt32LE(offset + 16),
305
+ raw: source.readUInt32LE(offset + 20)
306
+ }
307
+ );
308
+ }
309
+ function fileOffset(address) {
310
+ const section = sections.find(
311
+ function containing(item) {
312
+ return address >= item.address && address < item.address + Math.max(item.virtualSize, item.rawSize);
313
+ }
314
+ );
315
+ if (!section) throw new Error('The PE resource address has no containing section.');
316
+ return section.raw + address - section.address;
317
+ }
318
+ const resourceAddress = source.readUInt32LE(directories + 16);
319
+ const rootOffset = resourceAddress ? fileOffset(resourceAddress) : null;
320
+ function readDirectory(relative, ancestors = new Set()) {
321
+ if (ancestors.has(relative)) throw new Error('The PE resource directory contains a cycle.');
322
+ const offset = rootOffset + relative;
323
+ const children = [];
324
+ const total = source.readUInt16LE(offset + 12) + source.readUInt16LE(offset + 14);
325
+ for (let index = 0; index < total; index++) {
326
+ const entry = offset + 16 + index * 8;
327
+ const name = source.readUInt32LE(entry);
328
+ const target = source.readUInt32LE(entry + 4);
329
+ let key = name;
330
+ if (name & 0x80000000) {
331
+ const location = rootOffset + (name & 0x7fffffff);
332
+ const end = location + 2 + source.readUInt16LE(location) * 2;
333
+ if (end > source.length) throw new Error('The PE resource name is incomplete.');
334
+ key = source.toString('utf16le', location + 2, end);
335
+ }
336
+ let value;
337
+ if (target & 0x80000000) {
338
+ const parents = new Set(
339
+ [...ancestors, relative]
340
+ );
341
+ value = readDirectory(target & 0x7fffffff, parents);
342
+ } else {
343
+ const location = rootOffset + target;
344
+ const start = fileOffset(
345
+ source.readUInt32LE(location)
346
+ );
347
+ const end = start + source.readUInt32LE(location + 4);
348
+ if (end > source.length) throw new Error('The PE resource payload is incomplete.');
349
+ value = {
350
+ data: source.subarray(start, end),
351
+ codePage: source.readUInt32LE(location + 8),
352
+ reserved: source.readUInt32LE(location + 12)
353
+ };
354
+ }
355
+ children.push(
356
+ {key, value}
357
+ );
358
+ }
359
+ const header = Buffer.from(
360
+ source.subarray(offset, offset + 12)
361
+ );
362
+ return {header, children};
363
+ }
364
+ return {
365
+ pe, optional, directories, count, sectionTable, sections,
366
+ headerSize: source.readUInt32LE(optional + 60),
367
+ fileAlignment: source.readUInt32LE(optional + 36),
368
+ sectionAlignment: source.readUInt32LE(optional + 32),
369
+ resources: rootOffset === null ? {
370
+ header: Buffer.alloc(12), children: []
371
+ } : readDirectory(0)
372
+ };
373
+ }
374
+
375
+ function directoryChild(directory, key) {
376
+ let child = directory.children.find(
377
+ function matching(item) { return item.key === key; }
378
+ );
379
+ if (!child) {
380
+ child = {
381
+ key,
382
+ value: {
383
+ header: Buffer.alloc(12), children: []
384
+ }
385
+ };
386
+ directory.children.push(child);
387
+ }
388
+ return child.value;
389
+ }
390
+
391
+ function resourceOrder(left, right) {
392
+ if (typeof left.key !== typeof right.key) return typeof left.key === 'string' ? -1 : 1;
393
+ return left.key < right.key ? -1 : left.key > right.key ? 1 : 0;
394
+ }
395
+
396
+ function replaceMainIcon(resources, images) {
397
+ const icons = directoryChild(resources, 3);
398
+ const groups = directoryChild(resources, 14);
399
+ groups.children.sort(resourceOrder);
400
+ const group = groups.children.length ? groups.children[0].value : directoryChild(groups, 1);
401
+ const languages = group.children.length ? group.children.map(
402
+ function language(item) { return item.key; }
403
+ ) : [0];
404
+ const occupied = new Set(
405
+ icons.children.map(
406
+ function iconId(item) { return item.key; }
407
+ )
408
+ );
409
+ const ids = [];
410
+ let next = 1;
411
+ for (const image of images) {
412
+ while (occupied.has(next)) next++;
413
+ if (next > 65535) throw unsupported('The selected Windows launcher has no available icon resource identifier.');
414
+ const id = next++;
415
+ occupied.add(id);
416
+ ids.push(id);
417
+ const icon = directoryChild(icons, id);
418
+ icon.children = languages.map(
419
+ function imageLanguage(key) {
420
+ return {
421
+ key,
422
+ value: {
423
+ data: image.data, codePage: 0, reserved: 0
424
+ }
425
+ };
426
+ }
427
+ );
428
+ }
429
+ const data = Buffer.alloc(6 + images.length * 14);
430
+ data.writeUInt16LE(1, 2);
431
+ data.writeUInt16LE(images.length, 4);
432
+ for (const [index, image] of images.entries()) {
433
+ const offset = 6 + index * 14;
434
+ data[offset] = image.width === 256 ? 0 : image.width;
435
+ data[offset + 1] = image.height === 256 ? 0 : image.height;
436
+ data[offset + 2] = image.colors;
437
+ data.writeUInt16LE(image.planes, offset + 4);
438
+ data.writeUInt16LE(image.depth, offset + 6);
439
+ data.writeUInt32LE(image.data.length, offset + 8);
440
+ data.writeUInt16LE(ids[index], offset + 12);
441
+ }
442
+ group.children = languages.map(
443
+ function groupLanguage(key) {
444
+ const previous = group.children.find(
445
+ function matching(item) { return item.key === key; }
446
+ );
447
+ return {
448
+ key,
449
+ value: {
450
+ ...previous?.value,
451
+ data,
452
+ codePage: previous?.value.codePage ?? 0,
453
+ reserved: previous?.value.reserved ?? 0
454
+ }
455
+ };
456
+ }
457
+ );
458
+ }
459
+
460
+ function resourceSection(root, address) {
461
+ const directories = [];
462
+ const names = [];
463
+ const payloads = [];
464
+ let position = 0;
465
+ function placeDirectory(directory) {
466
+ directory.children.sort(resourceOrder);
467
+ directory.offset = position;
468
+ position += 16 + directory.children.length * 8;
469
+ directories.push(directory);
470
+ for (const child of directory.children) {
471
+ if (typeof child.key === 'string') names.push(child);
472
+ if (child.value.children) placeDirectory(child.value);
473
+ else payloads.push(child.value);
474
+ }
475
+ }
476
+ placeDirectory(root);
477
+ for (const name of names) {
478
+ name.nameOffset = position;
479
+ position += 2 + name.key.length * 2;
480
+ }
481
+ position = align(position, 4);
482
+ for (const payload of payloads) {
483
+ payload.offset = position;
484
+ position += 16;
485
+ }
486
+ for (const payload of payloads) {
487
+ payload.dataOffset = position;
488
+ position = align(position + payload.data.length, 4);
489
+ }
490
+ const output = Buffer.alloc(position);
491
+ for (const directory of directories) {
492
+ directory.header.copy(output, directory.offset);
493
+ const named = directory.children.filter(
494
+ function namedEntry(child) { return typeof child.key === 'string'; }
495
+ );
496
+ output.writeUInt16LE(named.length, directory.offset + 12);
497
+ output.writeUInt16LE(directory.children.length - named.length, directory.offset + 14);
498
+ for (const [index, child] of directory.children.entries()) {
499
+ const entry = directory.offset + 16 + index * 8;
500
+ output.writeUInt32LE(typeof child.key === 'string' ? (child.nameOffset | 0x80000000) >>> 0 : child.key, entry);
501
+ output.writeUInt32LE(child.value.children ? (child.value.offset | 0x80000000) >>> 0 : child.value.offset, entry + 4);
502
+ }
503
+ }
504
+ for (const name of names) {
505
+ output.writeUInt16LE(name.key.length, name.nameOffset);
506
+ output.write(name.key, name.nameOffset + 2, 'utf16le');
507
+ }
508
+ for (const payload of payloads) {
509
+ output.writeUInt32LE(address + payload.dataOffset, payload.offset);
510
+ output.writeUInt32LE(payload.data.length, payload.offset + 4);
511
+ output.writeUInt32LE(payload.codePage, payload.offset + 8);
512
+ output.writeUInt32LE(payload.reserved, payload.offset + 12);
513
+ payload.data.copy(output, payload.dataOffset);
514
+ }
515
+ return output;
516
+ }
517
+
518
+ function executableIcon(source, images) {
519
+ const pe = readExecutable(source);
520
+ const rawSections = pe.sections.filter(
521
+ function hasRaw(section) { return section.rawSize; }
522
+ );
523
+ const firstRaw = Math.min(
524
+ ...rawSections.map(
525
+ function raw(section) { return section.raw; }
526
+ )
527
+ );
528
+ const sectionHeader = pe.sectionTable + pe.count * 40;
529
+ if (sectionHeader + 40 > Math.min(firstRaw, pe.headerSize)) {
530
+ throw unsupported('The selected Windows launcher has no declared section-header room for its app icon.');
531
+ }
532
+ replaceMainIcon(pe.resources, images);
533
+ const sectionEnds = pe.sections.map(
534
+ function sectionEnd(section) {
535
+ return section.address + Math.max(section.virtualSize, section.rawSize);
536
+ }
537
+ );
538
+ const address = align(
539
+ Math.max(...sectionEnds),
540
+ pe.sectionAlignment
541
+ );
542
+ const resources = resourceSection(pe.resources, address);
543
+ const raw = align(source.length, pe.fileAlignment);
544
+ const rawSize = align(resources.length, pe.fileAlignment);
545
+ const output = Buffer.alloc(raw + rawSize);
546
+ source.copy(output);
547
+ resources.copy(output, raw);
548
+ output.fill(0, sectionHeader, sectionHeader + 40);
549
+ output.write('.arcicon', sectionHeader, 'ascii');
550
+ output.writeUInt32LE(resources.length, sectionHeader + 8);
551
+ output.writeUInt32LE(address, sectionHeader + 12);
552
+ output.writeUInt32LE(rawSize, sectionHeader + 16);
553
+ output.writeUInt32LE(raw, sectionHeader + 20);
554
+ output.writeUInt32LE(0x40000040, sectionHeader + 36);
555
+ output.writeUInt16LE(pe.count + 1, pe.pe + 6);
556
+ output.writeUInt32LE(output.readUInt32LE(pe.optional + 8) + rawSize, pe.optional + 8);
557
+ output.writeUInt32LE(
558
+ align(address + resources.length, pe.sectionAlignment),
559
+ pe.optional + 56
560
+ );
561
+ output.writeUInt32LE(address, pe.directories + 16);
562
+ output.writeUInt32LE(resources.length, pe.directories + 20);
563
+ return output;
564
+ }
565
+
566
+ /** Native file-format conversion only; the selected source asset remains unchanged. */
567
+ export async function createWindowsApplicationIcon({source, extension, executable}) {
568
+ const format = extension.toLowerCase();
569
+ if (!['.png', '.ico'].includes(format)) throw unsupported(`Windows executable icon conversion is unavailable for ${format || 'this image format'}.`);
570
+ let images;
571
+ if (format === '.ico') images = readIco(source);
572
+ else {
573
+ const raster = await readPng(source);
574
+ images = [16, 32, 48, 256].map(
575
+ function nativeImage(dimension) { return bitmapIcon(raster, dimension); }
576
+ );
577
+ }
578
+ return {
579
+ icon: format === '.ico' ? Buffer.from(source) : iconFile(images),
580
+ executable: executableIcon(executable, images)
581
+ };
582
+ }
@@ -7,7 +7,8 @@ import Is from 'strong-type';
7
7
  import {ArcaneError, ERROR_CODES, throwIfAborted} from '../errors.mjs';
8
8
  import {runProcess} from '../process.mjs';
9
9
  import {createPortableNativeProvider} from './portable-provider.mjs';
10
- import {writeOutput} from './portable-layout.mjs';
10
+ import {outputFile, writeOutput} from './portable-layout.mjs';
11
+ import {createWindowsApplicationIcon} from './windows-icon.mjs';
11
12
 
12
13
  const is = new Is(false);
13
14
  const PROTOCOL = 'arcane-native-builder/1';
@@ -151,6 +152,61 @@ async function copyHostAssets(source, root, files, signal, relative = '', ancest
151
152
  }
152
153
  }
153
154
 
155
+ async function applyApplicationIcon(request, root, files) {
156
+ const {appDescriptor, release, signal, onEvent} = request;
157
+ const icon = appDescriptor.native?.icon;
158
+ if (!icon) return {};
159
+ // The release owns the app's actual position. A nested entry's directory
160
+ // alone is not the app root; remove its complete app-relative entry.
161
+ const entry = release.manifest?.app?.entry ?? appDescriptor.package?.entry;
162
+ const start = release.manifest?.app?.start;
163
+ const startPath = start ? decodeURIComponent(new URL(start, 'https://arcane.invalid/').pathname).replace(/^\//u, '') : '';
164
+ const appDirectory = entry && startPath.endsWith(entry) ? startPath.substring(0, startPath.length - entry.length) : '';
165
+ const selected = `app/${appDirectory}${icon}`;
166
+ const source = await readFile(
167
+ outputFile(root, selected),
168
+ {signal}
169
+ );
170
+ throwIfAborted(signal);
171
+ const executable = path.join(root, 'Arcane.exe');
172
+ let branded;
173
+ try {
174
+ const launcher = await readFile(
175
+ executable,
176
+ {signal}
177
+ );
178
+ branded = await createWindowsApplicationIcon(
179
+ {source, extension: path.posix.extname(icon), executable: launcher}
180
+ );
181
+ } catch (error) {
182
+ if (error.code !== 'ARCANE_WINDOWS_ICON_UNSUPPORTED') throw error;
183
+ // Other descriptor formats retain ordinary assembly. The running
184
+ // Windows host may support the source image independently of PE icons.
185
+ await emit(
186
+ onEvent,
187
+ {
188
+ type: 'native.icon.unsupported', target: 'windows-x64', appId: appDescriptor.id,
189
+ icon, code: error.code, message: error.message, executable: false
190
+ }
191
+ );
192
+ throwIfAborted(signal);
193
+ return {icon: selected, executableIcon: false};
194
+ }
195
+ throwIfAborted(signal);
196
+ const generated = 'runtime/arcane-app.ico';
197
+ await writeOutput(root, generated, branded.icon, files, signal);
198
+ await writeFile(
199
+ executable, branded.executable,
200
+ {signal}
201
+ );
202
+ await emit(
203
+ onEvent,
204
+ {type: 'native.icon.completed', target: 'windows-x64', appId: appDescriptor.id, icon, executable: true}
205
+ );
206
+ throwIfAborted(signal);
207
+ return {icon: generated, executableIcon: true};
208
+ }
209
+
154
210
  /** Compose app-owned services with the SDK's precompiled Windows host. */
155
211
  export function createWindowsNativeProvider({services, hostDirectory} = {}) {
156
212
  const portable = createPortableNativeProvider(services === undefined ? {} : {services});
@@ -213,6 +269,7 @@ export function createWindowsNativeProvider({services, hostDirectory} = {}) {
213
269
  const host = selectedHost ?? await releasedHostDirectory(path.resolve(request.outputRoot), artifact.manifest.sdk.version, {signal, onEvent});
214
270
  const files = [...artifact.manifest.files];
215
271
  await copyHostAssets(host, root, files, signal);
272
+ const branding = await applyApplicationIcon(request, root, files);
216
273
  const manifest = {
217
274
  ...artifact.manifest,
218
275
  kind: 'arcane-windows-native',
@@ -221,7 +278,8 @@ export function createWindowsNativeProvider({services, hostDirectory} = {}) {
221
278
  executable: 'Arcane.exe',
222
279
  platform: 'windows', architecture: 'x64', runtime: 'webview2',
223
280
  coreExecutable: 'runtime/ArcaneCore.exe',
224
- coreLoader: 'runtime/arcane-core-loader.cjs'
281
+ coreLoader: 'runtime/arcane-core-loader.cjs',
282
+ ...branding
225
283
  },
226
284
  files
227
285
  };