diff --git a/.github/scripts/create-electron-bridge-manifest.mjs b/.github/scripts/create-electron-bridge-manifest.mjs index e143d4bcc6..8d5944975c 100644 --- a/.github/scripts/create-electron-bridge-manifest.mjs +++ b/.github/scripts/create-electron-bridge-manifest.mjs @@ -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 ( diff --git a/.github/workflows/desktop-release.yml b/.github/workflows/desktop-release.yml index 01fc39d1f5..8bb3e757ec 100644 --- a/.github/workflows/desktop-release.yml +++ b/.github/workflows/desktop-release.yml @@ -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 diff --git a/docs/design/desktop-electron-to-tauri-update-bridge.md b/docs/design/desktop-electron-to-tauri-update-bridge.md index bc3a98d6cc..8c233ec7e6 100644 --- a/docs/design/desktop-electron-to-tauri-update-bridge.md +++ b/docs/design/desktop-electron-to-tauri-update-bridge.md @@ -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. diff --git a/packages/desktop-shell/README.md b/packages/desktop-shell/README.md index f537837ec7..891c939565 100644 --- a/packages/desktop-shell/README.md +++ b/packages/desktop-shell/README.md @@ -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`. diff --git a/packages/desktop-shell/scripts/test-release.js b/packages/desktop-shell/scripts/test-release.js index 1de08cba47..dd795f123a 100755 --- a/packages/desktop-shell/scripts/test-release.js +++ b/packages/desktop-shell/scripts/test-release.js @@ -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); diff --git a/packages/desktop-shell/src-tauri/tauri.conf.json b/packages/desktop-shell/src-tauri/tauri.conf.json index 00d732ea08..a7ae4533b6 100644 --- a/packages/desktop-shell/src-tauri/tauri.conf.json +++ b/packages/desktop-shell/src-tauri/tauri.conf.json @@ -51,7 +51,8 @@ }, "nsis": { "installMode": "currentUser", - "installerIcon": "icons/icon.ico" + "installerIcon": "icons/icon.ico", + "installerHooks": "windows/electron-migration.nsh" } } }, diff --git a/packages/desktop-shell/src-tauri/windows/electron-migration.nsh b/packages/desktop-shell/src-tauri/windows/electron-migration.nsh new file mode 100644 index 0000000000..8a95884155 --- /dev/null +++ b/packages/desktop-shell/src-tauri/windows/electron-migration.nsh @@ -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