cross-packer 0.1.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.
Files changed (126) hide show
  1. package/LICENSE +661 -0
  2. package/README.md +151 -0
  3. package/README.zh-CN.md +151 -0
  4. package/dist/archive/ar.d.ts +12 -0
  5. package/dist/archive/seven-za.d.ts +32 -0
  6. package/dist/archive/tar.d.ts +30 -0
  7. package/dist/archive/zip-reader.d.ts +17 -0
  8. package/dist/asar/dependency/collect.d.ts +2 -0
  9. package/dist/asar/dependency/detector.d.ts +8 -0
  10. package/dist/asar/dependency/hoister.d.ts +27 -0
  11. package/dist/asar/dependency/utils.d.ts +59 -0
  12. package/dist/asar/fileset/matcher.d.ts +12 -0
  13. package/dist/asar/fileset/platform-fileset.d.ts +36 -0
  14. package/dist/asar/fileset/transformer.d.ts +2 -0
  15. package/dist/asar/fileset/unpack-detector.d.ts +10 -0
  16. package/dist/asar/fileset/walker.d.ts +14 -0
  17. package/dist/asar/pack.d.ts +23 -0
  18. package/dist/asar/packer/app-collector.d.ts +2 -0
  19. package/dist/asar/packer/collector.d.ts +18 -0
  20. package/dist/asar/packer/concurrency.d.ts +1 -0
  21. package/dist/asar/packer/integrity.d.ts +8 -0
  22. package/dist/asar/packer/nm-collector.d.ts +7 -0
  23. package/dist/asar/packer/stream-builder.d.ts +18 -0
  24. package/dist/cli.d.ts +2 -0
  25. package/dist/config/index.d.ts +97 -0
  26. package/dist/electron/dist-zip.d.ts +18 -0
  27. package/dist/electron/download.d.ts +21 -0
  28. package/dist/index.d.ts +11 -0
  29. package/dist/packer/common/app-update-yml.d.ts +5 -0
  30. package/dist/packer/common/extra-resources.d.ts +39 -0
  31. package/dist/packer/common/staging.d.ts +3 -0
  32. package/dist/packer/common/template.d.ts +2 -0
  33. package/dist/packer/linux/linux.d.ts +2 -0
  34. package/dist/packer/mac/mac.d.ts +23 -0
  35. package/dist/packer/win/nsis.d.ts +24 -0
  36. package/dist/packer/win/resedit.d.ts +5 -0
  37. package/dist/packer/win/toolchain.d.ts +8 -0
  38. package/dist/packer/win/win.d.ts +12 -0
  39. package/dist/pipeline/pipeline.d.ts +41 -0
  40. package/dist/pipeline/targets.d.ts +28 -0
  41. package/dist/shared/hash.d.ts +2 -0
  42. package/dist/shared/logger.d.ts +39 -0
  43. package/dist/shared/platform.d.ts +26 -0
  44. package/dist/shared/types.d.ts +68 -0
  45. package/dist/update/blockmap.d.ts +13 -0
  46. package/dist/update/metadata.d.ts +20 -0
  47. package/dist/update/version.d.ts +7 -0
  48. package/package.json +71 -0
  49. package/src/archive/ar.ts +41 -0
  50. package/src/archive/seven-za.ts +160 -0
  51. package/src/archive/tar.ts +142 -0
  52. package/src/archive/zip-reader.ts +61 -0
  53. package/src/asar/dependency/collect.ts +437 -0
  54. package/src/asar/dependency/detector.ts +73 -0
  55. package/src/asar/dependency/hoister.ts +714 -0
  56. package/src/asar/dependency/utils.ts +225 -0
  57. package/src/asar/fileset/matcher.ts +101 -0
  58. package/src/asar/fileset/platform-fileset.ts +416 -0
  59. package/src/asar/fileset/transformer.ts +154 -0
  60. package/src/asar/fileset/unpack-detector.ts +38 -0
  61. package/src/asar/fileset/walker.ts +115 -0
  62. package/src/asar/pack.ts +186 -0
  63. package/src/asar/packer/app-collector.ts +74 -0
  64. package/src/asar/packer/collector.ts +134 -0
  65. package/src/asar/packer/concurrency.ts +24 -0
  66. package/src/asar/packer/integrity.ts +53 -0
  67. package/src/asar/packer/nm-collector.ts +194 -0
  68. package/src/asar/packer/stream-builder.ts +723 -0
  69. package/src/cli.ts +182 -0
  70. package/src/config/index.ts +365 -0
  71. package/src/electron/dist-zip.ts +65 -0
  72. package/src/electron/download.ts +121 -0
  73. package/src/index.ts +14 -0
  74. package/src/packer/common/app-update-yml.ts +21 -0
  75. package/src/packer/common/extra-resources.ts +83 -0
  76. package/src/packer/common/staging.ts +33 -0
  77. package/src/packer/common/template.ts +24 -0
  78. package/src/packer/linux/linux.ts +329 -0
  79. package/src/packer/mac/mac.ts +495 -0
  80. package/src/packer/win/nsis.ts +244 -0
  81. package/src/packer/win/resedit.ts +60 -0
  82. package/src/packer/win/toolchain.ts +63 -0
  83. package/src/packer/win/win.ts +421 -0
  84. package/src/pipeline/pipeline.ts +149 -0
  85. package/src/pipeline/targets.ts +67 -0
  86. package/src/shared/hash.ts +15 -0
  87. package/src/shared/logger.ts +91 -0
  88. package/src/shared/platform.ts +41 -0
  89. package/src/shared/types.ts +82 -0
  90. package/src/update/blockmap.ts +247 -0
  91. package/src/update/metadata.ts +97 -0
  92. package/src/update/version.ts +59 -0
  93. package/templates/linux/app.desktop +11 -0
  94. package/templates/linux/control +9 -0
  95. package/templates/linux/postinst +10 -0
  96. package/templates/linux/postrm +8 -0
  97. package/templates/linux/preinst +49 -0
  98. package/templates/linux/prerm +43 -0
  99. package/templates/mac/Info.plist +18 -0
  100. package/templates/mac/app-update.yml +4 -0
  101. package/templates/win/nsis/README.md +51 -0
  102. package/templates/win/nsis/THIRD-PARTY-NOTICES.md +20 -0
  103. package/templates/win/nsis/assistedInstaller.nsh +154 -0
  104. package/templates/win/nsis/assistedMessages.yml +368 -0
  105. package/templates/win/nsis/common.nsh +147 -0
  106. package/templates/win/nsis/empty-license.txt +1 -0
  107. package/templates/win/nsis/include/FileAssociation.nsh +123 -0
  108. package/templates/win/nsis/include/StdUtils.nsh +496 -0
  109. package/templates/win/nsis/include/StrContains.nsh +48 -0
  110. package/templates/win/nsis/include/UAC.nsh +299 -0
  111. package/templates/win/nsis/include/allowOnlyOneInstallerInstance.nsh +164 -0
  112. package/templates/win/nsis/include/extractAppPackage.nsh +138 -0
  113. package/templates/win/nsis/include/getProcessInfo.nsh +157 -0
  114. package/templates/win/nsis/include/installUtil.nsh +248 -0
  115. package/templates/win/nsis/include/installer.nsh +247 -0
  116. package/templates/win/nsis/include/nsProcess.nsh +28 -0
  117. package/templates/win/nsis/include/webPackage.nsh +64 -0
  118. package/templates/win/nsis/installSection.nsh +110 -0
  119. package/templates/win/nsis/installer.nsi +132 -0
  120. package/templates/win/nsis/messages.yml +240 -0
  121. package/templates/win/nsis/multiUser.nsh +134 -0
  122. package/templates/win/nsis/multiUserUi.nsh +230 -0
  123. package/templates/win/nsis/oneClick.nsh +19 -0
  124. package/templates/win/nsis/portable.nsi +91 -0
  125. package/templates/win/nsis/uninstaller.nsh +263 -0
  126. package/templates/win/nsis-header.nsh +57 -0
@@ -0,0 +1,421 @@
1
+ /**
2
+ * Windows NSIS packer.
3
+ * The NSIS build runs in two phases: phase 1 emits the uninstaller, and
4
+ * phase 2 emits the installer.
5
+ */
6
+ import { spawnSync } from 'node:child_process'
7
+ import crypto from 'node:crypto'
8
+ import { existsSync } from 'node:fs'
9
+ import { cp, mkdir, readFile, rm, stat, writeFile } from 'node:fs/promises'
10
+ import path from 'node:path'
11
+ import { compress, computeEstimatedSize, get7zaPath } from '../../archive/seven-za.ts'
12
+ import { getEntryBuffer, isDefaultAppEntry, type ZipEntry } from '../../archive/zip-reader.ts'
13
+ import type { NormalizedConfig } from '../../config/index.ts'
14
+ import { downloadAndReadElectron } from '../../electron/dist-zip.ts'
15
+ import type { PackerContext } from '../../pipeline/targets.ts'
16
+ import { hashFile } from '../../shared/hash.ts'
17
+ import { logger } from '../../shared/logger.ts'
18
+ import { Arch, NodePlatform } from '../../shared/platform.ts'
19
+ import { buildBlockMap } from '../../update/blockmap.ts'
20
+ import { generateUpdateMetadata } from '../../update/metadata.ts'
21
+ import { getBuildVersion } from '../../update/version.ts'
22
+ import { getUpdaterCacheDirName, renderAppUpdateYml } from '../common/app-update-yml.ts'
23
+ import { isExtraResourcePathExcluded, resolveExtraResources } from '../common/extra-resources.ts'
24
+ import { createStagingDir, removeStagingDir } from '../common/staging.ts'
25
+ import {
26
+ buildNsisScriptHeader,
27
+ executeMakensis,
28
+ getNsisTemplatesDir,
29
+ prepareNsisToolchain,
30
+ } from './nsis.ts'
31
+ import { editExeResources } from './resedit.ts'
32
+
33
+ export async function packWin({
34
+ config,
35
+ arch,
36
+ outputDir,
37
+ asarPath,
38
+ unpackedDir,
39
+ }: PackerContext): Promise<string> {
40
+ if (asarPath === undefined) throw new Error('packWin: asarPath is required')
41
+ logger.info(`\n[Windows] ${arch} packaging started`)
42
+
43
+ const stagingDir = await createStagingDir(outputDir, 'win', arch)
44
+
45
+ try {
46
+ logger.info(' [1/11] Downloading and reading Windows Electron dist ...')
47
+ const { entries } = await downloadAndReadElectron(NodePlatform.WIN32, arch, config)
48
+
49
+ logger.info(' [2/11] Assembling app directory ...')
50
+ const appDir = path.join(stagingDir, 'app')
51
+ await mkdir(appDir, { recursive: true })
52
+ await assembleAppDir({ entries, appDir, asarPath, unpackedDir, config, arch })
53
+
54
+ logger.info(' [3/11] Setting exe icon and version info ...')
55
+ const productExePath = path.join(appDir, `${config.productName}.exe`)
56
+ const iconSrc = config.win.iconFile ? path.join(config.projectDir, config.win.iconFile) : null
57
+ const iconPath = iconSrc && existsSync(iconSrc) ? iconSrc : null
58
+ await editExeResources(productExePath, { iconPath, config })
59
+
60
+ if (config.sign.enabled && config.sign.win?.enabled && config.sign.win?.hook) {
61
+ logger.info(' [4/11] Running sign hook on app directory ...')
62
+ await config.sign.win.hook(appDir, { config, arch, outputDir })
63
+ } else {
64
+ logger.info(' [4/11] Signing disabled, skipping app directory binaries')
65
+ }
66
+
67
+ logger.info(' [5/11] Creating 7z archive ...')
68
+ const sevenZipPath = get7zaPath()
69
+ const archiveFile = path.join(stagingDir, `app-${arch}.7z`)
70
+ const compression = compress({
71
+ sevenZipPath,
72
+ src: appDir,
73
+ dest: archiveFile,
74
+ format: '7z',
75
+ level: config.win.compressionLevel,
76
+ })
77
+ logger.info(
78
+ ` [5/11] app-${arch}.7z ready (mx=${compression.level}, mmt=${compression.threads})`,
79
+ )
80
+
81
+ logger.info(' [6/11] Preparing NSIS toolchain ...')
82
+ const { nsisDir, nsisResourcesDir } = await prepareNsisToolchain(config.projectDir, config.win)
83
+
84
+ logger.info(' [7/11] Building NSIS installer script ...')
85
+ const exeFilename = buildWindowsInstallerFilename(config)
86
+ const exePath = path.join(outputDir, exeFilename)
87
+
88
+ const nsisTemplatesDir = getNsisTemplatesDir()
89
+ const pluginArch = 'x86-unicode'
90
+ const nsisPluginsDir = path.join(nsisResourcesDir, 'plugins', pluginArch)
91
+ const nsisIncludeDir = path.join(nsisTemplatesDir, 'include')
92
+
93
+ const guid = generateGuid(config.appId)
94
+ const uninstallAppKey = guid.replace(/\\/g, ' - ')
95
+ const identity = resolveWindowsPackageIdentity(config)
96
+ const appPackageName = config.name.replace(/\//g, '\\')
97
+ const updaterCacheDirName = identity.updaterCacheDirName
98
+
99
+ const installerNsiPath = path.join(nsisTemplatesDir, 'installer.nsi')
100
+ const installerNsiScript = await readFile(installerNsiPath, 'utf8')
101
+ logger.info(' [8/11] Generating uninstaller ...')
102
+
103
+ const uninstallerBuilderExe = path.join(stagingDir, `__uninstaller_builder_${Date.now()}.exe`)
104
+ const uninstallerExePath = path.join(stagingDir, `${exeFilename}__uninstaller.exe`)
105
+
106
+ const definesUninstaller: Record<string, string | null> = {
107
+ APP_ID: config.appId,
108
+ APP_GUID: guid,
109
+ UNINSTALL_APP_KEY: uninstallAppKey,
110
+ PRODUCT_NAME: config.productName,
111
+ PRODUCT_FILENAME: identity.productFilename,
112
+ APP_FILENAME: identity.appFilename,
113
+ APP_DESCRIPTION: smarten(config.description || ''),
114
+ APP_PACKAGE_NAME: appPackageName,
115
+ VERSION: config.version,
116
+ PROJECT_DIR: config.projectDir,
117
+ BUILD_RESOURCES_DIR: path.join(config.projectDir, 'build'),
118
+ COMPRESSION_METHOD: '7z',
119
+ COMPRESS: 'auto',
120
+ ONE_CLICK: null,
121
+ RUN_AFTER_FINISH: null,
122
+ BUILD_UNINSTALLER: null,
123
+ UNINSTALLER_OUT_FILE: uninstallerExePath,
124
+ SHORTCUT_NAME: identity.shortcutName,
125
+ UNINSTALL_DISPLAY_NAME: `${config.productName} ${config.version}`,
126
+ APP_INSTALLER_STORE_FILE: `${updaterCacheDirName}\\installer.exe`,
127
+ ...(identity.appFilename !== identity.productFilename
128
+ ? { APP_PRODUCT_FILENAME: identity.productFilename }
129
+ : {}),
130
+ }
131
+ if (config.author) {
132
+ definesUninstaller.COMPANY_NAME = config.author
133
+ }
134
+ if (uninstallAppKey !== guid) {
135
+ definesUninstaller.UNINSTALL_REGISTRY_KEY_2 = `Software\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\${guid}`
136
+ }
137
+ const archDefineKey = arch === Arch.ARM64 ? 'APP_ARM64' : 'APP_64'
138
+ definesUninstaller[archDefineKey] = archiveFile
139
+
140
+ const commandsUninstaller: Record<string, string | boolean | string[]> = {
141
+ OutFile: `"${uninstallerBuilderExe}"`,
142
+ VIProductVersion: getBuildVersion(config.version, config.buildNumber),
143
+ VIAddVersionKey: computeVersionKey(config),
144
+ Unicode: true,
145
+ SetCompressor: 'zlib',
146
+ }
147
+ if (iconPath) {
148
+ definesUninstaller.MUI_ICON = iconPath
149
+ definesUninstaller.MUI_UNICON = iconPath
150
+ }
151
+ const headerUninstaller = await buildNsisScriptHeader({
152
+ nsisIncludeDir,
153
+ nsisPluginsDir,
154
+ pluginArch,
155
+ nsisTemplatesDir,
156
+ stagingDir,
157
+ })
158
+ await executeMakensis({
159
+ nsisDir,
160
+ defines: definesUninstaller,
161
+ commands: commandsUninstaller,
162
+ script: headerUninstaller + installerNsiScript,
163
+ cwd: nsisTemplatesDir,
164
+ })
165
+
166
+ if (!existsSync(uninstallerBuilderExe)) {
167
+ throw new Error(`Phase 1 installer not found: ${uninstallerBuilderExe}`)
168
+ }
169
+ const runResult = spawnSync(uninstallerBuilderExe, [], {
170
+ stdio: 'pipe',
171
+ windowsHide: true,
172
+ env: { __COMPAT_LAYER: 'RunAsInvoker' },
173
+ timeout: 30000,
174
+ })
175
+ if (runResult.status !== 0) {
176
+ console.warn(` Warning: Phase 1 installer exited with code ${runResult.status}`)
177
+ }
178
+ if (!existsSync(uninstallerExePath)) {
179
+ throw new Error(
180
+ `Uninstaller not generated after running Phase 1 installer. Expected: ${uninstallerExePath}`,
181
+ )
182
+ }
183
+
184
+ if (existsSync(uninstallerBuilderExe)) {
185
+ await rm(uninstallerBuilderExe, { force: true })
186
+ }
187
+
188
+ if (config.sign.enabled && config.sign.win?.enabled && config.sign.win?.hook) {
189
+ logger.info(' [9/11] Running sign hook on uninstaller ...')
190
+ await config.sign.win.hook(uninstallerExePath, { config, arch, outputDir, singleFile: true })
191
+ }
192
+
193
+ logger.info(' [10/11] Compiling NSIS installer ...')
194
+
195
+ const { BUILD_UNINSTALLER: _omit, ...definesRest } = definesUninstaller
196
+ const defines: Record<string, string | null> = definesRest
197
+ defines.UNINSTALLER_OUT_FILE = uninstallerExePath
198
+
199
+ const commands = { ...commandsUninstaller }
200
+ commands.OutFile = `"${exePath}"`
201
+
202
+ const archiveHashHex = hashFile(archiveFile, 'sha512', 'hex').toUpperCase()
203
+ defines[`${archDefineKey}_NAME`] = path.basename(archiveFile)
204
+ defines[`${archDefineKey}_HASH`] = archiveHashHex
205
+
206
+ const archiveStat = await stat(archiveFile)
207
+ const estimatedUnpackedSize = Math.ceil((archiveStat.size * 3) / 1024).toString()
208
+ defines[`${archDefineKey}_UNPACKED_SIZE`] = estimatedUnpackedSize
209
+
210
+ const estimatedSize = computeEstimatedSize(sevenZipPath, archiveFile)
211
+ if (estimatedSize > 0) {
212
+ defines.ESTIMATED_SIZE = String(Math.round(estimatedSize / 1024))
213
+ }
214
+
215
+ const headerInstaller = await buildNsisScriptHeader({
216
+ nsisIncludeDir,
217
+ nsisPluginsDir,
218
+ pluginArch,
219
+ nsisTemplatesDir,
220
+ stagingDir,
221
+ })
222
+ await executeMakensis({
223
+ nsisDir,
224
+ defines,
225
+ commands,
226
+ script: headerInstaller + installerNsiScript,
227
+ cwd: nsisTemplatesDir,
228
+ })
229
+
230
+ if (existsSync(uninstallerExePath)) {
231
+ await rm(uninstallerExePath, { force: true })
232
+ }
233
+
234
+ if (config.sign.enabled && config.sign.win?.enabled && config.sign.win?.hook) {
235
+ logger.info(' [11/11] Running sign hook on installer ...')
236
+ await config.sign.win.hook(exePath, { config, arch, outputDir, singleFile: true })
237
+ } else {
238
+ logger.info(' [11/11] Signing disabled')
239
+ }
240
+
241
+ const blockmapPath = `${exePath}.blockmap`
242
+ const blockMapSize = (await buildBlockMap(exePath, 'gzip', blockmapPath)).blockMapSize
243
+ logger.info(` [Windows] ${arch} blockmap ready: ${blockmapPath}`)
244
+
245
+ if (config.update.generateMetadata) {
246
+ generateUpdateMetadata({
247
+ filePath: exePath,
248
+ version: config.version,
249
+ outputDir,
250
+ platform: NodePlatform.WIN32,
251
+ blockMapSize,
252
+ channel: config.update.channel,
253
+ })
254
+ }
255
+
256
+ logger.info(` [Windows] ${arch} packaging complete: ${exePath}`)
257
+ return exePath
258
+ } finally {
259
+ await removeStagingDir(stagingDir)
260
+ }
261
+ }
262
+
263
+ /** Derive the installer .exe filename from the distribution naming contract. */
264
+ export function buildWindowsInstallerFilename(config: NormalizedConfig): string {
265
+ return `${config.artifactName}_${config.version}.exe`
266
+ }
267
+
268
+ /** Derive the Windows package identity (install dir, shortcut, updater cache). */
269
+ export function resolveWindowsPackageIdentity(config: NormalizedConfig): {
270
+ appFilename: string
271
+ productFilename: string
272
+ shortcutName: string
273
+ updaterCacheDirName: string
274
+ } {
275
+ return {
276
+ appFilename: getWindowsInstallationDirName(config),
277
+ productFilename: config.productName,
278
+ shortcutName: config.win.shortcutName || config.productName,
279
+ updaterCacheDirName: getUpdaterCacheDirName(config),
280
+ }
281
+ }
282
+
283
+ async function assembleAppDir({
284
+ entries,
285
+ appDir,
286
+ asarPath,
287
+ unpackedDir,
288
+ config,
289
+ arch,
290
+ }: {
291
+ entries: ZipEntry[]
292
+ appDir: string
293
+ asarPath: string
294
+ unpackedDir?: string
295
+ config: NormalizedConfig
296
+ arch: Arch
297
+ }): Promise<void> {
298
+ const productExeName = `${config.productName}.exe`
299
+
300
+ for (const e of entries) {
301
+ if (isDefaultAppEntry(e.path)) continue
302
+
303
+ if (e.dir) {
304
+ await mkdir(path.join(appDir, e.path), { recursive: true })
305
+ continue
306
+ }
307
+
308
+ let destPath = e.path
309
+ if (e.path === 'electron.exe') {
310
+ destPath = productExeName
311
+ }
312
+
313
+ const fullPath = path.join(appDir, destPath)
314
+ const parentDir = path.dirname(fullPath)
315
+ if (!existsSync(parentDir)) {
316
+ await mkdir(parentDir, { recursive: true })
317
+ }
318
+
319
+ const buffer = await getEntryBuffer(e.entry)
320
+ await writeFile(fullPath, buffer)
321
+ }
322
+
323
+ const asarDest = path.join(appDir, 'resources', 'app.asar')
324
+ if (existsSync(asarPath)) {
325
+ await cp(asarPath, asarDest)
326
+ }
327
+
328
+ if (unpackedDir && existsSync(unpackedDir)) {
329
+ const unpackedDest = path.join(appDir, 'resources', 'app.asar.unpacked')
330
+ await cp(unpackedDir, unpackedDest, { recursive: true })
331
+ }
332
+
333
+ await injectExtraResources(appDir, config, arch)
334
+ await writeAppUpdateYml(appDir, config)
335
+ }
336
+
337
+ async function writeAppUpdateYml(appDir: string, config: NormalizedConfig): Promise<void> {
338
+ if (!config.update.url) return
339
+
340
+ const updateYml = await renderAppUpdateYml(config)
341
+ const destDir = path.join(appDir, 'resources')
342
+ await mkdir(destDir, { recursive: true })
343
+ await writeFile(path.join(destDir, 'app-update.yml'), updateYml, 'utf8')
344
+ }
345
+
346
+ async function injectExtraResources(
347
+ appDir: string,
348
+ config: NormalizedConfig,
349
+ _arch: Arch,
350
+ ): Promise<void> {
351
+ for (const { res, srcPath, srcStat, destPath: resTo } of await resolveExtraResources(config)) {
352
+ const destPath = path.join(appDir, 'resources', resTo)
353
+ if (srcStat.isDirectory()) {
354
+ await cp(srcPath, destPath, {
355
+ recursive: true,
356
+ filter: (entryPath) => !isExtraResourcePathExcluded(res, path.relative(srcPath, entryPath)),
357
+ })
358
+ } else {
359
+ const destDir = path.dirname(destPath)
360
+ if (!existsSync(destDir)) await mkdir(destDir, { recursive: true })
361
+ await cp(srcPath, destPath)
362
+ }
363
+ }
364
+ }
365
+
366
+ /** Build the NSIS version-info keys (locale 1033) for the installer. */
367
+ function computeVersionKey(config: NormalizedConfig): string[] {
368
+ const localeId = '1033'
369
+ const keys = [
370
+ `/LANG=${localeId} ProductName "${config.productName}"`,
371
+ `/LANG=${localeId} ProductVersion "${config.version}"`,
372
+ `/LANG=${localeId} LegalCopyright "${config.copyright}"`,
373
+ `/LANG=${localeId} FileDescription "${config.description || ''}"`,
374
+ `/LANG=${localeId} FileVersion "${config.version}"`,
375
+ ]
376
+ if (config.author) {
377
+ keys.push(`/LANG=${localeId} CompanyName "${config.author}"`)
378
+ }
379
+ return keys
380
+ }
381
+
382
+ /** Derive a stable v5 UUID (sha1 name-based) from the appId. */
383
+ function generateGuid(appId: string): string {
384
+ const NAMESPACE = Buffer.from([
385
+ 0x50, 0xe0, 0x65, 0xbc, 0x31, 0x34, 0x11, 0xe6, 0x9b, 0xab, 0x38, 0xc9, 0x86, 0x2b, 0xda, 0xf3,
386
+ ])
387
+ const hash = crypto.createHash('sha1')
388
+ hash.update(NAMESPACE)
389
+ hash.update(appId)
390
+ const buffer = hash.digest()
391
+ const byte2hex: string[] = []
392
+ for (let i = 0; i < 256; i++) {
393
+ byte2hex[i] = (i + 0x100).toString(16).substring(1)
394
+ }
395
+ // Map a byte offset to its lowercase hex digit.
396
+ const h = (i: number): string => byte2hex[buffer[i]]
397
+ return (
398
+ `${h(0)}${h(1)}${h(2)}${h(3)}-` +
399
+ `${h(4)}${h(5)}-` +
400
+ `${byte2hex[(buffer[6] & 0x0f) | 0x50]}${h(7)}-` +
401
+ `${byte2hex[(buffer[8] & 0x3f) | 0x80]}${h(9)}-` +
402
+ `${h(10)}${h(11)}${h(12)}${h(13)}${h(14)}${h(15)}`
403
+ )
404
+ }
405
+
406
+ /** Derive a filesystem-safe installation directory name. */
407
+ function getWindowsInstallationDirName(config: NormalizedConfig): string {
408
+ const productFilename = config.productName
409
+ if (/^[-_+0-9a-zA-Z .]+$/.test(productFilename)) {
410
+ return productFilename
411
+ }
412
+ return config.name
413
+ .replace(/@/g, '')
414
+ .replace(/\//g, '-')
415
+ .replace(/[^-._+0-9a-zA-Z ]/g, '')
416
+ }
417
+
418
+ /** Replace ASCII quotes with a typographic quote for NSIS strings. */
419
+ function smarten(str: string): string {
420
+ return str.replace(/"/g, '”')
421
+ }
@@ -0,0 +1,149 @@
1
+ /**
2
+ * Main packaging pipeline: validate, collect the payload, then pack each target.
3
+ *
4
+ * Targets run concurrently; a target failure is captured and reported
5
+ * without aborting the remaining targets.
6
+ */
7
+ import { mkdir } from 'node:fs/promises'
8
+ import path from 'node:path'
9
+ import { setPrebuiltDirResolver } from '../asar/fileset/platform-fileset.ts'
10
+ import { collectProjectFiles, packAsarForTarget } from '../asar/pack.ts'
11
+ import type { NormalizedConfig } from '../config/index.ts'
12
+ import { removeStagingDir } from '../packer/common/staging.ts'
13
+ import { createLogger } from '../shared/logger.ts'
14
+ import type { Arch, Platform } from '../shared/platform.ts'
15
+ import type { FileSet } from '../shared/types.ts'
16
+ import { type PackTarget, runPlatformPackers } from './targets.ts'
17
+
18
+ export interface PackResult {
19
+ ok: boolean
20
+ outputDir: string
21
+ targets: TargetResult[]
22
+ }
23
+
24
+ export interface PipelineOptions {
25
+ platforms: Platform[]
26
+ arches: Arch[]
27
+ output: string
28
+ dryRun: boolean
29
+ onTargetComplete?: (result: TargetResult) => void
30
+ }
31
+
32
+ export interface TargetResult {
33
+ platform: Platform
34
+ arch: Arch
35
+ output?: string
36
+ error?: string
37
+ }
38
+
39
+ export interface TargetSpec {
40
+ platform: Platform
41
+ arches?: Arch[]
42
+ config?: (config: NormalizedConfig) => NormalizedConfig
43
+ }
44
+
45
+ interface CollectedProjectFiles {
46
+ appFileSet: FileSet
47
+ nmFileSets: FileSet[]
48
+ transformer: (file: string) => Promise<string | null> | string | null
49
+ }
50
+
51
+ interface SharedState {
52
+ collectPromise?: Promise<CollectedProjectFiles>
53
+ }
54
+
55
+ export async function resolveAsarForTarget(
56
+ target: PackTarget,
57
+ config: NormalizedConfig,
58
+ outputDir: string,
59
+ shared: SharedState,
60
+ ) {
61
+ if (config.asar?.path) {
62
+ return {
63
+ asarPath: path.resolve(config.projectDir, config.asar.path),
64
+ unpackedDir: config.asar.unpackedDir
65
+ ? path.resolve(config.projectDir, config.asar.unpackedDir)
66
+ : undefined,
67
+ }
68
+ }
69
+
70
+ if (!shared.collectPromise) {
71
+ shared.collectPromise = collectProjectFiles(config.projectDir, config)
72
+ }
73
+ const { appFileSet, nmFileSets, transformer } = await shared.collectPromise
74
+
75
+ const stagingDir = path.join(outputDir, 'cross-packer-asar-staging')
76
+ await mkdir(stagingDir, { recursive: true })
77
+ return packAsarForTarget({
78
+ platform: target.platform,
79
+ arch: target.arch,
80
+ appFileSet,
81
+ nmFileSets: nmFileSets ?? [],
82
+ transformer: transformer ?? (() => null),
83
+ config,
84
+ projectDir: config.projectDir,
85
+ stagingDir,
86
+ })
87
+ }
88
+
89
+ export async function runPack(
90
+ config: NormalizedConfig,
91
+ options: PipelineOptions,
92
+ targetSpecs?: TargetSpec[],
93
+ ): Promise<PackResult> {
94
+ const { dryRun } = options
95
+ const logger = createLogger()
96
+
97
+ setPrebuiltDirResolver(config.nativeModules?.prebuiltRules ?? null)
98
+
99
+ const outputDir = resolveOutputDir(config, options)
100
+ await mkdir(outputDir, { recursive: true })
101
+ const results: TargetResult[] = []
102
+
103
+ if (dryRun) {
104
+ logger.info('Dry run: config validated, targets resolved, no build performed.')
105
+ for (const platform of options.platforms) {
106
+ for (const arch of options.arches) {
107
+ const entry = { platform, arch, output: '(dry-run)' }
108
+ results.push(entry)
109
+ options.onTargetComplete?.(entry)
110
+ }
111
+ }
112
+ return { ok: true, outputDir, targets: results }
113
+ }
114
+
115
+ const targets = runPlatformPackers({ config, options, outputDir, targetSpecs })
116
+
117
+ // State shared across targets: project file collection runs only once.
118
+ const shared: SharedState = {}
119
+
120
+ const settled = await Promise.all(
121
+ targets.map(async (target: PackTarget) => {
122
+ try {
123
+ const asar = await resolveAsarForTarget(target, config, outputDir, shared)
124
+ const output = await target.run(asar)
125
+ return { platform: target.platform, arch: target.arch, output }
126
+ } catch (err) {
127
+ const message = err instanceof Error ? err.message : String(err)
128
+ logger.error(`${target.platform}-${target.arch}: ${message}`)
129
+ return { platform: target.platform, arch: target.arch, error: message }
130
+ }
131
+ }),
132
+ )
133
+
134
+ for (const entry of settled) {
135
+ options.onTargetComplete?.(entry)
136
+ }
137
+
138
+ const stagingDir = path.join(outputDir, 'cross-packer-asar-staging')
139
+ await removeStagingDir(stagingDir)
140
+
141
+ results.push(...settled)
142
+ return { ok: !results.some((r) => r.error), outputDir, targets: results }
143
+ }
144
+
145
+ function resolveOutputDir(config: NormalizedConfig, options: PipelineOptions): string {
146
+ if (options.output) return path.resolve(options.output)
147
+ const stamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19)
148
+ return path.join(config.projectDir, 'dist', `cross-packer-${stamp}`)
149
+ }
@@ -0,0 +1,67 @@
1
+ /**
2
+ * Target resolution: maps (platform, arch) pairs to packer implementations.
3
+ */
4
+
5
+ import type { NormalizedConfig } from '../config/index.ts'
6
+ import { packLinux } from '../packer/linux/linux.ts'
7
+ import { packMac } from '../packer/mac/mac.ts'
8
+ import { packWin } from '../packer/win/win.ts'
9
+ import type { Arch, Platform } from '../shared/platform.ts'
10
+ import type { PipelineOptions, TargetSpec } from './pipeline.ts'
11
+
12
+ const PACKERS: Record<Platform, (ctx: PackerContext) => Promise<string>> = {
13
+ win: packWin,
14
+ mac: packMac,
15
+ linux: packLinux,
16
+ }
17
+
18
+ export interface PackerContext {
19
+ config: NormalizedConfig
20
+ arch: Arch
21
+ outputDir: string
22
+ asarPath?: string
23
+ unpackedDir?: string
24
+ }
25
+
26
+ /** A single (platform, arch) packaging unit. */
27
+ export interface PackTarget {
28
+ platform: Platform
29
+ arch: Arch
30
+ run: (asar: { asarPath?: string; unpackedDir?: string }) => Promise<string>
31
+ }
32
+
33
+ export function runPlatformPackers({
34
+ config,
35
+ options,
36
+ outputDir,
37
+ targetSpecs,
38
+ }: {
39
+ config: NormalizedConfig
40
+ options: PipelineOptions
41
+ outputDir: string
42
+ targetSpecs?: TargetSpec[]
43
+ }) {
44
+ const specs: TargetSpec[] =
45
+ targetSpecs && targetSpecs.length > 0
46
+ ? targetSpecs
47
+ : options.platforms.map((platform) => ({ platform, arches: options.arches }))
48
+
49
+ const targets: PackTarget[] = []
50
+ for (const spec of specs) {
51
+ const packer = PACKERS[spec.platform]
52
+ if (!packer) {
53
+ throw new Error(`No packer registered for platform "${spec.platform}"`)
54
+ }
55
+ const arches = spec.arches ?? options.arches
56
+ const targetConfig = spec.config ? spec.config(config) : config
57
+ for (const arch of arches) {
58
+ targets.push({
59
+ platform: spec.platform,
60
+ arch,
61
+ run: (asar: { asarPath?: string; unpackedDir?: string }) =>
62
+ packer({ config: targetConfig, arch, outputDir, ...asar }),
63
+ })
64
+ }
65
+ }
66
+ return targets
67
+ }
@@ -0,0 +1,15 @@
1
+ /**
2
+ * File hashing helpers shared by update metadata, toolchain verification,
3
+ * and archive hashing. Zero project dependencies.
4
+ */
5
+ import { createHash } from 'node:crypto'
6
+ import { readFileSync } from 'node:fs'
7
+
8
+ /** Hash a file's contents with the given algorithm and digest encoding. */
9
+ export function hashFile(
10
+ filePath: string,
11
+ algorithm: 'sha256' | 'sha512' = 'sha512',
12
+ encoding: 'hex' | 'base64' = 'hex',
13
+ ): string {
14
+ return createHash(algorithm).update(readFileSync(filePath)).digest(encoding)
15
+ }