Skip to content

fix(updater): don't throw when determining which package manager to use #9113

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 30 commits into from
May 27, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
7680148
fix: don't error out when trying to determine package manager
mmaietta May 17, 2025
5e3de84
can we add test now that we don't need sudo?
mmaietta May 17, 2025
eb3e673
verify installation
mmaietta May 17, 2025
a5edd08
refactor and add dockerfiles for each linux updater test
mmaietta May 17, 2025
d757b32
fedora
mmaietta May 17, 2025
d0bab15
retry with grep ID
mmaietta May 17, 2025
8d40b84
retry with grep ID
mmaietta May 17, 2025
d0d4f3d
execSync
mmaietta May 17, 2025
0cc35d1
decrease test
mmaietta May 17, 2025
c76d091
package manager detector using `command -v`
mmaietta May 17, 2025
298b0ab
refactor to LinuxUpdater abstract class
mmaietta May 17, 2025
9768544
extract docker builds to separate build step
mmaietta May 17, 2025
a6e0947
edge case for sudo
mmaietta May 17, 2025
4f6d8e8
adding helper script for running linux updater tests
mmaietta May 17, 2025
6daf53f
cleanup
mmaietta May 17, 2025
b758ee3
swap order of package managers
mmaietta May 17, 2025
e3b5410
Make commands more robust
mmaietta May 18, 2025
be3db74
add loop in LinuxUpdaterTest with override value to force update logi…
mmaietta May 19, 2025
9c77213
Merge branch 'master' into fix/updater-which-package-manager
mmaietta May 22, 2025
4429c2e
Merge branch 'master' into fix/updater-which-package-manager
mmaietta May 25, 2025
4a5935c
re-enable test for linux
mmaietta May 25, 2025
f1fd185
rebase off master and retry pacman
mmaietta May 26, 2025
0d82fdb
oops
mmaietta May 26, 2025
e043a1f
got em
mmaietta May 26, 2025
381b997
get deb working with older fpm
mmaietta May 26, 2025
9d20009
deb works!
mmaietta May 26, 2025
bf7fac0
run all?
mmaietta May 26, 2025
459287f
tmp save
mmaietta May 26, 2025
edb5b72
Potential fix for code scanning alert no. 50: Incomplete string escap…
mmaietta May 27, 2025
1b148e8
bump updater timeout since it compiles docker images now
mmaietta May 27, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/little-bulldogs-thank.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"electron-updater": patch
---

fix: don't error out when trying to determine package manager
2 changes: 1 addition & 1 deletion .github/workflows/test.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -114,7 +114,7 @@ jobs:
# Need to separate from other tests because logic is specific to when TOKEN env vars are set
test-updater:
runs-on: ubuntu-22.04
timeout-minutes: 20
timeout-minutes: 30
needs: [check-if-docker-build, run-docker-build]
# Wonky if-conditional to allow this step to run AFTER docker images are rebuilt OR if the build stage skipped and we want to use dockerhub registry for images
if: |
Expand Down
21 changes: 20 additions & 1 deletion packages/app-builder-lib/src/targets/FpmTarget.ts
Original file line number Diff line number Diff line change
Expand Up @@ -292,7 +292,8 @@ export default class FpmTarget extends Target {

private async executeFpm(target: string, fpmConfiguration: FpmConfiguration, env: any) {
const fpmArgs = ["-s", "dir", "--force", "-t", target]
if (process.env.FPM_DEBUG === "true") {
const forceDebugLogging = process.env.FPM_DEBUG === "true"
if (forceDebugLogging) {
fpmArgs.push("--debug")
}
if (log.isDebugEnabled) {
Expand All @@ -317,6 +318,24 @@ export default class FpmTarget extends Target {
log.error(null, hint + "(sudo apt-get install rpm)")
}
}
if (e.message.includes("xz: not found")) {
const hint = "to build rpm, executable xz is required, please install xz package on your system. "
if (process.platform === "darwin") {
log.error(null, hint + "(brew install xz)")
} else {
log.error(null, hint + "(sudo apt-get install xz-utils)")
}
}
if (e.message.includes("error: File not found")) {
log.error(
{ fpmArgs, ...fpmConfiguration },
"fpm failed to find the specified files. Please check your configuration and ensure all paths are correct. To see what files triggered this, set the environment variable FPM_DEBUG=true"
)
if (forceDebugLogging) {
log.error(null, e.message)
}
throw new Error(`FPM failed to find the specified files. Please check your configuration and ensure all paths are correct. Command: ${fpmPath} ${fpmArgs.join(" ")}`)
}
throw e
})
}
Expand Down
2 changes: 1 addition & 1 deletion packages/electron-updater/src/AppImageUpdater.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,7 @@ export class AppImageUpdater extends BaseUpdater {
const existingBaseName = path.basename(appImageFile)
const installerPath = this.installerPath
if (installerPath == null) {
this.dispatchError(new Error("No valid update available, can't quit and install"))
this.dispatchError(new Error("No update filepath provided, can't quit and install"))
return false
}
// https://github.com/electron-userland/electron-builder/issues/2964
Expand Down
18 changes: 1 addition & 17 deletions packages/electron-updater/src/BaseUpdater.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ export abstract class BaseUpdater extends AppUpdater {
const installerPath = this.installerPath
const downloadedFileInfo = downloadedUpdateHelper == null ? null : downloadedUpdateHelper.downloadedFileInfo
if (installerPath == null || downloadedFileInfo == null) {
this.dispatchError(new Error("No valid update available, can't quit and install"))
this.dispatchError(new Error("No update filepath provided, can't quit and install"))
return false
}

Expand Down Expand Up @@ -103,22 +103,6 @@ export abstract class BaseUpdater extends AppUpdater {
})
}

protected wrapSudo() {
const { name } = this.app
const installComment = `"${name} would like to update"`
const sudo = this.spawnSyncLog("which gksudo || which kdesudo || which pkexec || which beesu")
const command = [sudo]
if (/kdesudo/i.test(sudo)) {
command.push("--comment", installComment)
command.push("-c")
} else if (/gksudo/i.test(sudo)) {
command.push("--message", installComment)
} else if (/pkexec/i.test(sudo)) {
command.push("--disable-internal-agent")
}
return command.join(" ")
}

protected spawnSyncLog(cmd: string, args: string[] = [], env = {}): string {
this._logger.info(`Executing: ${cmd} with args: ${args}`)
const response = spawnSync(cmd, args, {
Expand Down
58 changes: 45 additions & 13 deletions packages/electron-updater/src/DebUpdater.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
import { AllPublishOptions } from "builder-util-runtime"
import { AppAdapter } from "./AppAdapter"
import { DownloadUpdateOptions } from "./AppUpdater"
import { BaseUpdater, InstallOptions } from "./BaseUpdater"
import { InstallOptions } from "./BaseUpdater"
import { findFile } from "./providers/Provider"
import { DOWNLOAD_PROGRESS } from "./types"
import { DOWNLOAD_PROGRESS, Logger } from "./types"
import { LinuxUpdater } from "./LinuxUpdater"

export class DebUpdater extends BaseUpdater {
export class DebUpdater extends LinuxUpdater {
constructor(options?: AllPublishOptions | null, app?: AppAdapter) {
super(options, app)
}
Expand All @@ -27,24 +28,55 @@ export class DebUpdater extends BaseUpdater {
})
}

protected get installerPath(): string | null {
return super.installerPath?.replace(/ /g, "\\ ") ?? null
}

protected doInstall(options: InstallOptions): boolean {
const sudo = this.wrapSudo()
// pkexec doesn't want the command to be wrapped in " quotes
const wrapper = /pkexec/i.test(sudo) ? "" : `"`
const installerPath = this.installerPath
if (installerPath == null) {
this.dispatchError(new Error("No valid update available, can't quit and install"))
this.dispatchError(new Error("No update filepath provided, can't quit and install"))
return false
}
if (!this.hasCommand("dpkg") && !this.hasCommand("apt")) {
this.dispatchError(new Error("Neither dpkg nor apt command found. Cannot install .deb package."))
return false
}
const priorityList = ["dpkg", "apt"]
const packageManager = this.detectPackageManager(priorityList)
try {
DebUpdater.installWithCommandRunner(packageManager as any, installerPath, this.runCommandWithSudoIfNeeded.bind(this), this._logger)
} catch (error: any) {
this.dispatchError(error)
return false
}
const cmd = ["dpkg", "-i", installerPath, "||", "apt-get", "install", "-f", "-y"]
this.spawnSyncLog(sudo, [`${wrapper}/bin/bash`, "-c", `'${cmd.join(" ")}'${wrapper}`])
if (options.isForceRunAfter) {
this.app.relaunch()
}
return true
}

static installWithCommandRunner(packageManager: "dpkg" | "apt", installerPath: string, commandRunner: (commandWithArgs: string[]) => void, logger: Logger) {
if (packageManager === "dpkg") {
try {
// Primary: Install unsigned .deb directly with dpkg
commandRunner(["dpkg", "-i", installerPath])
} catch (error: any) {
// Handle missing dependencies via apt-get
logger.warn(error.message ?? error)
logger.warn("dpkg installation failed, trying to fix broken dependencies with apt-get")
commandRunner(["apt-get", "install", "-f", "-y"])
}
} else if (packageManager === "apt") {
// Fallback: Use apt for direct install (less safe for unsigned .deb)
logger.warn("Using apt to install a local .deb. This may fail for unsigned packages unless properly configured.")
commandRunner([
"apt",
"install",
"-y",
"--allow-unauthenticated", // needed for unsigned .debs
"--allow-downgrades", // allow lower version installs
"--allow-change-held-packages",
installerPath,
])
} else {
throw new Error(`Package manager ${packageManager} not supported`)
}
}
}
95 changes: 95 additions & 0 deletions packages/electron-updater/src/LinuxUpdater.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
import { AllPublishOptions } from "builder-util-runtime"
import { AppAdapter } from "./AppAdapter"
import { BaseUpdater } from "./BaseUpdater"

export abstract class LinuxUpdater extends BaseUpdater {
constructor(options?: AllPublishOptions | null, app?: AppAdapter) {
super(options, app)
}

/**
* Returns true if the current process is running as root.
*/
protected isRunningAsRoot(): boolean {
return process.getuid?.() === 0
}

/**
* Sanitizies the installer path for using with command line tools.
*/
protected get installerPath(): string | null {
return super.installerPath?.replace(/\\/g, "\\\\").replace(/ /g, "\\ ") ?? null
}

protected runCommandWithSudoIfNeeded(commandWithArgs: string[]) {
if (this.isRunningAsRoot()) {
this._logger.info("Running as root, no need to use sudo")
return this.spawnSyncLog(commandWithArgs[0], commandWithArgs.slice(1))
}

const { name } = this.app
const installComment = `"${name} would like to update"`
const sudo = this.sudoWithArgs(installComment)
this._logger.info(`Running as non-root user, using sudo to install: ${sudo}`)
// pkexec doesn't want the command to be wrapped in " quotes
const wrapper = /pkexec/i.test(sudo[0]) ? "" : `"`
return this.spawnSyncLog(sudo[0], [...(sudo.length > 1 ? sudo.slice(1) : []), `${wrapper}/bin/bash`, "-c", `'${commandWithArgs.join(" ")}'${wrapper}`])
}

protected sudoWithArgs(installComment: string): string[] {
const sudo = this.determineSudoCommand()
const command = [sudo]
if (/kdesudo/i.test(sudo)) {
command.push("--comment", installComment)
command.push("-c")
} else if (/gksudo/i.test(sudo)) {
command.push("--message", installComment)
} else if (/pkexec/i.test(sudo)) {
command.push("--disable-internal-agent")
}
return command
}

protected hasCommand(cmd: string): boolean {
try {
this.spawnSyncLog(`command`, ["-v", cmd])
return true
} catch {
return false
}
}

protected determineSudoCommand(): string {
const sudos = ["gksudo", "kdesudo", "pkexec", "beesu"]
for (const sudo of sudos) {
if (this.hasCommand(sudo)) {
return sudo
}
}
return "sudo"
}

/**
* Detects the package manager to use based on the available commands.
* Allows overriding the default behavior by setting the ELECTRON_BUILDER_LINUX_PACKAGE_MANAGER environment variable.
* If the environment variable is set, it will be used directly. (This is useful for testing each package manager logic path.)
* Otherwise, it checks for the presence of the specified package manager commands in the order provided.
* @param pms - An array of package manager commands to check for, in priority order.
* @returns The detected package manager command or "unknown" if none are found.
*/
protected detectPackageManager(pms: string[]): string {
const pmOverride = process.env.ELECTRON_BUILDER_LINUX_PACKAGE_MANAGER?.trim()
if (pmOverride) {
return pmOverride
}
// Check for the package manager in the order of priority
for (const pm of pms) {
if (this.hasCommand(pm)) {
return pm
}
}
// return the first package manager in the list if none are found, this will throw upstream for proper logging
this._logger.warn(`No package manager found in the list: ${pms.join(", ")}. Defaulting to the first one: ${pms[0]}`)
return pms[0]
}
}
2 changes: 1 addition & 1 deletion packages/electron-updater/src/NsisUpdater.ts
Original file line number Diff line number Diff line change
Expand Up @@ -126,7 +126,7 @@ export class NsisUpdater extends BaseUpdater {
protected doInstall(options: InstallOptions): boolean {
const installerPath = this.installerPath
if (installerPath == null) {
this.dispatchError(new Error("No valid update available, can't quit and install"))
this.dispatchError(new Error("No update filepath provided, can't quit and install"))
return false
}

Expand Down
45 changes: 31 additions & 14 deletions packages/electron-updater/src/PacmanUpdater.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
import { AllPublishOptions } from "builder-util-runtime"
import { AppAdapter } from "./AppAdapter"
import { DownloadUpdateOptions } from "./AppUpdater"
import { BaseUpdater, InstallOptions } from "./BaseUpdater"
import { DOWNLOAD_PROGRESS } from "./types"
import { InstallOptions } from "./BaseUpdater"
import { DOWNLOAD_PROGRESS, Logger } from "./types"
import { findFile } from "./providers/Provider"
import { LinuxUpdater } from "./LinuxUpdater"

export class PacmanUpdater extends BaseUpdater {
export class PacmanUpdater extends LinuxUpdater {
constructor(options?: AllPublishOptions | null, app?: AppAdapter) {
super(options, app)
}
Expand All @@ -27,24 +28,40 @@ export class PacmanUpdater extends BaseUpdater {
})
}

protected get installerPath(): string | null {
return super.installerPath?.replace(/ /g, "\\ ") ?? null
}

protected doInstall(options: InstallOptions): boolean {
const sudo = this.wrapSudo()
// pkexec doesn't want the command to be wrapped in " quotes
const wrapper = /pkexec/i.test(sudo) ? "" : `"`
const installerPath = this.installerPath
if (installerPath == null) {
this.dispatchError(new Error("No valid update available, can't quit and install"))
this.dispatchError(new Error("No update filepath provided, can't quit and install"))
return false
}
try {
PacmanUpdater.installWithCommandRunner(installerPath, this.runCommandWithSudoIfNeeded.bind(this), this._logger)
} catch (error: any) {
this.dispatchError(error)
return false
}
const cmd = ["pacman", "-U", "--noconfirm", installerPath]
this.spawnSyncLog(sudo, [`${wrapper}/bin/bash`, "-c", `'${cmd.join(" ")}'${wrapper}`])
if (options.isForceRunAfter) {
this.app.relaunch()
this.app.relaunch() // note: `app` is undefined in tests since vite doesn't run in electron
}
return true
}

static installWithCommandRunner(installerPath: string, commandRunner: (commandWithArgs: string[]) => void, logger: Logger) {
try {
commandRunner(["pacman", "-U", "--noconfirm", installerPath])
} catch (error: any) {
logger.warn(error.message ?? error)
logger.warn("pacman installation failed, attempting to update package database and retry")

try {
// Update package database (not a full upgrade, just sync)
commandRunner(["pacman", "-Sy", "--noconfirm"])
// Retry installation
commandRunner(["pacman", "-U", "--noconfirm", installerPath])
} catch (retryError: any) {
logger.error("Retry after pacman -Sy failed")
throw retryError
}
}
}
}
Loading