fix(desktop): bridge Electron users on Windows and Linux (#9079)

* fix(desktop): bridge Electron users on Windows and Linux

* test(desktop): satisfy bridge contract lint

* fix(desktop): harden Electron bridge migration

* chore(desktop): note sibling-script regex sync for bridge artifacts

* fix(desktop): satisfy release manifest lint

* fix(desktop): validate Electron uninstaller path before migration

The migration ExecWait target is assembled from the HKCU InstallLocation
registry value; require the uninstaller executable to exist before running
it, and pin the conjunct in the release contract suite.
This commit is contained in:
易良 2026-08-13 15:42:31 +00:00 committed by GitHub
parent 1095e0bbc4
commit 85a06bbec8
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 213 additions and 109 deletions

View file

@ -6,12 +6,21 @@ import path from 'node:path';
const options = parseArguments(process.argv.slice(2));
const assets = fs.readdirSync(options.assets).sort();
const names = [
'Qwen-Code-Desktop-arm64.zip',
'Qwen-Code-Desktop-x64.zip',
'Qwen-Code-Desktop-arm64.dmg',
'Qwen-Code-Desktop-x64.dmg',
];
const patterns = {
macos: [
/-arm64\.zip$/i,
/-x64\.zip$/i,
/-arm64\.dmg$/i,
/-x64\.dmg$/i,
],
windows: [/-setup\.exe$/i],
linux: [/\.AppImage$/i],
};
const selectedPatterns = patterns[options.platform];
if (!selectedPatterns) {
throw new Error(`Invalid --platform: ${options.platform}`);
}
const names = selectedPatterns.map((pattern) => selectArtifact(assets, pattern));
const artifacts = names.map((name) => readArtifact(assets, name));
const primary = artifacts[0];
@ -29,10 +38,18 @@ const lines = [
];
fs.writeFileSync(options.output, `${lines.join('\n')}\n`);
function readArtifact(assets, name) {
if (!assets.includes(name)) {
throw new Error(`Missing Electron bridge artifact: ${name}`);
// Keep the selection regexes in sync with create-desktop-update-manifest.mjs.
function selectArtifact(assets, pattern) {
const matches = assets.filter((asset) => pattern.test(asset));
if (matches.length !== 1) {
throw new Error(
`Expected one Electron bridge artifact matching ${pattern}, found ${matches.length}: ${matches.join(', ')}`,
);
}
return matches[0];
}
function readArtifact(assets, name) {
const file = path.join(options.assets, name);
return {
name,
@ -52,7 +69,7 @@ function parseArguments(args) {
if (!name || value === undefined) throw new Error('Invalid arguments.');
values[name] = value;
}
for (const required of ['assets', 'version', 'output']) {
for (const required of ['assets', 'platform', 'version', 'output']) {
if (!values[required]) throw new Error(`Missing --${required}`);
}
if (

View file

@ -15,7 +15,7 @@ on:
default: 'main'
type: 'string'
electron_bridge:
description: 'Publish the one-time macOS Electron-to-Tauri update bridge.'
description: 'Publish the one-time Electron-to-Tauri update bridge for macOS, Windows, and Linux.'
required: true
default: false
type: 'boolean'
@ -581,10 +581,15 @@ jobs:
--version "$RELEASE_VERSION" \
--output desktop-latest.json
if [ "$ELECTRON_BRIDGE" = 'true' ]; then
node ../.github/scripts/create-electron-bridge-manifest.mjs \
--assets . \
--version "$RELEASE_VERSION" \
--output latest-mac.yml
for manifest in macos:latest-mac.yml windows:latest.yml linux:latest-linux.yml; do
platform="${manifest%%:*}"
output="${manifest#*:}"
node ../.github/scripts/create-electron-bridge-manifest.mjs \
--assets . \
--platform "$platform" \
--version "$RELEASE_VERSION" \
--output "$output"
done
fi
sha256sum -- * > SHA256SUMS.txt
@ -629,12 +634,23 @@ jobs:
set -euo pipefail
feed_assets=(release-assets/desktop-latest.json)
if [ "$ELECTRON_BRIDGE" = 'true' ]; then
shopt -s nullglob
windows_installers=(release-assets/*-setup.exe)
linux_appimages=(release-assets/*.AppImage)
if [ "${#windows_installers[@]}" -ne 1 ] || [ "${#linux_appimages[@]}" -ne 1 ]; then
echo '::error::The Electron bridge requires one Windows installer and one Linux AppImage.'
exit 1
fi
feed_assets+=(
release-assets/latest-mac.yml
release-assets/latest.yml
release-assets/latest-linux.yml
release-assets/Qwen-Code-Desktop-arm64.zip
release-assets/Qwen-Code-Desktop-x64.zip
release-assets/Qwen-Code-Desktop-arm64.dmg
release-assets/Qwen-Code-Desktop-x64.dmg
"${windows_installers[0]}"
"${linux_appimages[0]}"
)
fi
if gh release view "$FEED_TAG" >/dev/null 2>&1; then
@ -648,6 +664,10 @@ jobs:
fi
newest="$(printf '%s\n%s\n' "$RELEASE_VERSION" "$current" | sort -V | tail -n 1)"
if [ "$current" != "$RELEASE_VERSION" ] && [ "$newest" = "$current" ]; then
if [ "$ELECTRON_BRIDGE" = 'true' ]; then
echo "::error::Electron bridge $RELEASE_VERSION cannot replace newer stable feed $current."
exit 1
fi
echo "::notice::Desktop $RELEASE_VERSION will not replace newer stable feed $current."
exit 0
fi

View file

@ -2,65 +2,21 @@
## Context
The last published desktop release, `desktop-v0.0.5`, is an Electron app named `Qwen Code Desktop` with bundle identifier `com.alibaba.qwen-code`. Its macOS updater reads `latest-mac.yml` from the fixed `desktop-latest` release and installs a ZIP archive.
The new desktop shell is a Tauri app. It currently uses a different product name and bundle identifier and publishes `desktop-latest.json`, so the existing Electron app cannot discover or replace it.
## Goals
- Let signed macOS Electron `0.0.5` installations update directly to the first stable Tauri release.
- Preserve the existing macOS application identity so the updater replaces the installed app bundle.
- Keep Tauri's signed updater feed for all releases after the migration.
- Make the bridge opt-in and one-time; later releases must not need Electron build tooling.
## Non-goals
- Migrating Electron settings, sessions, or workspace state. The Tauri app may ask for a workspace on first launch.
- Bridging Windows or Linux Electron installations.
- Generating Electron differential blockmaps. Electron updater falls back to the checksum-verified full ZIP.
The legacy Electron desktop reads `latest-mac.yml`, `latest.yml`, or `latest-linux.yml` from the fixed `desktop-latest` release. The Tauri desktop reads `desktop-latest.json` from the same release. A stable release can therefore expose both update formats over the same Tauri installers without building Electron again.
## Compatibility contract
The Tauri bundle uses the legacy macOS identity:
The Tauri bundle keeps the legacy product name and application identifier. With `electron_bridge` enabled, the release workflow publishes:
- product name: `Qwen Code Desktop`
- bundle identifier: `com.alibaba.qwen-code`
- artifact prefix: `Qwen-Code-Desktop`
- signing identity: the existing Developer ID Application certificate
- `latest-mac.yml` plus ZIP and DMG payloads for Apple Silicon and Intel;
- `latest.yml` plus the x64 NSIS installer for Windows;
- `latest-linux.yml` plus the x64 AppImage for Linux;
- `desktop-latest.json` for Tauri clients on all platforms.
The bridge release must be newer than `0.0.5`. It publishes two updater views over the same signed app bundles:
The macOS ZIPs are created from the signed and notarized Tauri app. Windows removes the matching per-user Electron installation through its registered uninstaller before Tauri writes files, preserving user data and avoiding duplicate uninstall entries. Linux AppImage updates replace the current AppImage directly.
1. `latest-mac.yml` points legacy Electron clients at `Qwen-Code-Desktop-arm64.zip` or `Qwen-Code-Desktop-x64.zip`.
2. `desktop-latest.json` points Tauri clients at the signed Tauri updater archives.
## Release usage
The ZIP is created from the already signed and notarized `.app`; it is not rebuilt by Electron tooling.
Run `Desktop Release` for the next stable version with `electron_bridge=true`, `dry_run=false`, `draft=false`, and `prerelease=false`. The bridge is one-time: the fixed `desktop-latest` release retains the three Electron manifests and payloads when later Tauri-only releases update `desktop-latest.json`.
## Release flow
`Desktop Release` gains an `electron_bridge` input, disabled by default.
- All macOS builds continue to produce the Tauri app, DMG, updater archive, and updater signature.
- When `electron_bridge` is enabled, each macOS build also creates a legacy-compatible ZIP.
- The publish job generates `latest-mac.yml` from the two ZIPs and two DMGs.
- A stable bridge release uploads the legacy metadata and payloads to `desktop-latest` together with `desktop-latest.json`.
- Later stable releases leave `electron_bridge` disabled. Updating `desktop-latest.json` does not remove the bridge files, so Electron installations that return later can still cross to Tauri.
Draft and prerelease runs may build and publish bridge artifacts for inspection, but they never update the stable feed.
## Signing credentials
The repository already stores the Electron-era Apple certificate and App Store Connect API key under `MAC_CSC_*` and `APPLE_NOTARY_*` secret names. The workflow accepts those names as fallbacks for the newer Tauri names, so the Developer ID identity remains unchanged.
Tauri updater artifacts additionally require `TAURI_SIGNING_PRIVATE_KEY`; `TAURI_SIGNING_PRIVATE_KEY_PASSWORD` is only needed for an encrypted private key. The private key must match the public key in the Tauri configuration before the first published Tauri release.
## Validation
Automated release-helper tests verify:
- the legacy application identity,
- exact bridge artifact selection,
- SHA-512 and size values in `latest-mac.yml`,
- failure when a required bridge artifact is missing,
- existing Tauri updater manifest and version synchronization behavior.
Before the stable release, install the signed `desktop-v0.0.5` arm64 and x64 builds, point them at an isolated bridge feed, and verify both `0.0.5 -> Tauri bridge` and `Tauri bridge -> newer Tauri` updates.
Before publishing, verify each signed legacy client can install the bridge and that the resulting Tauri app can then update to a newer Tauri release. Do not remove the bridge assets from `desktop-latest` while legacy Electron installations remain supported.

View file

@ -65,6 +65,6 @@ cargo test --manifest-path src-tauri/Cargo.toml
The `Desktop Release` workflow builds signed updater artifacts when `dry_run` is disabled. Published releases require the Tauri updater private key. macOS releases also require Apple signing and notarization credentials.
The first stable Tauri release may set `electron_bridge=true` to publish the macOS ZIPs and `latest-mac.yml` consumed by Electron `0.0.5`. Leave the input disabled for later releases; the fixed `desktop-latest` release retains the bridge assets while `desktop-latest.json` advances independently.
The first stable Tauri release may set `electron_bridge=true` to publish the macOS ZIPs and DMGs, Windows NSIS installer, Linux AppImage, and their Electron `0.0.5` manifests. Leave the input disabled for later releases; the fixed `desktop-latest` release retains the bridge assets while `desktop-latest.json` advances independently.
The macOS workflow accepts either the Tauri-era `APPLE_*` certificate and notarization secrets or the existing `MAC_CSC_*` and `APPLE_NOTARY_*` secrets. `TAURI_SIGNING_PRIVATE_KEY` must match the public key in `src-tauri/tauri.conf.json`.

View file

@ -224,6 +224,29 @@ function testLegacyApplicationIdentity() {
);
assert.equal(config.productName, 'Qwen Code Desktop');
assert.equal(config.identifier, 'com.alibaba.qwen-code');
assert.equal(
config.bundle.windows.nsis.installerHooks,
'windows/electron-migration.nsh',
);
const migrationHook = fs.readFileSync(
path.join(packageDir, 'src-tauri', 'windows', 'electron-migration.nsh'),
'utf8',
);
assert.match(migrationHook, /Software\\821b18a9-7c63-5bb4-9e20-51ba63d5ecc3/);
assert.match(migrationHook, /!macro NSIS_HOOK_PREINSTALL/);
assert.match(
migrationHook,
/StrCpy \$R1 \$R1 17\s*\n\s*\$\{If\} \$R0 != ""\s*\n\s*\$\{AndIf\} \$R1 == "Qwen Code Desktop"/,
);
assert.match(
migrationHook,
/\$\{AndIf\} \$\{FileExists\} "\$R0\\Uninstall Qwen Code Desktop\.exe"/,
);
assert.match(
migrationHook,
/ExecWait '"\$R0\\Uninstall Qwen Code Desktop\.exe" \/currentuser \/S --updated _\?=\$R0'/,
);
assert.match(migrationHook, /\$\{If\} \$R2 != 0\s*\n\s*Abort/);
}
function testElectronBridgeWorkflow() {
@ -233,6 +256,22 @@ function testElectronBridgeWorkflow() {
);
assert.match(workflow, /^ {6}electron_bridge:$/m);
assert.match(workflow, /create-electron-bridge-manifest\.mjs/);
assert.match(workflow, /macos:latest-mac\.yml/);
assert.match(workflow, /windows:latest\.yml/);
assert.match(workflow, /linux:latest-linux\.yml/);
assert.match(
workflow,
/windows_installers=\(release-assets\/\*-setup\.exe\)/,
);
assert.match(workflow, /linux_appimages=\(release-assets\/\*\.AppImage\)/);
assert.match(workflow, /^\s+release-assets\/latest\.yml$/m);
assert.match(workflow, /^\s+release-assets\/latest-linux\.yml$/m);
assert.match(workflow, /^\s+"\$\{windows_installers\[0\]\}"$/m);
assert.match(workflow, /^\s+"\$\{linux_appimages\[0\]\}"$/m);
assert.match(
workflow,
/if \[ "\$ELECTRON_BRIDGE" = 'true' \]; then\s+echo "::error::Electron bridge \$RELEASE_VERSION cannot replace newer stable feed \$current\."\s+exit 1/,
);
for (const artifact of [
'Qwen-Code-Desktop-arm64.zip',
'Qwen-Code-Desktop-x64.zip',
@ -873,46 +912,89 @@ function testElectronBridgeManifest(directory) {
for (const artifact of artifacts) {
fs.writeFileSync(path.join(assets, artifact), `contents:${artifact}`);
}
const output = path.join(directory, 'latest-mac.yml');
execFileSync(process.execPath, [
electronBridgeScript,
'--assets',
assets,
'--version',
'0.1.0',
'--output',
output,
]);
const manifest = fs.readFileSync(output, 'utf8');
assert.match(manifest, /^version: 0\.1\.0$/m);
assert.match(
manifest,
/^releaseDate: '\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z'$/m,
artifacts.push(
'Qwen-Code-Desktop_0.1.0_x64-setup.exe',
'Qwen-Code-Desktop_0.1.0_amd64.AppImage',
);
for (const artifact of artifacts) {
const contents = fs.readFileSync(path.join(assets, artifact));
const sha512 = crypto
.createHash('sha512')
.update(contents)
.digest('base64');
for (const artifact of artifacts.slice(4)) {
fs.writeFileSync(path.join(assets, artifact), `contents:${artifact}`);
}
const macOutput = path.join(directory, 'latest-mac.yml');
for (const [platform, filename, selected] of [
['macos', 'latest-mac.yml', artifacts.slice(0, 4)],
['windows', 'latest.yml', artifacts.slice(4, 5)],
['linux', 'latest-linux.yml', artifacts.slice(5, 6)],
]) {
const output = path.join(directory, filename);
execFileSync(process.execPath, [
electronBridgeScript,
'--assets',
assets,
'--platform',
platform,
'--version',
'0.1.0',
'--output',
output,
]);
const manifest = fs.readFileSync(output, 'utf8');
assert.match(manifest, /^version: 0\.1\.0$/m);
assert.match(
manifest,
new RegExp(
`^ - url: ${artifact.replaceAll('.', '\\.')}\\n sha512: ${sha512.replaceAll('+', '\\+')}\\n size: ${contents.length}$`,
'm',
),
/^releaseDate: '\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z'$/m,
);
for (const artifact of selected) {
const contents = fs.readFileSync(path.join(assets, artifact));
const sha512 = crypto
.createHash('sha512')
.update(contents)
.digest('base64');
assert.match(
manifest,
new RegExp(
`^ - url: ${artifact.replaceAll('.', '\\.')}\\n sha512: ${sha512.replaceAll('+', '\\+')}\\n size: ${contents.length}$`,
'm',
),
);
if (artifact === selected[0]) {
assert.match(
manifest,
new RegExp(`^path: ${artifact.replaceAll('.', '\\.')}$`, 'm'),
);
assert.match(
manifest,
new RegExp(`^sha512: ${sha512.replaceAll('+', '\\+')}$`, 'm'),
);
}
}
assert.equal(
(manifest.match(/^ {2}- url:/gm) ?? []).length,
selected.length,
);
}
const arm64Contents = fs.readFileSync(path.join(assets, artifacts[0]));
const arm64Sha512 = crypto
.createHash('sha512')
.update(arm64Contents)
.digest('base64');
assert.match(manifest, /^path: Qwen-Code-Desktop-arm64\.zip$/m);
assert.match(
manifest,
new RegExp(`^sha512: ${arm64Sha512.replaceAll('+', '\\+')}$`, 'm'),
const duplicateWindowsArtifact = path.join(
assets,
'Qwen-Code-Desktop_0.1.0_arm64-setup.exe',
);
fs.writeFileSync(duplicateWindowsArtifact, 'duplicate');
const ambiguousWindows = spawnSync(
process.execPath,
[
electronBridgeScript,
'--assets',
assets,
'--platform',
'windows',
'--version',
'0.1.0',
'--output',
path.join(directory, 'ambiguous-windows.yml'),
],
{ encoding: 'utf8' },
);
assert.notEqual(ambiguousWindows.status, 0);
assert.match(ambiguousWindows.stderr, /found 2/);
fs.rmSync(duplicateWindowsArtifact);
fs.rmSync(path.join(assets, artifacts[1]));
const failure = spawnSync(
@ -921,15 +1003,17 @@ function testElectronBridgeManifest(directory) {
electronBridgeScript,
'--assets',
assets,
'--platform',
'macos',
'--version',
'0.1.0',
'--output',
output,
macOutput,
],
{ encoding: 'utf8' },
);
assert.notEqual(failure.status, 0);
assert.match(failure.stderr, /Missing Electron bridge artifact/);
assert.match(failure.stderr, /Expected one Electron bridge artifact/);
const invalidVersion = spawnSync(
process.execPath,
@ -937,10 +1021,12 @@ function testElectronBridgeManifest(directory) {
electronBridgeScript,
'--assets',
assets,
'--platform',
'macos',
'--version',
'0.1',
'--output',
output,
macOutput,
],
{ encoding: 'utf8' },
);
@ -949,7 +1035,15 @@ function testElectronBridgeManifest(directory) {
const missingOutput = spawnSync(
process.execPath,
[electronBridgeScript, '--assets', assets, '--version', '0.1.0'],
[
electronBridgeScript,
'--assets',
assets,
'--platform',
'macos',
'--version',
'0.1.0',
],
{ encoding: 'utf8' },
);
assert.notEqual(missingOutput.status, 0);

View file

@ -51,7 +51,8 @@
},
"nsis": {
"installMode": "currentUser",
"installerIcon": "icons/icon.ico"
"installerIcon": "icons/icon.ico",
"installerHooks": "windows/electron-migration.nsh"
}
}
},

View file

@ -0,0 +1,16 @@
!define ELECTRON_INSTALL_KEY "Software\821b18a9-7c63-5bb4-9e20-51ba63d5ecc3"
!define ELECTRON_UNINSTALL_KEY "Software\Microsoft\Windows\CurrentVersion\Uninstall\821b18a9-7c63-5bb4-9e20-51ba63d5ecc3"
!macro NSIS_HOOK_PREINSTALL
ReadRegStr $R0 HKCU "${ELECTRON_INSTALL_KEY}" "InstallLocation"
ReadRegStr $R1 HKCU "${ELECTRON_UNINSTALL_KEY}" "DisplayName"
StrCpy $R1 $R1 17
${If} $R0 != ""
${AndIf} $R1 == "Qwen Code Desktop"
${AndIf} ${FileExists} "$R0\Uninstall Qwen Code Desktop.exe"
ExecWait '"$R0\Uninstall Qwen Code Desktop.exe" /currentuser /S --updated _?=$R0' $R2
${If} $R2 != 0
Abort "Could not remove the previous Qwen Code Desktop installation."
${EndIf}
${EndIf}
!macroend