diff --git a/apps/app/src/app/lib/migration.ts b/apps/app/src/app/lib/migration.ts new file mode 100644 index 00000000..965723fb --- /dev/null +++ b/apps/app/src/app/lib/migration.ts @@ -0,0 +1,141 @@ +// One-way Tauri → Electron migration snapshot plumbing. +// +// The Tauri shell exports localStorage keys the user actively depends on +// (workspace list, selected workspace, per-workspace last-session, server +// list) into a JSON file in app_data_dir just before launching the +// Electron installer. Electron reads that file on first launch, hydrates +// localStorage for the keys that are still empty, then marks the file as +// acknowledged. +// +// Scope decision: we migrate *workspace* keys only. Everything else +// (theme, font zoom, sidebar widths, feature flags) is cheap to redo and +// not worth the complexity of a cross-origin localStorage transfer. + +import { invoke } from "@tauri-apps/api/core"; + +export const MIGRATION_SNAPSHOT_VERSION = 1; + +// Keep this list tiny and strict. Adding keys here expands blast radius +// if a later release renames them. +export const MIGRATION_KEY_PATTERNS: Array = [ + /^openwork\.react\.activeWorkspace$/, + /^openwork\.react\.sessionByWorkspace$/, + /^openwork\.server\.list$/, + /^openwork\.server\.active$/, + /^openwork\.server\.urlOverride$/, + /^openwork\.server\.token$/, +]; + +export type MigrationSnapshot = { + version: typeof MIGRATION_SNAPSHOT_VERSION; + writtenAt: number; + source: "tauri"; + keys: Record; +}; + +function matchesMigrationKey(key: string) { + return MIGRATION_KEY_PATTERNS.some((pattern) => pattern.test(key)); +} + +function collectMigrationKeysFromLocalStorage(): Record { + const out: Record = {}; + if (typeof window === "undefined") return out; + for (let i = 0; i < window.localStorage.length; i++) { + const key = window.localStorage.key(i); + if (!key || !matchesMigrationKey(key)) continue; + const value = window.localStorage.getItem(key); + if (value != null) out[key] = value; + } + return out; +} + +/** + * Tauri-only. Called by the last Tauri release right before it kicks off + * the Electron installer. Snapshots the workspace-related localStorage + * keys to /migration-snapshot.v1.json via a Rust command + * that does the actual disk write (renderer can't write outside the + * sandbox on its own). + */ +export async function writeMigrationSnapshotFromTauri(): Promise<{ + ok: boolean; + keyCount: number; + reason?: string; +}> { + try { + const keys = collectMigrationKeysFromLocalStorage(); + const snapshot: MigrationSnapshot = { + version: MIGRATION_SNAPSHOT_VERSION, + writtenAt: Date.now(), + source: "tauri", + keys, + }; + await invoke("write_migration_snapshot", { snapshot }); + return { ok: true, keyCount: Object.keys(keys).length }; + } catch (error) { + return { + ok: false, + keyCount: 0, + reason: error instanceof Error ? error.message : String(error), + }; + } +} + +type ElectronMigrationBridge = { + readSnapshot: () => Promise; + ackSnapshot: () => Promise<{ ok: boolean; moved: boolean }>; +}; + +function electronMigrationBridge(): ElectronMigrationBridge | null { + if (typeof window === "undefined") return null; + const bridge = (window as unknown as { + __OPENWORK_ELECTRON__?: { migration?: ElectronMigrationBridge }; + }).__OPENWORK_ELECTRON__; + return bridge?.migration ?? null; +} + +/** + * Electron-only. Called once during app boot. Reads the migration + * snapshot (if any), hydrates localStorage for keys that aren't already + * set on the Electron install, and acks the file so we don't re-ingest + * on subsequent launches. + * + * Returns the number of keys hydrated. Returns 0 when there is no + * snapshot, which is the steady-state case after the first launch. + */ +export async function ingestMigrationSnapshotOnElectronBoot(): Promise { + const bridge = electronMigrationBridge(); + if (!bridge) return 0; + + let snapshot: MigrationSnapshot | null = null; + try { + snapshot = await bridge.readSnapshot(); + } catch { + return 0; + } + if (!snapshot || snapshot.version !== MIGRATION_SNAPSHOT_VERSION) return 0; + + const entries = Object.entries(snapshot.keys ?? {}); + let hydrated = 0; + if (typeof window !== "undefined") { + for (const [key, value] of entries) { + if (!matchesMigrationKey(key)) continue; + if (window.localStorage.getItem(key) != null) continue; + try { + window.localStorage.setItem(key, value); + hydrated += 1; + } catch { + // localStorage write failures are non-fatal; the user just won't + // see that key migrated this launch. + } + } + } + + try { + await bridge.ackSnapshot(); + } catch { + // A failed ack means we'll re-ingest on next launch, but the + // "skip if already set" guard keeps that idempotent. + } + + return hydrated; +} diff --git a/apps/app/src/react-app/shell/desktop-runtime-boot.ts b/apps/app/src/react-app/shell/desktop-runtime-boot.ts index 2ec62511..b9d04a3e 100644 --- a/apps/app/src/react-app/shell/desktop-runtime-boot.ts +++ b/apps/app/src/react-app/shell/desktop-runtime-boot.ts @@ -9,8 +9,9 @@ import { resolveWorkspaceListSelectedId, workspaceBootstrap, } from "../../app/lib/desktop"; +import { ingestMigrationSnapshotOnElectronBoot } from "../../app/lib/migration"; import { hydrateOpenworkServerSettingsFromEnv, writeOpenworkServerSettings } from "../../app/lib/openwork-server"; -import { isDesktopRuntime, safeStringify } from "../../app/utils"; +import { isDesktopRuntime, isElectronRuntime, safeStringify } from "../../app/utils"; import { useServer } from "../kernel/server-provider"; import { useBootState } from "./boot-state"; @@ -45,6 +46,18 @@ export function useDesktopRuntimeBoot() { void (async () => { try { + // On Electron specifically: if the previous Tauri install dropped + // a migration snapshot, fold it into localStorage before any of + // the boot code reads workspace preferences. Idempotent across + // launches (the helper only writes keys that are still empty + // and acks the file after ingestion). + if (isElectronRuntime()) { + const hydrated = await ingestMigrationSnapshotOnElectronBoot(); + if (hydrated > 0) { + // eslint-disable-next-line no-console -- valuable one-time signal + console.info(`[migration] hydrated ${hydrated} localStorage keys from Tauri snapshot`); + } + } hydrateOpenworkServerSettingsFromEnv(); setPhase("bootstrapping-workspaces"); diff --git a/apps/app/vite.config.ts b/apps/app/vite.config.ts index 485df2a9..1265d12d 100644 --- a/apps/app/vite.config.ts +++ b/apps/app/vite.config.ts @@ -26,7 +26,13 @@ if (shortHostname && shortHostname !== hostname) { } const appRoot = resolve(fileURLToPath(new URL(".", import.meta.url))); +// Electron packaged builds load index.html via `file://`, so asset URLs +// must be relative. Tauri serves via its own protocol so absolute paths +// work there. Gate on an env var the electron build script sets. +const isElectronPackagedBuild = process.env.OPENWORK_ELECTRON_BUILD === "1"; + export default defineConfig({ + base: isElectronPackagedBuild ? "./" : "/", plugins: [ { name: "openwork-dev-server-id", diff --git a/apps/desktop/electron-builder.yml b/apps/desktop/electron-builder.yml index 4a4dd676..6b333933 100644 --- a/apps/desktop/electron-builder.yml +++ b/apps/desktop/electron-builder.yml @@ -1,4 +1,7 @@ -appId: com.differentai.openwork.electron +# IMPORTANT: appId intentionally matches the Tauri shell so both builds share +# the same macOS bundle identifier, keychain entries, Launchpad slot, and TCC +# permissions. In-place migration from Tauri to Electron depends on this. +appId: com.differentai.openwork productName: OpenWork directories: output: dist-electron @@ -12,11 +15,24 @@ extraResources: to: sidecars asar: true npmRebuild: false +# electron-updater feed. Matches the existing release workflow's GitHub target +# so both Tauri (latest.json) and Electron (latest*.yml) can live on the same +# release assets during the migration window. +publish: + - provider: github + owner: different-ai + repo: openwork + releaseType: release mac: icon: src-tauri/icons/icon.icns + category: public.app-category.developer-tools + hardenedRuntime: true + gatekeeperAssess: false target: - - dmg - - zip + - target: dmg + arch: [x64, arm64] + - target: zip + arch: [x64, arm64] linux: icon: src-tauri/icons/icon.png target: @@ -26,4 +42,4 @@ win: icon: src-tauri/icons/icon.ico target: - nsis -artifactName: openwork-electron-${os}-${arch}-${version}.${ext} +artifactName: openwork-${os}-${arch}-${version}.${ext} diff --git a/apps/desktop/electron/main.mjs b/apps/desktop/electron/main.mjs index 3f21b0ab..1b405ba0 100644 --- a/apps/desktop/electron/main.mjs +++ b/apps/desktop/electron/main.mjs @@ -16,10 +16,32 @@ import path from "node:path"; import { fileURLToPath } from "node:url"; import { app, BrowserWindow, dialog, ipcMain, nativeImage, shell } from "electron"; +// electron-updater is dynamically imported later because it pulls in a +// larger dep graph and we only want it loaded in packaged builds. import { createRuntimeManager } from "./runtime.mjs"; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const NATIVE_DEEP_LINK_EVENT = "openwork:deep-link-native"; +const TAURI_APP_IDENTIFIER = "com.differentai.openwork"; +const MIGRATION_SNAPSHOT_FILENAME = "migration-snapshot.v1.json"; +const MIGRATION_SNAPSHOT_DONE_FILENAME = "migration-snapshot.v1.done.json"; + +// Share the same on-disk state folder as the Tauri shell so in-place +// migration is a no-op for almost every file. Done BEFORE whenReady so all +// app.getPath("userData") callers see the unified path. +// +// Override via OPENWORK_ELECTRON_USERDATA so dogfooders can isolate their +// Electron install from the real Tauri app. +app.setName("OpenWork"); +const userDataOverride = process.env.OPENWORK_ELECTRON_USERDATA?.trim(); +if (userDataOverride) { + app.setPath("userData", userDataOverride); +} else { + app.setPath( + "userData", + path.join(app.getPath("appData"), TAURI_APP_IDENTIFIER), + ); +} // Resolve and cache the app icon (reused for BrowserWindow + mac dock). // Packaged builds ship icons via electron-builder config, but for `dev:electron` @@ -153,6 +175,34 @@ function workspaceStatePath() { return path.join(app.getPath("userData"), "workspace-state.json"); } +// Tauri shell writes the same data to openwork-workspaces.json. Electron +// reads the legacy filename on first launch when the Electron-native file +// isn't present yet, then writes to the Electron filename going forward. +function legacyTauriWorkspaceStatePath() { + return path.join(app.getPath("userData"), "openwork-workspaces.json"); +} + +async function migrateLegacyWorkspaceStateIfNeeded() { + const current = workspaceStatePath(); + const legacy = legacyTauriWorkspaceStatePath(); + try { + if (existsSync(current)) return false; + if (!existsSync(legacy)) return false; + await mkdir(path.dirname(current), { recursive: true }); + const raw = await readFile(legacy, "utf8"); + // Write to the Electron name without deleting the legacy file for a few + // releases so a rollback to Tauri (same bundle id) still finds data. + await writeFile(current, raw, "utf8"); + console.info( + "[migration] copied openwork-workspaces.json → workspace-state.json", + ); + return true; + } catch (error) { + console.warn("[migration] legacy workspace-state copy failed", error); + return false; + } +} + function configHomePath() { if (process.env.XDG_CONFIG_HOME?.trim()) { return process.env.XDG_CONFIG_HOME.trim(); @@ -1170,6 +1220,113 @@ ipcMain.handle("openwork:shell:relaunch", async () => { app.exit(0); }); +// Migration snapshot: one-way handoff from the last Tauri release into the +// first Electron launch. The Tauri shell writes migration-snapshot.v1.json +// into app_data_dir before it kicks off the Electron installer. Electron +// renders the workspace list / session-by-workspace preferences from it on +// first boot and then marks it .done so subsequent boots don't re-import. +function migrationSnapshotPath(done = false) { + return path.join( + app.getPath("userData"), + done ? MIGRATION_SNAPSHOT_DONE_FILENAME : MIGRATION_SNAPSHOT_FILENAME, + ); +} + +ipcMain.handle("openwork:migration:read", async () => { + const snapshotPath = migrationSnapshotPath(); + if (!existsSync(snapshotPath)) return null; + try { + const raw = await readFile(snapshotPath, "utf8"); + const parsed = JSON.parse(raw); + if (parsed && typeof parsed === "object" && parsed.version === 1) { + return parsed; + } + return null; + } catch (error) { + console.warn("[migration] failed to read snapshot", error); + return null; + } +}); + +ipcMain.handle("openwork:migration:ack", async () => { + const snapshotPath = migrationSnapshotPath(); + const donePath = migrationSnapshotPath(true); + if (!existsSync(snapshotPath)) return { ok: true, moved: false }; + try { + await rename(snapshotPath, donePath); + return { ok: true, moved: true }; + } catch (error) { + console.warn("[migration] failed to rename snapshot", error); + return { ok: false, moved: false }; + } +}); + +// electron-updater wiring. Packaged-only; dev builds skip this so the +// updater doesn't try to probe a non-existent release channel. +let autoUpdaterInstance = null; +let autoUpdaterLoaded = false; + +async function ensureAutoUpdater() { + if (!app.isPackaged) return null; + if (autoUpdaterLoaded) return autoUpdaterInstance; + autoUpdaterLoaded = true; + try { + const mod = await import("electron-updater"); + autoUpdaterInstance = mod.autoUpdater ?? mod.default?.autoUpdater ?? null; + if (autoUpdaterInstance) { + autoUpdaterInstance.autoDownload = false; + autoUpdaterInstance.autoInstallOnAppQuit = true; + autoUpdaterInstance.on("error", (err) => { + console.warn("[updater] error", err); + }); + } + } catch (error) { + console.warn("[updater] electron-updater not available", error); + autoUpdaterInstance = null; + } + return autoUpdaterInstance; +} + +ipcMain.handle("openwork:updater:check", async () => { + const updater = await ensureAutoUpdater(); + if (!updater) return { available: false, reason: "unavailable" }; + try { + const result = await updater.checkForUpdates(); + const info = result?.updateInfo ?? null; + return { + available: Boolean(info && info.version && info.version !== app.getVersion()), + currentVersion: app.getVersion(), + latestVersion: info?.version ?? null, + releaseDate: info?.releaseDate ?? null, + releaseNotes: info?.releaseNotes ?? null, + }; + } catch (error) { + return { available: false, reason: String(error?.message ?? error) }; + } +}); + +ipcMain.handle("openwork:updater:download", async () => { + const updater = await ensureAutoUpdater(); + if (!updater) return { ok: false, reason: "unavailable" }; + try { + await updater.downloadUpdate(); + return { ok: true }; + } catch (error) { + return { ok: false, reason: String(error?.message ?? error) }; + } +}); + +ipcMain.handle("openwork:updater:installAndRestart", async () => { + const updater = await ensureAutoUpdater(); + if (!updater) return { ok: false, reason: "unavailable" }; + try { + updater.quitAndInstall(false, true); + return { ok: true }; + } catch (error) { + return { ok: false, reason: String(error?.message ?? error) }; + } +}); + if (!app.requestSingleInstanceLock()) { app.quit(); } else { @@ -1190,11 +1347,22 @@ if (!app.requestSingleInstanceLock()) { }); app.whenReady().then(async () => { + // Copy Tauri workspace state on first launch so the Electron sidebar + // reflects the exact workspace list users see in the Tauri app today. + await migrateLegacyWorkspaceStateIfNeeded(); + queueDeepLinks(forwardedDeepLinks(process.argv)); const win = await createMainWindow(); win.webContents.on("did-finish-load", () => { flushPendingDeepLinks(); }); + + // Kick the packaged-only updater after the window is up so the user + // sees a working app first. This is a no-op in dev. + void ensureAutoUpdater().then((updater) => { + if (!updater) return; + void updater.checkForUpdates().catch(() => undefined); + }); }); app.on("activate", async () => { diff --git a/apps/desktop/electron/preload.mjs b/apps/desktop/electron/preload.mjs index 95bed1e1..c23d4aa8 100644 --- a/apps/desktop/electron/preload.mjs +++ b/apps/desktop/electron/preload.mjs @@ -20,6 +20,25 @@ contextBridge.exposeInMainWorld("__OPENWORK_ELECTRON__", { return ipcRenderer.invoke("openwork:shell:relaunch"); }, }, + migration: { + readSnapshot() { + return ipcRenderer.invoke("openwork:migration:read"); + }, + ackSnapshot() { + return ipcRenderer.invoke("openwork:migration:ack"); + }, + }, + updater: { + check() { + return ipcRenderer.invoke("openwork:updater:check"); + }, + download() { + return ipcRenderer.invoke("openwork:updater:download"); + }, + installAndRestart() { + return ipcRenderer.invoke("openwork:updater:installAndRestart"); + }, + }, meta: { initialDeepLinks: [], platform: normalizePlatform(process.platform), diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 5ec79ee5..4928d62c 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -25,6 +25,9 @@ "electron": "pnpm exec electron ./electron/main.mjs", "prepare:sidecar": "node ./scripts/prepare-sidecar.mjs" }, + "dependencies": { + "electron-updater": "^6.3.9" + }, "devDependencies": { "electron": "^35.0.0", "electron-builder": "^25.1.8", diff --git a/apps/desktop/scripts/electron-build.mjs b/apps/desktop/scripts/electron-build.mjs index 8f43736b..e65d143e 100644 --- a/apps/desktop/scripts/electron-build.mjs +++ b/apps/desktop/scripts/electron-build.mjs @@ -9,11 +9,12 @@ const repoRoot = resolve(desktopRoot, "../.."); const pnpmCmd = process.platform === "win32" ? "pnpm.cmd" : "pnpm"; const nodeCmd = process.execPath; -function run(command, args, cwd) { +function run(command, args, cwd, env) { const result = spawnSync(command, args, { cwd, stdio: "inherit", shell: process.platform === "win32", + env: env ? { ...process.env, ...env } : process.env, }); if (result.status !== 0) { process.exit(result.status ?? 1); @@ -21,7 +22,12 @@ function run(command, args, cwd) { } run(nodeCmd, [resolve(__dirname, "prepare-sidecar.mjs"), "--force"], desktopRoot); -run(pnpmCmd, ["--filter", "@openwork/app", "build"], repoRoot); +// OPENWORK_ELECTRON_BUILD tells Vite to emit relative asset paths so +// index.html resolves /assets/* correctly when loaded via file:// from +// inside the packaged .app bundle. +run(pnpmCmd, ["--filter", "@openwork/app", "build"], repoRoot, { + OPENWORK_ELECTRON_BUILD: "1", +}); run(nodeCmd, ["--check", resolve(desktopRoot, "electron/main.mjs")], repoRoot); run(nodeCmd, ["--check", resolve(desktopRoot, "electron/preload.mjs")], repoRoot); diff --git a/apps/desktop/src-tauri/src/commands/migration.rs b/apps/desktop/src-tauri/src/commands/migration.rs new file mode 100644 index 00000000..93042b7c --- /dev/null +++ b/apps/desktop/src-tauri/src/commands/migration.rs @@ -0,0 +1,61 @@ +use serde::Deserialize; +use std::collections::HashMap; +use std::fs; +use std::path::PathBuf; +use tauri::{AppHandle, Manager}; + +const MIGRATION_SNAPSHOT_FILENAME: &str = "migration-snapshot.v1.json"; + +#[derive(Debug, Deserialize)] +pub struct MigrationSnapshotPayload { + pub version: u32, + #[serde(rename = "writtenAt")] + pub written_at: Option, + pub source: Option, + pub keys: HashMap, +} + +fn migration_snapshot_path(app: &AppHandle) -> Result { + let data_dir = app + .path() + .app_data_dir() + .map_err(|e| format!("Failed to resolve app_data_dir: {e}"))?; + fs::create_dir_all(&data_dir) + .map_err(|e| format!("Failed to create app_data_dir: {e}"))?; + Ok(data_dir.join(MIGRATION_SNAPSHOT_FILENAME)) +} + +/// Snapshot workspace-related localStorage keys into app_data_dir so the +/// next-launch Electron shell can hydrate them. Called by the last Tauri +/// release right before it kicks off the Electron installer. +#[tauri::command] +pub fn write_migration_snapshot( + app: AppHandle, + snapshot: MigrationSnapshotPayload, +) -> Result<(), String> { + if snapshot.version != 1 { + return Err(format!( + "Unsupported migration snapshot version: {}", + snapshot.version + )); + } + + let path = migration_snapshot_path(&app)?; + let serialized = serde_json::json!({ + "version": snapshot.version, + "writtenAt": snapshot.written_at, + "source": snapshot.source.unwrap_or_else(|| "tauri".to_string()), + "keys": snapshot.keys, + }); + let contents = serde_json::to_string_pretty(&serialized) + .map_err(|e| format!("Failed to serialize snapshot: {e}"))?; + fs::write(&path, contents).map_err(|e| format!("Failed to write snapshot: {e}"))?; + + println!( + "[migration] wrote {} key(s) to {}", + snapshot.keys.len(), + path.display() + ); + + Ok(()) +} diff --git a/apps/desktop/src-tauri/src/commands/mod.rs b/apps/desktop/src-tauri/src/commands/mod.rs index d7e50c87..3f5cf154 100644 --- a/apps/desktop/src-tauri/src/commands/mod.rs +++ b/apps/desktop/src-tauri/src/commands/mod.rs @@ -2,6 +2,7 @@ pub mod command_files; pub mod config; pub mod desktop_bootstrap; pub mod engine; +pub mod migration; pub mod misc; pub mod opencode_router; pub mod openwork_server; diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs index 8456cd37..e655358c 100644 --- a/apps/desktop/src-tauri/src/lib.rs +++ b/apps/desktop/src-tauri/src/lib.rs @@ -24,6 +24,7 @@ use commands::desktop_bootstrap::{get_desktop_bootstrap_config, set_desktop_boot use commands::engine::{ engine_doctor, engine_info, engine_install, engine_restart, engine_start, engine_stop, }; +use commands::migration::write_migration_snapshot; use commands::misc::{ app_build_info, nuke_openwork_and_opencode_config_and_exit, opencode_mcp_auth, reset_opencode_cache, reset_openwork_state, @@ -208,6 +209,7 @@ pub fn run() { get_desktop_bootstrap_config, set_desktop_bootstrap_config, updater_environment, + write_migration_snapshot, app_build_info, nuke_openwork_and_opencode_config_and_exit, reset_openwork_state, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 120b6c7f..a22a646e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -142,6 +142,10 @@ importers: version: 6.4.1(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2) apps/desktop: + dependencies: + electron-updater: + specifier: ^6.3.9 + version: 6.8.3 devDependencies: '@tauri-apps/cli': specifier: ^2.0.0 @@ -4000,6 +4004,10 @@ packages: resolution: {integrity: sha512-6p/gfG1RJSQeIbz8TK5aPNkoztgY1q5TgmGFMAXcY8itsGW6Y2ld1ALsZ5UJn8rog7hKF3zHx5iQbNQ8uLcRlw==} engines: {node: '>=12.0.0'} + builder-util-runtime@9.5.1: + resolution: {integrity: sha512-qt41tMfgHTllhResqM5DcnHyDIWNgzHvuY2jDcYP9iaGpkWxTUzV6GQjDeLnlR1/DtdlcsWQbA7sByMpmJFTLQ==} + engines: {node: '>=12.0.0'} + builder-util@25.1.7: resolution: {integrity: sha512-7jPjzBwEGRbwNcep0gGNpLXG9P94VA3CPAZQCzxkFXiV2GMQKlziMbY//rXPI7WKfhsvGgFXjTcXdBEwgXw9ww==} @@ -4690,6 +4698,9 @@ packages: electron-to-chromium@1.5.267: resolution: {integrity: sha512-0Drusm6MVRXSOJpGbaSVgcQsuB4hEkMpHXaVstcPmhu5LIedxs1xNK/nIxmQIU/RPC0+1/o0AVZfBTkTNJOdUw==} + electron-updater@6.8.3: + resolution: {integrity: sha512-Z6sgw3jgbikWKXei1ENdqFOxBP0WlXg3TtKfz0rgw2vIZFJUyI4pD7ZN7jrkm7EoMK+tcm/qTnPUdqfZukBlBQ==} + electron@35.7.5: resolution: {integrity: sha512-dnL+JvLraKZl7iusXTVTGYs10TKfzUi30uEDTqsmTm0guN9V2tbOjTzyIZbh9n3ygUjgEYyo+igAwMRXIi3IPw==} engines: {node: '>= 12.20.55'} @@ -5538,9 +5549,16 @@ packages: lodash.difference@4.5.0: resolution: {integrity: sha512-dS2j+W26TQ7taQBGN8Lbbq04ssV3emRw4NY58WErlTO29pIqS0HmoT5aJ9+TUQ1N3G+JOZSji4eugsWwGp9yPA==} + lodash.escaperegexp@4.1.2: + resolution: {integrity: sha512-TM9YBvyC84ZxE3rgfefxUWiQKLilstD6k7PTGt6wfbtXF8ixIJLOL3VYyV/z+ZiPLsVxAsKAFVwWlWeb2Y8Yyw==} + lodash.flatten@4.4.0: resolution: {integrity: sha512-C5N2Z3DgnnKr0LOpv/hKCgKdb7ZZwafIrsesve6lmzvZIRZRGaZ/l6Q8+2W7NaT+ZwO3fFlSCzCzrDCFdJfZ4g==} + lodash.isequal@4.5.0: + resolution: {integrity: sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ==} + deprecated: This package is deprecated. Use require('node:util').isDeepStrictEqual instead. + lodash.isplainobject@4.0.6: resolution: {integrity: sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==} @@ -6916,6 +6934,9 @@ packages: resolution: {integrity: sha512-nt6AMGKW1p/70DF/hGBdJB57B8Tspmbp5gfJ8ilhLnt7kkr2ye7hzD6NVG8GGErk2HWF34igrL2CXmNIkzKqKw==} engines: {node: '>=18'} + tiny-typed-emitter@2.1.0: + resolution: {integrity: sha512-qVtvMxeXbVej0cQWKqVSSAHmKZEHAvxdF8HEUBFWts8h+xEo5m/lEiPakuyZ3BnCBjOD8i24kzNOiOLLgsSxhA==} + tinycolor2@1.6.0: resolution: {integrity: sha512-XPaBkWQJdsf3pLKJV9p4qN/S+fm2Oj8AIPo1BTUhg5oxkvm9+SVEGFdhyOz7tTdUTfvxMiAs4sp6/eZO2Ew+pw==} @@ -11153,6 +11174,13 @@ snapshots: transitivePeerDependencies: - supports-color + builder-util-runtime@9.5.1: + dependencies: + debug: 4.4.3 + sax: 1.4.4 + transitivePeerDependencies: + - supports-color + builder-util@25.1.7: dependencies: 7zip-bin: 5.2.0 @@ -11837,6 +11865,19 @@ snapshots: electron-to-chromium@1.5.267: {} + electron-updater@6.8.3: + dependencies: + builder-util-runtime: 9.5.1 + fs-extra: 10.1.0 + js-yaml: 4.1.1 + lazy-val: 1.0.5 + lodash.escaperegexp: 4.1.2 + lodash.isequal: 4.5.0 + semver: 7.7.4 + tiny-typed-emitter: 2.1.0 + transitivePeerDependencies: + - supports-color + electron@35.7.5: dependencies: '@electron/get': 2.0.3 @@ -12797,8 +12838,12 @@ snapshots: lodash.difference@4.5.0: {} + lodash.escaperegexp@4.1.2: {} + lodash.flatten@4.4.0: {} + lodash.isequal@4.5.0: {} + lodash.isplainobject@4.0.6: {} lodash.union@4.6.0: {} @@ -14508,6 +14553,8 @@ snapshots: throttleit@2.1.0: {} + tiny-typed-emitter@2.1.0: {} + tinycolor2@1.6.0: {} tinyexec@0.3.2: {} diff --git a/prds/electron-migration-plan.md b/prds/electron-migration-plan.md index 270c9327..d2591e68 100644 --- a/prds/electron-migration-plan.md +++ b/prds/electron-migration-plan.md @@ -235,19 +235,64 @@ from ~120MB full bundle to ~5-20MB. A net win over Tauri's no-delta default. - **Third-party integrations depending on the Tauri identifier** (Sparkle, crash reporters, etc.): none in the current build, so zero action. -### 9 — Concrete next-step PRs (order matters) +### 9 — localStorage migration -1. **This PR** (#1522) — Electron shell lives side-by-side. No user impact. -2. **Follow-up PR**: "unify app identity with Tauri". Flips `appId` to - `com.differentai.openwork`, points `userData` at the Tauri folder, adds - the `openwork-workspaces.json` → `workspace-state.json` rename. -3. **Follow-up PR**: "electron-updater + release workflow". Wires - electron-builder `publish:` config, teaches `Release App` to emit the - electron-updater feed manifests. -4. **Final PR**: "last Tauri release v0.12.0". Ships the Tauri - `migrate_to_electron` command + prompt. Triggers migration for every - existing user on their next Tauri update. +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. -After (4) lands and rolls out, flip the default -`apps/desktop/package.json` scripts so `dev` / `build` / `package` use -Electron, and delete `src-tauri/`. +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 workspace +- `openwork.react.sessionByWorkspace` — per-workspace last session +- `openwork.server.list` — multi-server list +- `openwork.server.active` — selected server +- `openwork.server.urlOverride` — server override +- `openwork.server.token` — server token + +Implementation (landed in this PR): +- Tauri Rust command `write_migration_snapshot(payload)` serializes the + allowlist into `/migration-snapshot.v1.json`. +- `apps/app/src/app/lib/migration.ts` exposes: + - `writeMigrationSnapshotFromTauri()` — scrapes localStorage via the + pattern list and hands it to the Rust command. + - `ingestMigrationSnapshotOnElectronBoot()` — called once from + `desktop-runtime-boot.ts` on Electron only; hydrates localStorage + keys that are empty, then acks via IPC so the snapshot is renamed + to `migration-snapshot.v1.done.json`. +- Electron main exposes `openwork:migration:read` and + `openwork:migration:ack` IPC handlers; preload bridges them under + `window.__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) + +1. **PR #1522** (merged) — Electron shell lives side-by-side. No user impact. +2. **This PR** — "migration engine". Unifies `appId` + `userData` path to + Tauri's, adds `openwork-workspaces.json` → `workspace-state.json` + auto-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). +3. **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()` then + `migrate_to_electron()`; + - bumps `tauri.conf.json` + `latest.json` so the existing Tauri + updater delivers this release. +4. **Release-engineering PR**: update `Release App` workflow to emit + Electron artifacts + `latest*.yml` feeds 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/`.