Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
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
6 changes: 6 additions & 0 deletions .changeset/sad-ends-vanish.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"electron-updater": minor
"app-builder-lib": minor
---

feat(nsis): transition UAC elevation to pure `powershell + Start-Process + RunAs` with legacy `elevate.exe` as fallback
18 changes: 14 additions & 4 deletions packages/app-builder-lib/src/targets/nsis/nsisUtil.ts
Original file line number Diff line number Diff line change
Expand Up @@ -129,14 +129,24 @@ export class CopyElevateHelper {
return promise
}

promise = NSIS_PATH().then(it => {
promise = NSIS_PATH().then(async it => {
const sourceFile = path.join(it, "elevate.exe")
const outFile = path.join(appOutDir, "resources", "elevate.exe")
const promise = copyFile(path.join(it, "elevate.exe"), outFile, false)
try {
await fs.access(sourceFile)
} catch (e: any) {
if (e.code !== "ENOENT") {
throw e
}
log.debug({ path: sourceFile }, "elevate.exe not included in NSIS directory — skipping (UAC elevation uses PowerShell instead as primary method)")
return
}
const copyPromise = copyFile(sourceFile, outFile, false)
const { signAndEditExecutable, signExecutable } = target.packager.platformSpecificBuildOptions
if (signAndEditExecutable !== false && signExecutable !== false) {
return promise.then(() => target.packager.signIf(outFile))
return copyPromise.then(() => target.packager.signIf(outFile))
}
return promise
return copyPromise
})
this.copied.set(appOutDir, promise)
return promise
Expand Down
17 changes: 14 additions & 3 deletions packages/electron-updater/src/NsisUpdater.ts
Original file line number Diff line number Diff line change
Expand Up @@ -151,11 +151,22 @@ export class NsisUpdater extends BaseUpdater {
}

const callUsingElevation = (): void => {
this.spawnLog(path.join(process.resourcesPath, "elevate.exe"), [installerPath].concat(args)).catch(e => this.dispatchError(e))
// Wrap args containing spaces in Win32 double-quotes so Start-Process preserves them as single tokens
const psInstallArgs = args.map(a => (a.includes(" ") ? `'"${a.replace(/"/g, '""')}"'` : `'${a.replace(/'/g, "''")}'`)).join(",")
const psScript = `Start-Process -FilePath '${installerPath.replace(/'/g, "''")}' -ArgumentList @(${psInstallArgs}) -Verb RunAs`
const encodedCmd = Buffer.from(psScript, "utf16le").toString("base64")
this.spawnLog("powershell.exe", ["-NonInteractive", "-NoProfile", "-EncodedCommand", encodedCmd]).catch(e => {
if ((e as NodeJS.ErrnoException).code !== "ENOENT") {
this.dispatchError(e)
return
}
// powershell.exe not found — fall back to legacy elevate.exe
this.spawnLog(path.join(process.resourcesPath, "elevate.exe"), [installerPath].concat(args)).catch(err => this.dispatchError(err))
})
Comment thread
mmaietta marked this conversation as resolved.
}

if (options.isAdminRightsRequired) {
this._logger.info("isAdminRightsRequired is set to true, run installer using elevate.exe")
this._logger.info("isAdminRightsRequired is set to true, running installer with UAC elevation via PowerShell (elevate.exe fallback if PowerShell unavailable)")
callUsingElevation()
return true
}
Expand All @@ -165,7 +176,7 @@ export class NsisUpdater extends BaseUpdater {
// Node 8 sends errors: https://nodejs.org/dist/latest-v8.x/docs/api/errors.html#errors_common_system_errors
const errorCode = (e as NodeJS.ErrnoException).code
this._logger.info(
`Cannot run installer: error code: ${errorCode}, error message: "${e.message}", will be executed again using elevate if EACCES, and will try to use electron.shell.openItem if ENOENT`
`Cannot run installer: error code: ${errorCode}, error message: "${e.message}", will be executed again using UAC elevation if EACCES, and will try to use electron.shell.openPath if ENOENT`
)
if (errorCode === "UNKNOWN" || errorCode === "EACCES") {
callUsingElevation()
Expand Down
11 changes: 11 additions & 0 deletions test/snapshots/updater/nsisUpdaterInstallTest.js.snap
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html

exports[`NsisUpdater.doInstall elevation > uses PowerShell with -NoProfile and -EncodedCommand when isAdminRightsRequired 1`] = `
[
"-NonInteractive",
"-NoProfile",
"-EncodedCommand",
]
`;

exports[`NsisUpdater.doInstall elevation > uses PowerShell with -NoProfile and -EncodedCommand when isAdminRightsRequired 2`] = `"Start-Process -FilePath '/fake/installer.exe' -ArgumentList @('--updated','/S') -Verb RunAs"`;
113 changes: 113 additions & 0 deletions test/src/updater/nsisUpdaterInstallTest.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
import * as path from "path"
import { vi, beforeEach, afterEach } from "vitest"
import { createNsisUpdater } from "../helpers/updaterTestUtil"

describe("NsisUpdater.doInstall elevation", () => {
beforeEach(() => {
// process.resourcesPath is Electron-specific; stub it for the Node test environment
Object.defineProperty(process, "resourcesPath", { value: "/fake/resources", writable: true, configurable: true })
})

afterEach(() => {
vi.restoreAllMocks()
Object.defineProperty(process, "resourcesPath", { value: undefined, writable: true, configurable: true })
})

test("uses PowerShell with -NoProfile and -EncodedCommand when isAdminRightsRequired", async ({ expect }) => {
const updater = await createNsisUpdater()
const spawnLogMock = vi.spyOn(updater as any, "spawnLog").mockResolvedValue(true)
vi.spyOn(updater as any, "installerPath", "get").mockReturnValue("/fake/installer.exe")

;(updater as any).doInstall({ isSilent: true, isForceRunAfter: false, isAdminRightsRequired: true })

expect(spawnLogMock).toHaveBeenCalledOnce()
const [cmd, args] = spawnLogMock.mock.calls[0] as [string, string[]]
expect(cmd).toBe("powershell.exe")
const encodedIdx = args.indexOf("-EncodedCommand")
const script = Buffer.from(args[encodedIdx + 1], "base64").toString("utf16le")
expect(args.slice(0, encodedIdx + 1)).toMatchSnapshot()
expect(script).toMatchSnapshot()
})

test("wraps installer args containing spaces in Win32 double-quotes", async ({ expect }) => {
const updater = await createNsisUpdater()
updater.installDirectory = "C:\\Program Files\\My App"
const spawnLogMock = vi.spyOn(updater as any, "spawnLog").mockResolvedValue(true)
vi.spyOn(updater as any, "installerPath", "get").mockReturnValue("/fake/installer.exe")

;(updater as any).doInstall({ isSilent: false, isForceRunAfter: false, isAdminRightsRequired: true })

const [, args] = spawnLogMock.mock.calls[0] as [string, string[]]
const encodedIdx = args.indexOf("-EncodedCommand")
const script = Buffer.from(args[encodedIdx + 1], "base64").toString("utf16le")
expect(script).toContain('"/D=C:\\Program Files\\My App"')
})

test("dispatches error when powershell.exe fails with non-ENOENT error", async ({ expect }) => {
const updater = await createNsisUpdater()
const error = Object.assign(new Error("spawn UNKNOWN"), { code: "UNKNOWN" })
vi.spyOn(updater as any, "spawnLog").mockRejectedValue(error)
vi.spyOn(updater as any, "installerPath", "get").mockReturnValue("/fake/installer.exe")
const dispatchErrorMock = vi.spyOn(updater as any, "dispatchError").mockImplementation(() => {})

;(updater as any).doInstall({ isSilent: false, isForceRunAfter: false, isAdminRightsRequired: true })
await new Promise(resolve => setTimeout(resolve, 10))

expect(dispatchErrorMock).toHaveBeenCalledWith(error)
})

test("falls back to elevate.exe spawn when powershell.exe not found", async ({ expect }) => {
const updater = await createNsisUpdater()
const psError = Object.assign(new Error("spawn ENOENT"), { code: "ENOENT" })
const spawnLogMock = vi.spyOn(updater as any, "spawnLog").mockRejectedValueOnce(psError).mockResolvedValueOnce(true)
vi.spyOn(updater as any, "installerPath", "get").mockReturnValue("/fake/installer.exe")

;(updater as any).doInstall({ isSilent: false, isForceRunAfter: false, isAdminRightsRequired: true })
await new Promise(resolve => setTimeout(resolve, 10))

expect(spawnLogMock).toHaveBeenCalledTimes(2)
expect(spawnLogMock.mock.calls[0][0]).toBe("powershell.exe")
expect(spawnLogMock.mock.calls[1][0]).toBe(path.join("/fake/resources", "elevate.exe"))
})
})

describe.ifWindows("NsisUpdater.doInstall elevation — Windows integration", () => {
test("powershell.exe accepts -EncodedCommand on this system", async ({ expect }) => {
const { execFile } = await import("child_process")
const { promisify } = await import("util")
const execFileAsync = promisify(execFile)
const script = "Write-Output 'elevation-test-ok'"
const encoded = Buffer.from(script, "utf16le").toString("base64")
const { stdout } = await execFileAsync("powershell.exe", ["-NoProfile", "-NonInteractive", "-EncodedCommand", encoded], { encoding: "utf8" })
expect(stdout.trim()).toBe("elevation-test-ok")
})

test("generated Start-Process script is syntactically valid for plain args", async ({ expect }) => {
const installerPath = "C:\\fake\\installer.exe"
const args = ["--updated", "/S"]
const psInstallArgs = args.map(a => `'${a.replace(/'/g, "''")}'`).join(",")
const psScript = `Start-Process -FilePath '${installerPath.replace(/'/g, "''")}' -ArgumentList @(${psInstallArgs}) -Verb RunAs`
await assertPsScriptParses(psScript)
expect(true).toBe(true)
})

test("generated Start-Process script is syntactically valid for args containing spaces", async ({ expect }) => {
const installerPath = "C:\\fake\\installer.exe"
const args = ["--updated", "/D=C:\\Program Files\\My App"]
const psInstallArgs = args.map(a => (a.includes(" ") ? `'"${a.replace(/"/g, '""')}"'` : `'${a.replace(/'/g, "''")}'`)).join(",")
const psScript = `Start-Process -FilePath '${installerPath.replace(/'/g, "''")}' -ArgumentList @(${psInstallArgs}) -Verb RunAs`
await assertPsScriptParses(psScript)
expect(true).toBe(true)
})
})

async function assertPsScriptParses(script: string): Promise<void> {
const { execFile } = await import("child_process")
const { promisify } = await import("util")
const execFileAsync = promisify(execFile)
// Count parse errors without executing the script
const parseCmd = `$errors = $null; $null = [System.Management.Automation.Language.Parser]::ParseInput(${JSON.stringify(script)}, [ref]$null, [ref]$errors); exit $errors.Count`
const encoded = Buffer.from(parseCmd, "utf16le").toString("base64")
// Throws on non-zero exit code (= parse errors found)
await execFileAsync("powershell.exe", ["-NoProfile", "-NonInteractive", "-EncodedCommand", encoded])
}
Loading