diff --git a/.changeset/gpupatch-integration.md b/.changeset/gpupatch-integration.md new file mode 100644 index 00000000..799b3179 --- /dev/null +++ b/.changeset/gpupatch-integration.md @@ -0,0 +1,11 @@ +--- +"@pipelab/app": minor +--- + +feat(plugin-electron): add "Patch executable" option to force high-performance GPU on Windows + +Adds a "Patch executable" checkbox to the Electron plugin. When enabled, the packaged +Windows executable is patched with [gpupatch](https://github.com/CynToolkit/gpupatch) +to inject `NvOptimusEnablement` and `AmdPowerXpressRequestHighPerformance`, forcing the +app to use the discrete GPU on laptops. The gpupatch CLI is downloaded on first use and +the option is surfaced as Windows-only in the UI. diff --git a/src/definitions.d.ts b/src/definitions.d.ts index 5a788447..4b17034c 100644 --- a/src/definitions.d.ts +++ b/src/definitions.d.ts @@ -43,6 +43,7 @@ declare namespace DesktopApp { electronVersion: string disableAsarPackaging: boolean forceHighPerformanceGpu: boolean + patchExecutable: boolean clearServiceWorkerOnBoot: boolean } diff --git a/src/main/presets/c3toSteam.ts b/src/main/presets/c3toSteam.ts index 0652a961..924b3197 100644 --- a/src/main/presets/c3toSteam.ts +++ b/src/main/presets/c3toSteam.ts @@ -185,6 +185,10 @@ export const c3toSteamPreset: PresetFn = async () => { editor: 'simple', value: 'false' }, + patchExecutable: { + editor: 'simple', + value: 'false' + }, websocketApi: { editor: 'simple', value: '[]' diff --git a/src/shared/libs/plugin-electron/forge.ts b/src/shared/libs/plugin-electron/forge.ts index aa465e86..cf10b111 100644 --- a/src/shared/libs/plugin-electron/forge.ts +++ b/src/shared/libs/plugin-electron/forge.ts @@ -17,6 +17,7 @@ import { app } from 'electron' import { detectRuntime } from '@@/plugins' import { dirname } from 'node:path' import * as esbuild from 'esbuild' +import { patchExecutableWithGpupatch } from './gpupatch' // TODO: https://js.electronforge.io/modules/_electron_forge_core.html @@ -334,6 +335,17 @@ export const configureParams = { type: 'boolean' } }, + patchExecutable: { + required: false, + description: + 'Whether to patch the packaged executable with gpupatch to force high-performance discrete GPU utilization on Windows laptops.', + label: 'Patch executable', + platforms: ['win32'], + value: false, + control: { + type: 'boolean' + } + }, // websocket apis websocketApi: { @@ -890,10 +902,19 @@ export const forge = async ( const binName = getBinName(completeConfiguration.name) const output = join(destinationFolder, 'out', outName) + const binary = join(output, binName) + + if (completeConfiguration.patchExecutable) { + await patchExecutableWithGpupatch(binary, finalPlatform as NodeJS.Platform, { + log, + abortSignal + }) + } + setOutput('output', output) return { folder: output, - binary: join(output, binName) + binary } } else { const output = join(destinationFolder, 'out', 'make') diff --git a/src/shared/libs/plugin-electron/gpupatch.spec.ts b/src/shared/libs/plugin-electron/gpupatch.spec.ts new file mode 100644 index 00000000..803d5e51 --- /dev/null +++ b/src/shared/libs/plugin-electron/gpupatch.spec.ts @@ -0,0 +1,16 @@ +import { expect, test } from 'vitest' +import { gpupatchAssetName } from './gpupatch.js' + +test('maps supported host platforms and architectures to gpupatch assets', () => { + expect(gpupatchAssetName('win32', 'x64')).toBe('gpupatch-cli-x86_64-pc-windows-msvc.exe') + expect(gpupatchAssetName('linux', 'x64')).toBe('gpupatch-cli-x86_64-unknown-linux-gnu') + expect(gpupatchAssetName('darwin', 'x64')).toBe('gpupatch-cli-x86_64-apple-darwin') + expect(gpupatchAssetName('darwin', 'arm64')).toBe('gpupatch-cli-aarch64-apple-darwin') +}) + +test('throws for unsupported platforms and architectures', () => { + expect(() => gpupatchAssetName('linux', 'arm64')).toThrow() + expect(() => gpupatchAssetName('win32', 'ia32')).toThrow() + expect(() => gpupatchAssetName('win32', 'arm64')).toThrow() + expect(() => gpupatchAssetName('freebsd', 'x64')).toThrow() +}) diff --git a/src/shared/libs/plugin-electron/gpupatch.ts b/src/shared/libs/plugin-electron/gpupatch.ts new file mode 100644 index 00000000..d797af6e --- /dev/null +++ b/src/shared/libs/plugin-electron/gpupatch.ts @@ -0,0 +1,104 @@ +import { downloadFile, fileExists, runWithLiveLogs } from '../plugin-core' +import { chmod, mkdir, rename } from 'node:fs/promises' +import { join } from 'node:path' + +const GPUPATCH_RELEASE = 'latest' + +export const gpupatchAssetName = (platform: NodeJS.Platform, arch: NodeJS.Architecture): string => { + if (platform === 'darwin') { + if (arch === 'arm64') { + return 'gpupatch-cli-aarch64-apple-darwin' + } + if (arch === 'x64') { + return 'gpupatch-cli-x86_64-apple-darwin' + } + } else if (platform === 'linux') { + if (arch === 'x64') { + return 'gpupatch-cli-x86_64-unknown-linux-gnu' + } + } else if (platform === 'win32') { + if (arch === 'x64') { + return 'gpupatch-cli-x86_64-pc-windows-msvc.exe' + } + } + throw new Error(`gpupatch is not available for ${platform}-${arch}`) +} + +export const ensureGpupatch = async ( + log: typeof console.log, + abortSignal?: AbortSignal +): Promise => { + const { app } = await import('electron') + const userData = app.getPath('userData') + const gpupatchFolder = join(userData, 'thirdparty', 'gpupatch') + const extension = process.platform === 'win32' ? '.exe' : '' + const gpupatchPath = join(gpupatchFolder, `gpupatch-cli${extension}`) + + if (await fileExists(gpupatchPath)) { + return gpupatchPath + } + + const assetName = gpupatchAssetName(process.platform, process.arch) + const url = `https://github.com/CynToolkit/gpupatch/releases/${GPUPATCH_RELEASE}/download/${assetName}` + + log('Downloading gpupatch from', url) + + await mkdir(gpupatchFolder, { recursive: true }) + + await downloadFile( + url, + gpupatchPath, + { + onProgress: ({ progress }) => { + log(`Downloading gpupatch: ${progress.toFixed(2)}%`) + } + }, + abortSignal + ) + + if (process.platform !== 'win32') { + await chmod(gpupatchPath, 0o755) + } + + return gpupatchPath +} + +export const patchExecutableWithGpupatch = async ( + binaryPath: string, + targetPlatform: NodeJS.Platform, + { log, abortSignal }: { log: typeof console.log; abortSignal?: AbortSignal } +): Promise => { + if (targetPlatform !== 'win32') { + log('gpupatch only supports Windows executables, skipping patch') + return + } + + const exePath = binaryPath.endsWith('.exe') ? binaryPath : `${binaryPath}.exe` + + log('Patching executable with gpupatch:', exePath) + + const gpupatchPath = await ensureGpupatch(log, abortSignal) + + const patchedPath = `${exePath}.gpupatch` + + await runWithLiveLogs( + gpupatchPath, + [exePath, patchedPath], + { + cancelSignal: abortSignal + }, + log, + { + onStderr(data) { + log(data) + }, + onStdout(data) { + log(data) + } + } + ) + + await rename(patchedPath, exePath) + + log('Patched executable with gpupatch') +} diff --git a/src/shared/libs/plugin-electron/package-v2.ts b/src/shared/libs/plugin-electron/package-v2.ts index 9f03b368..d487038e 100644 --- a/src/shared/libs/plugin-electron/package-v2.ts +++ b/src/shared/libs/plugin-electron/package-v2.ts @@ -20,6 +20,7 @@ export const packageV2Runner = createActionRunner