feat: msix auto-updater (#49230)

* feat: native auto updater for MSIX on Windows

* doc: added MSIX debug documentation

* fix: allow downgrade with json release file and emit update-available

* test: msix auot-update tests

* doc: API documentation

* test: add package version validation

* fix: docs typo

* fix: don't allow auto-updating when using appinstaller manifest

* fix: getPackageInfo interface implementation

* fix: review feedback, add comment

* fix: missed filename commit

* fix: install test cert on demand

* fix: time stamp mismatch in tests

* fix: feedback - rename to MSIXPackageInfo

* fix: update and reference windowsStore property

* fix: remove getPackagInfo from public API

* fix: type error bcause of removed API
This commit is contained in:
Jan Hannemann
2026-01-29 13:38:26 -08:00
committed by GitHub
parent 92a3f7d6c1
commit d74fcfcecb
20 changed files with 1707 additions and 8 deletions

View File

@@ -0,0 +1,336 @@
import { expect } from 'chai';
import * as express from 'express';
import * as http from 'node:http';
import { AddressInfo } from 'node:net';
import {
getElectronExecutable,
getMainJsFixturePath,
getMsixFixturePath,
getMsixPackageVersion,
installMsixCertificate,
installMsixPackage,
registerExecutableWithIdentity,
shouldRunMsixTests,
spawn,
uninstallMsixPackage,
unregisterExecutableWithIdentity
} from './lib/msix-helpers';
import { ifdescribe } from './lib/spec-helpers';
const ELECTRON_MSIX_ALIAS = 'ElectronMSIX.exe';
const MAIN_JS_PATH = getMainJsFixturePath();
const MSIX_V1 = getMsixFixturePath('v1');
const MSIX_V2 = getMsixFixturePath('v2');
// We can only test the MSIX updater on Windows
ifdescribe(shouldRunMsixTests)('autoUpdater MSIX behavior', function () {
this.timeout(120000);
before(async function () {
await installMsixCertificate();
const electronExec = getElectronExecutable();
await registerExecutableWithIdentity(electronExec);
});
after(async function () {
await unregisterExecutableWithIdentity();
});
const launchApp = (executablePath: string, args: string[] = []) => {
return spawn(executablePath, args);
};
const logOnError = (what: any, fn: () => void) => {
try {
fn();
} catch (err) {
console.error(what);
throw err;
}
};
it('should launch Electron via MSIX alias', async () => {
const launchResult = await launchApp(ELECTRON_MSIX_ALIAS, ['--version']);
logOnError(launchResult, () => {
expect(launchResult.code).to.equal(0);
});
});
it('should print package identity information', async () => {
const launchResult = await launchApp(ELECTRON_MSIX_ALIAS, [MAIN_JS_PATH, '--printPackageId']);
logOnError(launchResult, () => {
expect(launchResult.code).to.equal(0);
expect(launchResult.out).to.include('Family Name: Electron.Dev.MSIX_rdjwn13tdj8dy');
expect(launchResult.out).to.include('Package ID: Electron.Dev.MSIX_1.0.0.0_x64__rdjwn13tdj8dy');
expect(launchResult.out).to.include('Version: 1.0.0.0');
});
});
describe('with update server', () => {
let port = 0;
let server: express.Application = null as any;
let httpServer: http.Server = null as any;
let requests: express.Request[] = [];
beforeEach((done) => {
requests = [];
server = express();
server.use((req, res, next) => {
requests.push(req);
next();
});
httpServer = server.listen(0, '127.0.0.1', () => {
port = (httpServer.address() as AddressInfo).port;
done();
});
});
afterEach(async () => {
if (httpServer) {
await new Promise<void>(resolve => {
httpServer.close(() => {
httpServer = null as any;
server = null as any;
resolve();
});
});
}
await uninstallMsixPackage('com.electron.myapp');
});
it('should not update when no update is available', async () => {
server.get('/update-check', (req, res) => {
res.status(204).send();
});
const launchResult = await launchApp(ELECTRON_MSIX_ALIAS, [MAIN_JS_PATH, '--checkUpdate', `http://localhost:${port}/update-check`]);
logOnError(launchResult, () => {
expect(launchResult.code).to.equal(0);
expect(requests.length).to.be.greaterThan(0);
expect(launchResult.out).to.include('Checking for update...');
expect(launchResult.out).to.include('Update not available');
});
});
it('should hit the update endpoint with custom headers when checkForUpdates is called', async () => {
server.get('/update-check', (req, res) => {
expect(req.headers['x-appversion']).to.equal('1.0.0');
expect(req.headers.authorization).to.equal('Bearer test-token');
res.status(204).send();
});
const launchResult = await launchApp(ELECTRON_MSIX_ALIAS, [
MAIN_JS_PATH,
'--checkUpdate',
`http://localhost:${port}/update-check`,
'--useCustomHeaders'
]);
logOnError(launchResult, () => {
expect(launchResult.code).to.equal(0);
expect(requests.length).to.be.greaterThan(0);
expect(launchResult.out).to.include('Checking for update...');
expect(launchResult.out).to.include('Update not available');
});
});
it('should update successfully with direct link to MSIX file', async () => {
await installMsixPackage(MSIX_V1);
const initialVersion = await getMsixPackageVersion('com.electron.myapp');
expect(initialVersion).to.equal('1.0.0.0');
server.get('/update.msix', (req, res) => {
res.download(MSIX_V2);
});
const launchResult = await launchApp(ELECTRON_MSIX_ALIAS, [
MAIN_JS_PATH,
'--checkUpdate',
`http://localhost:${port}/update.msix`
]);
logOnError(launchResult, () => {
expect(launchResult.code).to.equal(0);
expect(requests.length).to.be.greaterThan(0);
expect(launchResult.out).to.include('Checking for update...');
expect(launchResult.out).to.include('Update available');
expect(launchResult.out).to.include('Update downloaded');
expect(launchResult.out).to.include('Release Name: N/A');
expect(launchResult.out).to.include('Release Notes: N/A');
expect(launchResult.out).to.include(`Update URL: http://localhost:${port}/update.msix`);
});
const updatedVersion = await getMsixPackageVersion('com.electron.myapp');
expect(updatedVersion).to.equal('2.0.0.0');
});
it('should update successfully with JSON response', async () => {
await installMsixPackage(MSIX_V1);
const initialVersion = await getMsixPackageVersion('com.electron.myapp');
expect(initialVersion).to.equal('1.0.0.0');
const fixedPubDate = '2011-11-11T11:11:11.000Z';
const expectedDateStr = new Date(fixedPubDate).toDateString();
server.get('/update-check', (req, res) => {
res.json({
url: `http://localhost:${port}/update.msix`,
name: '2.0.0',
notes: 'Test release notes',
pub_date: fixedPubDate
});
});
server.get('/update.msix', (req, res) => {
res.download(MSIX_V2);
});
const launchResult = await launchApp(ELECTRON_MSIX_ALIAS, [MAIN_JS_PATH, '--checkUpdate', `http://localhost:${port}/update-check`]);
logOnError(launchResult, () => {
expect(launchResult.code).to.equal(0);
expect(requests.length).to.be.greaterThan(0);
expect(launchResult.out).to.include('Checking for update...');
expect(launchResult.out).to.include('Update available');
expect(launchResult.out).to.include('Update downloaded');
expect(launchResult.out).to.include('Release Name: 2.0.0');
expect(launchResult.out).to.include('Release Notes: Test release notes');
expect(launchResult.out).to.include(`Release Date: ${expectedDateStr}`);
expect(launchResult.out).to.include(`Update URL: http://localhost:${port}/update.msix`);
});
const updatedVersion = await getMsixPackageVersion('com.electron.myapp');
expect(updatedVersion).to.equal('2.0.0.0');
});
it('should update successfully with static JSON releases file', async () => {
await installMsixPackage(MSIX_V1);
const initialVersion = await getMsixPackageVersion('com.electron.myapp');
expect(initialVersion).to.equal('1.0.0.0');
const fixedPubDate = '2011-11-11T11:11:11.000Z';
const expectedDateStr = new Date(fixedPubDate).toDateString();
server.get('/update-check', (req, res) => {
res.json({
currentRelease: '2.0.0',
releases: [
{
version: '1.0.0',
updateTo: {
version: '1.0.0',
url: `http://localhost:${port}/update-v1.msix`,
name: '1.0.0',
notes: 'Initial release',
pub_date: '2010-10-10T10:10:10.000Z'
}
},
{
version: '2.0.0',
updateTo: {
version: '2.0.0',
url: `http://localhost:${port}/update-v2.msix`,
name: '2.0.0',
notes: 'Test release notes for static format',
pub_date: fixedPubDate
}
}
]
});
});
server.get('/update-v2.msix', (req, res) => {
res.download(MSIX_V2);
});
const launchResult = await launchApp(ELECTRON_MSIX_ALIAS, [MAIN_JS_PATH, '--checkUpdate', `http://localhost:${port}/update-check`]);
logOnError(launchResult, () => {
expect(launchResult.code).to.equal(0);
expect(requests.length).to.be.greaterThan(0);
expect(launchResult.out).to.include('Checking for update...');
expect(launchResult.out).to.include('Update available');
expect(launchResult.out).to.include('Update downloaded');
expect(launchResult.out).to.include('Release Name: 2.0.0');
expect(launchResult.out).to.include('Release Notes: Test release notes for static format');
expect(launchResult.out).to.include(`Release Date: ${expectedDateStr}`);
expect(launchResult.out).to.include(`Update URL: http://localhost:${port}/update-v2.msix`);
});
const updatedVersion = await getMsixPackageVersion('com.electron.myapp');
expect(updatedVersion).to.equal('2.0.0.0');
});
it('should not update with update File JSON Format if currentRelease is older than installed version', async () => {
await installMsixPackage(MSIX_V2);
server.get('/update-check', (req, res) => {
res.json({
currentRelease: '1.0.0',
releases: [
{
version: '1.0.0',
updateTo: {
version: '1.0.0',
url: `http://localhost:${port}/update-v1.msix`,
name: '1.0.0',
notes: 'Initial release',
pub_date: '2010-10-10T10:10:10.000Z'
}
}
]
});
});
const launchResult = await launchApp(ELECTRON_MSIX_ALIAS, [MAIN_JS_PATH, '--checkUpdate', `http://localhost:${port}/update-check`]);
logOnError(launchResult, () => {
expect(launchResult.code).to.equal(0);
expect(requests.length).to.be.greaterThan(0);
expect(launchResult.out).to.include('Checking for update...');
expect(launchResult.out).to.include('Update not available');
});
});
it('should downgrade to older version with JSON server format and allowAnyVersion is true', async () => {
await installMsixPackage(MSIX_V2);
const initialVersion = await getMsixPackageVersion('com.electron.myapp');
expect(initialVersion).to.equal('2.0.0.0');
const fixedPubDate = '2010-10-10T10:10:10.000Z';
const expectedDateStr = new Date(fixedPubDate).toDateString();
server.get('/update-check', (req, res) => {
res.json({
url: `http://localhost:${port}/update-v1.msix`,
name: '1.0.0',
notes: 'Initial release',
pub_date: fixedPubDate
});
});
server.get('/update-v1.msix', (req, res) => {
res.download(MSIX_V1);
});
const launchResult = await launchApp(ELECTRON_MSIX_ALIAS, [MAIN_JS_PATH, '--checkUpdate', `http://localhost:${port}/update-check`, '--allowAnyVersion']);
logOnError(launchResult, () => {
expect(launchResult.code).to.equal(0);
expect(requests.length).to.be.greaterThan(0);
expect(launchResult.out).to.include('Checking for update...');
expect(launchResult.out).to.include('Update available');
expect(launchResult.out).to.include('Update downloaded');
expect(launchResult.out).to.include('Release Name: 1.0.0');
expect(launchResult.out).to.include('Release Notes: Initial release');
expect(launchResult.out).to.include(`Release Date: ${expectedDateStr}`);
expect(launchResult.out).to.include(`Update URL: http://localhost:${port}/update-v1.msix`);
});
const downgradedVersion = await getMsixPackageVersion('com.electron.myapp');
expect(downgradedVersion).to.equal('1.0.0.0');
});
});
});

View File

@@ -0,0 +1,50 @@
<?xml version="1.0" encoding="utf-8"?>
<Package
xmlns="http://schemas.microsoft.com/appx/manifest/foundation/windows10"
xmlns:uap="http://schemas.microsoft.com/appx/manifest/uap/windows10"
xmlns:rescap="http://schemas.microsoft.com/appx/manifest/foundation/windows10/restrictedcapabilities"
xmlns:desktop="http://schemas.microsoft.com/appx/manifest/desktop/windows10"
xmlns:uap3="http://schemas.microsoft.com/appx/manifest/uap/windows10/3"
xmlns:desktop2="http://schemas.microsoft.com/appx/manifest/desktop/windows10/2"
IgnorableNamespaces="uap uap3 desktop2">
<Identity Name="Electron.Dev.MSIX"
ProcessorArchitecture="x64"
Version="1.0.0.0"
Publisher="CN=Electron"/>
<Properties>
<DisplayName>Electron Dev MSIX</DisplayName>
<PublisherDisplayName>Electron</PublisherDisplayName>
<Logo>assets\icon.png</Logo>
</Properties>
<Dependencies>
<TargetDeviceFamily Name="Windows.Desktop" MinVersion="10.0.17763.0" MaxVersionTested="10.0.17763.0" />
</Dependencies>
<Resources>
<Resource Language="en-US" />
</Resources>
<Applications>
<Application Id="ElectronMSIX" Executable="Electron.exe" EntryPoint="Windows.FullTrustApplication">
<uap:VisualElements
DisplayName="Electron Dev MSIX"
Description="Electron running with Identity"
Square44x44Logo="assets\Square44x44Logo.png"
Square150x150Logo="assets\Square150x150Logo.png"
BackgroundColor="transparent">
</uap:VisualElements>
<Extensions>
<uap3:Extension
Category="windows.appExecutionAlias"
Executable="Electron.exe"
EntryPoint="Windows.FullTrustApplication">
<uap3:AppExecutionAlias>
<desktop:ExecutionAlias Alias="ElectronMSIX.exe" />
</uap3:AppExecutionAlias>
</uap3:Extension>
</Extensions>
</Application>
</Applications>
<Capabilities>
<rescap:Capability Name="runFullTrust" />
<Capability Name="internetClient" />
</Capabilities>
</Package>

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -0,0 +1,22 @@
Add-Type -AssemblyName System.Security.Cryptography.X509Certificates
# Path to cert file one folder up relative to script location
$scriptDir = Split-Path -Parent $PSCommandPath
$certPath = Join-Path $scriptDir "MSIXDevCert.cer" | Resolve-Path
# Load the certificate from file
$cert = [System.Security.Cryptography.X509Certificates.X509CertificateLoader]::LoadCertificateFromFile($certPath)
$trustedStore = Get-ChildItem -Path "cert:\LocalMachine\TrustedPeople" | Where-Object { $_.Thumbprint -eq $cert.Thumbprint }
if (-not $trustedStore) {
# We gonna need admin privileges to install the cert
if (-Not ([Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) {
Start-Process powershell -ArgumentList "-NoProfile -ExecutionPolicy Bypass -File `"$PSCommandPath`"" -Verb RunAs
exit
}
# Install the public cert to LocalMachine\TrustedPeople (for MSIX trust)
Import-Certificate -FilePath $certPath -CertStoreLocation "cert:\LocalMachine\TrustedPeople" | Out-Null
Write-Host " 🏛️ Installed to: cert:\LocalMachine\TrustedPeople"
} else {
Write-Host " ✅ Certificate already trusted in: cert:\LocalMachine\TrustedPeople"
}

View File

@@ -0,0 +1,96 @@
const { app, autoUpdater } = require('electron');
// Parse command-line arguments
const args = process.argv.slice(2);
const command = args[0];
const commandArg = args[1];
app.whenReady().then(() => {
try {
// Debug: log received arguments
if (process.env.DEBUG) {
console.log('Command:', command);
console.log('Command arg:', commandArg);
console.log('All args:', JSON.stringify(args));
}
if (command === '--printPackageId') {
const packageInfo = autoUpdater.getPackageInfo();
if (packageInfo.familyName) {
console.log(`Family Name: ${packageInfo.familyName}`);
console.log(`Package ID: ${packageInfo.id || 'N/A'}`);
console.log(`Version: ${packageInfo.version || 'N/A'}`);
console.log(`Development Mode: ${packageInfo.developmentMode ? 'Yes' : 'No'}`);
console.log(`Signature Kind: ${packageInfo.signatureKind || 'N/A'}`);
if (packageInfo.appInstallerUri) {
console.log(`App Installer URI: ${packageInfo.appInstallerUri}`);
}
app.quit();
} else {
console.error('No package identity found. Process is not running in an MSIX package context.');
app.exit(1);
}
} else if (command === '--checkUpdate') {
if (!commandArg) {
console.error('Update URL is required for --checkUpdate');
app.exit(1);
return;
}
// Use hardcoded headers if --useCustomHeaders flag is provided
let headers;
let allowAnyVersion = false;
if (args[2] === '--useCustomHeaders') {
headers = {
'X-AppVersion': '1.0.0',
Authorization: 'Bearer test-token'
};
} else if (args[2] === '--allowAnyVersion') {
allowAnyVersion = true;
}
// Set up event listeners
autoUpdater.on('checking-for-update', () => {
console.log('Checking for update...');
});
autoUpdater.on('update-available', () => {
console.log('Update available');
});
autoUpdater.on('update-not-available', () => {
console.log('Update not available');
app.quit();
});
autoUpdater.on('update-downloaded', (event, releaseNotes, releaseName, releaseDate, updateUrl) => {
console.log('Update downloaded');
console.log(`Release Name: ${releaseName || 'N/A'}`);
console.log(`Release Notes: ${releaseNotes || 'N/A'}`);
console.log(`Release Date: ${releaseDate || 'N/A'}`);
console.log(`Update URL: ${updateUrl || 'N/A'}`);
app.quit();
});
autoUpdater.on('error', (error, message) => {
console.error(`Update error: ${message || error.message || 'Unknown error'}`);
app.exit(1);
});
// Set the feed URL with optional headers and allowAnyVersion, then check for updates
if (headers || allowAnyVersion) {
autoUpdater.setFeedURL({ url: commandArg, headers, allowAnyVersion });
} else {
autoUpdater.setFeedURL(commandArg);
}
autoUpdater.checkForUpdates();
} else {
console.error(`Unknown command: ${command || '(none)'}`);
app.exit(1);
}
} catch (error) {
console.error('Unhandled error:', error.message);
console.error(error.stack);
app.exit(1);
}
});

149
spec/lib/msix-helpers.ts Normal file
View File

@@ -0,0 +1,149 @@
import { expect } from 'chai';
import * as cp from 'node:child_process';
import * as fs from 'node:fs';
import * as path from 'node:path';
const fixturesPath = path.resolve(__dirname, '..', 'fixtures', 'api', 'autoupdater', 'msix');
const manifestFixturePath = path.resolve(fixturesPath, 'ElectronDevAppxManifest.xml');
const installCertScriptPath = path.resolve(fixturesPath, 'install_test_cert.ps1');
// Install the signing certificate for MSIX test packages to the Trusted People store
// This is required to install self-signed MSIX packages
export async function installMsixCertificate (): Promise<void> {
const result = cp.spawnSync('powershell', [
'-NoProfile',
'-ExecutionPolicy', 'Bypass',
'-File', installCertScriptPath
]);
if (result.status !== 0) {
throw new Error(`Failed to install MSIX certificate: ${result.stderr.toString() || result.stdout.toString()}`);
}
}
// Check if we should run MSIX tests
export const shouldRunMsixTests =
process.platform === 'win32';
// Get the Electron executable path
export function getElectronExecutable (): string {
return process.execPath;
}
// Get path to main.js fixture
export function getMainJsFixturePath (): string {
return path.resolve(fixturesPath, 'main.js');
}
// Register executable with identity
export async function registerExecutableWithIdentity (executablePath: string): Promise<void> {
if (!fs.existsSync(manifestFixturePath)) {
throw new Error(`Manifest fixture not found: ${manifestFixturePath}`);
}
const executableDir = path.dirname(executablePath);
const manifestPath = path.join(executableDir, 'AppxManifest.xml');
const escapedManifestPath = manifestPath.replace(/'/g, "''").replace(/\\/g, '\\\\');
const psCommand = `
$ErrorActionPreference = 'Stop';
try {
Add-AppxPackage -Register '${escapedManifestPath}' -ForceUpdateFromAnyVersion
} catch {
Write-Error $_.Exception.Message
exit 1
}
`;
fs.copyFileSync(manifestFixturePath, manifestPath);
const result = cp.spawnSync('powershell', ['-NoProfile', '-Command', psCommand]);
if (result.status !== 0) {
const errorMsg = result.stderr.toString() || result.stdout.toString();
try {
fs.unlinkSync(manifestPath);
} catch {
// Ignore cleanup errors
}
throw new Error(`Failed to register executable with identity: ${errorMsg}`);
}
}
// Unregister the Electron Dev MSIX package
// This removes the sparse package registration created by registerExecutableWithIdentity
export async function unregisterExecutableWithIdentity (): Promise<void> {
const result = cp.spawnSync('powershell', ['-NoProfile', '-Command', ' Get-AppxPackage Electron.Dev.MSIX | Remove-AppxPackage']);
// Don't throw if package doesn't exist
if (result.status !== 0) {
throw new Error(`Failed to unregister executable with identity: ${result.stderr.toString() || result.stdout.toString()}`);
}
const electronExec = getElectronExecutable();
const executableDir = path.dirname(electronExec);
const manifestPath = path.join(executableDir, 'AppxManifest.xml');
try {
if (fs.existsSync(manifestPath)) {
fs.unlinkSync(manifestPath);
}
} catch {
// Ignore cleanup errors
}
}
// Get path to MSIX fixture package
export function getMsixFixturePath (version: 'v1' | 'v2'): string {
const filename = `HelloMSIX_${version}.msix`;
return path.resolve(fixturesPath, filename);
}
// Install MSIX package
export async function installMsixPackage (msixPath: string): Promise<void> {
// Use Add-AppxPackage PowerShell cmdlet
const result = cp.spawnSync('powershell', [
'-Command',
`Add-AppxPackage -Path "${msixPath}" -ForceApplicationShutdown`
]);
if (result.status !== 0) {
throw new Error(`Failed to install MSIX package: ${result.stderr.toString()}`);
}
}
// Uninstall MSIX package by name
export async function uninstallMsixPackage (name: string): Promise<void> {
const result = cp.spawnSync('powershell', ['-NoProfile', '-Command', `Get-AppxPackage ${name} | Remove-AppxPackage`]);
// Don't throw if package doesn't exist
if (result.status !== 0) {
throw new Error(`Failed to uninstall MSIX package: ${result.stderr.toString() || result.stdout.toString()}`);
}
}
// Get version of installed MSIX package by name
export async function getMsixPackageVersion (name: string): Promise<string | null> {
const psCommand = `(Get-AppxPackage -Name '${name}').Version`;
const result = cp.spawnSync('powershell', ['-NoProfile', '-Command', psCommand]);
if (result.status !== 0) {
return null;
}
const version = result.stdout.toString().trim();
return version || null;
}
export function spawn (cmd: string, args: string[], opts: any = {}): Promise<{ code: number, out: string }> {
let out = '';
const child = cp.spawn(cmd, args, opts);
child.stdout.on('data', (chunk: Buffer) => {
out += chunk.toString();
});
child.stderr.on('data', (chunk: Buffer) => {
out += chunk.toString();
});
return new Promise<{ code: number, out: string }>((resolve) => {
child.on('exit', (code, signal) => {
expect(signal).to.equal(null);
resolve({
code: code!,
out
});
});
});
}