Skip to content

Commit 9b1dbb2

Browse files
authored
feat: update dmgbuild and migrate to portable dmgbuild python bundle (#9516)
1 parent 2760327 commit 9b1dbb2

29 files changed

Lines changed: 282 additions & 7518 deletions

.changeset/fuzzy-flowers-fix.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
"app-builder-lib": patch
3+
"dmg-builder": patch
4+
---
5+
6+
feat: update `dmgbuild` and migrate to portable `dmgbuild` python bundle. Fixes `badge-icon` property

packages/app-builder-lib/package.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@
5050
"@develar/schema-utils": "~2.6.5",
5151
"@electron/asar": "3.4.1",
5252
"@electron/fuses": "^1.8.0",
53+
"@electron/get": "^3.0.0",
5354
"@electron/notarize": "2.5.0",
5455
"@electron/osx-sign": "1.3.3",
5556
"@electron/rebuild": "4.0.1",
@@ -75,6 +76,7 @@
7576
"lazy-val": "^1.0.5",
7677
"minimatch": "^10.0.3",
7778
"plist": "3.1.0",
79+
"proper-lockfile": "^4.1.2",
7880
"resedit": "^1.7.0",
7981
"semver": "~7.7.3",
8082
"tar": "7.5.3",
@@ -108,6 +110,7 @@
108110
"@types/hosted-git-info": "3.0.2",
109111
"@types/js-yaml": "4.0.3",
110112
"@types/plist": "3.0.5",
113+
"@types/proper-lockfile": "^4.1.4",
111114
"@types/semver": "7.7.1",
112115
"@types/tiny-async-pool": "^1",
113116
"@types/which": "^3.0.4",

packages/app-builder-lib/scheme.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -871,7 +871,7 @@
871871
]
872872
},
873873
"badgeIcon": {
874-
"description": "The path to DMG icon (badge icon), which will be shown when mounted, relative to the [build resources](./contents.md#extraresources) or to the project directory.\nDefaults to the application icon (`build/icon.icns`).",
874+
"description": "The path to DMG icon (badge icon), which will be shown when mounted, relative to the [build resources](./contents.md#extraresources) or to the project directory.",
875875
"type": [
876876
"null",
877877
"string"

packages/app-builder-lib/src/binDownload.ts

Lines changed: 173 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,179 @@
1-
import { executeAppBuilder } from "builder-util"
1+
import * as get from "@electron/get"
2+
import { ElectronDownloadCacheMode, ElectronDownloadRequest, ElectronDownloadRequestOptions, GotDownloaderOptions } from "@electron/get"
3+
import { executeAppBuilder, exists, log, PADDING } from "builder-util"
24
import { Nullish } from "builder-util-runtime"
35
import { sanitizeFileName } from "builder-util/out/filename"
6+
import { MultiProgress } from "electron-publish/out/multiProgress"
7+
import * as fs from "fs/promises"
8+
import * as os from "os"
49
import * as path from "path"
10+
import * as lockfile from "proper-lockfile"
11+
import * as tar from "tar"
12+
13+
/**
14+
* Deterministic <length>-character URL-safe hash (a–z0–9)
15+
*/
16+
export function hashUrlSafe(input: string, length = 6): string {
17+
let hash = 5381
18+
for (let i = 0; i < input.length; i++) {
19+
hash = ((hash << 5) + hash) ^ input.charCodeAt(i) // hash * 33 ^ c
20+
}
21+
// Force unsigned 32-bit
22+
hash >>>= 0
23+
// Base-36 (0–9a–z)
24+
const out = hash.toString(36)
25+
// Ensure exactly `length` chars
26+
if (out.length >= length) {
27+
return out.slice(0, length)
28+
}
29+
return out.padStart(length, "0")
30+
}
31+
32+
/**
33+
* Get cache directory for electron-builder
34+
*/
35+
export function getCacheDirectory(isAvoidSystemOnWindows = false): string {
36+
const env = process.env.ELECTRON_BUILDER_CACHE?.trim()
37+
if (env) {
38+
return env
39+
}
40+
41+
const appName = "electron-builder"
42+
43+
const platform = os.platform()
44+
const homeDir = os.homedir()
45+
46+
if (platform === "darwin") {
47+
return path.join(homeDir, "Library", "Caches", appName)
48+
}
49+
50+
if (platform === "win32") {
51+
const localAppData = process.env.LOCALAPPDATA?.trim()
52+
const username = process.env.USERNAME?.trim()?.toLowerCase()
53+
const isSystemUser = isAvoidSystemOnWindows && (localAppData?.toLowerCase()?.includes("\\windows\\system32\\") || username === "system")
54+
if (!localAppData || isSystemUser) {
55+
return path.join(os.tmpdir(), `${appName}-cache`)
56+
}
57+
return path.join(localAppData, appName, "Cache")
58+
}
59+
60+
// linux
61+
const xdgCache = process.env.XDG_CACHE_HOME
62+
if (xdgCache) {
63+
return path.join(xdgCache, appName)
64+
}
65+
66+
return path.join(homeDir, ".cache", appName)
67+
}
68+
69+
/**
70+
* Downloads an artifact from GitHub releases (convenience wrapper)
71+
*/
72+
export async function downloadArtifact(options: { releaseName: string; filenameWithExt: string; checksums: Record<string, string>; githubOrgRepo?: string }): Promise<string> {
73+
const { releaseName, filenameWithExt, checksums, githubOrgRepo = "electron-userland/electron-builder-binaries" } = options
74+
75+
const file = await _downloadArtifact(`https://github.com/${githubOrgRepo}/releases/download/`, releaseName, filenameWithExt, checksums)
76+
77+
return file
78+
}
79+
80+
/**
81+
* Downloads, validates, and extracts a .tar.gz from a release URL
82+
*/
83+
async function _downloadArtifact(baseUrl: string, releaseName: string, filenameWithExt: string, checksums: Record<string, string>): Promise<string> {
84+
const suffix = hashUrlSafe(`${baseUrl}-${releaseName}-${filenameWithExt}`, 5)
85+
const folderName = `${filenameWithExt.replace(/\.(tar\.gz|tgz)$/, "")}-${suffix}`
86+
const extractDir = path.join(getCacheDirectory(), releaseName, folderName)
87+
const extractionCompleteMarker = `${extractDir}.complete`
88+
89+
// Ensure download directory exists before trying to lock
90+
await fs.mkdir(extractDir, { recursive: true })
91+
92+
// Acquire the lock
93+
let release: (() => Promise<void>) | undefined
94+
try {
95+
release = await lockfile.lock(extractDir, {
96+
retries: {
97+
retries: 5,
98+
minTimeout: 1000,
99+
maxTimeout: 5000,
100+
},
101+
stale: 60000,
102+
})
103+
104+
const varName = "ELECTRON_DOWNLOAD_CACHE_MODE"
105+
const cacheOverride = process.env[varName]?.trim()
106+
107+
let cacheMode: ElectronDownloadCacheMode = ElectronDownloadCacheMode.ReadWrite
108+
if (cacheOverride && Number(cacheOverride) in ElectronDownloadCacheMode) {
109+
cacheMode = Number(cacheOverride)
110+
log.debug({ mode: cacheMode }, `cache mode overridden via env var ${varName}`)
111+
}
112+
113+
if (await exists(extractionCompleteMarker)) {
114+
log.debug({ file: filenameWithExt, path: extractDir }, "using cached artifact - skipping download/extract")
115+
return extractDir
116+
}
117+
118+
// These are just stubs. Actual url construction/file naming are in `mirrorOptions` below.
119+
const details: ElectronDownloadRequest = {
120+
// Needs to be higher than 1.3.2 to avoid @electron/get validation shortcut
121+
// https://github.com/electron/get/blob/05c466d4fc60fa0c83064df28dce245eb83d63c9/src/index.ts#L60
122+
version: "9.9.9",
123+
artifactName: filenameWithExt, // also is the output filename
124+
}
125+
126+
const progress = process.stdout.isTTY ? new MultiProgress() : null
127+
const progressBar = progress?.createBar(`${" ".repeat(PADDING + 2)}[:bar] :percent | ${filenameWithExt}`, { total: 100 })
128+
129+
const downloadOptions: GotDownloaderOptions = {
130+
getProgressCallback: info => {
131+
progressBar?.update(info.percent != null ? Math.floor(info.percent * 100) : 0)
132+
return Promise.resolve()
133+
},
134+
}
135+
const options: ElectronDownloadRequestOptions = {
136+
cacheRoot: path.resolve(getCacheDirectory(), "downloads"),
137+
cacheMode,
138+
downloadOptions,
139+
checksums,
140+
mirrorOptions: {
141+
// `${opts.mirror}${opts.customDir}/${opts.customFilename}`
142+
mirror: baseUrl,
143+
customDir: releaseName,
144+
customFilename: filenameWithExt,
145+
},
146+
}
147+
148+
log.info({ release: releaseName, file: filenameWithExt }, "downloading")
149+
progressBar?.render()
150+
const downloadedFile = await get.downloadArtifact({
151+
...details,
152+
...options,
153+
isGeneric: true,
154+
})
155+
156+
await tar.extract({
157+
file: downloadedFile,
158+
cwd: extractDir,
159+
strip: 1, // Strip the top-level directory from the archive
160+
})
161+
162+
// Write the extraction complete marker file to indicate successful extraction and prevent future re-extraction
163+
await fs.writeFile(extractionCompleteMarker, "")
164+
165+
log.debug({ file: filenameWithExt, path: extractDir }, "downloaded")
166+
progressBar?.update(100)
167+
progressBar?.terminate()
168+
169+
return extractDir
170+
} finally {
171+
// Release the lock
172+
if (release) {
173+
await release()
174+
}
175+
}
176+
}
5177

6178
const versionToPromise = new Map<string, Promise<string>>()
7179

packages/app-builder-lib/src/options/macOptions.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -253,7 +253,6 @@ export interface DmgOptions extends TargetSpecificOptions {
253253

254254
/**
255255
* The path to DMG icon (badge icon), which will be shown when mounted, relative to the [build resources](./contents.md#extraresources) or to the project directory.
256-
* Defaults to the application icon (`build/icon.icns`).
257256
*/
258257
badgeIcon?: string | null
259258

packages/app-builder-lib/src/platformPackager.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -796,7 +796,7 @@ export abstract class PlatformPackager<DC extends PlatformSpecificBuildOptions>
796796
}
797797

798798
private cachedIcnsFromIconFile = new Map<string, Promise<string>>()
799-
private async generateIcnsFromIcon(iconPath: string): Promise<string> {
799+
async generateIcnsFromIcon(iconPath: string): Promise<string> {
800800
const cachedPromise = this.cachedIcnsFromIconFile.get(iconPath)
801801
if (cachedPromise) {
802802
return cachedPromise

packages/dmg-builder/package.json

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,7 @@
1313
"homepage": "https://github.com/electron-userland/electron-builder",
1414
"files": [
1515
"out",
16-
"templates",
17-
"vendor"
16+
"templates"
1817
],
1918
"dependencies": {
2019
"app-builder-lib": "workspace:*",

packages/dmg-builder/src/dmgUtil.ts

Lines changed: 42 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
11
import { DmgOptions, MacPackager, PlatformPackager } from "app-builder-lib"
2+
import { downloadArtifact } from "app-builder-lib/out/binDownload"
23
import { exec, executeFinally, exists, isEmptyOrSpaces, TmpDir } from "builder-util"
3-
import * as path from "path"
4-
import { hdiUtil, hdiutilTransientExitCodes } from "./hdiuil"
54
import { writeFile } from "fs-extra"
5+
import * as path from "path"
66
import { DmgBuildConfig } from "./dmg"
7+
import { hdiUtil, hdiutilTransientExitCodes } from "./hdiuil"
78

89
export { DmgTarget } from "./dmg"
910

@@ -13,11 +14,30 @@ export function getDmgTemplatePath() {
1314
return path.join(root, "templates")
1415
}
1516

16-
export function getDmgVendorPath() {
17-
return path.join(root, "vendor")
17+
async function getDmgVendorPath(): Promise<string> {
18+
const customDmgbuildPath = process.env.CUSTOM_DMGBUILD_PATH?.trim()
19+
if (customDmgbuildPath) {
20+
return path.resolve(customDmgbuildPath)
21+
}
22+
23+
// https://github.com/electron-userland/electron-builder-binaries/releases/tag/dmg-builder%401.1.0
24+
const releaseVersion = "9614277"
25+
const arch = process.arch === "arm64" ? "arm64" : "x86_64"
26+
const config = {
27+
"dmgbuild-bundle-arm64-9614277.tar.gz": "28e11550cf990f78180a2d82090f35a24588beda3d9165098837714f90ee47ce",
28+
"dmgbuild-bundle-x86_64-9614277.tar.gz": "4dbf1cc186af62921f8b6f4a5956b28d8622d211797a8b05eb75a260ee9c3fdb",
29+
}
30+
const filename: keyof typeof config = `dmgbuild-bundle-${arch}-${releaseVersion}.tar.gz`
31+
const file = await downloadArtifact({
32+
releaseName: "dmg-builder@1.1.0",
33+
filenameWithExt: filename,
34+
checksums: config,
35+
githubOrgRepo: "electron-userland/electron-builder-binaries",
36+
})
37+
return path.resolve(file, "dmgbuild")
1838
}
1939

20-
export async function attachAndExecute(dmgPath: string, readWrite: boolean, task: (devicePath: string) => Promise<any>) {
40+
export async function attachAndExecute(dmgPath: string, readWrite: boolean, forceDetach: boolean, task: (devicePath: string) => Promise<any>) {
2141
//noinspection SpellCheckingInspection
2242
const args = ["attach", "-noverify", "-noautoopen"]
2343
if (readWrite) {
@@ -36,7 +56,7 @@ export async function attachAndExecute(dmgPath: string, readWrite: boolean, task
3656
throw new Error(`Cannot find volume mount path for device: ${device}`)
3757
}
3858

39-
return await executeFinally(task(volumePath), () => detach(device))
59+
return await executeFinally(task(volumePath), () => detach(device, forceDetach))
4060
}
4161

4262
/**
@@ -58,9 +78,9 @@ async function findMountPath(devName: string, index: number = 1): Promise<string
5878
return matches.length >= index ? matches[index - 1] : null
5979
}
6080

61-
export async function detach(name: string) {
81+
export async function detach(name: string, alwaysForce: boolean) {
6282
return hdiUtil(["detach", "-quiet", name]).catch(async e => {
63-
if (hdiutilTransientExitCodes.has(e.code)) {
83+
if (hdiutilTransientExitCodes.has(e.code) || alwaysForce) {
6484
// Delay then force unmount with verbose output
6585
await new Promise(resolve => setTimeout(resolve, 3000))
6686
return hdiUtil(["detach", "-force", name])
@@ -105,12 +125,9 @@ export async function customizeDmg({ appPath, artifactPath, volumeName, specific
105125
const iconTextSize = isValidIconTextSize ? specification.iconTextSize : 12
106126
const volumePath = path.join("/Volumes", volumeName)
107127
// https://github.com/electron-userland/electron-builder/issues/2115
108-
const backgroundFile = specification.background == null ? null : await transformBackgroundFileIfNeed(specification.background, packager.info.tempDirManager)
109128

110129
const settings: DmgBuildConfig = {
111130
title: path.basename(volumePath),
112-
icon: await packager.getResource(specification.icon),
113-
"badge-icon": await packager.getResource(specification.badgeIcon),
114131
"icon-size": specification.iconSize,
115132
"text-size": iconTextSize,
116133

@@ -128,6 +145,16 @@ export async function customizeDmg({ appPath, artifactPath, volumeName, specific
128145
})) || [],
129146
}
130147

148+
if (specification.badgeIcon) {
149+
let badgeIcon = await packager.getResource(specification.badgeIcon)
150+
if (badgeIcon && badgeIcon.toLowerCase().endsWith(".icon")) {
151+
badgeIcon = await packager.generateIcnsFromIcon(badgeIcon)
152+
}
153+
settings["badge-icon"] = badgeIcon
154+
} else {
155+
settings.icon = await packager.getResource(specification.icon)
156+
}
157+
131158
if (specification.backgroundColor != null || specification.background == null) {
132159
settings["background-color"] = specification.backgroundColor || "#ffffff"
133160

@@ -145,8 +172,7 @@ export async function customizeDmg({ appPath, artifactPath, volumeName, specific
145172
}
146173
}
147174
} else {
148-
settings.background = backgroundFile
149-
delete settings["background-color"]
175+
settings.background = specification.background == null ? null : await transformBackgroundFileIfNeed(specification.background, packager.info.tempDirManager)
150176
}
151177

152178
if (!isEmptyOrSpaces(settings.background)) {
@@ -157,15 +183,8 @@ export async function customizeDmg({ appPath, artifactPath, volumeName, specific
157183
const settingsFile = await packager.getTempFile(".json")
158184
await writeFile(settingsFile, JSON.stringify(settings, null, 2))
159185

160-
const python3Check = () => exec("command", ["-v", "python3"])
161-
const pythonCheck = () => exec("command", ["-v", "python"])
162-
const pythonPath = process.env.PYTHON_PATH || (await python3Check().catch(pythonCheck)) || (await pythonCheck())
163-
if (pythonPath == null || isEmptyOrSpaces(pythonPath.trim())) {
164-
throw new Error("Cannot find 'python' or 'python3' executable, please ensure Python is installed and available in PATH or set PYTHON_PATH environment variable")
165-
}
166-
const vendorDir = getDmgVendorPath()
167-
await exec(pythonPath.trim(), [path.join(vendorDir, "run_dmgbuild.py"), "-s", settingsFile, path.basename(volumePath), artifactPath], {
168-
cwd: vendorDir,
186+
const dmgbuild = await getDmgVendorPath()
187+
await exec(dmgbuild, ["-s", settingsFile, path.basename(volumePath), artifactPath], {
169188
env: {
170189
...process.env,
171190
PYTHONIOENCODING: "utf8",
@@ -175,7 +194,7 @@ export async function customizeDmg({ appPath, artifactPath, volumeName, specific
175194
// effectiveOptionComputed, when present, is purely for verifying result during test execution
176195
return (
177196
packager.packagerOptions.effectiveOptionComputed == null ||
178-
(await attachAndExecute(artifactPath, false, async volumePath => {
197+
(await attachAndExecute(artifactPath, false, true, async volumePath => {
179198
return !(await packager.packagerOptions.effectiveOptionComputed!({
180199
volumePath,
181200
specification: {
-6 KB
Binary file not shown.

0 commit comments

Comments
 (0)