Skip to content

Commit 1d13001

Browse files
authored
feat: Introducing deb and rpm auto-updates (#7060)
introducing RPM and Deb auto-updates by adding a self-identifying `package-type` resource file to each deb/rpm package
1 parent 0b8826e commit 1d13001

21 files changed

+485
-70
lines changed

.changeset/seven-garlics-tell.md

+6
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
"app-builder-lib": minor
3+
"electron-updater": minor
4+
---
5+
6+
feat: Introducing deb and rpm auto-updates as beta feature

.github/workflows/test.yaml

+3-3
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ jobs:
2323
strategy:
2424
matrix:
2525
testFiles:
26-
- ArtifactPublisherTest,BuildTest,ExtraBuildTest,RepoSlugTest,binDownloadTest,configurationValidationTest,filenameUtilTest,filesTest,globTest,ignoreTest,macroExpanderTest,mainEntryTest,urlUtilTest,extraMetadataTest,linuxArchiveTest,linuxPackagerTest,HoistedNodeModuleTest,PublishManagerTest
26+
- ArtifactPublisherTest,BuildTest,ExtraBuildTest,RepoSlugTest,binDownloadTest,configurationValidationTest,filenameUtilTest,filesTest,globTest,ignoreTest,macroExpanderTest,mainEntryTest,urlUtilTest,extraMetadataTest,linuxArchiveTest,linuxPackagerTest,HoistedNodeModuleTest
2727
- snapTest,debTest,fpmTest,protonTest
2828
steps:
2929
- name: Checkout code repository
@@ -69,7 +69,7 @@ jobs:
6969
- name: Test
7070
run: pnpm ci:test
7171
env:
72-
TEST_FILES: masTest,dmgTest,protonTest
72+
TEST_FILES: masTest,dmgTest,protonTest,filesTest
7373
FORCE_COLOR: 1
7474

7575
# Need to separate from other tests because logic is specific to when TOKEN env vars are set
@@ -88,7 +88,7 @@ jobs:
8888
- name: Test
8989
run: pnpm ci:test
9090
env:
91-
TEST_FILES: nsisUpdaterTest
91+
TEST_FILES: nsisUpdaterTest,linuxUpdaterTest,PublishManagerTest
9292
KEYGEN_TOKEN: ${{ secrets.KEYGEN_TOKEN }}
9393
BITBUCKET_TOKEN: ${{ secrets.BITBUCKET_TOKEN }}
9494
GH_TOKEN: ${{ secrets.GH_TOKEN }}

docs/api/electron-builder.md

+12-2
Original file line numberDiff line numberDiff line change
@@ -1430,6 +1430,7 @@ return path.join(target.outDir, <code>__${target.name}-${getArtifactArchName(arc
14301430
<li><a href="#module_electron-updater.AppUpdater+quitAndInstall"><code>.quitAndInstall(isSilent, isForceRunAfter)</code></a></li>
14311431
</ul>
14321432
</li>
1433+
<li><a href="#DebUpdater">.DebUpdater</a> ⇐ <code>module:electron-updater/out/BaseUpdater.BaseUpdater</code></li>
14331434
<li><a href="#MacUpdater">.MacUpdater</a> ⇐ <code><a href="#AppUpdater">AppUpdater</a></code>
14341435
<ul>
14351436
<li><a href="#module_electron-updater.MacUpdater+quitAndInstall"><code>.quitAndInstall()</code></a></li>
@@ -1450,6 +1451,7 @@ return path.join(target.outDir, <code>__${target.name}-${getArtifactArchName(arc
14501451
<li><a href="#module_electron-updater.Provider+resolveFiles"><code>.resolveFiles(updateInfo)</code></a> ⇒ <code>Array&lt;<a href="#ResolvedUpdateFileInfo">ResolvedUpdateFileInfo</a>&gt;</code></li>
14511452
</ul>
14521453
</li>
1454+
<li><a href="#RpmUpdater">.RpmUpdater</a> ⇐ <code>module:electron-updater/out/BaseUpdater.BaseUpdater</code></li>
14531455
<li><a href="#UpdaterSignal">.UpdaterSignal</a>
14541456
<ul>
14551457
<li><a href="#module_electron-updater.UpdaterSignal+login"><code>.login(handler)</code></a></li>
@@ -1752,7 +1754,11 @@ This is different from the normal quit event sequence.</p>
17521754
</tr>
17531755
</tbody>
17541756
</table>
1755-
<p><a name="MacUpdater"></a></p>
1757+
<p><a name="DebUpdater"></a></p>
1758+
<h2 id="debupdater-%E2%87%90-module%3Aelectron-updater%2Fout%2Fbaseupdater.baseupdater">DebUpdater ⇐ <code>module:electron-updater/out/BaseUpdater.BaseUpdater</code></h2>
1759+
<p><strong>Kind</strong>: class of <a href="#module_electron-updater"><code>electron-updater</code></a><br/>
1760+
<strong>Extends</strong>: <code>module:electron-updater/out/BaseUpdater.BaseUpdater</code><br>
1761+
<a name="MacUpdater"></a></p>
17561762
<h2 id="macupdater-%E2%87%90-appupdater">MacUpdater ⇐ <code><a href="#AppUpdater">AppUpdater</a></code></h2>
17571763
<p><strong>Kind</strong>: class of <a href="#module_electron-updater"><code>electron-updater</code></a><br/>
17581764
<strong>Extends</strong>: <code><a href="#AppUpdater">AppUpdater</a></code></p>
@@ -1910,7 +1916,11 @@ This is different from the normal quit event sequence.</p>
19101916
</tr>
19111917
</tbody>
19121918
</table>
1913-
<p><a name="UpdaterSignal"></a></p>
1919+
<p><a name="RpmUpdater"></a></p>
1920+
<h2 id="rpmupdater-%E2%87%90-module%3Aelectron-updater%2Fout%2Fbaseupdater.baseupdater">RpmUpdater ⇐ <code>module:electron-updater/out/BaseUpdater.BaseUpdater</code></h2>
1921+
<p><strong>Kind</strong>: class of <a href="#module_electron-updater"><code>electron-updater</code></a><br/>
1922+
<strong>Extends</strong>: <code>module:electron-updater/out/BaseUpdater.BaseUpdater</code><br>
1923+
<a name="UpdaterSignal"></a></p>
19141924
<h2 id="updatersignal">UpdaterSignal</h2>
19151925
<p><strong>Kind</strong>: class of <a href="#module_electron-updater"><code>electron-updater</code></a><br/></p>
19161926
<ul>

docs/configuration/publish.md

+5-5
Original file line numberDiff line numberDiff line change
@@ -133,7 +133,7 @@ In all publish options <a href="/file-patterns#file-macros">File Macros</a> are
133133
<p><code id="GenericServerOptions-requestHeaders">requestHeaders</code> module:http.OutgoingHttpHeaders - Any custom request headers</p>
134134
</li>
135135
<li>
136-
<p><code id="GenericServerOptions-timeout">timeout</code> = <code>60000</code> Number | “undefined” - Request timeout in milliseconds. (Default is 2 minutes; O is ignored)</p>
136+
<p><code id="GenericServerOptions-timeout">timeout</code> = <code>120000</code> Number | “undefined” - Request timeout in milliseconds. (Default is 2 minutes; O is ignored)</p>
137137
</li>
138138
</ul>
139139
<h2 id="githuboptions">GithubOptions</h2>
@@ -183,7 +183,7 @@ Define <code>GH_TOKEN</code> environment variable.</p>
183183
<p><code id="GithubOptions-requestHeaders">requestHeaders</code> module:http.OutgoingHttpHeaders - Any custom request headers</p>
184184
</li>
185185
<li>
186-
<p><code id="GithubOptions-timeout">timeout</code> = <code>60000</code> Number | “undefined” - Request timeout in milliseconds. (Default is 2 minutes; O is ignored)</p>
186+
<p><code id="GithubOptions-timeout">timeout</code> = <code>120000</code> Number | “undefined” - Request timeout in milliseconds. (Default is 2 minutes; O is ignored)</p>
187187
</li>
188188
</ul>
189189
<h2 id="snapstoreoptions">SnapStoreOptions</h2>
@@ -203,7 +203,7 @@ Define <code>GH_TOKEN</code> environment variable.</p>
203203
<p><code id="SnapStoreOptions-requestHeaders">requestHeaders</code> module:http.OutgoingHttpHeaders - Any custom request headers</p>
204204
</li>
205205
<li>
206-
<p><code id="SnapStoreOptions-timeout">timeout</code> = <code>60000</code> Number | “undefined” - Request timeout in milliseconds. (Default is 2 minutes; O is ignored)</p>
206+
<p><code id="SnapStoreOptions-timeout">timeout</code> = <code>120000</code> Number | “undefined” - Request timeout in milliseconds. (Default is 2 minutes; O is ignored)</p>
207207
</li>
208208
</ul>
209209
<h2 id="spacesoptions">SpacesOptions</h2>
@@ -238,7 +238,7 @@ Define <code>KEYGEN_TOKEN</code> environment variable.</p>
238238
<p><code id="KeygenOptions-requestHeaders">requestHeaders</code> module:http.OutgoingHttpHeaders - Any custom request headers</p>
239239
</li>
240240
<li>
241-
<p><code id="KeygenOptions-timeout">timeout</code> = <code>60000</code> Number | “undefined” - Request timeout in milliseconds. (Default is 2 minutes; O is ignored)</p>
241+
<p><code id="KeygenOptions-timeout">timeout</code> = <code>120000</code> Number | “undefined” - Request timeout in milliseconds. (Default is 2 minutes; O is ignored)</p>
242242
</li>
243243
</ul>
244244
<h2 id="bitbucketoptions">BitbucketOptions</h2>
@@ -269,7 +269,7 @@ Define <code>BITBUCKET_TOKEN</code> environment variable.</p>
269269
<p><code id="BitbucketOptions-requestHeaders">requestHeaders</code> module:http.OutgoingHttpHeaders - Any custom request headers</p>
270270
</li>
271271
<li>
272-
<p><code id="BitbucketOptions-timeout">timeout</code> = <code>60000</code> Number | “undefined” - Request timeout in milliseconds. (Default is 2 minutes; O is ignored)</p>
272+
<p><code id="BitbucketOptions-timeout">timeout</code> = <code>120000</code> Number | “undefined” - Request timeout in milliseconds. (Default is 2 minutes; O is ignored)</p>
273273
</li>
274274
</ul>
275275
<h2 id="s3options">S3Options</h2>

package.json

+1-1
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@
2020
"///": "Please see https://github.com/electron-userland/electron-builder/blob/master/CONTRIBUTING.md#run-test-using-cli how to run particular test instead full (and very slow) run",
2121
"test": "node ./test/out/helpers/runTests.js skipArtifactPublisher",
2222
"test-all": "pnpm compile && pnpm pretest && pnpm ci:test",
23-
"test-linux": "docker run --rm -e UPDATE_SNAPSHOT=${UPDATE_SNAPSHOT:-false} -e TEST_FILES=\"${TEST_FILES:-HoistedNodeModuleTest}\" -v $(pwd):/project -v $(pwd)-node-modules:/project/node_modules -v $HOME/Library/Caches/electron:/root/.cache/electron -v $HOME/Library/Caches/electron-builder:/root/.cache/electron-builder electronuserland/builder:wine-mono /bin/bash -c \"pnpm install && node ./test/out/helpers/runTests.js\"",
23+
"test-linux": "docker run --rm -e DEBUG=${DEBUG:-} -e UPDATE_SNAPSHOT=${UPDATE_SNAPSHOT:-false} -e TEST_FILES=\"${TEST_FILES:-HoistedNodeModuleTest}\" -v $(pwd):/project -v $(pwd)-node-modules:/project/node_modules -v $HOME/Library/Caches/electron:/root/.cache/electron -v $HOME/Library/Caches/electron-builder:/root/.cache/electron-builder electronuserland/builder:wine-mono /bin/bash -c \"pnpm install && node ./test/out/helpers/runTests.js\"",
2424
"test-update": "UPDATE_SNAPSHOT=true pnpm test-all",
2525
"docker-images": "docker/build.sh",
2626
"docker-push": "docker/push.sh",

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

+2-2
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import { PlatformPackager } from "./platformPackager"
66
import { RemoteBuilder } from "./remoteBuilder/RemoteBuilder"
77
import AppImageTarget from "./targets/AppImageTarget"
88
import FlatpakTarget from "./targets/FlatpakTarget"
9-
import FpmTarget from "./targets/fpm"
9+
import FpmTarget from "./targets/FpmTarget"
1010
import { LinuxTargetHelper } from "./targets/LinuxTargetHelper"
1111
import SnapTarget from "./targets/snap"
1212
import { createCommonTarget } from "./targets/targetFactory"
@@ -57,7 +57,7 @@ export class LinuxPackager extends PlatformPackager<LinuxConfiguration> {
5757
case "pacman":
5858
case "apk":
5959
case "p5p":
60-
return require("./targets/fpm").default
60+
return require("./targets/FpmTarget").default
6161
default:
6262
return null
6363
}

packages/app-builder-lib/src/publish/PublishManager.ts

-3
Original file line numberDiff line numberDiff line change
@@ -109,9 +109,6 @@ export class PublishManager implements PublishContext {
109109
if (!event.targets.some(it => isSuitableWindowsTarget(it))) {
110110
return
111111
}
112-
} else {
113-
// AppImage writes data to AppImage stage dir, not to linux-unpacked
114-
return
115112
}
116113

117114
const publishConfig = await getAppUpdatePublishConfiguration(packager, event.arch, this.isPublish)

packages/app-builder-lib/src/targets/fpm.ts packages/app-builder-lib/src/targets/FpmTarget.ts

+41-4
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { path7za } from "7zip-bin"
2-
import { Arch, executeAppBuilder, log, TmpDir, toLinuxArchString, use } from "builder-util"
2+
import { Arch, executeAppBuilder, getArchSuffix, log, TmpDir, toLinuxArchString, use, serializeToYaml } from "builder-util"
33
import { unlinkIfExists } from "builder-util/out/fs"
4-
import { outputFile } from "fs-extra"
4+
import { outputFile, stat } from "fs-extra"
55
import { mkdir, readFile } from "fs/promises"
66
import * as path from "path"
77
import { smarten } from "../appInfo"
@@ -15,6 +15,9 @@ import { isMacOsSierra } from "../util/macosVersion"
1515
import { getTemplatePath } from "../util/pathManager"
1616
import { installPrefix, LinuxTargetHelper } from "./LinuxTargetHelper"
1717
import { getLinuxToolsPath } from "./tools"
18+
import { hashFile } from "../util/hash"
19+
import { ArtifactCreated } from "../packagerApi"
20+
import { getAppUpdatePublishConfiguration } from "../publish/PublishManager"
1821

1922
interface FpmOptions {
2023
name: string
@@ -109,7 +112,8 @@ export default class FpmTarget extends Target {
109112
}
110113

111114
const packager = this.packager
112-
const artifactPath = path.join(this.outDir, packager.expandArtifactNamePattern(this.options, target, arch, nameFormat, !isUseArchIfX64))
115+
const artifactName = packager.expandArtifactNamePattern(this.options, target, arch, nameFormat, !isUseArchIfX64)
116+
const artifactPath = path.join(this.outDir, artifactName)
113117

114118
await packager.info.callArtifactBuildStarted({
115119
targetPresentableName: target,
@@ -122,6 +126,18 @@ export default class FpmTarget extends Target {
122126
await mkdir(this.outDir, { recursive: true })
123127
}
124128

129+
const publishConfig = this.supportsAutoUpdate(target)
130+
? await getAppUpdatePublishConfiguration(packager, arch, false /* in any case validation will be done on publish */)
131+
: null
132+
if (publishConfig != null) {
133+
const linuxDistType = this.packager.packagerOptions.prepackaged || path.join(this.outDir, `linux${getArchSuffix(arch)}-unpacked`)
134+
const resourceDir = packager.getResourcesDir(linuxDistType)
135+
log.info({ resourceDir }, `adding autoupdate files for: ${target}. (Beta feature)`)
136+
await outputFile(path.join(resourceDir, "app-update.yml"), serializeToYaml(publishConfig))
137+
// Extra file needed for auto-updater to detect installation method
138+
await outputFile(path.join(resourceDir, "package-type"), target)
139+
}
140+
125141
const scripts = await this.scriptFiles
126142
const appInfo = packager.appInfo
127143
const options = this.options
@@ -229,7 +245,28 @@ export default class FpmTarget extends Target {
229245

230246
await executeAppBuilder(["fpm", "--configuration", JSON.stringify(fpmConfiguration)], undefined, { env })
231247

232-
await packager.dispatchArtifactCreated(artifactPath, this, arch)
248+
let info: ArtifactCreated = {
249+
file: artifactPath,
250+
target: this,
251+
arch,
252+
packager,
253+
}
254+
if (publishConfig != null) {
255+
info = {
256+
...info,
257+
safeArtifactName: packager.computeSafeArtifactName(artifactName, target, arch, !isUseArchIfX64),
258+
isWriteUpdateInfo: true,
259+
updateInfo: {
260+
sha512: await hashFile(artifactPath),
261+
size: (await stat(artifactPath)).size,
262+
},
263+
}
264+
}
265+
await packager.info.callArtifactBuildCompleted(info)
266+
}
267+
268+
private supportsAutoUpdate(target: string) {
269+
return ["deb", "rpm"].includes(target)
233270
}
234271
}
235272

packages/builder-util-runtime/src/publishOptions.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,7 @@ export interface PublishConfiguration {
5050
/**
5151
* Request timeout in milliseconds. (Default is 2 minutes; O is ignored)
5252
*
53-
* @default 60000
53+
* @default 120000
5454
*/
5555
readonly timeout?: number | null
5656
}

packages/electron-updater/src/AppImageUpdater.ts

+5-9
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { AllPublishOptions, newError } from "builder-util-runtime"
2-
import { execFileSync, spawn } from "child_process"
2+
import { execFileSync } from "child_process"
33
import { chmod } from "fs-extra"
44
import { unlinkSync } from "fs"
55
import * as path from "path"
@@ -30,7 +30,7 @@ export class AppImageUpdater extends BaseUpdater {
3030
/*** @private */
3131
protected doDownloadUpdate(downloadUpdateOptions: DownloadUpdateOptions): Promise<Array<string>> {
3232
const provider = downloadUpdateOptions.updateInfoAndProvider.provider
33-
const fileInfo = findFile(provider.resolveFiles(downloadUpdateOptions.updateInfoAndProvider.info), "AppImage")!
33+
const fileInfo = findFile(provider.resolveFiles(downloadUpdateOptions.updateInfoAndProvider.info), "AppImage", ["rpm", "deb"])!
3434
return this.executeDownload({
3535
fileExtension: "AppImage",
3636
fileInfo,
@@ -99,19 +99,15 @@ export class AppImageUpdater extends BaseUpdater {
9999
}
100100

101101
const env: any = {
102-
...process.env,
103102
APPIMAGE_SILENT_INSTALL: "true",
104103
}
105104

106105
if (options.isForceRunAfter) {
107-
spawn(destination, [], {
108-
detached: true,
109-
stdio: "ignore",
110-
env,
111-
}).unref()
106+
// eslint-disable-next-line @typescript-eslint/no-floating-promises
107+
this.spawnLog(destination, [], env)
112108
} else {
113109
env.APPIMAGE_EXIT_AFTER_INSTALL = "true"
114-
execFileSync(destination, [], { env })
110+
execFileSync(destination, [], env)
115111
}
116112
return true
117113
}

packages/electron-updater/src/BaseUpdater.ts

+55
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { AllPublishOptions } from "builder-util-runtime"
2+
import { spawn, spawnSync } from "child_process"
23
import { AppAdapter } from "./AppAdapter"
34
import { AppUpdater, DownloadExecutorTask } from "./AppUpdater"
45

@@ -98,6 +99,60 @@ export abstract class BaseUpdater extends AppUpdater {
9899
this.install(true, false)
99100
})
100101
}
102+
103+
protected wrapSudo() {
104+
const { name } = this.app
105+
const installComment = `"${name} would like to update"`
106+
const sudo = this.spawnSyncLog("which gksudo || which kdesudo || which pkexec || which beesu")
107+
const command = [sudo]
108+
if (/kdesudo/i.test(sudo)) {
109+
command.push("--comment", installComment)
110+
command.push("-c")
111+
} else if (/gksudo/i.test(sudo)) {
112+
command.push("--message", installComment)
113+
} else if (/pkexec/i.test(sudo)) {
114+
command.push("--disable-internal-agent")
115+
}
116+
return command.join(" ")
117+
}
118+
119+
protected spawnSyncLog(cmd: string, args: string[] = [], env = {}): string {
120+
this._logger.info(`Executing: ${cmd} with args: ${args}`)
121+
const response = spawnSync(cmd, args, {
122+
stdio: "pipe",
123+
env: { ...process.env, ...env },
124+
encoding: "utf-8",
125+
shell: true,
126+
})
127+
return response.stdout.trim()
128+
}
129+
130+
/**
131+
* This handles both node 8 and node 10 way of emitting error when spawning a process
132+
* - node 8: Throws the error
133+
* - node 10: Emit the error(Need to listen with on)
134+
*/
135+
// https://github.com/electron-userland/electron-builder/issues/1129
136+
// Node 8 sends errors: https://nodejs.org/dist/latest-v8.x/docs/api/errors.html#errors_common_system_errors
137+
protected async spawnLog(cmd: string, args: string[] = [], env: any = {}): Promise<boolean> {
138+
this._logger.info(`Executing: ${cmd} with args: ${args}`)
139+
return new Promise<boolean>((resolve, reject) => {
140+
try {
141+
const p = spawn(cmd, args, {
142+
stdio: "pipe",
143+
env: { ...process.env, ...env },
144+
detached: true,
145+
})
146+
p.on("error", error => {
147+
reject(error)
148+
})
149+
p.unref()
150+
resolve(p.pid !== undefined)
151+
} catch (error) {
152+
reject(error)
153+
}
154+
})
155+
}
101156
}
102157

103158
export interface InstallOptions {
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import { AllPublishOptions } from "builder-util-runtime"
2+
import { AppAdapter } from "./AppAdapter"
3+
import { DownloadUpdateOptions } from "./AppUpdater"
4+
import { BaseUpdater, InstallOptions } from "./BaseUpdater"
5+
import { DOWNLOAD_PROGRESS } from "./main"
6+
import { findFile } from "./providers/Provider"
7+
8+
export class DebUpdater extends BaseUpdater {
9+
constructor(options?: AllPublishOptions | null, app?: AppAdapter) {
10+
super(options, app)
11+
}
12+
13+
/*** @private */
14+
protected doDownloadUpdate(downloadUpdateOptions: DownloadUpdateOptions): Promise<Array<string>> {
15+
const provider = downloadUpdateOptions.updateInfoAndProvider.provider
16+
const fileInfo = findFile(provider.resolveFiles(downloadUpdateOptions.updateInfoAndProvider.info), "deb", ["AppImage", "rpm"])!
17+
return this.executeDownload({
18+
fileExtension: "deb",
19+
fileInfo,
20+
downloadUpdateOptions,
21+
task: async (updateFile, downloadOptions) => {
22+
if (this.listenerCount(DOWNLOAD_PROGRESS) > 0) {
23+
downloadOptions.onProgress = it => this.emit(DOWNLOAD_PROGRESS, it)
24+
}
25+
await this.httpExecutor.download(fileInfo.url, updateFile, downloadOptions)
26+
},
27+
})
28+
}
29+
30+
protected doInstall(options: InstallOptions): boolean {
31+
const sudo = this.wrapSudo()
32+
// pkexec doesn't want the command to be wrapped in " quotes
33+
const wrapper = /pkexec/i.test(sudo) ? "" : `"`
34+
const cmd = ["dpkg", "-i", options.installerPath, "||", "apt-get", "install", "-f", "-y"]
35+
this.spawnSyncLog(sudo, [`${wrapper}/bin/bash`, "-c", `'${cmd.join(" ")}'${wrapper}`])
36+
return true
37+
}
38+
}

0 commit comments

Comments
 (0)