yapin.js-client 0.1.0 → 0.1.4

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,763 @@
1
+ import { execFileSync } from 'node:child_process';
2
+ import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
3
+ import { tmpdir } from 'node:os';
4
+ import { join, sep } from 'node:path';
5
+ import { inflateSync } from 'node:zlib';
6
+ import { defaultStrings } from '../installer/page.js';
7
+ import { fileVersion, pngToIco } from './windows.js';
8
+
9
+ /**
10
+ * The Windows setup wizard: an NSIS (https://nsis.sourceforge.io) installer with the Modern UI pages
11
+ * people know from other Windows installers — Welcome, License, a page to choose what to do, the
12
+ * progress page with "Show details", and Finish with "Open Yapin’".
13
+ *
14
+ * NSIS shows the pages; the work is done by installer.mjs, run with the desktop app's own Node (see
15
+ * src/installer/nsis.js for how the two talk). Building needs makensis, from the NSIS installer on
16
+ * Windows, or `apt install nsis` / `brew install makensis` elsewhere.
17
+ */
18
+
19
+ /** makensis: the configured path, MAKENSIS, the usual install folders, or the PATH. Null if none works. */
20
+ export function findMakensis({ configured = null, env = process.env, platform = process.platform } = {}) {
21
+ const candidates = [];
22
+ if (typeof configured === 'string') candidates.push(configured);
23
+ if (env.MAKENSIS) candidates.push(env.MAKENSIS);
24
+ if (platform === 'win32') {
25
+ for (const base of [env['ProgramFiles(x86)'], env.ProgramFiles, env.ProgramW6432]) if (base) candidates.push(join(base, 'NSIS', 'makensis.exe'));
26
+ }
27
+ candidates.push('makensis');
28
+ for (const candidate of candidates) {
29
+ if (candidate !== 'makensis' && !existsSync(candidate)) continue;
30
+ try {
31
+ execFileSync(candidate, ['-VERSION'], { stdio: 'pipe', windowsHide: true });
32
+ return candidate;
33
+ } catch {
34
+ // not this one
35
+ }
36
+ }
37
+ return null;
38
+ }
39
+
40
+ /** A double-quoted NSIS string. NSIS strings are limited to 1024 characters, so long text is cut. */
41
+ export function nsisString(text, max = 1000) {
42
+ let value = String(text ?? '');
43
+ if (value.length > max) value = `${value.slice(0, max - 1).trimEnd()}…`;
44
+ return `"${value.replace(/\$/g, '$$$$').replace(/"/g, '$$\\"').replace(/\t/g, '$$\\t').replace(/\r?\n/g, '$$\\r$$\\n')}"`;
45
+ }
46
+
47
+ /** An NSIS string mixing text with variables: nsisVars('Yapper ', ['$R3']) keeps $R3 a variable. */
48
+ export function nsisVars(...parts) {
49
+ return `"${parts.map((part) => (Array.isArray(part) ? part.join('') : nsisString(part).slice(1, -1))).join('')}"`;
50
+ }
51
+
52
+ /** Marketing text without its markdown: **bold**, *italic*, `code` and [links](…) become plain words. */
53
+ export function plainText(text) {
54
+ return String(text ?? '')
55
+ .replace(/\[([^\]]+)\]\([^)]*\)/g, '$1')
56
+ .replace(/\*\*([^*]+)\*\*/g, '$1')
57
+ .replace(/(^|[^*])\*([^*]+)\*/g, '$1$2')
58
+ .replace(/`([^`]+)`/g, '$1');
59
+ }
60
+
61
+ /** Decodes an 8-bit, non-interlaced PNG to RGBA. Returns null for anything else. */
62
+ export function decodePng(png) {
63
+ if (!Buffer.isBuffer(png) || png.length < 33 || png.readUInt32BE(0) !== 0x89504e47) return null;
64
+ let offset = 8;
65
+ let header = null;
66
+ let palette = null;
67
+ let transparency = null;
68
+ const data = [];
69
+ while (offset + 8 <= png.length) {
70
+ const length = png.readUInt32BE(offset);
71
+ const type = png.toString('ascii', offset + 4, offset + 8);
72
+ const body = png.subarray(offset + 8, offset + 8 + length);
73
+ if (type === 'IHDR') header = { width: body.readUInt32BE(0), height: body.readUInt32BE(4), depth: body[8], color: body[9], interlace: body[12] };
74
+ else if (type === 'PLTE') palette = body;
75
+ else if (type === 'tRNS') transparency = body;
76
+ else if (type === 'IDAT') data.push(body);
77
+ else if (type === 'IEND') break;
78
+ offset += 12 + length;
79
+ }
80
+ if (!header || header.depth !== 8 || header.interlace !== 0) return null;
81
+ const channels = { 0: 1, 2: 3, 3: 1, 4: 2, 6: 4 }[header.color];
82
+ if (!channels || (header.color === 3 && !palette)) return null;
83
+ const { width, height } = header;
84
+ let raw;
85
+ try {
86
+ raw = inflateSync(Buffer.concat(data));
87
+ } catch {
88
+ return null;
89
+ }
90
+ const stride = width * channels;
91
+ if (raw.length < (stride + 1) * height) return null;
92
+ const pixels = Buffer.alloc(stride * height);
93
+ for (let y = 0; y < height; y++) {
94
+ const filter = raw[y * (stride + 1)];
95
+ const line = raw.subarray(y * (stride + 1) + 1, (y + 1) * (stride + 1));
96
+ for (let x = 0; x < stride; x++) {
97
+ const left = x >= channels ? pixels[y * stride + x - channels] : 0;
98
+ const up = y > 0 ? pixels[(y - 1) * stride + x] : 0;
99
+ const corner = x >= channels && y > 0 ? pixels[(y - 1) * stride + x - channels] : 0;
100
+ let value = line[x];
101
+ if (filter === 1) value += left;
102
+ else if (filter === 2) value += up;
103
+ else if (filter === 3) value += (left + up) >> 1;
104
+ else if (filter === 4) {
105
+ const p = left + up - corner;
106
+ const pa = Math.abs(p - left);
107
+ const pb = Math.abs(p - up);
108
+ const pc = Math.abs(p - corner);
109
+ value += pa <= pb && pa <= pc ? left : pb <= pc ? up : corner;
110
+ }
111
+ pixels[y * stride + x] = value & 0xff;
112
+ }
113
+ }
114
+ const rgba = Buffer.alloc(width * height * 4);
115
+ for (let i = 0; i < width * height; i++) {
116
+ const s = i * channels;
117
+ let r, g, b, a;
118
+ if (header.color === 0) [r, g, b, a] = [pixels[s], pixels[s], pixels[s], 255];
119
+ else if (header.color === 4) [r, g, b, a] = [pixels[s], pixels[s], pixels[s], pixels[s + 1]];
120
+ else if (header.color === 2) [r, g, b, a] = [pixels[s], pixels[s + 1], pixels[s + 2], 255];
121
+ else if (header.color === 6) [r, g, b, a] = [pixels[s], pixels[s + 1], pixels[s + 2], pixels[s + 3]];
122
+ else {
123
+ const index = pixels[s];
124
+ [r, g, b] = [palette[index * 3], palette[index * 3 + 1], palette[index * 3 + 2]];
125
+ a = transparency && index < transparency.length ? transparency[index] : 255;
126
+ }
127
+ rgba.set([r, g, b, a], i * 4);
128
+ }
129
+ return { width, height, rgba };
130
+ }
131
+
132
+ /** A width×height RGB canvas, filled by `paint(x, y)` → [r, g, b]. */
133
+ function canvas(width, height, paint) {
134
+ const rgb = Buffer.alloc(width * height * 3);
135
+ for (let y = 0; y < height; y++) for (let x = 0; x < width; x++) rgb.set(paint(x, y), (y * width + x) * 3);
136
+ return { width, height, rgb };
137
+ }
138
+
139
+ /** Draws `image` (RGBA) scaled to fit a size×size box at (left, top), averaging the pixels it covers. */
140
+ function drawImage(target, image, left, top, size) {
141
+ const scale = Math.min(size / image.width, size / image.height);
142
+ const w = Math.max(1, Math.round(image.width * scale));
143
+ const h = Math.max(1, Math.round(image.height * scale));
144
+ left += Math.floor((size - w) / 2);
145
+ top += Math.floor((size - h) / 2);
146
+ for (let y = 0; y < h; y++) {
147
+ for (let x = 0; x < w; x++) {
148
+ const x0 = Math.floor(x / scale);
149
+ const y0 = Math.floor(y / scale);
150
+ const x1 = Math.min(image.width, Math.max(x0 + 1, Math.floor((x + 1) / scale)));
151
+ const y1 = Math.min(image.height, Math.max(y0 + 1, Math.floor((y + 1) / scale)));
152
+ let r = 0, g = 0, b = 0, a = 0, n = 0;
153
+ for (let sy = y0; sy < y1; sy++) {
154
+ for (let sx = x0; sx < x1; sx++) {
155
+ const i = (sy * image.width + sx) * 4;
156
+ const alpha = image.rgba[i + 3];
157
+ r += image.rgba[i] * alpha;
158
+ g += image.rgba[i + 1] * alpha;
159
+ b += image.rgba[i + 2] * alpha;
160
+ a += alpha;
161
+ n++;
162
+ }
163
+ }
164
+ const tx = left + x;
165
+ const ty = top + y;
166
+ if (!a || tx < 0 || ty < 0 || tx >= target.width || ty >= target.height) continue;
167
+ const coverage = a / n / 255;
168
+ const o = (ty * target.width + tx) * 3;
169
+ target.rgb[o] = Math.round(target.rgb[o] * (1 - coverage) + (r / a) * coverage);
170
+ target.rgb[o + 1] = Math.round(target.rgb[o + 1] * (1 - coverage) + (g / a) * coverage);
171
+ target.rgb[o + 2] = Math.round(target.rgb[o + 2] * (1 - coverage) + (b / a) * coverage);
172
+ }
173
+ }
174
+ }
175
+
176
+ /** A 24-bit Windows bitmap, the format NSIS wants for its pictures. */
177
+ export function toBmp({ width, height, rgb }) {
178
+ const stride = Math.ceil((width * 3) / 4) * 4;
179
+ const file = Buffer.alloc(54 + stride * height);
180
+ file.write('BM', 0, 'ascii');
181
+ file.writeUInt32LE(file.length, 2);
182
+ file.writeUInt32LE(54, 10);
183
+ file.writeUInt32LE(40, 14);
184
+ file.writeInt32LE(width, 18);
185
+ file.writeInt32LE(height, 22);
186
+ file.writeUInt16LE(1, 26);
187
+ file.writeUInt16LE(24, 28);
188
+ file.writeUInt32LE(stride * height, 34);
189
+ for (let y = 0; y < height; y++) {
190
+ const row = 54 + (height - 1 - y) * stride;
191
+ for (let x = 0; x < width; x++) {
192
+ const i = (y * width + x) * 3;
193
+ file[row + x * 3] = rgb[i + 2];
194
+ file[row + x * 3 + 1] = rgb[i + 1];
195
+ file[row + x * 3 + 2] = rgb[i];
196
+ }
197
+ }
198
+ return file;
199
+ }
200
+
201
+ function hexColor(value, fallback = [124, 92, 255]) {
202
+ const match = /^#([0-9a-f]{3,8})$/i.exec(String(value ?? ''));
203
+ if (!match) return fallback;
204
+ const hex = match[1].length < 6 ? [...match[1].slice(0, 3)].map((c) => c + c).join('') : match[1].slice(0, 6);
205
+ return [0, 2, 4].map((i) => Number.parseInt(hex.slice(i, i + 2), 16));
206
+ }
207
+
208
+ /** The picture down the left of the Welcome and Finish pages (164×314): the accent colour, darkening, with the logo. */
209
+ export function sidebarBitmap({ accent, logo }) {
210
+ const [r, g, b] = hexColor(accent);
211
+ const image = canvas(164, 314, (x, y) => {
212
+ const t = y / 313;
213
+ const shade = 1 - 0.55 * t - 0.1 * (x / 163);
214
+ return [r * shade, g * shade, b * shade].map((c) => Math.max(0, Math.min(255, Math.round(c))));
215
+ });
216
+ if (logo) drawImage(image, logo, 34, 36, 96);
217
+ return toBmp(image);
218
+ }
219
+
220
+ /** The picture in the top right of the other pages (150×57): the logo on white. */
221
+ export function headerBitmap({ logo }) {
222
+ const image = canvas(150, 57, () => [255, 255, 255]);
223
+ if (logo) drawImage(image, logo, 150 - 44 - 8, 6, 44);
224
+ return toBmp(image);
225
+ }
226
+
227
+ /** The logo to draw, from a PNG data URI or file. Null if there's none, or it isn't a PNG we can read. */
228
+ export function readLogo(source) {
229
+ if (!source || typeof source !== 'string') return null;
230
+ if (/^data:image\/png;base64,/i.test(source)) return decodePng(Buffer.from(source.slice(source.indexOf(',') + 1), 'base64'));
231
+ if (/\.png$/i.test(source) && existsSync(source)) return decodePng(readFileSync(source));
232
+ return null;
233
+ }
234
+
235
+ /** Every text the wizard shows, with {name}, {version} and {product} filled in. */
236
+ export function wizardStrings(installer) {
237
+ const strings = { ...defaultStrings, ...(installer.marketing?.strings ?? {}) };
238
+ const vars = { name: installer.app.name, version: installer.app.version, product: installer.target?.productName ?? 'Yapper' };
239
+ return Object.fromEntries(Object.entries(strings).map(([key, text]) => [key, String(text).replace(/\{(name|version|product)\}/g, (_, k) => vars[k])]));
240
+ }
241
+
242
+ /** The Welcome page's text: the intro, then the marketing description (or tagline), the notice, and "Click Next". */
243
+ export function welcomeText(installer, strings = wizardStrings(installer)) {
244
+ const m = installer.marketing ?? {};
245
+ const app = installer.app;
246
+ let about = plainText(m.description ?? m.tagline ?? app.description ?? '').trim();
247
+ if (about.length > 360) about = `${about.slice(0, 359).trimEnd()}…`;
248
+ const notice = m.notice === false ? '' : plainText(m.notice ?? `${app.name} is an unofficial modification of the Yapin’ desktop app. It isn’t made, supported or endorsed by Yapin’.`);
249
+ return [strings.setupWelcomeText, about, notice, strings.setupContinue].filter(Boolean).join('\n\n');
250
+ }
251
+
252
+ /**
253
+ * The .nsi script. `files` holds the paths of what the build wrote for it (license, bitmaps, icon);
254
+ * `source` is the installer folder that gets packed in.
255
+ */
256
+ export function nsisScript({ installer, source, out, files, company = '', description = '', exeLocations = [] }) {
257
+ const s = wizardStrings(installer);
258
+ const q = (text) => nsisString(text);
259
+ const app = installer.app;
260
+ const m = installer.marketing ?? {};
261
+ const closeApp = installer.closeApp ?? 'ask';
262
+ const terms = m.terms?.text ? { title: m.terms.title ?? null, requireAccept: m.terms.requireAccept !== false } : null;
263
+ const product = installer.target?.productName ?? 'Yapper';
264
+ const ini = '$PLUGINSDIR\\status.ini';
265
+ const result = '$PLUGINSDIR\\result.ini';
266
+
267
+ return `; Generated by yapin.js-client: the Windows setup wizard for ${app.name.replace(/[\r\n]/g, ' ')}.
268
+ Unicode true
269
+ ManifestDPIAware true
270
+ SetCompressor /SOLID lzma
271
+ RequestExecutionLevel user
272
+
273
+ !include "MUI2.nsh"
274
+ !include "nsDialogs.nsh"
275
+ !include "LogicLib.nsh"
276
+ !include "WinMessages.nsh"
277
+ !include "FileFunc.nsh"
278
+
279
+ Name ${q(app.name)}
280
+ OutFile ${q(out)}
281
+ BrandingText ${q(`${app.name} ${app.version}`)}
282
+ VIProductVersion "${fileVersion(app.version)}"
283
+ VIAddVersionKey "ProductName" ${q(app.name)}
284
+ VIAddVersionKey "ProductVersion" ${q(app.version)}
285
+ VIAddVersionKey "FileVersion" ${q(app.version)}
286
+ VIAddVersionKey "FileDescription" ${q(description || `${app.name} Setup`)}
287
+ ${company ? `VIAddVersionKey "CompanyName" ${q(company)}\nVIAddVersionKey "LegalCopyright" ${q(`© ${new Date().getFullYear()} ${company}`)}` : ''}
288
+
289
+ ${files.icon ? `!define MUI_ICON ${q(files.icon)}` : ''}
290
+ !define MUI_WELCOMEFINISHPAGE_BITMAP ${q(files.sidebar)}
291
+ !define MUI_HEADERIMAGE
292
+ !define MUI_HEADERIMAGE_RIGHT
293
+ !define MUI_HEADERIMAGE_BITMAP ${q(files.header)}
294
+ !define MUI_ABORTWARNING
295
+
296
+ Var Runtime
297
+ Var Electron
298
+ Var Count
299
+ Var Running
300
+ Var HasData
301
+ Var Index
302
+ Var Action
303
+ Var Purge
304
+ Var MainLabel
305
+ Var ProgressTitle
306
+ Var ProgressText
307
+ Var DoneTitle
308
+ Var DoneText
309
+ Var FinishText
310
+ Var Dialog
311
+ Var List
312
+ Var Where
313
+ Var State
314
+ Var RadioMain
315
+ Var RadioUninstall
316
+ Var PurgeBox
317
+ Var Notice
318
+
319
+ !define MUI_WELCOMEPAGE_TITLE ${q(s.setupWelcomeTitle)}
320
+ !define MUI_WELCOMEPAGE_TEXT ${q(welcomeText(installer, s))}
321
+ !insertmacro MUI_PAGE_WELCOME
322
+ ${
323
+ terms
324
+ ? `${terms.title ? `!define MUI_PAGE_HEADER_TEXT ${q(terms.title)}\n` : ''}${
325
+ terms.requireAccept ? `!define MUI_LICENSEPAGE_CHECKBOX\n!define MUI_LICENSEPAGE_CHECKBOX_TEXT ${q(s.accept)}` : '!define MUI_LICENSEPAGE_BUTTON "$(^NextBtn)"\n!define MUI_LICENSEPAGE_TEXT_BOTTOM " "'
326
+ }
327
+ !insertmacro MUI_PAGE_LICENSE ${q(files.license)}`
328
+ : ''
329
+ }
330
+ Page custom ChoosePage ChooseLeave
331
+ !define MUI_PAGE_HEADER_TEXT "$ProgressTitle"
332
+ !define MUI_PAGE_HEADER_SUBTEXT "$ProgressText"
333
+ !define MUI_INSTFILESPAGE_FINISHHEADER_TEXT "$DoneTitle"
334
+ !define MUI_INSTFILESPAGE_FINISHHEADER_SUBTEXT "$DoneText"
335
+ !define MUI_INSTFILESPAGE_ABORTHEADER_TEXT ${q(s.setupFailedTitle)}
336
+ !define MUI_INSTFILESPAGE_ABORTHEADER_SUBTEXT ${q(s.setupFailedText)}
337
+ !insertmacro MUI_PAGE_INSTFILES
338
+ !define MUI_FINISHPAGE_TITLE ${q(s.setupFinishTitle)}
339
+ !define MUI_FINISHPAGE_TEXT "$FinishText"
340
+ !define MUI_FINISHPAGE_RUN
341
+ !define MUI_FINISHPAGE_RUN_TEXT ${q(s.launch)}
342
+ !define MUI_FINISHPAGE_RUN_FUNCTION LaunchApp
343
+ ${installer.launchAfter === false ? '!define MUI_FINISHPAGE_RUN_NOTCHECKED' : ''}
344
+ !insertmacro MUI_PAGE_FINISH
345
+ !insertmacro MUI_LANGUAGE "English"
346
+
347
+ ; Runs installer.mjs with the arguments in $R9: with its output in the log when $R8 is "log". Exit code in $R0.
348
+ Function RunInstaller
349
+ \${If} $Electron == 1
350
+ System::Call 'kernel32::SetEnvironmentVariable(t "ELECTRON_RUN_AS_NODE", t "1")'
351
+ \${Else}
352
+ System::Call 'kernel32::SetEnvironmentVariable(t "ELECTRON_RUN_AS_NODE", p 0)'
353
+ \${EndIf}
354
+ SetDetailsPrint none
355
+ SetOutPath "$PLUGINSDIR\\app"
356
+ SetDetailsPrint lastused
357
+ \${If} $R8 == "log"
358
+ nsExec::ExecToLog '"$Runtime" "$PLUGINSDIR\\app\\installer.mjs" $R9'
359
+ \${Else}
360
+ nsExec::Exec '"$Runtime" "$PLUGINSDIR\\app\\installer.mjs" $R9'
361
+ \${EndIf}
362
+ Pop $R0
363
+ System::Call 'kernel32::SetEnvironmentVariable(t "ELECTRON_RUN_AS_NODE", p 0)'
364
+ SetDetailsPrint none
365
+ SetOutPath "$TEMP"
366
+ SetDetailsPrint lastused
367
+ FunctionEnd
368
+
369
+ ; The desktop app's own Node (Electron run as Node), or node.exe.
370
+ Function FindRuntime
371
+ StrCpy $Electron 1
372
+ ${exeLocations
373
+ .map(
374
+ (exe) => ` ExpandEnvStrings $R0 ${q(exe)}
375
+ \${If} \${FileExists} "$R0"
376
+ StrCpy $Runtime $R0
377
+ Return
378
+ \${EndIf}`,
379
+ )
380
+ .join('\n')}
381
+ StrCpy $Electron 0
382
+ SearchPath $Runtime "node.exe"
383
+ FunctionEnd
384
+
385
+ Function GetStatus
386
+ Delete "${ini}"
387
+ StrCpy $R8 "quiet"
388
+ StrCpy $R9 'status --nsis "${ini}"'
389
+ Call RunInstaller
390
+ ReadINIStr $Count "${ini}" status count
391
+ \${If} $Count == ""
392
+ MessageBox MB_ICONSTOP|MB_OK ${q(s.setupFailed)} /SD IDOK
393
+ Quit
394
+ \${EndIf}
395
+ ReadINIStr $Running "${ini}" status running
396
+ ReadINIStr $HasData "${ini}" status hasData
397
+ FunctionEnd
398
+
399
+ ; What Next does for install $Index: install, repair (also reinstalling the same version), or "" when nothing can be done.
400
+ Function DefaultAction
401
+ ReadINIStr $R0 "${ini}" "install$Index" state
402
+ ReadINIStr $R1 "${ini}" "install$Index" installedVersion
403
+ ReadINIStr $R2 "${ini}" "install$Index" canInstall
404
+ ReadINIStr $R3 "${ini}" "install$Index" ours
405
+ StrCpy $Action ""
406
+ \${If} $R2 == 1
407
+ StrCpy $Action "install"
408
+ StrCpy $MainLabel ${q(s.install)}
409
+ \${If} $R0 == "needs-repair"
410
+ StrCpy $Action "repair"
411
+ StrCpy $MainLabel ${q(s.repair)}
412
+ \${ElseIf} $R0 == "installed"
413
+ StrCpy $MainLabel ${q(s.update)}
414
+ \${If} $R1 == ${q(app.version)}
415
+ StrCpy $Action "repair"
416
+ StrCpy $MainLabel ${q(s.repair)}
417
+ \${EndIf}
418
+ \${EndIf}
419
+ \${EndIf}
420
+ FunctionEnd
421
+
422
+ Function .onInit
423
+ InitPluginsDir
424
+ SetOutPath "$PLUGINSDIR\\app"
425
+ File /r /x "*.sh" /x "*.command" ${q(`${source}${sep}*`)}
426
+ SetOutPath "$TEMP"
427
+ Call FindRuntime
428
+ \${If} $Runtime == ""
429
+ MessageBox MB_ICONSTOP|MB_OK ${q(s.notFound)} /SD IDOK
430
+ Abort
431
+ \${EndIf}
432
+ Call GetStatus
433
+ StrCpy $Index 0
434
+ StrCpy $Purge 0
435
+ Call DefaultAction
436
+ ; Silent mode (/S) does what Next would, or uninstalls with /UNINSTALL (and deletes data with /PURGE).
437
+ \${GetParameters} $R0
438
+ ClearErrors
439
+ \${GetOptions} $R0 "/UNINSTALL" $R1
440
+ \${IfNot} \${Errors}
441
+ StrCpy $Action "uninstall"
442
+ \${EndIf}
443
+ ClearErrors
444
+ \${GetOptions} $R0 "/PURGE" $R1
445
+ \${IfNot} \${Errors}
446
+ StrCpy $Purge 1
447
+ \${EndIf}
448
+ FunctionEnd
449
+
450
+ Function ChoosePage
451
+ !insertmacro MUI_HEADER_TEXT ${q(s.setupChooseTitle)} ${q(s.setupChooseSubtitle)}
452
+ nsDialogs::Create 1018
453
+ Pop $Dialog
454
+ \${If} $Dialog == error
455
+ Abort
456
+ \${EndIf}
457
+ \${If} $Count == 0
458
+ \${NSD_CreateLabel} 0 0 100% 40u ${q(s.notFound)}
459
+ Pop $R0
460
+ GetDlgItem $R0 $HWNDPARENT 1
461
+ EnableWindow $R0 0
462
+ nsDialogs::Show
463
+ Return
464
+ \${EndIf}
465
+ \${If} $Count > 1
466
+ \${NSD_CreateLabel} 0 0 100% 10u ${q(s.installInto)}
467
+ Pop $R0
468
+ \${NSD_CreateDropList} 0 11u 100% 80u ""
469
+ Pop $List
470
+ StrCpy $R1 0
471
+ \${DoWhile} $R1 < $Count
472
+ ReadINIStr $R2 "${ini}" "install$R1" location
473
+ ReadINIStr $R3 "${ini}" "install$R1" appVersion
474
+ \${NSD_CB_AddString} $List ${nsisVars(`${product} `, ['$R3'], ' (', ['$R2'], ')')}
475
+ IntOp $R1 $R1 + 1
476
+ \${Loop}
477
+ \${NSD_CB_SetSelectionIndex} $List $Index
478
+ \${NSD_OnChange} $List OnPickInstall
479
+ \${Else}
480
+ ReadINIStr $R3 "${ini}" "install0" appVersion
481
+ \${NSD_CreateLabel} 0 0 100% 10u ${nsisVars(`${product} `, ['$R3'])}
482
+ Pop $R0
483
+ CreateFont $R1 "$(^Font)" "$(^FontSize)" 700
484
+ SendMessage $R0 \${WM_SETFONT} $R1 0
485
+ \${NSD_CreateLabel} 0 11u 100% 12u ""
486
+ Pop $Where
487
+ \${NSD_AddStyle} $Where 0x8000 ; SS_PATHELLIPSIS: long paths lose their middle, not their end
488
+ \${EndIf}
489
+ \${NSD_CreateLabel} 0 28u 100% 10u ""
490
+ Pop $State
491
+ \${NSD_CreateRadioButton} 0 46u 100% 12u ""
492
+ Pop $RadioMain
493
+ \${NSD_OnClick} $RadioMain OnPickAction
494
+ \${NSD_CreateRadioButton} 0 60u 100% 12u ${q(s.setupUninstall)}
495
+ Pop $RadioUninstall
496
+ \${NSD_OnClick} $RadioUninstall OnPickAction
497
+ \${NSD_CreateCheckBox} 12u 74u -12u 12u ${q(s.purge)}
498
+ Pop $PurgeBox
499
+ \${NSD_CreateLabel} 0 96u 100% 40u ""
500
+ Pop $Notice
501
+ Call UpdateChoice
502
+ nsDialogs::Show
503
+ FunctionEnd
504
+
505
+ Function OnPickInstall
506
+ Pop $R0
507
+ \${NSD_CB_GetSelectionIndex} $List $Index
508
+ Call DefaultAction
509
+ Call UpdateChoice
510
+ FunctionEnd
511
+
512
+ Function OnPickAction
513
+ Pop $R0
514
+ Call UpdateButtons
515
+ FunctionEnd
516
+
517
+ ; Shows install $Index: where it is, what's there, and the choices that make sense.
518
+ Function UpdateChoice
519
+ ReadINIStr $R0 "${ini}" "install$Index" state
520
+ ReadINIStr $R1 "${ini}" "install$Index" ours
521
+ ReadINIStr $R2 "${ini}" "install$Index" installedName
522
+ ReadINIStr $R3 "${ini}" "install$Index" installedVersion
523
+ ReadINIStr $R4 "${ini}" "install$Index" blocked
524
+ ReadINIStr $R5 "${ini}" "install$Index" location
525
+ \${If} $Count == 1
526
+ \${NSD_SetText} $Where "$R5"
527
+ \${EndIf}
528
+
529
+ \${If} $R4 != ""
530
+ \${NSD_SetText} $State ${q(s.setupBlockedState)}
531
+ \${ElseIf} $R0 == "installed"
532
+ \${NSD_SetText} $State ${nsisVars(`${s.setupInstalledState} `, ['$R2'], ' ', ['$R3'])}
533
+ \${ElseIf} $R0 == "needs-repair"
534
+ \${NSD_SetText} $State ${q(s.setupNeedsRepairState)}
535
+ \${Else}
536
+ \${NSD_SetText} $State ${q(s.setupNotInstalled)}
537
+ \${EndIf}
538
+
539
+ ; Uninstalling is offered for this client app (installed, or broken by a desktop update).
540
+ StrCpy $R6 0
541
+ \${If} $R0 == "needs-repair"
542
+ StrCpy $R6 1
543
+ \${ElseIf} $R0 == "installed"
544
+ \${AndIf} $R1 == 1
545
+ StrCpy $R6 1
546
+ \${EndIf}
547
+
548
+ \${If} $Action == ""
549
+ \${OrIf} $R6 == 0
550
+ ShowWindow $RadioMain \${SW_HIDE}
551
+ \${Else}
552
+ ShowWindow $RadioMain \${SW_SHOW}
553
+ \${EndIf}
554
+ \${If} $Action == "repair"
555
+ \${AndIf} $R0 == "needs-repair"
556
+ \${NSD_SetText} $RadioMain ${q(s.setupRepair)}
557
+ \${ElseIf} $Action == "repair"
558
+ \${NSD_SetText} $RadioMain ${q(s.setupReinstall)}
559
+ \${ElseIf} $R0 == "installed"
560
+ \${NSD_SetText} $RadioMain ${q(s.setupUpdate)}
561
+ \${Else}
562
+ \${NSD_SetText} $RadioMain ${q(s.setupInstall)}
563
+ \${EndIf}
564
+ \${If} $R6 == 1
565
+ ShowWindow $RadioUninstall \${SW_SHOW}
566
+ \${Else}
567
+ ShowWindow $RadioUninstall \${SW_HIDE}
568
+ \${EndIf}
569
+ \${If} $R6 == 1
570
+ \${AndIf} $HasData == 1
571
+ ShowWindow $PurgeBox \${SW_SHOW}
572
+ \${Else}
573
+ ShowWindow $PurgeBox \${SW_HIDE}
574
+ \${EndIf}
575
+ \${If} $Action == ""
576
+ \${AndIf} $R6 == 1
577
+ \${NSD_Uncheck} $RadioMain
578
+ \${NSD_Check} $RadioUninstall
579
+ \${Else}
580
+ \${NSD_Check} $RadioMain
581
+ \${NSD_Uncheck} $RadioUninstall
582
+ \${EndIf}
583
+
584
+ \${If} $R4 != ""
585
+ \${NSD_SetText} $Notice "$R4"
586
+ \${ElseIf} $R0 == "needs-repair"
587
+ \${NSD_SetText} $Notice ${q(s.needsRepair)}
588
+ \${ElseIf} $Running == 1
589
+ \${NSD_SetText} $Notice ${q(closeApp === 'never' ? s.setupStillRunning : s.appRunning)}
590
+ \${Else}
591
+ \${NSD_SetText} $Notice ""
592
+ \${EndIf}
593
+ Call UpdateButtons
594
+ FunctionEnd
595
+
596
+ ; Next says what it will do, and the "delete my data" box only applies to uninstalling.
597
+ Function UpdateButtons
598
+ GetDlgItem $R0 $HWNDPARENT 1
599
+ \${NSD_GetState} $RadioUninstall $R1
600
+ \${If} $R1 == \${BST_CHECKED}
601
+ EnableWindow $PurgeBox 1
602
+ SendMessage $R0 \${WM_SETTEXT} 0 ${q(`STR:${s.uninstall}`)}
603
+ EnableWindow $R0 1
604
+ \${Else}
605
+ EnableWindow $PurgeBox 0
606
+ \${NSD_Uncheck} $PurgeBox
607
+ SendMessage $R0 \${WM_SETTEXT} 0 "STR:$MainLabel"
608
+ \${If} $Action == ""
609
+ EnableWindow $R0 0
610
+ \${Else}
611
+ EnableWindow $R0 1
612
+ \${EndIf}
613
+ \${EndIf}
614
+ FunctionEnd
615
+
616
+ Function ChooseLeave
617
+ \${NSD_GetState} $RadioUninstall $R0
618
+ \${If} $R0 == \${BST_CHECKED}
619
+ StrCpy $Action "uninstall"
620
+ \${NSD_GetState} $PurgeBox $R1
621
+ \${If} $R1 == \${BST_CHECKED}
622
+ StrCpy $Purge 1
623
+ \${Else}
624
+ StrCpy $Purge 0
625
+ \${EndIf}
626
+ \${Else}
627
+ Call DefaultAction
628
+ StrCpy $Purge 0
629
+ \${EndIf}
630
+ \${If} $Action == ""
631
+ Abort
632
+ \${EndIf}
633
+ ${
634
+ closeApp === 'never'
635
+ ? ` \${If} $Running == 1
636
+ Call GetStatus
637
+ \${If} $Running == 1
638
+ MessageBox MB_ICONEXCLAMATION|MB_OK ${q(s.setupStillRunning)}
639
+ Abort
640
+ \${EndIf}
641
+ \${EndIf}`
642
+ : closeApp === 'ask'
643
+ ? ` \${If} $Running == 1
644
+ MessageBox MB_ICONQUESTION|MB_OKCANCEL ${q(s.appRunning)} IDOK +2
645
+ Abort
646
+ \${EndIf}`
647
+ : ''
648
+ }
649
+ Call SetProgressText
650
+ FunctionEnd
651
+
652
+ Function SetProgressText
653
+ StrCpy $DoneTitle ${q(s.setupDoneTitle)}
654
+ StrCpy $DoneText ${q(s.setupDoneText)}
655
+ \${If} $Action == "uninstall"
656
+ StrCpy $ProgressTitle ${q(s.setupUninstallingTitle)}
657
+ StrCpy $ProgressText ${q(s.setupUninstallingText)}
658
+ \${ElseIf} $Action == "repair"
659
+ StrCpy $ProgressTitle ${q(s.setupRepairingTitle)}
660
+ StrCpy $ProgressText ${q(s.setupRepairingText)}
661
+ \${Else}
662
+ StrCpy $ProgressTitle ${q(s.setupInstallingTitle)}
663
+ StrCpy $ProgressText ${q(s.setupInstallingText)}
664
+ \${EndIf}
665
+ FunctionEnd
666
+
667
+ Section "-${app.id}"
668
+ \${If} $Action == ""
669
+ DetailPrint ${q(s.setupBlockedState)}
670
+ Abort
671
+ \${EndIf}
672
+ Call SetProgressText
673
+ ReadINIStr $R7 "${ini}" "install$Index" state
674
+ IntOp $R1 $Index + 1
675
+ StrCpy $R9 '$Action --nsis "${result}" --index $R1${closeApp === 'never' ? '' : ' --close'}'
676
+ \${If} $Purge == 1
677
+ StrCpy $R9 "$R9 --purge"
678
+ \${EndIf}
679
+ Delete "${result}"
680
+ StrCpy $R8 "log"
681
+ Call RunInstaller
682
+ ReadINIStr $R1 "${result}" result ok
683
+ \${If} $R1 != 1
684
+ ReadINIStr $R2 "${result}" result error
685
+ ReadINIStr $R3 "${result}" result hint
686
+ \${If} $R2 == ""
687
+ StrCpy $R2 ${q(s.setupFailed)}
688
+ \${EndIf}
689
+ DetailPrint "$R2"
690
+ \${If} $R3 != ""
691
+ DetailPrint "$R3"
692
+ \${EndIf}
693
+ SetDetailsView show
694
+ MessageBox MB_ICONSTOP|MB_OK "$R2$\\r$\\n$\\r$\\n$R3" /SD IDOK
695
+ Abort
696
+ \${EndIf}
697
+ ReadINIStr $R4 "${result}" result previous
698
+ \${If} $Action == "uninstall"
699
+ StrCpy $FinishText ${q(s.uninstalled)}
700
+ \${ElseIf} $Action == "repair"
701
+ \${AndIf} $R7 == "needs-repair"
702
+ StrCpy $FinishText ${q(s.repaired)}
703
+ \${ElseIf} $R4 == "installed"
704
+ \${AndIf} $Action == "install"
705
+ StrCpy $FinishText ${q(s.updated)}
706
+ \${Else}
707
+ StrCpy $FinishText ${q(s.installed)}
708
+ \${EndIf}
709
+ StrCpy $R5 ${q(s.setupFinishText)}
710
+ StrCpy $FinishText "$FinishText$\\r$\\n$\\r$\\n$R5"
711
+ SectionEnd
712
+
713
+ ; Starts the desktop app from its own folder, so this installer's temporary folder can be removed.
714
+ Function LaunchApp
715
+ ReadINIStr $R0 "${ini}" "install$Index" executable
716
+ ReadINIStr $R1 "${ini}" "install$Index" folder
717
+ \${If} \${FileExists} "$R0"
718
+ SetOutPath "$R1"
719
+ Exec '"$R0"'
720
+ SetOutPath "$TEMP"
721
+ \${EndIf}
722
+ FunctionEnd
723
+ `;
724
+ }
725
+
726
+ /** Text NSIS shows on the license page (its title is the page's heading): UTF-16 with a byte order mark, so any language works. */
727
+ function licenseFile(installer) {
728
+ const text = String(installer.marketing.terms.text).replace(/\r?\n/g, '\r\n');
729
+ return Buffer.concat([Buffer.from([0xff, 0xfe]), Buffer.from(text, 'utf16le')]);
730
+ }
731
+
732
+ /**
733
+ * Builds the setup wizard into `out` with makensis. `installer` is installer.json's content and
734
+ * `source` the installer folder. Pictures: `sidebar` (164×314) and `header` (150×57) .bmp files
735
+ * replace the generated ones; `icon` is a PNG (at most 256 px) or .ico.
736
+ */
737
+ export function buildNsisExe({ makensis, installer, source, out, icon = null, sidebar = null, header = null, company = '', description = '', exeLocations = [] }) {
738
+ const work = mkdtempSync(join(tmpdir(), 'yc-nsis-'));
739
+ try {
740
+ const logo = readLogo(installer.marketing?.logo);
741
+ const files = { sidebar: sidebar ?? join(work, 'sidebar.bmp'), header: header ?? join(work, 'header.bmp'), icon: null, license: null };
742
+ if (!sidebar) writeFileSync(files.sidebar, sidebarBitmap({ accent: installer.marketing?.accent, logo }));
743
+ if (!header) writeFileSync(files.header, headerBitmap({ logo }));
744
+ if (icon) {
745
+ files.icon = join(work, 'icon.ico');
746
+ writeFileSync(files.icon, /\.ico$/i.test(icon) ? readFileSync(icon) : pngToIco(readFileSync(icon)));
747
+ }
748
+ if (installer.marketing?.terms?.text) {
749
+ files.license = join(work, 'license.txt');
750
+ writeFileSync(files.license, licenseFile(installer));
751
+ }
752
+ const script = join(work, 'setup.nsi');
753
+ writeFileSync(script, `${nsisScript({ installer, source, out, files, company, description, exeLocations })}`);
754
+ try {
755
+ execFileSync(makensis, ['-V2', '-INPUTCHARSET', 'UTF8', script], { stdio: 'pipe', windowsHide: true });
756
+ } catch (error) {
757
+ throw new Error(`makensis failed: ${String(error.stdout ?? '').trim() || String(error.stderr ?? '').trim() || error.message}`);
758
+ }
759
+ return out;
760
+ } finally {
761
+ rmSync(work, { recursive: true, force: true });
762
+ }
763
+ }