Skip to content

Commit 94ddc2e

Browse files
committed
fix(desktop): preserve Electron to Tauri upgrades (#83)
* fix(desktop): preserve Electron to Tauri upgrades * test(desktop): satisfy bridge contract lint
1 parent 9ac44b1 commit 94ddc2e

11 files changed

Lines changed: 215 additions & 78 deletions

File tree

.github/scripts/create-electron-bridge-manifest.mjs

Lines changed: 28 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -6,15 +6,24 @@ import path from 'node:path';
66

77
const options = parseArguments(process.argv.slice(2));
88
const assets = fs.readdirSync(options.assets).sort();
9-
const names = [
10-
'Qwen-Code-Desktop-arm64.zip',
11-
'Qwen-Code-Desktop-x64.zip',
12-
'Qwen-Code-Desktop-arm64.dmg',
13-
'Qwen-Code-Desktop-x64.dmg',
14-
];
15-
const artifacts = names.map((name) => readArtifact(assets, name));
9+
const patterns = {
10+
macos: [
11+
/[-_]arm64\.zip$/i,
12+
/[-_]x64\.zip$/i,
13+
/[-_]arm64\.dmg$/i,
14+
/[-_]x64\.dmg$/i,
15+
],
16+
windows: [/-setup\.exe$/i],
17+
linux: [/\.AppImage$/i],
18+
};
19+
const selectedPatterns = patterns[options.platform];
20+
if (!selectedPatterns) {
21+
throw new Error(`Invalid --platform: ${options.platform}`);
22+
}
23+
const artifacts = selectedPatterns.map((pattern) =>
24+
readArtifact(selectArtifact(assets, pattern)),
25+
);
1626
const primary = artifacts[0];
17-
1827
const lines = [
1928
`version: ${options.version}`,
2029
'files:',
@@ -29,10 +38,17 @@ const lines = [
2938
];
3039
fs.writeFileSync(options.output, `${lines.join('\n')}\n`);
3140

32-
function readArtifact(assets, name) {
33-
if (!assets.includes(name)) {
34-
throw new Error(`Missing Electron bridge artifact: ${name}`);
41+
function selectArtifact(assets, pattern) {
42+
const matches = assets.filter((asset) => pattern.test(asset));
43+
if (matches.length !== 1) {
44+
throw new Error(
45+
`Expected one Electron bridge artifact matching ${pattern}, found ${matches.length}: ${matches.join(', ')}`,
46+
);
3547
}
48+
return matches[0];
49+
}
50+
51+
function readArtifact(name) {
3652
const file = path.join(options.assets, name);
3753
return {
3854
name,
@@ -52,7 +68,7 @@ function parseArguments(args) {
5268
if (!name || value === undefined) throw new Error('Invalid arguments.');
5369
values[name] = value;
5470
}
55-
for (const required of ['assets', 'version', 'output']) {
71+
for (const required of ['assets', 'platform', 'version', 'output']) {
5672
if (!values[required]) throw new Error(`Missing --${required}`);
5773
}
5874
if (

.github/workflows/desktop-build.yml

Lines changed: 39 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,9 @@ on:
1212
tag:
1313
required: true
1414
type: 'string'
15+
electron_bridge:
16+
required: true
17+
type: 'boolean'
1518
publish:
1619
required: true
1720
type: 'boolean'
@@ -34,9 +37,11 @@ jobs:
3437
- name: 'macOS Apple Silicon'
3538
os: 'macos-15'
3639
target: 'aarch64-apple-darwin'
40+
legacy_arch: 'arm64'
3741
- name: 'macOS Intel'
3842
os: 'macos-15-intel'
3943
target: 'x86_64-apple-darwin'
44+
legacy_arch: 'x64'
4045
- name: 'Windows x64'
4146
os: 'windows-2025'
4247
target: 'x86_64-pc-windows-msvc'
@@ -234,6 +239,23 @@ jobs:
234239
$signature = Get-AuthenticodeSignature $installer.FullName
235240
if ($signature.Status -ne 'Valid') { throw "Invalid Authenticode signature: $($signature.Status)" }
236241
242+
- name: 'Create Electron bridge archive'
243+
if: "runner.os == 'macOS' && inputs.electron_bridge"
244+
shell: 'bash'
245+
env:
246+
LEGACY_ARCH: '${{ matrix.legacy_arch }}'
247+
RELEASE_VERSION: '${{ inputs.version }}'
248+
run: |
249+
set -euo pipefail
250+
app="$(find packages/desktop-shell/src-tauri/target/${{ matrix.target }}/release/bundle/macos -maxdepth 1 -name 'OpenWork.app' -print -quit)"
251+
if [[ -z "$app" ]]; then
252+
echo '::error::The OpenWork macOS app bundle was not produced.'
253+
exit 1
254+
fi
255+
destination="packages/desktop-shell/src-tauri/target/${{ matrix.target }}/release/bundle/electron-bridge"
256+
mkdir -p "$destination"
257+
ditto -c -k --sequesterRsrc --keepParent "$app" "$destination/OpenWork_${RELEASE_VERSION}_${LEGACY_ARCH}.zip"
258+
237259
- name: 'Smoke packaged application (macOS)'
238260
if: "runner.os == 'macOS'"
239261
shell: 'bash'
@@ -259,6 +281,9 @@ jobs:
259281

260282
- name: 'Collect verified artifacts'
261283
shell: 'bash'
284+
env:
285+
LEGACY_ARCH: '${{ matrix.legacy_arch }}'
286+
RELEASE_VERSION: '${{ inputs.version }}'
262287
run: |
263288
set -euo pipefail
264289
destination="$RUNNER_TEMP/openwork-desktop-artifacts"
@@ -270,7 +295,8 @@ jobs:
270295
case "$name" in
271296
*.app.tar.gz.sig) name="${name%.app.tar.gz.sig}-${{ matrix.target }}.app.tar.gz.sig" ;;
272297
*.app.tar.gz) name="${name%.app.tar.gz}-${{ matrix.target }}.app.tar.gz" ;;
273-
*.dmg) name="OpenWork-${{ matrix.target }}.dmg" ;;
298+
*.dmg) name="OpenWork_${RELEASE_VERSION}_${LEGACY_ARCH}.dmg" ;;
299+
OpenWork_*.zip) ;;
274300
*) continue ;;
275301
esac
276302
elif [[ "$RUNNER_OS" == 'Windows' ]]; then
@@ -279,7 +305,7 @@ jobs:
279305
case "$name" in *.AppImage|*.AppImage.sig|*.deb|*.deb.sig) ;; *) continue ;; esac
280306
fi
281307
cp "$artifact" "$destination/${name// /-}"
282-
done < <(find "$bundle_root" -mindepth 2 -maxdepth 2 -type f \( -name '*.dmg' -o -name '*.AppImage' -o -name '*.deb' -o -name '*.exe' -o -name '*.app.tar.gz' -o -name '*.sig' \) -print0)
308+
done < <(find "$bundle_root" -mindepth 2 -maxdepth 2 -type f \( -name '*.dmg' -o -name '*.AppImage' -o -name '*.deb' -o -name '*.exe' -o -name '*.zip' -o -name '*.app.tar.gz' -o -name '*.sig' \) -print0)
283309
if [[ -z "$(find "$destination" -type f -print -quit)" ]]; then
284310
echo '::error::No desktop artifacts were produced.'
285311
exit 1
@@ -316,23 +342,33 @@ jobs:
316342
- name: 'Generate updater manifest and checksums'
317343
shell: 'bash'
318344
env:
345+
ELECTRON_BRIDGE: '${{ inputs.electron_bridge }}'
319346
RELEASE_TAG: '${{ inputs.tag }}'
320347
RELEASE_VERSION: '${{ inputs.version }}'
321348
run: |
322349
set -euo pipefail
323350
node .github/scripts/create-desktop-update-manifest.mjs --assets release-assets --repository "$GITHUB_REPOSITORY" --tag "$RELEASE_TAG" --version "$RELEASE_VERSION" --output release-assets/latest.json
351+
if [[ "$ELECTRON_BRIDGE" == 'true' ]]; then
352+
for manifest in macos:latest-mac.yml windows:latest.yml linux:latest-linux.yml; do
353+
platform="${manifest%%:*}"
354+
output="${manifest#*:}"
355+
node .github/scripts/create-electron-bridge-manifest.mjs --assets release-assets --platform "$platform" --version "$RELEASE_VERSION" --output "release-assets/$output"
356+
done
357+
fi
324358
(cd release-assets && sha256sum -- * > SHA256SUMS.txt)
325359
326360
- name: 'Create GitHub release'
327361
env:
328362
GH_TOKEN: '${{ github.token }}'
363+
ELECTRON_BRIDGE: '${{ inputs.electron_bridge }}'
329364
RELEASE_DRAFT: '${{ inputs.draft }}'
330365
RELEASE_NAME: '${{ inputs.release_name }}'
331366
RELEASE_PRERELEASE: '${{ inputs.prerelease }}'
332367
RELEASE_TAG: '${{ inputs.tag }}'
333368
run: |
334369
set -euo pipefail
335-
args=("$RELEASE_TAG" release-assets/* --target "$GITHUB_SHA" --title "$RELEASE_NAME" --generate-notes --latest=false)
370+
args=("$RELEASE_TAG" release-assets/* --target "$GITHUB_SHA" --title "$RELEASE_NAME" --generate-notes)
371+
if [[ "$RELEASE_DRAFT" == 'false' && "$RELEASE_PRERELEASE" == 'false' && "$ELECTRON_BRIDGE" == 'true' ]]; then args+=(--latest); else args+=(--latest=false); fi
336372
if [[ "$RELEASE_DRAFT" == 'true' ]]; then args+=(--draft); fi
337373
if [[ "$RELEASE_PRERELEASE" == 'true' ]]; then args+=(--prerelease); fi
338374
gh release create "${args[@]}"

.github/workflows/desktop-release.yml

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,11 @@ on:
1313
description: 'Release title. Defaults to openwork-v<version>.'
1414
required: false
1515
type: 'string'
16+
electron_bridge:
17+
description: 'Publish Electron-compatible update manifests and payloads for macOS, Windows, and Linux.'
18+
required: true
19+
default: true
20+
type: 'boolean'
1621
dry_run:
1722
description: 'Build installers without publishing a release.'
1823
required: true
@@ -50,6 +55,7 @@ jobs:
5055
name: 'Validate version and source'
5156
shell: 'bash'
5257
env:
58+
ELECTRON_BRIDGE: '${{ inputs.electron_bridge }}'
5359
INPUT_VERSION: '${{ inputs.version }}'
5460
INPUT_RELEASE_NAME: '${{ inputs.release_name }}'
5561
IS_DRAFT: '${{ inputs.draft }}'
@@ -75,6 +81,14 @@ jobs:
7581
echo "::error::Published stable releases require an X.Y.Z version: $INPUT_VERSION"
7682
exit 1
7783
fi
84+
if [[ "$ELECTRON_BRIDGE" == "true" ]]; then
85+
core="${version%%[-+]*}"
86+
IFS='.' read -r major minor _ <<< "$core"
87+
if [[ "$major" -eq 0 && "$minor" -lt 2 ]]; then
88+
echo "::error::The Electron bridge starts at OpenWork 0.2.0: $INPUT_VERSION"
89+
exit 1
90+
fi
91+
fi
7892
if [[ "$INPUT_RELEASE_NAME" == *$'\n'* || "$INPUT_RELEASE_NAME" == *$'\r'* || ${#INPUT_RELEASE_NAME} -gt 200 ]]; then
7993
echo "::error::Release names must be a single line up to 200 characters."
8094
exit 1
@@ -97,6 +111,7 @@ jobs:
97111
version: '${{ needs.metadata.outputs.version }}'
98112
release_name: '${{ needs.metadata.outputs.release_name }}'
99113
tag: '${{ needs.metadata.outputs.tag }}'
114+
electron_bridge: '${{ inputs.electron_bridge }}'
100115
publish: false
101116
draft: '${{ inputs.draft }}'
102117
prerelease: '${{ inputs.prerelease }}'
@@ -112,6 +127,7 @@ jobs:
112127
version: '${{ needs.metadata.outputs.version }}'
113128
release_name: '${{ needs.metadata.outputs.release_name }}'
114129
tag: '${{ needs.metadata.outputs.tag }}'
130+
electron_bridge: '${{ inputs.electron_bridge }}'
115131
publish: true
116132
draft: '${{ inputs.draft }}'
117133
prerelease: '${{ inputs.prerelease }}'

docs/design/desktop-electron-to-tauri-update-bridge.md

Lines changed: 10 additions & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -2,65 +2,21 @@
22

33
## Context
44

5-
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.
6-
7-
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.
8-
9-
## Goals
10-
11-
- Let signed macOS Electron `0.0.5` installations update directly to the first stable Tauri release.
12-
- Preserve the existing macOS application identity so the updater replaces the installed app bundle.
13-
- Keep Tauri's signed updater feed for all releases after the migration.
14-
- Make the bridge opt-in and one-time; later releases must not need Electron build tooling.
15-
16-
## Non-goals
17-
18-
- Migrating Electron settings, sessions, or workspace state. The Tauri app may ask for a workspace on first launch.
19-
- Bridging Windows or Linux Electron installations.
20-
- Generating Electron differential blockmaps. Electron updater falls back to the checksum-verified full ZIP.
5+
OpenWork Electron releases use GitHub's latest stable release and read `latest-mac.yml`, `latest.yml`, or `latest-linux.yml`. Tauri reads `latest.json` from the fixed `desktop-latest` release. A stable Tauri release must therefore publish both update formats, and the versioned release must remain GitHub Latest for legacy clients.
216

227
## Compatibility contract
238

24-
The Tauri bundle uses the legacy macOS identity:
25-
26-
- product name: `Qwen Code Desktop`
27-
- bundle identifier: `com.alibaba.qwen-code`
28-
- artifact prefix: `Qwen-Code-Desktop`
29-
- signing identity: the existing Developer ID Application certificate
30-
31-
The bridge release must be newer than `0.0.5`. It publishes two updater views over the same signed app bundles:
32-
33-
1. `latest-mac.yml` points legacy Electron clients at `Qwen-Code-Desktop-arm64.zip` or `Qwen-Code-Desktop-x64.zip`.
34-
2. `desktop-latest.json` points Tauri clients at the signed Tauri updater archives.
35-
36-
The ZIP is created from the already signed and notarized `.app`; it is not rebuilt by Electron tooling.
37-
38-
## Release flow
39-
40-
`Desktop Release` gains an `electron_bridge` input, disabled by default.
41-
42-
- All macOS builds continue to produce the Tauri app, DMG, updater archive, and updater signature.
43-
- When `electron_bridge` is enabled, each macOS build also creates a legacy-compatible ZIP.
44-
- The publish job generates `latest-mac.yml` from the two ZIPs and two DMGs.
45-
- A stable bridge release uploads the legacy metadata and payloads to `desktop-latest` together with `desktop-latest.json`.
46-
- 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.
47-
48-
Draft and prerelease runs may build and publish bridge artifacts for inspection, but they never update the stable feed.
49-
50-
## Signing credentials
51-
52-
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.
9+
OpenWork 0.2.0 keeps the Electron product name `OpenWork` and application identifier `com.alibaba.openwork`. With `electron_bridge` enabled, a release contains:
5310

54-
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.
11+
- `latest-mac.yml` plus versioned ZIP and DMG payloads for Apple Silicon and Intel;
12+
- `latest.yml` plus the x64 NSIS installer for Windows;
13+
- `latest-linux.yml` plus the x64 AppImage for Linux;
14+
- `latest.json` and signed updater archives for Tauri clients.
5515

56-
## Validation
16+
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.
5717

58-
Automated release-helper tests verify:
18+
## Release usage
5919

60-
- the legacy application identity,
61-
- exact bridge artifact selection,
62-
- SHA-512 and size values in `latest-mac.yml`,
63-
- failure when a required bridge artifact is missing,
64-
- existing Tauri updater manifest and version synchronization behavior.
20+
`Desktop Release` defaults `electron_bridge` to true. For a stable release, use `dry_run=false`, `draft=false`, and `prerelease=false`. A stable bridge release is marked GitHub Latest and updates the fixed Tauri feed. Keep the bridge enabled on later stable releases while Electron installations remain supported. Once support is intentionally retired, disable it; later Tauri-only releases use `--latest=false`, so the previous bridge release remains GitHub Latest for dormant Electron clients.
6521

66-
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.
22+
Before publishing, verify signed 0.1.4 clients on each platform can install the bridge and that the resulting Tauri app can then update to a newer Tauri release. Retire the bridge only after the legacy support window is explicitly closed.

packages/desktop-shell/package-lock.json

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

packages/desktop-shell/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@openwork/desktop-shell",
3-
"version": "0.1.0",
3+
"version": "0.2.0",
44
"private": true,
55
"type": "module",
66
"scripts": {

0 commit comments

Comments
 (0)