* feat(desktop): migration engine for Tauri → Electron handoff
Implements the infrastructure needed to move users from Tauri to Electron
without losing state or Launchpad/Dock presence. Zero user impact on its
own — this becomes live only when paired with a final Tauri release that
calls the snapshot writer and installs Electron.
What lands here:
- Unify app identity with Tauri. electron-builder appId goes from
com.differentai.openwork.electron → com.differentai.openwork so macOS
reuses the same bundle identifier (no duplicate Dock icon, no new
Gatekeeper prompt, same TCC permissions).
- Unify userData path. Electron's app.setPath("userData", ...) now points
at the exact folder Tauri uses (~/Library/Application Support/com.differentai.openwork
on macOS, %APPDATA%/com.differentai.openwork on Windows, ~/.config/
com.differentai.openwork on Linux). An OPENWORK_ELECTRON_USERDATA env
var override is available for dogfooders who want isolation.
- Filename compat. On first launch, Electron copies Tauri's
openwork-workspaces.json → workspace-state.json (leaves the legacy
file in place for rollback safety).
- electron-updater wiring. New deps + IPC handlers
(openwork:updater:check / download / installAndRestart). Packaged-only.
Publish config points at the same GitHub release as Tauri.
- Migration snapshot plumbing.
* Tauri Rust command `write_migration_snapshot` serializes an
allowlist of localStorage keys to app_data_dir/migration-snapshot.v1.json.
* `apps/app/src/app/lib/migration.ts` has matching
writeMigrationSnapshotFromTauri() / ingestMigrationSnapshotOnElectronBoot().
* Scope: workspace list + selection only
(openwork.react.activeWorkspace, .sessionByWorkspace,
openwork.server.list/active/urlOverride/token). Everything else is
cheap to redo.
* Electron main exposes openwork:migration:read / ack IPC; preload
bridges them under window.__OPENWORK_ELECTRON__.migration.
* desktop-runtime-boot.ts ingests the snapshot once on first launch,
hydrates empty localStorage keys, then acks.
- Updated prds/electron-migration-plan.md with the localStorage scope
decision and the remaining work (last Tauri release ships the UI
prompt + installer downloader).
Verified: `pnpm --filter @openwork/app typecheck` ✓,
`pnpm --filter @openwork/desktop build:electron` ✓, `cargo check`
against src-tauri ✓.
* fix(desktop): emit relative asset paths for Electron packaged builds
Packaged Electron loads index.html via file:// (Contents/Resources/app-dist/
index.html inside the .app bundle), but Vite was building with the default
base: '/' which resolves /assets/*.js to the filesystem root. Result: a
working dev experience (Vite dev server on localhost:5173) and a broken
packaged .app that renders an empty <div id="root"></div>.
The Tauri shell doesn't hit this because Tauri serves the built HTML
through its own tauri:// protocol which rewrites paths. Electron's
file:// loader has no such rewriter.
Fix: electron-build.mjs sets OPENWORK_ELECTRON_BUILD=1 when invoking
vite build, and vite.config.ts flips base to './' only when that env
var is set. Dev server and Tauri builds unchanged.
Discovered while manually exercising the Electron prod-build migration
flow (packaged app launch, snapshot ingest, idempotency, updater IPC).
Latent since PR #1522 landed the packaged build path; never caught
because dogfood ran via dev:electron which uses the Vite dev server.
---------
Co-authored-by: Benjamin Shafii <benjamin@openworklabs.com>
12 KiB
Migration plan: Tauri → Electron
Goal: every existing Tauri user ends up on the Electron build without manual action, keeps all workspaces / tokens / sessions, and continues to auto-update from Electron going forward — all through the update mechanism users already trust.
Where data lives today
Tauri shell — app_data_dir() per OS
| OS | Path |
|---|---|
| macOS | ~/Library/Application Support/com.differentai.openwork/ |
| Windows | %APPDATA%\com.differentai.openwork\ |
| Linux | ~/.config/com.differentai.openwork/ |
Contents (observed on a real machine):
openwork-workspaces.json(Tauri's name)openwork-server-state.jsonopenwork-server-tokens.jsonworkspaces/subtree
Electron shell — app.getPath("userData") default
| OS | Path |
|---|---|
| macOS | ~/Library/Application Support/Electron/ |
| Windows | %APPDATA%\Electron\ |
| Linux | ~/.config/Electron/ |
Contents written today:
workspace-state.json(Electron's name — differs from Tauri's)openwork-server-state.jsonopenwork-server-tokens.jsondesktop-bootstrap.json
Shared state (already portable)
~/.openwork/openwork-orchestrator/— orchestrator daemon data- Each workspace's own
.opencode/— sessions, messages, skills, MCP config - Neither has to migrate.
Tauri updater today
apps/desktop/src-tauri/tauri.conf.json→endpoints: ["https://github.com/different-ai/openwork/releases/latest/download/latest.json"]- minisign signature required (pubkey baked into config)
- installs a DMG/zip in place
A straight-swap to an Electron installer fails: the Tauri updater won't accept an asset that isn't minisign-signed in the format it expects.
Plan
1 — Make Electron read the same folder Tauri writes
Before any user-facing migration, flip two knobs in the current PR's Electron shell:
// apps/desktop/electron/main.mjs
app.setName("OpenWork");
app.setPath(
"userData",
path.join(app.getPath("appData"), "com.differentai.openwork"),
);
# apps/desktop/electron-builder.yml
appId: com.differentai.openwork # (currently com.differentai.openwork.electron)
Effects:
- macOS Launchpad / Dock / notarization identity stay the same → Gatekeeper doesn't re-prompt, the icon doesn't split into two slots.
- First Electron launch finds the Tauri-written
openwork-server-*.jsonalready present → workspaces, tokens, orchestrator state survive with zero copy. Sameworkspaces/subtree, same orchestrator data dir, same workspace.opencode/dirs (they live inside user folders anyway).
Filename compatibility layer:
// Electron runtime on load, once per launch
async function readWorkspaceState() {
const legacy = path.join(userData, "openwork-workspaces.json"); // Tauri
const current = path.join(userData, "workspace-state.json"); // Electron
if (existsSync(legacy) && !existsSync(current)) {
await rename(legacy, current); // idempotent migration
}
return existsSync(current) ? JSON.parse(await readFile(current)) : EMPTY;
}
2 — One final Tauri release: v0.12.0-migrate
This release uses the existing Tauri updater. Users click "Install update" as they always do. What v0.12.0-migrate ships:
-
A single new command
migrate_to_electron()in the Tauri shell that:- Downloads the matching Electron installer from the same GitHub Release
(
OpenWork-0.12.0-mac-<arch>.dmg,.exe,.AppImage). - Verifies signature via OS-native tools (
codesign --verify --deep --stricton mac, Authenticode on Windows, minisign or GH attestations on Linux). - Opens the installer and schedules Tauri quit.
- Downloads the matching Electron installer from the same GitHub Release
(
-
A one-time prompt:
OpenWork is moving to a new desktop engine. We'll install the new version and keep all your workspaces. ~30 seconds. [Install now] [Later]
"Later" defers 24h once, then force-installs on next launch — no indefinite stragglers.
-
tauri.conf.json.version→0.12.0,latest.json.version→0.12.0, minisign-signed as usual. Installed = still a Tauri binary, but whose only remaining job is to launch the Electron installer.
This is the only new Tauri release required. After v0.12.0 we stop
publishing latest.json updates.
3 — Flow (ASCII)
Tauri v0.11.x
│ (normal Tauri updater poll)
▼
latest.json says 0.12.0 is out → DMG installed in-place → Tauri v0.12.0-migrate
│ on first launch shows migration prompt
▼
migrate_to_electron():
download OpenWork-0.12.0-electron-mac.dmg from same release
codesign --verify ✓
open installer, schedule Tauri quit
│
▼
Installer replaces the .app bundle
appId = com.differentai.openwork (same as Tauri)
Launchpad slot + Dock pin preserved, no duplicate "OpenWork" icon
│
▼
OpenWork Electron v0.12.0 first launch
app.setPath("userData", .../com.differentai.openwork) points at the
Tauri-written folder → tokens, workspaces, orchestrator state already there
rename openwork-workspaces.json → workspace-state.json (once)
│
▼
electron-updater now owns the feed (latest-mac.yml, latest.yml, latest-linux.yml)
every future release is an Electron-only .dmg / .exe / .AppImage
4 — Post-migration auto-updater
Use electron-updater (ships with electron-builder) against the same
GitHub release stream:
# apps/desktop/electron-builder.yml
publish:
- provider: github
owner: different-ai
repo: openwork
releaseType: release
mac:
notarize: true # reuse existing Apple Developer ID
icon: src-tauri/icons/icon.icns
win:
sign: ./scripts/sign-windows.mjs # reuse existing EV cert
icon: src-tauri/icons/icon.ico
Runtime:
import { autoUpdater } from "electron-updater";
autoUpdater.channel = app.isPackaged ? (releaseChannel ?? "latest") : "latest";
autoUpdater.autoDownload = prefs.updateAutoDownload;
autoUpdater.checkForUpdatesAndNotify();
Alpha/beta channels reuse the existing alpha GitHub release (the current
alpha-macos-aarch64.yml workflow publishes to alpha-macos-latest;
switch its generate-latest-json.mjs step to emit latest-mac.yml instead).
Delta updates: electron-updater's block-map diffs drop a typical mac update from ~120MB full bundle to ~5-20MB. A net win over Tauri's no-delta default.
5 — Release-engineering changes
Release Appworkflow:- Replaces
tauri buildwithpnpm --filter @openwork/desktop package:electron. - Uploads DMG + zip +
latest-mac.yml+latest.yml+latest-linux.ymlto the same GitHub release asset list. - Keeps publishing minisign-signed
latest.jsonfor the v0.12.0 release only (so current Tauri users can pick up the migration update). After that release, stop updatinglatest.json.
- Replaces
build-electron-desktop.yml(already scaffolded in this PR): flip to a required check once the migration release is in flight.
6 — Rollout
| Stage | Audience | What ships |
|---|---|---|
| Week 0 | this PR merged | Electron co-exists, Tauri is default, no user impact |
| Week 1 | internal | Dogfood pnpm dev:electron on same data dir as Tauri |
| Week 2 | alpha channel | First real Electron release via alpha updater. Only opt-in alpha users get it. |
| Week 3 | stable — v0.12.0 | Migration release. Tauri prompt → Electron install → back online, same data. |
| Week 4+ | stable — v0.12.x | Electron-only. Tauri latest.json frozen. |
7 — Rollback
- Users already on Electron: ship
0.12.1throughelectron-updater. Same mechanism as any normal update. - Users still on Tauri: they never received the migration prompt; they stay
on Tauri. Pull
latest.jsonif there's a systemic issue. - Users mid-migration: Tauri is only quit after the Electron installer finishes writing the new bundle. If the installer aborts, Tauri remains the working app until the user retries.
8 — Risks and mitigations
- Bundle-identifier drift. If Electron
appIdis different from Tauri, macOS treats it as a separate app (new Launchpad icon, new Gatekeeper prompt, new TCC permissions). Fixed in step 1 by unifying tocom.differentai.openwork. - Notarization / signing. Electron builds need Apple Developer ID +
notarization for the same team. Reusing the existing Tauri CI secrets
(
APPLE_CERTIFICATE,APPLE_API_KEY, etc.) makes this a config change rather than a new credential story. - Electron bundle size. First Electron update is ~120MB vs ~20MB today. Mac universal build keeps it to one download per platform. Future deltas via block-map diffs recover most of the gap.
- Third-party integrations depending on the Tauri identifier (Sparkle, crash reporters, etc.): none in the current build, so zero action.
9 — localStorage migration
localStorage lives inside Chromium leveldb keyed by origin. Tauri serves
the renderer from tauri://localhost, Electron serves it from file://
(packaged) or http://localhost:5173 (dev). Pointing Electron's
userData at the Tauri folder is not enough — the leveldb records are
invisible across origins.
Scope: workspace list + selection only. Everything else (theme, font zoom, sidebar widths, feature flags) is cheap to redo.
Migrated keys (allowlist):
openwork.react.activeWorkspace— last selected workspaceopenwork.react.sessionByWorkspace— per-workspace last sessionopenwork.server.list— multi-server listopenwork.server.active— selected serveropenwork.server.urlOverride— server overrideopenwork.server.token— server token
Implementation (landed in this PR):
- Tauri Rust command
write_migration_snapshot(payload)serializes the allowlist into<app_data_dir>/migration-snapshot.v1.json. apps/app/src/app/lib/migration.tsexposes:writeMigrationSnapshotFromTauri()— scrapes localStorage via the pattern list and hands it to the Rust command.ingestMigrationSnapshotOnElectronBoot()— called once fromdesktop-runtime-boot.tson Electron only; hydrates localStorage keys that are empty, then acks via IPC so the snapshot is renamed tomigration-snapshot.v1.done.json.
- Electron main exposes
openwork:migration:readandopenwork:migration:ackIPC handlers; preload bridges them underwindow.__OPENWORK_ELECTRON__.migration.
The "last Tauri release" still needs to call
writeMigrationSnapshotFromTauri() right before it kicks off the
Electron installer. That's the UI/Rust-command-downloader piece in the
final PR below.
10 — Concrete PRs (order matters)
- PR #1522 (merged) — Electron shell lives side-by-side. No user impact.
- This PR — "migration engine". Unifies
appId+userDatapath to Tauri's, addsopenwork-workspaces.json→workspace-state.jsonauto-copy, adds electron-updater wiring, adds migration snapshot read/write plumbing on both sides. Zero user impact by itself (there's no Tauri release yet that calls the snapshot writer). - Last Tauri release v0.12.0 — ships:
- a Rust command
migrate_to_electron()that downloads the Electron installer, verifies its code signature, and opens it; - a one-time prompt ("OpenWork is moving to a new engine — install?")
that calls
writeMigrationSnapshotFromTauri()thenmigrate_to_electron(); - bumps
tauri.conf.json+latest.jsonso the existing Tauri updater delivers this release.
- a Rust command
- Release-engineering PR: update
Release Appworkflow to emit Electron artifacts +latest*.ymlfeeds alongside the Tauri assets for the v0.12.0 release, and only Electron for v0.12.1+.
After (3) rolls out, flip the default apps/desktop/package.json
scripts so dev / build / package use Electron, and delete
src-tauri/.