pakstr 0.30.0 → 0.31.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.
- package/README.md +11 -1
- package/desktop-runtime/README.md +108 -0
- package/desktop-runtime/dist/main.js +22211 -0
- package/desktop-runtime/dist/preload.js +53 -0
- package/dist/cli.js +17 -3
- package/dist/commands/build.js +1 -0
- package/dist/commands/buildDesktop.js +140 -0
- package/dist/desktop/common/artifacts.js +16 -0
- package/dist/desktop/common/asar.js +60 -0
- package/dist/desktop/common/electron.js +50 -0
- package/dist/desktop/common/files.js +30 -0
- package/dist/desktop/common/fuses.js +62 -0
- package/dist/desktop/common/packager.js +46 -0
- package/dist/desktop/linux.js +425 -0
- package/dist/desktop/macos.js +125 -0
- package/dist/desktop/stage.js +117 -0
- package/dist/desktop/windows.js +384 -0
- package/dist/desktop/zip.js +204 -0
- package/dist/shared/nip46Pairing.js +278 -0
- package/package.json +14 -3
|
@@ -0,0 +1,384 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.WINDOWS_UNINSTALLER = exports.WINDOWS_X64_TARGET = void 0;
|
|
7
|
+
exports.assertWindowsAppIdentity = assertWindowsAppIdentity;
|
|
8
|
+
exports.windowsVersion = windowsVersion;
|
|
9
|
+
exports.windowsExeName = windowsExeName;
|
|
10
|
+
exports.windowsOutputPaths = windowsOutputPaths;
|
|
11
|
+
exports.windowsPackagerOptions = windowsPackagerOptions;
|
|
12
|
+
exports.packageWindowsApp = packageWindowsApp;
|
|
13
|
+
exports.assertMakensis = assertMakensis;
|
|
14
|
+
exports.validateWindowsZip = validateWindowsZip;
|
|
15
|
+
exports.verifyWindowsApp = verifyWindowsApp;
|
|
16
|
+
exports.assertWindowsVersionInfo = assertWindowsVersionInfo;
|
|
17
|
+
exports.assertAsarIntegrityResource = assertAsarIntegrityResource;
|
|
18
|
+
exports.assertNsisInstaller = assertNsisInstaller;
|
|
19
|
+
exports.nsisString = nsisString;
|
|
20
|
+
exports.nsisPath = nsisPath;
|
|
21
|
+
exports.windowsInstallerScript = windowsInstallerScript;
|
|
22
|
+
const fs_1 = __importDefault(require("fs"));
|
|
23
|
+
const path_1 = __importDefault(require("path"));
|
|
24
|
+
const process_1 = require("../runners/process");
|
|
25
|
+
const artifacts_1 = require("./common/artifacts");
|
|
26
|
+
const asar_1 = require("./common/asar");
|
|
27
|
+
const electron_1 = require("./common/electron");
|
|
28
|
+
const files_1 = require("./common/files");
|
|
29
|
+
const fuses_1 = require("./common/fuses");
|
|
30
|
+
const packager_1 = require("./common/packager");
|
|
31
|
+
const stage_1 = require("./stage");
|
|
32
|
+
const zip_1 = require("./zip");
|
|
33
|
+
exports.WINDOWS_X64_TARGET = "windows-x64";
|
|
34
|
+
/** The installer's uninstaller, next to the app executable. */
|
|
35
|
+
exports.WINDOWS_UNINSTALLER = "Uninstall.exe";
|
|
36
|
+
const NSIS_OPTIONS = {
|
|
37
|
+
timeoutMs: 20 * 60_000,
|
|
38
|
+
timeoutHint: "makensis stopped responding; try again.",
|
|
39
|
+
};
|
|
40
|
+
// Device names Windows reserves for a file name, with or without an extension.
|
|
41
|
+
const WINDOWS_RESERVED_NAME = /^(con|prn|aux|nul|com[0-9¹²³]|lpt[0-9¹²³]|conin\$|conout\$)$/i;
|
|
42
|
+
/**
|
|
43
|
+
* appName names `<appName>.exe`, the Start menu shortcut, and the portable
|
|
44
|
+
* folder; appId names the data and install directories. Both must be usable
|
|
45
|
+
* Windows file names.
|
|
46
|
+
*/
|
|
47
|
+
function assertWindowsAppIdentity(app) {
|
|
48
|
+
(0, stage_1.assertDesktopAppName)(app.appName);
|
|
49
|
+
if (/[<>"|?*]/.test(app.appName) || /[. ]$/.test(app.appName) || WINDOWS_RESERVED_NAME.test(app.appName.split(".")[0].trimEnd())) {
|
|
50
|
+
throw new Error(`app.appName "${app.appName}" cannot be used as a Windows file name: it must not contain < > " | ? *, ` +
|
|
51
|
+
"end with '.' or a space, or be a reserved device name such as CON or NUL");
|
|
52
|
+
}
|
|
53
|
+
if (WINDOWS_RESERVED_NAME.test(app.appId.split(".")[0])) {
|
|
54
|
+
throw new Error(`app.appId "${app.appId}" cannot be used on Windows: it starts with the reserved device name "${app.appId.split(".")[0]}"`);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
const WINDOWS_VERSION = /^v?(\d+)(?:\.(\d+))?(?:\.(\d+))?(?:\.(\d+))?(?:[-+][0-9A-Za-z.+-]*)?$/;
|
|
58
|
+
/**
|
|
59
|
+
* VERSIONINFO holds four 16-bit numbers, so the executable and installer get
|
|
60
|
+
* the numeric part of versionName (1.2.3-beta.1 -> 1.2.3.0). versionCode does
|
|
61
|
+
* not fit. The installed-apps list shows versionName itself.
|
|
62
|
+
*/
|
|
63
|
+
function windowsVersion(versionName) {
|
|
64
|
+
const match = WINDOWS_VERSION.exec(versionName);
|
|
65
|
+
const parts = match ? match.slice(1).map((part) => Number(part ?? 0)) : [];
|
|
66
|
+
if (!match || parts.some((part) => part > 0xffff)) {
|
|
67
|
+
throw new Error(`app.versionName "${versionName}" cannot be used as a Windows file version: ` +
|
|
68
|
+
"it must start with one to four numbers between 0 and 65535, like 1.2.3");
|
|
69
|
+
}
|
|
70
|
+
return parts.join(".");
|
|
71
|
+
}
|
|
72
|
+
function windowsExeName(app) {
|
|
73
|
+
return `${app.appName}.exe`;
|
|
74
|
+
}
|
|
75
|
+
/** Installer and portable ZIP next to build.out, named like the APK. */
|
|
76
|
+
function windowsOutputPaths(buildOut, appId) {
|
|
77
|
+
const dir = (0, artifacts_1.desktopOutputDir)(buildOut);
|
|
78
|
+
return {
|
|
79
|
+
installer: (0, artifacts_1.desktopArtifactPath)(dir, appId, exports.WINDOWS_X64_TARGET, "-setup.exe"),
|
|
80
|
+
portable: (0, artifacts_1.desktopArtifactPath)(dir, appId, exports.WINDOWS_X64_TARGET, ".zip"),
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* @electron/packager options: the shared base (with the allowlist `ignore`)
|
|
85
|
+
* plus the executable's version resource, rewritten from the app so none of
|
|
86
|
+
* Electron's "GitHub, Inc." strings remain.
|
|
87
|
+
*/
|
|
88
|
+
function windowsPackagerOptions(options) {
|
|
89
|
+
const { stagingDir, outDir, electronZipDir, app } = options;
|
|
90
|
+
const version = windowsVersion(app.versionName);
|
|
91
|
+
return {
|
|
92
|
+
...(0, packager_1.basePackagerOptions)({ stagingDir, outDir, electronZipDir }),
|
|
93
|
+
platform: "win32",
|
|
94
|
+
arch: "x64",
|
|
95
|
+
name: app.appName,
|
|
96
|
+
appVersion: version,
|
|
97
|
+
buildVersion: version,
|
|
98
|
+
appCopyright: app.appName,
|
|
99
|
+
win32metadata: {
|
|
100
|
+
CompanyName: app.appName,
|
|
101
|
+
FileDescription: app.appName,
|
|
102
|
+
InternalName: app.appName,
|
|
103
|
+
OriginalFilename: windowsExeName(app),
|
|
104
|
+
ProductName: app.appName,
|
|
105
|
+
},
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* Package the staged app as `<appName>.exe` plus Electron's files, flip
|
|
110
|
+
* fuses, and validate it. Then write the portable ZIP (validated from a fresh
|
|
111
|
+
* extraction) and the NSIS installer. Returns both paths in `workDir`.
|
|
112
|
+
*/
|
|
113
|
+
async function packageWindowsApp(options) {
|
|
114
|
+
const { stagingDir, workDir, app } = options;
|
|
115
|
+
assertWindowsAppIdentity(app);
|
|
116
|
+
windowsVersion(app.versionName);
|
|
117
|
+
let makensisVersion;
|
|
118
|
+
try {
|
|
119
|
+
makensisVersion = (0, process_1.runProcess)("makensis", ["-VERSION"], NSIS_OPTIONS).stdout;
|
|
120
|
+
}
|
|
121
|
+
catch (error) {
|
|
122
|
+
throw new Error("Windows installers need NSIS 3 (makensis) on PATH: brew install makensis, apt install nsis, " +
|
|
123
|
+
`or install NSIS on Windows. ${error.message}`);
|
|
124
|
+
}
|
|
125
|
+
assertMakensis(makensisVersion);
|
|
126
|
+
const electronZip = await (0, electron_1.fetchElectronZip)(electron_1.WINDOWS_ELECTRON_ZIP);
|
|
127
|
+
const { appDir, productPath: exePath } = await (0, packager_1.runPackager)(windowsPackagerOptions({ stagingDir, outDir: path_1.default.join(workDir, "out"), electronZipDir: path_1.default.dirname(electronZip), app }), { name: windowsExeName(app), type: "file" });
|
|
128
|
+
await (0, fuses_1.flipDesktopFuses)(exePath);
|
|
129
|
+
await verifyWindowsApp(appDir, app);
|
|
130
|
+
const portable = (0, artifacts_1.desktopArtifactPath)(workDir, app.appId, exports.WINDOWS_X64_TARGET, ".zip");
|
|
131
|
+
await (0, zip_1.zipDirectory)(appDir, portable, app.appName);
|
|
132
|
+
await validateWindowsZip(portable, appDir, app, path_1.default.join(workDir, "verify"));
|
|
133
|
+
const installer = (0, artifacts_1.desktopArtifactPath)(workDir, app.appId, exports.WINDOWS_X64_TARGET, "-setup.exe");
|
|
134
|
+
const script = path_1.default.join(workDir, "installer.nsi");
|
|
135
|
+
fs_1.default.writeFileSync(script, windowsInstallerScript({ app, appDir, outFile: installer }), { flag: "wx" });
|
|
136
|
+
// -NOCONFIG: a local nsisconf.nsh must not change the installer.
|
|
137
|
+
(0, process_1.runProcess)("makensis", ["-NOCONFIG", "-V2", "-WX", "-INPUTCHARSET", "UTF8", script], NSIS_OPTIONS);
|
|
138
|
+
assertNsisInstaller(installer);
|
|
139
|
+
return { installer, portable };
|
|
140
|
+
}
|
|
141
|
+
/** `makensis -VERSION` prints e.g. "v3.10"; Unicode installers need NSIS 3. */
|
|
142
|
+
function assertMakensis(versionOutput) {
|
|
143
|
+
const major = /^v?(\d+)\./.exec(versionOutput.trim())?.[1];
|
|
144
|
+
if (major !== "3") {
|
|
145
|
+
throw new Error(`Windows installers need NSIS 3 (makensis), found ${JSON.stringify(versionOutput.trim())}`);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
/** Extract the portable ZIP and check it holds exactly `<appName>/` with the validated app. */
|
|
149
|
+
async function validateWindowsZip(zipPath, appDir, app, extractDir) {
|
|
150
|
+
const extracted = await (0, zip_1.verifyPortableZip)(zipPath, extractDir, app.appName, (0, zip_1.listRegularFiles)(appDir), { ordered: true });
|
|
151
|
+
await verifyWindowsApp(extracted, app);
|
|
152
|
+
}
|
|
153
|
+
/** Executable, version resource, asar integrity, fuses, and packaged files of an app folder. */
|
|
154
|
+
async function verifyWindowsApp(appDir, app) {
|
|
155
|
+
const exePath = path_1.default.join(appDir, windowsExeName(app));
|
|
156
|
+
if (fs_1.default.existsSync(path_1.default.join(appDir, "electron.exe")))
|
|
157
|
+
throw new Error("Unexpected electron.exe next to the app executable");
|
|
158
|
+
const resources = path_1.default.join(appDir, "resources");
|
|
159
|
+
const resourceEntries = fs_1.default.readdirSync(resources);
|
|
160
|
+
if (resourceEntries.length !== 1 || resourceEntries[0] !== "app.asar") {
|
|
161
|
+
throw new Error(`resources must contain only app.asar, found: ${resourceEntries.join(", ")}`);
|
|
162
|
+
}
|
|
163
|
+
const asarPath = path_1.default.join(resources, "app.asar");
|
|
164
|
+
await (0, fuses_1.verifyDesktopFuses)(exePath);
|
|
165
|
+
const { NtExecutable, NtExecutableResource, Resource } = await import("resedit");
|
|
166
|
+
const exe = NtExecutable.from(fs_1.default.readFileSync(exePath));
|
|
167
|
+
if (exe.is32bit())
|
|
168
|
+
throw new Error(`${windowsExeName(app)} is not a 64-bit executable`);
|
|
169
|
+
const entries = NtExecutableResource.from(exe).entries;
|
|
170
|
+
const versionInfo = Resource.VersionInfo.fromEntries(entries);
|
|
171
|
+
if (versionInfo.length !== 1)
|
|
172
|
+
throw new Error(`${windowsExeName(app)} must have one version resource`);
|
|
173
|
+
const [info] = versionInfo;
|
|
174
|
+
const languages = info.getAllLanguagesForStringValues();
|
|
175
|
+
if (languages.length !== 1)
|
|
176
|
+
throw new Error(`${windowsExeName(app)} must have one version string table`);
|
|
177
|
+
assertWindowsVersionInfo({
|
|
178
|
+
fileVersion: fixedVersion(info.fixedInfo.fileVersionMS, info.fixedInfo.fileVersionLS),
|
|
179
|
+
productVersion: fixedVersion(info.fixedInfo.productVersionMS, info.fixedInfo.productVersionLS),
|
|
180
|
+
strings: info.getStringValues(languages[0]),
|
|
181
|
+
}, app);
|
|
182
|
+
const integrity = entries.filter((entry) => entry.type === "INTEGRITY" && entry.id === "ELECTRONASAR");
|
|
183
|
+
if (integrity.length !== 1)
|
|
184
|
+
throw new Error(`${windowsExeName(app)} must embed one asar integrity resource`);
|
|
185
|
+
const headerHash = await (0, asar_1.asarHeaderHash)(asarPath);
|
|
186
|
+
assertAsarIntegrityResource(Buffer.from(integrity[0].bin).toString("utf8"), headerHash);
|
|
187
|
+
await (0, asar_1.verifyAppAsar)(asarPath, app);
|
|
188
|
+
}
|
|
189
|
+
function fixedVersion(ms, ls) {
|
|
190
|
+
return [ms >>> 16, ms & 0xffff, ls >>> 16, ls & 0xffff].join(".");
|
|
191
|
+
}
|
|
192
|
+
/** The executable's VERSIONINFO carries the app's identity, not Electron's. */
|
|
193
|
+
function assertWindowsVersionInfo(info, app) {
|
|
194
|
+
const version = windowsVersion(app.versionName);
|
|
195
|
+
const expected = {
|
|
196
|
+
CompanyName: app.appName,
|
|
197
|
+
FileDescription: app.appName,
|
|
198
|
+
FileVersion: version,
|
|
199
|
+
InternalName: app.appName,
|
|
200
|
+
LegalCopyright: app.appName,
|
|
201
|
+
OriginalFilename: windowsExeName(app),
|
|
202
|
+
ProductName: app.appName,
|
|
203
|
+
ProductVersion: version,
|
|
204
|
+
};
|
|
205
|
+
for (const [key, value] of Object.entries(expected)) {
|
|
206
|
+
if (info.strings[key] !== value) {
|
|
207
|
+
throw new Error(`Executable version ${key} is ${JSON.stringify(info.strings[key])}, expected ${JSON.stringify(value)}`);
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
if (info.fileVersion !== version || info.productVersion !== version) {
|
|
211
|
+
throw new Error(`Executable version is ${info.fileVersion}/${info.productVersion}, expected ${version}`);
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
/** Electron's ELECTRONASAR resource: exactly resources\app.asar with its header's SHA-256. */
|
|
215
|
+
function assertAsarIntegrityResource(json, headerHash) {
|
|
216
|
+
let parsed;
|
|
217
|
+
try {
|
|
218
|
+
parsed = JSON.parse(json);
|
|
219
|
+
}
|
|
220
|
+
catch {
|
|
221
|
+
parsed = null;
|
|
222
|
+
}
|
|
223
|
+
const entries = Array.isArray(parsed) ? parsed : [];
|
|
224
|
+
const [entry] = entries;
|
|
225
|
+
if (entries.length !== 1 || entry?.file !== "resources\\app.asar" || entry.alg !== "SHA256" || entry.value !== headerHash) {
|
|
226
|
+
throw new Error("Executable asar integrity does not match resources\\app.asar");
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
/** A PE executable carrying an NSIS installer header. */
|
|
230
|
+
function assertNsisInstaller(installerPath) {
|
|
231
|
+
const head = (0, files_1.readHead)(installerPath, 1024 * 1024);
|
|
232
|
+
const peOffset = head.length >= 0x40 && head.toString("latin1", 0, 2) === "MZ" ? head.readUInt32LE(0x3c) : -1;
|
|
233
|
+
const isPe = peOffset > 0 && peOffset + 4 <= head.length && head.toString("latin1", peOffset, peOffset + 4) === "PE\0\0";
|
|
234
|
+
if (!isPe || !head.includes("NullsoftInst")) {
|
|
235
|
+
throw new Error(`Not an NSIS installer: ${installerPath}`);
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
/** Quote a runtime string for an NSIS script: `$` and `"` are escaped, line breaks are refused. */
|
|
239
|
+
function nsisString(value) {
|
|
240
|
+
if (/[\u0000-\u001f\u007f]/.test(value))
|
|
241
|
+
throw new Error(`Cannot use ${JSON.stringify(value)} in an NSIS script`);
|
|
242
|
+
return `"${value.replace(/\$/g, "$$$$").replace(/"/g, '$\\"')}"`;
|
|
243
|
+
}
|
|
244
|
+
/**
|
|
245
|
+
* Quote a build-host path (File, OutFile). makensis reads these raw (no `$`
|
|
246
|
+
* escapes) but still expands `${define}` and treats `*`/`?` as wildcards, so
|
|
247
|
+
* paths containing those or a quote are refused.
|
|
248
|
+
*/
|
|
249
|
+
function nsisPath(value) {
|
|
250
|
+
if (/[\u0000-\u001f\u007f"*?]|\$\{/.test(value))
|
|
251
|
+
throw new Error(`Cannot use the path ${JSON.stringify(value)} in an NSIS script`);
|
|
252
|
+
return `"${value}"`;
|
|
253
|
+
}
|
|
254
|
+
/**
|
|
255
|
+
* Per-user NSIS installer (no admin rights) into
|
|
256
|
+
* %LOCALAPPDATA%\Programs\<appId>, with a Start menu shortcut and an
|
|
257
|
+
* Apps & features entry keyed by appId. Install and uninstall use the same
|
|
258
|
+
* explicit file list, so the uninstaller never deletes anything it did not
|
|
259
|
+
* install (no recursive RMDir). A reinstall first runs the previous
|
|
260
|
+
* uninstaller, so files from an older version or appName do not linger.
|
|
261
|
+
* App data in %APPDATA%\<appId> is kept.
|
|
262
|
+
*/
|
|
263
|
+
function windowsInstallerScript(options) {
|
|
264
|
+
const { app, appDir, outFile } = options;
|
|
265
|
+
const files = (0, zip_1.listRegularFiles)(appDir);
|
|
266
|
+
if (files.includes(exports.WINDOWS_UNINSTALLER))
|
|
267
|
+
throw new Error(`The packaged app must not contain ${exports.WINDOWS_UNINSTALLER}`);
|
|
268
|
+
const version = windowsVersion(app.versionName);
|
|
269
|
+
const exe = windowsExeName(app);
|
|
270
|
+
const q = nsisString;
|
|
271
|
+
// Escaped for the inside of a quoted NSIS string.
|
|
272
|
+
const inner = (value) => q(value).slice(1, -1);
|
|
273
|
+
const win = (file) => file.split("/").join("\\");
|
|
274
|
+
/** A path under $INSTDIR as an NSIS string. */
|
|
275
|
+
const instdir = (file) => `"$INSTDIR\\${inner(win(file))}"`;
|
|
276
|
+
/** A command line running a file under $INSTDIR, quoted for CreateProcess. */
|
|
277
|
+
const command = (file, args = "") => `"$\\"$INSTDIR\\${inner(win(file))}$\\"${args ? ` ${args}` : ""}"`;
|
|
278
|
+
const uninstallKey = q(`Software\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\${app.appId}`);
|
|
279
|
+
const shortcut = `"$SMPROGRAMS\\${inner(app.appName)}.lnk"`;
|
|
280
|
+
const sizeKb = Math.ceil(files.reduce((total, file) => total + fs_1.default.statSync(path_1.default.join(appDir, ...file.split("/"))).size, 0) / 1024);
|
|
281
|
+
const dirs = [...new Set(files.flatMap((file) => file.split("/").slice(0, -1).map((_, i, parts) => parts.slice(0, i + 1).join("/"))))].sort();
|
|
282
|
+
const installFiles = [];
|
|
283
|
+
for (const dir of ["", ...dirs]) {
|
|
284
|
+
const inDir = files.filter((file) => path_1.default.posix.dirname(file) === (dir || "."));
|
|
285
|
+
if (inDir.length === 0)
|
|
286
|
+
continue;
|
|
287
|
+
installFiles.push(` SetOutPath ${dir ? instdir(dir) : '"$INSTDIR"'}`);
|
|
288
|
+
for (const file of inDir)
|
|
289
|
+
installFiles.push(` File ${nsisPath(path_1.default.join(appDir, ...file.split("/")))}`);
|
|
290
|
+
}
|
|
291
|
+
const ensureClosed = (abort) => [
|
|
292
|
+
` \${If} \${FileExists} ${instdir(exe)}`,
|
|
293
|
+
" ${Do}",
|
|
294
|
+
" ClearErrors",
|
|
295
|
+
` FileOpen $0 ${instdir(exe)} a`,
|
|
296
|
+
" ${IfNot} ${Errors}",
|
|
297
|
+
" FileClose $0",
|
|
298
|
+
" ${Break}",
|
|
299
|
+
" ${EndIf}",
|
|
300
|
+
` MessageBox MB_RETRYCANCEL|MB_ICONEXCLAMATION ${q(`Close ${app.appName}, then click Retry.`)} /SD IDCANCEL IDRETRY +2`,
|
|
301
|
+
` Abort ${q(abort)}`,
|
|
302
|
+
" ${Loop}",
|
|
303
|
+
" ${EndIf}",
|
|
304
|
+
];
|
|
305
|
+
return [
|
|
306
|
+
"; Generated by pakstr. Do not edit.",
|
|
307
|
+
"Unicode true",
|
|
308
|
+
"ManifestDPIAware true",
|
|
309
|
+
"RequestExecutionLevel user",
|
|
310
|
+
"SetCompressor /SOLID lzma",
|
|
311
|
+
`Name ${q(app.appName)}`,
|
|
312
|
+
`OutFile ${nsisPath(outFile)}`,
|
|
313
|
+
`InstallDir "$LOCALAPPDATA\\Programs\\${inner(app.appId)}"`,
|
|
314
|
+
`InstallDirRegKey HKCU ${uninstallKey} "InstallLocation"`,
|
|
315
|
+
`BrandingText ${q(`${app.appName} ${app.versionName}`)}`,
|
|
316
|
+
`VIProductVersion ${q(version)}`,
|
|
317
|
+
`VIFileVersion ${q(version)}`,
|
|
318
|
+
`VIAddVersionKey "ProductName" ${q(app.appName)}`,
|
|
319
|
+
`VIAddVersionKey "CompanyName" ${q(app.appName)}`,
|
|
320
|
+
`VIAddVersionKey "LegalCopyright" ${q(app.appName)}`,
|
|
321
|
+
`VIAddVersionKey "FileDescription" ${q(`${app.appName} Setup`)}`,
|
|
322
|
+
`VIAddVersionKey "FileVersion" ${q(app.versionName)}`,
|
|
323
|
+
`VIAddVersionKey "ProductVersion" ${q(app.versionName)}`,
|
|
324
|
+
"",
|
|
325
|
+
"!include LogicLib.nsh",
|
|
326
|
+
"!include MUI2.nsh",
|
|
327
|
+
"!include x64.nsh",
|
|
328
|
+
"",
|
|
329
|
+
"!define MUI_FINISHPAGE_RUN",
|
|
330
|
+
"!define MUI_FINISHPAGE_RUN_FUNCTION LaunchApp",
|
|
331
|
+
"!insertmacro MUI_PAGE_WELCOME",
|
|
332
|
+
"!insertmacro MUI_PAGE_INSTFILES",
|
|
333
|
+
"!insertmacro MUI_PAGE_FINISH",
|
|
334
|
+
"!insertmacro MUI_UNPAGE_CONFIRM",
|
|
335
|
+
"!insertmacro MUI_UNPAGE_INSTFILES",
|
|
336
|
+
'!insertmacro MUI_LANGUAGE "English"',
|
|
337
|
+
"",
|
|
338
|
+
"Function .onInit",
|
|
339
|
+
" ${IfNot} ${RunningX64}",
|
|
340
|
+
` MessageBox MB_ICONSTOP ${q(`${app.appName} requires 64-bit Windows.`)} /SD IDOK`,
|
|
341
|
+
" Abort",
|
|
342
|
+
" ${EndIf}",
|
|
343
|
+
"FunctionEnd",
|
|
344
|
+
"",
|
|
345
|
+
"Function LaunchApp",
|
|
346
|
+
` Exec ${command(exe)}`,
|
|
347
|
+
"FunctionEnd",
|
|
348
|
+
"",
|
|
349
|
+
'Section "Install"',
|
|
350
|
+
// A running copy keeps its executable locked.
|
|
351
|
+
...ensureClosed(`${app.appName} is running.`),
|
|
352
|
+
// Only an uninstaller this app's registry entry points at is run.
|
|
353
|
+
` ReadRegStr $1 HKCU ${uninstallKey} "InstallLocation"`,
|
|
354
|
+
` \${If} $1 == $INSTDIR`,
|
|
355
|
+
` \${AndIf} \${FileExists} ${instdir(exports.WINDOWS_UNINSTALLER)}`,
|
|
356
|
+
` ExecWait ${command(exports.WINDOWS_UNINSTALLER, "/S _?=$INSTDIR")}`,
|
|
357
|
+
" ${EndIf}",
|
|
358
|
+
...installFiles,
|
|
359
|
+
' SetOutPath "$INSTDIR"',
|
|
360
|
+
` WriteUninstaller ${instdir(exports.WINDOWS_UNINSTALLER)}`,
|
|
361
|
+
` CreateShortcut ${shortcut} ${instdir(exe)}`,
|
|
362
|
+
` WriteRegStr HKCU ${uninstallKey} "DisplayName" ${q(app.appName)}`,
|
|
363
|
+
` WriteRegStr HKCU ${uninstallKey} "DisplayVersion" ${q(app.versionName)}`,
|
|
364
|
+
` WriteRegStr HKCU ${uninstallKey} "DisplayIcon" "$INSTDIR\\${inner(exe)},0"`,
|
|
365
|
+
` WriteRegStr HKCU ${uninstallKey} "InstallLocation" "$INSTDIR"`,
|
|
366
|
+
` WriteRegStr HKCU ${uninstallKey} "UninstallString" ${command(exports.WINDOWS_UNINSTALLER)}`,
|
|
367
|
+
` WriteRegStr HKCU ${uninstallKey} "QuietUninstallString" ${command(exports.WINDOWS_UNINSTALLER, "/S")}`,
|
|
368
|
+
` WriteRegDWORD HKCU ${uninstallKey} "NoModify" 1`,
|
|
369
|
+
` WriteRegDWORD HKCU ${uninstallKey} "NoRepair" 1`,
|
|
370
|
+
` WriteRegDWORD HKCU ${uninstallKey} "EstimatedSize" ${sizeKb}`,
|
|
371
|
+
"SectionEnd",
|
|
372
|
+
"",
|
|
373
|
+
'Section "Uninstall"',
|
|
374
|
+
...ensureClosed(`${app.appName} is running.`),
|
|
375
|
+
` Delete ${shortcut}`,
|
|
376
|
+
...[...files].reverse().map((file) => ` Delete ${instdir(file)}`),
|
|
377
|
+
...[...dirs].reverse().map((dir) => ` RMDir ${instdir(dir)}`),
|
|
378
|
+
` Delete ${instdir(exports.WINDOWS_UNINSTALLER)}`,
|
|
379
|
+
' RMDir "$INSTDIR"',
|
|
380
|
+
` DeleteRegKey HKCU ${uninstallKey}`,
|
|
381
|
+
"SectionEnd",
|
|
382
|
+
"",
|
|
383
|
+
].join("\n");
|
|
384
|
+
}
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.listRegularFiles = listRegularFiles;
|
|
7
|
+
exports.zipDirectory = zipDirectory;
|
|
8
|
+
exports.extractZip = extractZip;
|
|
9
|
+
exports.verifyPortableZip = verifyPortableZip;
|
|
10
|
+
const fs_1 = __importDefault(require("fs"));
|
|
11
|
+
const path_1 = __importDefault(require("path"));
|
|
12
|
+
const stream_1 = require("stream");
|
|
13
|
+
const promises_1 = require("stream/promises");
|
|
14
|
+
const unzipper_1 = __importDefault(require("unzipper"));
|
|
15
|
+
const zlib_1 = __importDefault(require("zlib"));
|
|
16
|
+
// Neither macOS's ditto nor a zip tool exists on every build host, so desktop
|
|
17
|
+
// ZIPs that are not macOS bundles are written here. Plain ZIP only: no ZIP64,
|
|
18
|
+
// so every size and offset must fit in 32 bits (Electron apps are far below).
|
|
19
|
+
const MAX_ZIP32 = 0xffffffff;
|
|
20
|
+
const MAX_ENTRIES = 0xffff;
|
|
21
|
+
const ZIP_VERSION = 20;
|
|
22
|
+
const UTF8_NAMES = 0x0800;
|
|
23
|
+
const DEFLATE = 8;
|
|
24
|
+
// "Version made by" host bytes: MS-DOS keeps extractor defaults, Unix carries file modes.
|
|
25
|
+
const HOST_UNIX = 3;
|
|
26
|
+
const S_IFREG = 0o100000;
|
|
27
|
+
// Fixed 1980-01-01 00:00 timestamps keep the archive reproducible.
|
|
28
|
+
const DOS_TIME = 0;
|
|
29
|
+
const DOS_DATE = (1 << 5) | 1;
|
|
30
|
+
/**
|
|
31
|
+
* Sorted POSIX paths of the regular files under `root`. Symlinks and special
|
|
32
|
+
* files are refused; a directory is implied by the files inside it.
|
|
33
|
+
*/
|
|
34
|
+
function listRegularFiles(root, relative = "") {
|
|
35
|
+
const files = [];
|
|
36
|
+
const entries = fs_1.default.readdirSync(path_1.default.join(root, relative), { withFileTypes: true });
|
|
37
|
+
for (const entry of entries.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0))) {
|
|
38
|
+
const entryRelative = relative ? `${relative}/${entry.name}` : entry.name;
|
|
39
|
+
if (entry.isDirectory()) {
|
|
40
|
+
const nested = listRegularFiles(root, entryRelative);
|
|
41
|
+
if (nested.length === 0)
|
|
42
|
+
throw new Error(`Refusing to package empty directory ${entryRelative}`);
|
|
43
|
+
files.push(...nested);
|
|
44
|
+
}
|
|
45
|
+
else if (entry.isFile()) {
|
|
46
|
+
files.push(entryRelative);
|
|
47
|
+
}
|
|
48
|
+
else {
|
|
49
|
+
throw new Error(`Refusing to package ${entryRelative}: only regular files and directories are allowed`);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
return files;
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Deflate every file under `sourceDir` into a new `zipPath`, inside a
|
|
56
|
+
* top-level `rootName/` folder. With `unixModes`, each entry records its
|
|
57
|
+
* file's permission bits (never setuid, setgid, or sticky) so executables stay
|
|
58
|
+
* executable when extracted on Linux or macOS.
|
|
59
|
+
*/
|
|
60
|
+
async function zipDirectory(sourceDir, zipPath, rootName, options = {}) {
|
|
61
|
+
const files = listRegularFiles(sourceDir);
|
|
62
|
+
if (files.length > MAX_ENTRIES)
|
|
63
|
+
throw new Error(`Too many files for a ZIP archive: ${files.length}`);
|
|
64
|
+
const fd = fs_1.default.openSync(zipPath, "wx");
|
|
65
|
+
let offset = 0;
|
|
66
|
+
const append = (buffer) => {
|
|
67
|
+
fs_1.default.writeSync(fd, buffer, 0, buffer.length, offset);
|
|
68
|
+
offset += buffer.length;
|
|
69
|
+
if (offset > MAX_ZIP32)
|
|
70
|
+
throw new Error("Desktop package is too large for a ZIP archive without ZIP64");
|
|
71
|
+
};
|
|
72
|
+
try {
|
|
73
|
+
const entries = [];
|
|
74
|
+
for (const file of files) {
|
|
75
|
+
const source = path_1.default.join(sourceDir, ...file.split("/"));
|
|
76
|
+
const mode = options.unixModes ? fs_1.default.statSync(source).mode & 0o777 : null;
|
|
77
|
+
const entry = { name: Buffer.from(`${rootName}/${file}`, "utf8"), mode, offset, crc: 0, size: 0, compressedSize: 0 };
|
|
78
|
+
// Written again with the real CRC and sizes once the data is in.
|
|
79
|
+
append(localHeader(entry));
|
|
80
|
+
await (0, promises_1.pipeline)(fs_1.default.createReadStream(source), new stream_1.Transform({
|
|
81
|
+
transform(chunk, _encoding, done) {
|
|
82
|
+
entry.crc = zlib_1.default.crc32(chunk, entry.crc);
|
|
83
|
+
entry.size += chunk.length;
|
|
84
|
+
done(null, chunk);
|
|
85
|
+
},
|
|
86
|
+
}), zlib_1.default.createDeflateRaw(), new stream_1.Writable({
|
|
87
|
+
write(chunk, _encoding, done) {
|
|
88
|
+
try {
|
|
89
|
+
append(chunk);
|
|
90
|
+
entry.compressedSize += chunk.length;
|
|
91
|
+
done();
|
|
92
|
+
}
|
|
93
|
+
catch (error) {
|
|
94
|
+
done(error);
|
|
95
|
+
}
|
|
96
|
+
},
|
|
97
|
+
}));
|
|
98
|
+
if (entry.size > MAX_ZIP32)
|
|
99
|
+
throw new Error(`${file} is too large for a ZIP archive without ZIP64`);
|
|
100
|
+
const header = localHeader(entry);
|
|
101
|
+
fs_1.default.writeSync(fd, header, 0, header.length, entry.offset);
|
|
102
|
+
entries.push(entry);
|
|
103
|
+
}
|
|
104
|
+
const centralOffset = offset;
|
|
105
|
+
for (const entry of entries)
|
|
106
|
+
append(centralHeader(entry));
|
|
107
|
+
append(endOfCentralDirectory(entries.length, offset - centralOffset, centralOffset));
|
|
108
|
+
}
|
|
109
|
+
finally {
|
|
110
|
+
fs_1.default.closeSync(fd);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
function localHeader(entry) {
|
|
114
|
+
const header = Buffer.alloc(30 + entry.name.length);
|
|
115
|
+
header.writeUInt32LE(0x04034b50, 0);
|
|
116
|
+
header.writeUInt16LE(ZIP_VERSION, 4);
|
|
117
|
+
header.writeUInt16LE(UTF8_NAMES, 6);
|
|
118
|
+
header.writeUInt16LE(DEFLATE, 8);
|
|
119
|
+
header.writeUInt16LE(DOS_TIME, 10);
|
|
120
|
+
header.writeUInt16LE(DOS_DATE, 12);
|
|
121
|
+
header.writeUInt32LE(entry.crc, 14);
|
|
122
|
+
header.writeUInt32LE(entry.compressedSize, 18);
|
|
123
|
+
header.writeUInt32LE(entry.size, 22);
|
|
124
|
+
header.writeUInt16LE(entry.name.length, 26);
|
|
125
|
+
entry.name.copy(header, 30);
|
|
126
|
+
return header;
|
|
127
|
+
}
|
|
128
|
+
function centralHeader(entry) {
|
|
129
|
+
const header = Buffer.alloc(46 + entry.name.length);
|
|
130
|
+
header.writeUInt32LE(0x02014b50, 0);
|
|
131
|
+
// Made by MS-DOS (host 0): no Unix modes, so extractors use their defaults.
|
|
132
|
+
header.writeUInt16LE(entry.mode === null ? ZIP_VERSION : (HOST_UNIX << 8) | ZIP_VERSION, 4);
|
|
133
|
+
header.writeUInt16LE(ZIP_VERSION, 6);
|
|
134
|
+
header.writeUInt16LE(UTF8_NAMES, 8);
|
|
135
|
+
header.writeUInt16LE(DEFLATE, 10);
|
|
136
|
+
header.writeUInt16LE(DOS_TIME, 12);
|
|
137
|
+
header.writeUInt16LE(DOS_DATE, 14);
|
|
138
|
+
header.writeUInt32LE(entry.crc, 16);
|
|
139
|
+
header.writeUInt32LE(entry.compressedSize, 20);
|
|
140
|
+
header.writeUInt32LE(entry.size, 24);
|
|
141
|
+
header.writeUInt16LE(entry.name.length, 28);
|
|
142
|
+
if (entry.mode !== null)
|
|
143
|
+
header.writeUInt32LE(((S_IFREG | entry.mode) << 16) >>> 0, 38);
|
|
144
|
+
header.writeUInt32LE(entry.offset, 42);
|
|
145
|
+
entry.name.copy(header, 46);
|
|
146
|
+
return header;
|
|
147
|
+
}
|
|
148
|
+
function endOfCentralDirectory(count, size, offset) {
|
|
149
|
+
const record = Buffer.alloc(22);
|
|
150
|
+
record.writeUInt32LE(0x06054b50, 0);
|
|
151
|
+
record.writeUInt16LE(count, 8);
|
|
152
|
+
record.writeUInt16LE(count, 10);
|
|
153
|
+
record.writeUInt32LE(size, 12);
|
|
154
|
+
record.writeUInt32LE(offset, 16);
|
|
155
|
+
return record;
|
|
156
|
+
}
|
|
157
|
+
/**
|
|
158
|
+
* Extract a ZIP into the empty `targetDir`, checking every entry's CRC-32.
|
|
159
|
+
* Entries must be relative paths without `.`/`..` segments or backslashes.
|
|
160
|
+
* Entries made on Unix get their recorded permission bits; they must be
|
|
161
|
+
* regular files without setuid, setgid, or sticky bits.
|
|
162
|
+
*/
|
|
163
|
+
async function extractZip(zipPath, targetDir) {
|
|
164
|
+
const directory = await unzipper_1.default.Open.file(zipPath);
|
|
165
|
+
const names = [];
|
|
166
|
+
for (const file of directory.files) {
|
|
167
|
+
const segments = file.path.split("/");
|
|
168
|
+
if (file.path.startsWith("/") || file.path.includes("\\") || segments.some((segment) => segment === "" || segment === "." || segment === "..")) {
|
|
169
|
+
throw new Error(`Unsafe ZIP entry: ${JSON.stringify(file.path)}`);
|
|
170
|
+
}
|
|
171
|
+
if (file.type !== "File")
|
|
172
|
+
throw new Error(`Unexpected ZIP entry type ${file.type}: ${file.path}`);
|
|
173
|
+
const data = await file.buffer();
|
|
174
|
+
if (data.length !== file.uncompressedSize || zlib_1.default.crc32(data) !== file.crc32) {
|
|
175
|
+
throw new Error(`ZIP entry is corrupt: ${file.path}`);
|
|
176
|
+
}
|
|
177
|
+
const unixAttributes = file.versionMadeBy >> 8 === HOST_UNIX ? file.externalFileAttributes >>> 16 : null;
|
|
178
|
+
if (unixAttributes !== null && (unixAttributes & 0o177000) !== S_IFREG) {
|
|
179
|
+
throw new Error(`Unexpected ZIP entry mode ${unixAttributes.toString(8)}: ${file.path}`);
|
|
180
|
+
}
|
|
181
|
+
const target = path_1.default.join(targetDir, ...segments);
|
|
182
|
+
fs_1.default.mkdirSync(path_1.default.dirname(target), { recursive: true });
|
|
183
|
+
fs_1.default.writeFileSync(target, data, { flag: "wx" });
|
|
184
|
+
if (unixAttributes !== null)
|
|
185
|
+
fs_1.default.chmodSync(target, unixAttributes & 0o777);
|
|
186
|
+
names.push(file.path);
|
|
187
|
+
}
|
|
188
|
+
return names;
|
|
189
|
+
}
|
|
190
|
+
/**
|
|
191
|
+
* Extract a portable ZIP into the new `extractDir` and check it holds exactly
|
|
192
|
+
* `rootName/<file>` for each expected file. With `ordered`, the entries must
|
|
193
|
+
* also be in that order. Returns the extracted `rootName` folder.
|
|
194
|
+
*/
|
|
195
|
+
async function verifyPortableZip(zipPath, extractDir, rootName, expectedFiles, options = {}) {
|
|
196
|
+
fs_1.default.mkdirSync(extractDir);
|
|
197
|
+
const names = await extractZip(zipPath, extractDir);
|
|
198
|
+
const expected = (options.ordered ? expectedFiles : [...expectedFiles].sort()).map((file) => `${rootName}/${file}`);
|
|
199
|
+
const actual = options.ordered ? names : [...names].sort();
|
|
200
|
+
if (actual.length !== expected.length || actual.some((name, i) => name !== expected[i])) {
|
|
201
|
+
throw new Error(`Portable ZIP does not match the packaged ${rootName} folder`);
|
|
202
|
+
}
|
|
203
|
+
return path_1.default.join(extractDir, rootName);
|
|
204
|
+
}
|