mirror of
https://github.com/different-ai/openwork
synced 2026-04-25 17:15:34 +02:00
feat(desktop): migration engine for Tauri → Electron (draft, do not merge) (#1523)
* 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>
This commit is contained in:
141
apps/app/src/app/lib/migration.ts
Normal file
141
apps/app/src/app/lib/migration.ts
Normal file
@@ -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<RegExp> = [
|
||||
/^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<string, string>;
|
||||
};
|
||||
|
||||
function matchesMigrationKey(key: string) {
|
||||
return MIGRATION_KEY_PATTERNS.some((pattern) => pattern.test(key));
|
||||
}
|
||||
|
||||
function collectMigrationKeysFromLocalStorage(): Record<string, string> {
|
||||
const out: Record<string, string> = {};
|
||||
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 <app_data_dir>/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<MigrationSnapshot | null>;
|
||||
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<number> {
|
||||
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;
|
||||
}
|
||||
@@ -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");
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
61
apps/desktop/src-tauri/src/commands/migration.rs
Normal file
61
apps/desktop/src-tauri/src/commands/migration.rs
Normal file
@@ -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<i64>,
|
||||
pub source: Option<String>,
|
||||
pub keys: HashMap<String, String>,
|
||||
}
|
||||
|
||||
fn migration_snapshot_path(app: &AppHandle) -> Result<PathBuf, String> {
|
||||
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(())
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
47
pnpm-lock.yaml
generated
47
pnpm-lock.yaml
generated
@@ -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: {}
|
||||
|
||||
@@ -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 `<app_data_dir>/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/`.
|
||||
|
||||
Reference in New Issue
Block a user