diff --git a/README.md b/README.md index a5943f7..0bb94c6 100644 --- a/README.md +++ b/README.md @@ -12,12 +12,20 @@ Run fiddles from anywhere, on any Electron release # fiddle-core test ver (gist | repo URL | folder) # fiddle-core bisect ver1 ver2 (gist | repo URL | folder) # +# Run with Windows MSIX identity (Windows only): +# fiddle-core run:msix ver (gist | repo URL | folder) +# fiddle-core test:msix ver (gist | repo URL | folder) +# fiddle-core start:msix ver (gist | repo URL | folder) +# # Examples: $ fiddle-core run 12.0.0 /path/to/fiddle $ fiddle-core test 12.0.0 642fa8daaebea6044c9079e3f8a46390 $ fiddle-core bisect 8.0.0 13.0.0 https://github.com/my/testcase.git +# Run with Windows MSIX identity (gives Electron a Windows app identity) +$ fiddle-core run:msix 30.0.0 /path/to/fiddle + $ fiddle-core bisect 8.0.0 13.0.0 642fa8daaebea6044c9079e3f8a46390 ... @@ -75,9 +83,34 @@ const result = await runner.run('15.0.0-alpha.1', files); // bisect a regression test across a range of Electron versions const result = await runner.bisect('10.0.0', '13.1.7', path_or_gist_or_git_repo); +// run with Windows MSIX identity (Windows only) +// This registers Electron as a sparse MSIX package, giving it a Windows app identity +const result = await runner.run('30.0.0', fiddle, { runWithIdentity: true }); + // see also `Runner.spawn()` in Advanced Use ``` +### Running with Windows MSIX Identity + +On Windows, you can run Electron with a [sparse MSIX package](https://learn.microsoft.com/en-us/windows/msix/overview) identity. This gives Electron a Windows app identity, which is required for certain Windows features. + +```ts +import { Runner } from '@electron/fiddle-core'; + +const runner = await Runner.create(); + +// Run fiddle with Windows MSIX identity +const result = await runner.run('30.0.0', fiddle, { + runWithIdentity: true, +}); +``` + +When `runWithIdentity` is `true`, fiddle-core will: +1. Generate an AppxManifest.xml in the Electron installation directory +2. Register Electron as a sparse MSIX package using `Add-AppxPackage` +3. Run Electron via its registered execution alias + + ### Managing Electron Installations ```ts diff --git a/etc/fiddle-core.api.md b/etc/fiddle-core.api.md index 9fc618c..1f600f3 100644 --- a/etc/fiddle-core.api.md +++ b/etc/fiddle-core.api.md @@ -251,6 +251,8 @@ export interface RunnerOptions { // (undocumented) runFromAsar?: boolean; // (undocumented) + runWithIdentity?: boolean; + // (undocumented) showConfig?: boolean; } diff --git a/package.json b/package.json index 4d12e91..031d471 100644 --- a/package.json +++ b/package.json @@ -11,6 +11,7 @@ "bin": "dist/cli.js", "files": [ "dist", + "static", "README.md" ], "publishConfig": { diff --git a/src/command-line.ts b/src/command-line.ts index 8c19205..b870b17 100644 --- a/src/command-line.ts +++ b/src/command-line.ts @@ -17,6 +17,7 @@ export async function runFromCommandLine(argv: string[]): Promise { type Cmd = 'bisect' | 'test' | undefined; let cmd: Cmd = undefined; + let runWithIdentity: boolean = false; let fiddle: Fiddle | undefined = undefined; d('argv', inspect(argv)); @@ -27,6 +28,13 @@ export async function runFromCommandLine(argv: string[]): Promise { } else if (param === 'test' || param === 'start' || param === 'run') { d('it is test'); cmd = 'test'; + } else if ( + param === 'test:msix' || + param === 'start:msix' || + param === 'run:msix' + ) { + cmd = 'test'; + runWithIdentity = true; } else if (versions.isVersion(param)) { versionArgs.push(param); } else { @@ -56,6 +64,7 @@ export async function runFromCommandLine(argv: string[]): Promise { if (cmd === 'test' && versionArgs.length === 1) { const result = await runner.run(versionArgs[0], fiddle, { out: process.stdout, + runWithIdentity: runWithIdentity, }); const vals = ['test_passed', 'test_failed', 'test_error', 'system_error']; process.exitCode = vals.indexOf(result.status); diff --git a/src/runner.ts b/src/runner.ts index fdfb6c0..8781d1c 100644 --- a/src/runner.ts +++ b/src/runner.ts @@ -13,6 +13,9 @@ import { Installer } from './installer.js'; import { ElectronVersions, Versions } from './versions.js'; import { Fiddle, FiddleFactory, FiddleSource } from './fiddle.js'; import { DefaultPaths, Paths } from './paths.js'; +import { registerElectronIdentity } from './windows-identity.js'; + +const MSIX_EXEC_ALIAS = 'ElectronFiddleMSIX.exe'; export interface RunnerOptions { // extra arguments to be appended to the electron invocation @@ -25,6 +28,8 @@ export interface RunnerOptions { showConfig?: boolean; // whether to run the fiddle from asar runFromAsar?: boolean; + // whether to run Electron with Windows MSIX identity (Windows only). + runWithIdentity?: boolean; } const DefaultRunnerOpts: RunnerOptions = { @@ -153,7 +158,10 @@ export class Runner { // set up the electron binary and the fiddle const electronExec = await this.getExec(version); - let exec = electronExec; + let exec = + process.platform === 'win32' && opts.runWithIdentity + ? MSIX_EXEC_ALIAS + : electronExec; let args = [...(opts.args || []), fiddle.mainPath]; if (opts.headless) ({ exec, args } = Runner.headless(exec, args)); @@ -204,6 +212,14 @@ export class Runner { fiddle: FiddleSource, opts: RunnerSpawnOptions = DefaultRunnerOpts, ): Promise { + if (process.platform === 'win32' && opts.runWithIdentity) { + const electronVersion = + version instanceof SemVer ? version.version : version; + const electronExec = await this.getExec(electronVersion); + const electronDir = path.dirname(electronExec); + await registerElectronIdentity(electronVersion, electronDir); + } + const subprocess = await this.spawn(version, fiddle, opts); return new Promise((resolve) => { diff --git a/src/windows-identity.ts b/src/windows-identity.ts new file mode 100644 index 0000000..9959991 --- /dev/null +++ b/src/windows-identity.ts @@ -0,0 +1,148 @@ +import { spawn } from 'node:child_process'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const SOURCE_MANIFEST_FILENAME = 'FiddleAppxManifest.xml'; +const TARGET_MANIFEST_FILENAME = 'AppxManifest.xml'; +const SPARSE_PACKAGE_NAME = 'Electron.Fiddle.MSIX'; + +/** + * Map Node.js os.arch() to Windows AppxManifest ProcessorArchitecture values. + */ +function getAppxArchitecture(): string { + const arch = os.arch(); + switch (arch) { + case 'x64': + return 'x64'; + case 'ia32': + return 'x86'; + case 'arm64': + return 'arm64'; + default: + return 'x64'; + } +} + +/** + * Execute a PowerShell command and return the result. + */ +function executePowerShell(command: string): Promise { + return new Promise((resolve, reject) => { + const ps = spawn('powershell.exe', [ + '-NoProfile', + '-ExecutionPolicy', + 'Bypass', + '-Command', + command, + ]); + + let stdout = ''; + let stderr = ''; + + ps.stdout.on('data', (data: Buffer) => { + stdout += data.toString(); + }); + + ps.stderr.on('data', (data: Buffer) => { + stderr += data.toString(); + }); + + ps.on('close', (code: number) => { + if (code === 0) { + resolve(stdout.trim()); + } else { + reject(new Error(`PowerShell command failed: ${stderr || stdout}`)); + } + }); + + ps.on('error', (err: Error) => { + reject(err); + }); + }); +} + +/** + * Unregister any previously registered sparse packages with our package name. + */ +async function unregisterSparsePackage(): Promise { + try { + const result = await executePowerShell( + `Get-AppxPackage -Name "${SPARSE_PACKAGE_NAME}" | Select-Object -ExpandProperty PackageFullName`, + ); + + const packages = result + .trim() + .split('\n') + .map((p) => p.trim()) + .filter((p) => p.length > 0); + + for (const pkg of packages) { + console.log(`Unregistering sparse package: ${pkg}`); + await executePowerShell(`Remove-AppxPackage -Package "${pkg}"`); + console.log(`Successfully unregistered: ${pkg}`); + } + } catch { + console.log('No existing sparse package to unregister'); + } +} + +/** + * Register the sparse package for an Electron installation. + * This gives Electron a Windows app identity. Same as an MSIX package. + * + * @param version - The Electron version string to display in the manifest. + * @param electronDir - The directory containing the Electron executable. + */ +export async function registerElectronIdentity( + version: string, + electronDir: string, +): Promise { + if (process.platform !== 'win32') { + return; + } + + const electronExe = path.join(electronDir, 'electron.exe'); + + // Check if Electron is actually installed + if (!fs.existsSync(electronExe)) { + console.log( + `Electron not found at ${electronDir}, skipping identity registration`, + ); + return; + } + + try { + const __dirname = path.dirname(fileURLToPath(import.meta.url)); + const sourcePath = path.join( + __dirname, + '..', + 'static', + SOURCE_MANIFEST_FILENAME, + ); + const targetPath = path.join(electronDir, TARGET_MANIFEST_FILENAME); + + // Read manifest and replace placeholders + let manifest = fs.readFileSync(sourcePath, 'utf8'); + const displayName = `Electron (${version}) MSIX`; + const architecture = getAppxArchitecture(); + manifest = manifest.replace(/\$DISPLAY_NAME\$/g, displayName); + manifest = manifest.replace(/\$ARCHITECTURE\$/g, architecture); + + console.log(`Writing manifest with version ${version} to ${targetPath}`); + fs.writeFileSync(targetPath, manifest, 'utf8'); + + await unregisterSparsePackage(); + + console.log(`Registering sparse package from: ${electronDir}`); + await executePowerShell( + `Add-AppxPackage -ExternalLocation "${electronDir}" -Register "${targetPath}"`, + ); + + console.log('Sparse package registered successfully'); + } catch (error: unknown) { + const message = error instanceof Error ? error.message : String(error); + console.error('Failed to register sparse package:', message); + } +} diff --git a/static/FiddleAppxManifest.xml b/static/FiddleAppxManifest.xml new file mode 100644 index 0000000..f5f46c0 --- /dev/null +++ b/static/FiddleAppxManifest.xml @@ -0,0 +1,52 @@ + + + + + true + $DISPLAY_NAME$ + Electron + assets\icon.png + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/runner.test.ts b/tests/runner.test.ts index 4ff8dd3..8d114df 100644 --- a/tests/runner.test.ts +++ b/tests/runner.test.ts @@ -5,7 +5,15 @@ import path from 'node:path'; import { Writable } from 'node:stream'; import fs from 'graceful-fs'; -import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; +import { + afterAll, + beforeAll, + beforeEach, + describe, + expect, + it, + vi, +} from 'vitest'; import { Installer, @@ -14,8 +22,12 @@ import { Runner, TestResult, } from '../src/index.js'; +import * as windowsIdentity from '../src/windows-identity.js'; vi.mock('child_process'); +vi.mock('../src/windows-identity.js', () => ({ + registerElectronIdentity: vi.fn().mockResolvedValue(undefined), +})); const mockStdout = vi.fn(); @@ -55,6 +67,10 @@ afterAll(() => { fs.rmSync(tmpdir, { recursive: true, force: true }); }); +beforeEach(() => { + vi.clearAllMocks(); +}); + async function createFakeRunner({ pathToExecutable = '/path/to/electron/executable', generatedFiddle = { @@ -228,6 +244,30 @@ describe('Runner', () => { expect.anything(), ); }); + + it.runIf(process.platform === 'win32')( + 'spawns a subprocess with MSIX execution alias when runWithIdentity is true on Windows', + async () => { + const runner = await createFakeRunner({}); + vi.mocked(child_process.spawn).mockReturnValueOnce( + mockSubprocess as unknown as child_process.ChildProcess, + ); + + await runner.spawn('12.0.1', '642fa8daaebea6044c9079e3f8a46390', { + out: { + write: mockStdout, + } as Pick as Writable, + runWithIdentity: true, + }); + + expect(child_process.spawn).toHaveBeenCalledTimes(1); + expect(child_process.spawn).toHaveBeenCalledWith( + 'ElectronFiddleMSIX.exe', + ['/path/to/fiddle/'], + expect.anything(), + ); + }, + ); }); describe('run()', () => { @@ -252,6 +292,52 @@ describe('Runner', () => { expect(result).toStrictEqual({ status }); }, ); + + it.runIf(process.platform === 'win32')( + 'calls registerElectronIdentity when runWithIdentity is true on Windows', + async () => { + const runner = await createFakeRunner({}); + const fakeSubprocess = new EventEmitter(); + runner.spawn = vi.fn().mockResolvedValue(fakeSubprocess); + + // delay to ensure that the listeners in run() are set up. + process.nextTick(() => { + fakeSubprocess.emit('exit', 0); + }); + + await runner.run('12.0.1', '642fa8daaebea6044c9079e3f8a46390', { + runWithIdentity: true, + }); + + expect(windowsIdentity.registerElectronIdentity).toHaveBeenCalledTimes( + 1, + ); + expect(windowsIdentity.registerElectronIdentity).toHaveBeenCalledWith( + '12.0.1', + '/path/to/electron', + ); + }, + ); + + it.skipIf(process.platform === 'win32')( + 'does not call registerElectronIdentity when not on Windows', + async () => { + const runner = await createFakeRunner({}); + const fakeSubprocess = new EventEmitter(); + runner.spawn = vi.fn().mockResolvedValue(fakeSubprocess); + + // delay to ensure that the listeners in run() are set up. + process.nextTick(() => { + fakeSubprocess.emit('exit', 0); + }); + + await runner.run('12.0.1', '642fa8daaebea6044c9079e3f8a46390', { + runWithIdentity: true, + }); + + expect(windowsIdentity.registerElectronIdentity).not.toHaveBeenCalled(); + }, + ); }); describe('bisect()', () => {