@lakutata/builder 3.0.0-beta.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,366 @@
1
+ import { existsSync, readFileSync } from 'node:fs';
2
+ import { chmod, mkdir, mkdtemp, rm, stat, writeFile } from 'node:fs/promises';
3
+ import { createRequire } from 'node:module';
4
+ import os from 'node:os';
5
+ import path from 'node:path';
6
+ import { bundleProject } from '../Bundle.js';
7
+ import { applicationIcon, icnsFile, icoFile, writeLinuxIcons } from '../Icons.js';
8
+ import { scriptAction } from '../linux/Linux.js';
9
+ import { embeddedShell, encodedShell, INSTALLER_HOOKS, resolveScripts } from '../Scripts.js';
10
+ /**
11
+ * The installers of the Electron applications by default, the ones of electron-builder (its targets) otherwise
12
+ */
13
+ const ELECTRON_TARGETS = { linux: ['deb'], mac: ['dmg'], win: ['nsis'] };
14
+ /**
15
+ * The moments the installers of electron-builder run the scripts of the application at: the packages of fpm (deb, rpm)
16
+ * all of them; the pkg package of macOS the ones of the installation (an application of macOS is removed by its
17
+ * user); the installer of NSIS but the postremove one (its uninstaller ends with the removal of the files)
18
+ * @param platform
19
+ */
20
+ function electronHooks(platform) {
21
+ return (target) => {
22
+ if (platform === 'linux')
23
+ return target === 'deb' || target === 'rpm' ? INSTALLER_HOOKS : [];
24
+ if (platform === 'mac')
25
+ return target === 'pkg' ? ['preinstall', 'postinstall'] : [];
26
+ return target === 'nsis' ? ['preinstall', 'postinstall', 'preremove'] : [];
27
+ };
28
+ }
29
+ /**
30
+ * A configuration of electron-builder over another one: its objects merged, its other values replacing
31
+ * @param base
32
+ * @param over
33
+ */
34
+ function merge(base, over) {
35
+ const merged = { ...base };
36
+ for (const [key, value] of Object.entries(over)) {
37
+ const plain = (item) => !!item && typeof item === 'object' && !Array.isArray(item);
38
+ merged[key] = plain(value) && plain(merged[key]) ? merge(merged[key], value) : value;
39
+ }
40
+ return merged;
41
+ }
42
+ /**
43
+ * The maintainer of the packages of Linux, which fpm needs with its email ("Name <email>")
44
+ * @param maintainer
45
+ * @param name
46
+ * @param warnings
47
+ */
48
+ function packageMaintainer(maintainer, name, warnings) {
49
+ if (maintainer && /<[^>]+@[^>]+>/.test(maintainer))
50
+ return maintainer;
51
+ warnings.push(`The packages of Linux need the email of their maintainer ("Name <email>": the author of the package.json, or lakutata.installer.maintainer): ${name}@localhost is given`);
52
+ return `${maintainer || name} <${name}@localhost>`;
53
+ }
54
+ /**
55
+ * The scripts of the packages of fpm (deb, rpm) running the scripts of the application: the ones run before the
56
+ * installation and the removal (given to fpm), the ones run after them (templates of electron-builder: its own ones, then
57
+ * the ones of the application, encoded)
58
+ * @param target
59
+ * @param scripts
60
+ * @param prefix the directory of the application (/opt/<product>)
61
+ * @param templates the directory of the templates of electron-builder
62
+ * @param directory where they are written
63
+ */
64
+ async function fpmScripts(target, scripts, prefix, templates, directory) {
65
+ const options = {};
66
+ const fpm = [];
67
+ await mkdir(directory, { recursive: true });
68
+ const part = (hook, script) => [scriptAction(target, hook), 'if [ -n "$lakutata_action" ]; then', ...encodedShell(script, prefix, '"$lakutata_action"'), 'fi'];
69
+ for (const hook of INSTALLER_HOOKS) {
70
+ const script = scripts.hooks[hook];
71
+ if (!script)
72
+ continue;
73
+ const file = path.join(directory, `${target}-${hook}`);
74
+ if (hook === 'preinstall' || hook === 'preremove') {
75
+ await writeFile(file, ['#!/bin/sh', 'set -e', ...part(hook, script), ''].join('\n'), { mode: 0o755 });
76
+ fpm.push(hook === 'preinstall' ? '--before-install' : '--before-remove', file);
77
+ }
78
+ else {
79
+ //The template of electron-builder (the link of the program, the sandbox of Chromium…), then the script
80
+ const template = readFileSync(path.join(templates, hook === 'postinstall' ? 'after-install.tpl' : 'after-remove.tpl'), 'utf8');
81
+ await writeFile(file, [template.trimEnd(), '', '# The script of the application', ...part(hook, script), ''].join('\n'), { mode: 0o755 });
82
+ options[hook === 'postinstall' ? 'afterInstall' : 'afterRemove'] = file;
83
+ }
84
+ }
85
+ if (fpm.length)
86
+ options.fpm = fpm;
87
+ return options;
88
+ }
89
+ /**
90
+ * The scripts of the pkg package of macOS (pkgbuild): what the installation does (the application installed replaced by
91
+ * an upgrade), told by the preinstall script to the postinstall one, and the ones of the application; each run once
92
+ * @param scripts
93
+ * @param prefix the application (/Applications/<product>.app)
94
+ * @param directory where they are written
95
+ */
96
+ async function pkgScripts(scripts, prefix, directory) {
97
+ await mkdir(directory, { recursive: true });
98
+ const action = '"$INSTALLER_TEMP/lakutata-action"';
99
+ const quoted = `'${prefix.replace(/'/g, `'\\''`)}'`;
100
+ //electron-builder gives them as the scripts of the package and as the ones of its bundle: run once
101
+ const once = (name) => [`if [ -n "$INSTALLER_TEMP" ]; then [ -e "$INSTALLER_TEMP/lakutata-${name}" ] && exit 0; touch "$INSTALLER_TEMP/lakutata-${name}"; fi`];
102
+ await writeFile(path.join(directory, 'preinstall'), [
103
+ '#!/bin/sh',
104
+ 'set -e',
105
+ ...once('preinstall'),
106
+ `if [ -e ${quoted} ]; then lakutata_action=upgrade; else lakutata_action=install; fi`,
107
+ `if [ -n "$INSTALLER_TEMP" ]; then echo "$lakutata_action" > ${action}; fi`,
108
+ ...scripts.hooks.preinstall ? embeddedShell(scripts.hooks.preinstall, prefix, '"$lakutata_action"') : [],
109
+ ''
110
+ ].join('\n'));
111
+ await chmod(path.join(directory, 'preinstall'), 0o755);
112
+ if (scripts.hooks.postinstall) {
113
+ await writeFile(path.join(directory, 'postinstall'), [
114
+ '#!/bin/sh',
115
+ 'set -e',
116
+ ...once('postinstall'),
117
+ `lakutata_action="$(cat ${action} 2>/dev/null || echo install)"`,
118
+ ...embeddedShell(scripts.hooks.postinstall, prefix, '"$lakutata_action"'),
119
+ ''
120
+ ].join('\n'));
121
+ await chmod(path.join(directory, 'postinstall'), 0o755);
122
+ }
123
+ return directory;
124
+ }
125
+ /**
126
+ * The file of NSIS the installer of electron-builder includes: the one of the application, and its macros running the
127
+ * scripts of the application (from its temporary directory): customInit the preinstall one (an upgrade finds the
128
+ * application installed), customInstall the postinstall one, customUnInstall the preremove one (not the one of an
129
+ * upgrade, which runs the uninstaller of the version it replaces)
130
+ * @param scripts
131
+ * @param directory where it is written, with the scripts (marked as UTF-8 for Windows PowerShell)
132
+ */
133
+ async function nsisInclude(scripts, directory) {
134
+ const hooks = scripts.hooks;
135
+ if (!hooks.preinstall && !hooks.postinstall && !hooks.preremove)
136
+ return scripts.include && path.resolve(scripts.include.file);
137
+ await mkdir(directory, { recursive: true });
138
+ const macros = { customInit: hooks.preinstall || hooks.postinstall ? 'preinstall' : undefined, customInstall: hooks.postinstall && 'postinstall', customUnInstall: hooks.preremove && 'preremove' };
139
+ for (const [macro, hook] of Object.entries(macros)) {
140
+ if (hook && scripts.include && new RegExp(`!macro\\s+${macro}\\b`).test(scripts.include.content))
141
+ throw new Error(`The file of NSIS ${scripts.include.file} defines the macro ${macro}, which runs the ${hook} script of the application: run its code from the script`);
142
+ }
143
+ const run = (hook, action) => {
144
+ const file = path.join(directory, `${hook}.ps1`);
145
+ return [
146
+ ' InitPluginsDir',
147
+ ' System::Call \'Kernel32::SetEnvironmentVariable(t "LAKUTATA_INSTALL_DIR", t "$INSTDIR")i\'',
148
+ ` File "/oname=$PLUGINSDIR\\${hook}.ps1" "${file.replace(/\$/g, '$$$$')}"`,
149
+ ` nsExec::ExecToLog 'powershell.exe -NoProfile -ExecutionPolicy Bypass -File "$PLUGINSDIR\\${hook}.ps1" ${action}'`,
150
+ ' Pop $0',
151
+ ' ${If} $0 != "0"',
152
+ ` Abort "The ${hook} script failed ($0)"`,
153
+ ' ${EndIf}'
154
+ ];
155
+ };
156
+ for (const hook of ['preinstall', 'postinstall', 'preremove']) {
157
+ if (hooks[hook])
158
+ await writeFile(path.join(directory, `${hook}.ps1`), `\ufeff${hooks[hook].content}`);
159
+ }
160
+ const lines = [
161
+ ...scripts.include ? [`!include "${scripts.include.file}"`, ''] : [],
162
+ '; What the installation does: an upgrade replaces the application installed (the uninstaller, made apart, has none)',
163
+ '!ifndef BUILD_UNINSTALLER',
164
+ ' Var /GLOBAL lakutataAction',
165
+ '!endif',
166
+ '!macro customInit',
167
+ ' StrCpy $lakutataAction "install"',
168
+ ' ${If} ${FileExists} "$INSTDIR\\${APP_EXECUTABLE_FILENAME}"',
169
+ ' StrCpy $lakutataAction "upgrade"',
170
+ ' ${EndIf}',
171
+ ...hooks.preinstall ? run('preinstall', '$lakutataAction') : [],
172
+ '!macroend',
173
+ ...hooks.postinstall ? ['', '!macro customInstall', ...run('postinstall', '$lakutataAction'), '!macroend'] : [],
174
+ ...hooks.preremove ? ['', '!macro customUnInstall', ' ${IfNot} ${isUpdated}', ...run('preremove', 'remove').map((line) => ` ${line}`), ' ${EndIf}', '!macroend'] : [],
175
+ ''
176
+ ];
177
+ const file = path.join(directory, 'installer.nsh');
178
+ await writeFile(file, lines.join('\r\n'));
179
+ return file;
180
+ }
181
+ /**
182
+ * Make the installers of an Electron application (lakutata.installer.runtime: electron) for the platform it is made on,
183
+ * by electron-builder: its main process bundled with its dependencies (lakutata package bundle), Electron left out; its native
184
+ * addons and its resources (lakutata.resources: its renderer, its preload script) next to its archive (app.asar), in the
185
+ * resources of Electron. The configuration of electron-builder is made from lakutata.installer, lakutata.installer.electron
186
+ * merged over it; the scripts of the application are run by its installers
187
+ * @param project
188
+ * @param manifest
189
+ * @param config
190
+ * @param options
191
+ */
192
+ export async function buildElectronInstaller(project, manifest, config, options) {
193
+ if (config.service)
194
+ throw new Error('An Electron application is not a service (lakutata.installer.service): it runs in the session of its user');
195
+ const platform = process.platform === 'darwin' ? 'mac' : process.platform === 'win32' ? 'win' : 'linux';
196
+ const warnings = [];
197
+ const targets = options.targets?.length ? options.targets : config[platform]?.targets ?? ELECTRON_TARGETS[platform];
198
+ const nodeRequire = createRequire(path.join(project, 'package.json'));
199
+ let builder;
200
+ try {
201
+ builder = nodeRequire.resolve('electron-builder');
202
+ }
203
+ catch {
204
+ throw new Error('The installers of the Electron applications are made by electron-builder: install it (npm install --save-dev electron-builder)');
205
+ }
206
+ const electronBuilder = createRequire(builder)('electron-builder');
207
+ const templates = path.join(path.dirname(createRequire(builder).resolve('app-builder-lib/package.json')), 'templates', 'linux');
208
+ const { sanitizeFileName } = createRequire(builder)('builder-util/out/filename');
209
+ let electron;
210
+ try {
211
+ electron = path.dirname(nodeRequire.resolve('electron/package.json'));
212
+ }
213
+ catch {
214
+ throw new Error('The Electron application has no electron package: install it (npm install --save-dev electron)');
215
+ }
216
+ const version = String(manifest.version ?? '');
217
+ if (!/^\d+\.\d+\.\d+/.test(version))
218
+ throw new Error(`The version of the application "${version}" must be a version (1.2.3) of the package.json`);
219
+ const name = config.name ?? String(manifest.name ?? 'app').replace(/^@[^/]+\//, '').toLowerCase();
220
+ const productName = config.productName ?? (typeof manifest.productName === 'string' ? manifest.productName : name);
221
+ const appId = config.appId ?? name;
222
+ const description = config.description ?? manifest.description ?? productName;
223
+ const author = config.maintainer ?? (typeof manifest.author === 'string' ? manifest.author : manifest.author?.name && (manifest.author.email ? `${manifest.author.name} <${manifest.author.email}>` : manifest.author.name));
224
+ //Where the installers put the application
225
+ const prefix = platform === 'linux' ? `/opt/${sanitizeFileName(productName)}` : platform === 'mac' ? `/Applications/${productName}.app` : '';
226
+ const scripts = resolveScripts(project, platform, config[platform], targets, warnings, electronHooks(platform), false);
227
+ const work = await mkdtemp(path.join(os.tmpdir(), 'lakutata-electron-'));
228
+ try {
229
+ //The application of electron-builder: its main process, its package.json; its native addons and its resources next to it
230
+ const application = path.join(work, 'app');
231
+ const bundle = await bundleProject({
232
+ project: project,
233
+ entry: options.entry,
234
+ minify: options.minify,
235
+ external: ['electron', ...options.external ?? []],
236
+ outfile: path.join(application, 'main.cjs'),
237
+ directories: { native: path.join(work, 'main.native'), resources: path.join(work, 'main.resources') }
238
+ });
239
+ const maintainer = platform === 'linux' ? packageMaintainer(author, name, warnings) : author ?? name;
240
+ //The packages of fpm need a homepage
241
+ let homepage = config.homepage ?? (typeof manifest.homepage === 'string' ? manifest.homepage : undefined);
242
+ if (!homepage && platform === 'linux') {
243
+ warnings.push('The packages of Linux need the homepage of the application (the homepage of the package.json, or lakutata.installer.homepage): NONE is given');
244
+ homepage = 'NONE';
245
+ }
246
+ await writeFile(path.join(application, 'package.json'), `${JSON.stringify({
247
+ name: name,
248
+ productName: productName,
249
+ version: version,
250
+ description: description,
251
+ author: maintainer,
252
+ homepage: homepage,
253
+ license: config.license ?? manifest.license,
254
+ //The name of the windows on Linux, which associates them with the launcher of the application
255
+ desktopName: `${name}.desktop`,
256
+ main: 'main.cjs'
257
+ }, null, 2)}\n`);
258
+ await writeFile(path.join(work, 'package.json'), `${JSON.stringify({ name: name, version: version, private: true }, null, 2)}\n`);
259
+ const outdir = path.resolve(project, options.outdir ?? path.join('dist', 'installer'));
260
+ const own = {};
261
+ for (const target of targets) {
262
+ const resolved = scripts[target];
263
+ if (!resolved)
264
+ continue;
265
+ if (platform === 'linux' && (target === 'deb' || target === 'rpm') && Object.keys(resolved.hooks).length)
266
+ own[target] = await fpmScripts(target, resolved, prefix, templates, path.join(work, 'scripts'));
267
+ if (platform === 'mac' && target === 'pkg' && Object.keys(resolved.hooks).length)
268
+ own.pkg = { scripts: await pkgScripts(resolved, prefix, path.join(work, 'pkg-scripts')) };
269
+ if (platform === 'win' && target === 'nsis') {
270
+ const include = await nsisInclude({ ...resolved, include: resolved.include && { ...resolved.include, file: path.resolve(project, resolved.include.file) } }, path.join(work, 'nsis'));
271
+ if (include)
272
+ own.nsis = { include: include };
273
+ }
274
+ }
275
+ //The icon of the application, in the formats of the platform: icns (macOS), ico (Windows), the PNG of each size (Linux)
276
+ const appIcon = applicationIcon(project, manifest);
277
+ const icons = {};
278
+ if (appIcon) {
279
+ const directory = path.join(work, 'icons');
280
+ await mkdir(directory, { recursive: true });
281
+ if (platform === 'mac') {
282
+ if (appIcon.image.width < 1024)
283
+ warnings.push(`The icon of the application ${path.relative(project, appIcon.file)} is ${appIcon.image.width} pixels: 1024 make the icons of the Retina screens of macOS`);
284
+ await writeFile(path.join(directory, 'icon.icns'), icnsFile(appIcon.image));
285
+ icons.mac = { icon: path.join(directory, 'icon.icns') };
286
+ }
287
+ else if (platform === 'win') {
288
+ await writeFile(path.join(directory, 'icon.ico'), icoFile(appIcon.image));
289
+ icons.win = { icon: path.join(directory, 'icon.ico') };
290
+ }
291
+ else {
292
+ await writeLinuxIcons(appIcon.image, path.join(directory, 'linux'));
293
+ icons.linux = { icon: path.join(directory, 'linux') };
294
+ }
295
+ }
296
+ const electronDist = path.join(electron, 'dist');
297
+ const generated = {
298
+ appId: appId,
299
+ productName: productName,
300
+ electronVersion: JSON.parse(readFileSync(path.join(electron, 'package.json'), 'utf8')).version,
301
+ //The Electron of the project (the one of the platform): not downloaded again
302
+ ...existsSync(electronDist) ? { electronDist: electronDist } : {},
303
+ directories: { app: 'app', output: outdir },
304
+ files: ['main.cjs', 'package.json'],
305
+ extraResources: [
306
+ ...bundle.nativeDirectory ? [{ from: bundle.nativeDirectory, to: path.basename(bundle.nativeDirectory) }] : [],
307
+ ...bundle.resourcesDirectory ? [{ from: bundle.resourcesDirectory, to: path.basename(bundle.resourcesDirectory) }] : []
308
+ ],
309
+ //The dependencies are bundled
310
+ npmRebuild: false,
311
+ ...platform === 'linux' ? { linux: { maintainer: maintainer, ...config.vendor ? { vendor: config.vendor } : {} } } : {},
312
+ ...own
313
+ };
314
+ //Their own configurations (the one of Linux has its maintainer)
315
+ for (const [key, value] of Object.entries(icons))
316
+ generated[key] = { ...generated[key], ...value };
317
+ const electronConfig = config.electron ?? {};
318
+ if (!electronConfig || typeof electronConfig !== 'object' || Array.isArray(electronConfig))
319
+ throw new Error('The configuration of electron-builder (lakutata.installer.electron) must be an object');
320
+ for (const target of Object.keys(own)) {
321
+ for (const option of ['afterInstall', 'afterRemove', 'fpm', 'include', 'scripts']) {
322
+ if (electronConfig[target]?.[option] !== undefined && own[target][option] !== undefined)
323
+ throw new Error(`lakutata.installer.electron.${target}.${option} replaces what runs the scripts of the application: give the script to lakutata.installer.${platform}.scripts`);
324
+ }
325
+ }
326
+ const merged = merge(generated, electronConfig);
327
+ //The 7-Zip of electron-builder filters the executables of ARM64 by its ARM64 filter, which the installers of NSIS
328
+ //do not extract (they leave them out): the BCJ filter is the one they extract
329
+ const filter = platform === 'win' && process.arch === 'arm64' && process.env.ELECTRON_BUILDER_7Z_FILTER === undefined;
330
+ if (filter)
331
+ process.env.ELECTRON_BUILDER_7Z_FILTER = 'BCJ';
332
+ let artifacts;
333
+ try {
334
+ artifacts = await electronBuilder.build({
335
+ projectDir: work,
336
+ targets: electronBuilder.Platform.current().createTarget(targets, electronBuilder.Arch[process.arch]),
337
+ config: merged,
338
+ publish: 'never'
339
+ });
340
+ }
341
+ finally {
342
+ if (filter)
343
+ delete process.env.ELECTRON_BUILDER_7Z_FILTER;
344
+ }
345
+ const files = [];
346
+ for (const file of artifacts.filter((artifact) => !/\.(blockmap|yml)$/.test(artifact))) {
347
+ const target = targets.find((candidate) => file.toLowerCase().endsWith(`.${candidate.toLowerCase()}`)) ?? path.extname(file).slice(1);
348
+ files.push({ target: target, file: file, size: (await stat(file)).size });
349
+ }
350
+ const electronVersion = merged.electronVersion;
351
+ const executable = {
352
+ name: productName,
353
+ file: application,
354
+ platform: `${process.platform}-${process.arch}`,
355
+ size: bundle.size,
356
+ native: bundle.native?.modules ?? [],
357
+ resources: bundle.resources,
358
+ runtime: `Electron v${electronVersion}`,
359
+ warnings: []
360
+ };
361
+ return { name: name, version: version, files: files, executable: executable, warnings: warnings };
362
+ }
363
+ finally {
364
+ await rm(work, { recursive: true, force: true });
365
+ }
366
+ }