From 7a53c2964cd5634d1e88036d2e6835e1a293a975 Mon Sep 17 00:00:00 2001 From: ben Date: Wed, 22 Apr 2026 18:52:30 -0700 Subject: [PATCH] =?UTF-8?q?feat(desktop):=20Tauri=20=E2=86=92=20Electron?= =?UTF-8?q?=20migration=20release=20kit=20(#1526)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ships the last-missing piece of the Electron migration. After this PR merges there are three one-shot scripts under scripts/migration/ that carry the whole lifecycle from "cut v0.12.0" through "delete src-tauri": 01-cut-migration-release.mjs # bumps versions, writes the release env # fragment, tags, pushes. 02-validate-migration.mjs # guided smoke test against a cut release. 03-post-migration-cleanup.mjs # deletes src-tauri, flips defaults, # scrubs Tauri docs (dry-run by default). Code landing in the same PR (dormant until a release sets VITE_OPENWORK_MIGRATION_RELEASE=1): - apps/desktop/src-tauri/src/commands/migration.rs gains migrate_to_electron() — downloads the matching Electron .zip, verifies the Apple signature, swaps the .app bundle in place via a detached shell script, relaunches, and exits. Windows + Linux branches are stubbed with clear TODOs for the follow-up. - apps/app/src/app/lib/migration.ts grows migrateToElectron() + a "later" defer helper. - apps/app/src/react-app/shell/migration-prompt.tsx adds the one-time "OpenWork is moving to a new engine" modal. Mounted from providers.tsx. Gated on isTauriRuntime() AND the build-time flag, so Electron users and all non-migration-release builds never render it. - apps/app/vite.config.ts loads apps/app/.env.migration-release when present so the prompt gets the release-specific download URLs. - .gitignore allows the migration-release fragment to be committed only on the tagged migration-release commit (removed in cleanup step). Release workflow: - .github/workflows/release-macos-aarch64.yml gains a publish-electron job alongside the Tauri jobs. Gated on RELEASE_PUBLISH_ELECTRON repo var OR the new publish_electron workflow_dispatch input (default false). Uses the existing Apple Dev ID secrets — no new credential story. Produces latest-mac.yml alongside Tauri's latest.json so a v0.12.0 release serves both updaters. Verified: pnpm --filter @openwork/app typecheck ✓ cargo check --manifest-path apps/desktop/src-tauri/Cargo.toml ✓ node --check on all mjs scripts ✓ python yaml parse of release-macos-aarch64.yml ✓ Not verified (needs a real release cycle): end-to-end migration from a signed Tauri .app to a signed Electron .app through the detached-script install swap. Co-authored-by: Benjamin Shafii --- .github/workflows/release-macos-aarch64.yml | 58 ++++- .gitignore | 4 + apps/app/src/app/lib/migration.ts | 75 +++++++ .../src/react-app/shell/migration-prompt.tsx | 156 +++++++++++++ apps/app/src/react-app/shell/providers.tsx | 2 + apps/app/vite.config.ts | 30 +++ .../src-tauri/src/commands/migration.rs | 192 ++++++++++++++++ apps/desktop/src-tauri/src/lib.rs | 3 +- .../migration/01-cut-migration-release.mjs | 178 +++++++++++++++ scripts/migration/02-validate-migration.mjs | 205 +++++++++++++++++ .../migration/03-post-migration-cleanup.mjs | 211 ++++++++++++++++++ scripts/migration/README.md | 107 +++++++++ 12 files changed, 1219 insertions(+), 2 deletions(-) create mode 100644 apps/app/src/react-app/shell/migration-prompt.tsx create mode 100755 scripts/migration/01-cut-migration-release.mjs create mode 100755 scripts/migration/02-validate-migration.mjs create mode 100755 scripts/migration/03-post-migration-cleanup.mjs create mode 100644 scripts/migration/README.md diff --git a/.github/workflows/release-macos-aarch64.yml b/.github/workflows/release-macos-aarch64.yml index 81f57591..e4d66c2a 100644 --- a/.github/workflows/release-macos-aarch64.yml +++ b/.github/workflows/release-macos-aarch64.yml @@ -53,6 +53,11 @@ on: required: false type: boolean default: true + publish_electron: + description: "Build + publish Electron desktop artifacts (macOS) alongside Tauri" + required: false + type: boolean + default: false permissions: contents: write @@ -605,7 +610,58 @@ jobs: --repo "$GITHUB_REPOSITORY" \ --clobber - release-orchestrator-sidecars: + publish-electron: + name: Build + publish Electron desktop (macOS) + # Runs alongside the Tauri jobs so the same release carries both + # latest.json (Tauri updater) AND latest-mac.yml (electron-updater). + # Gated on RELEASE_PUBLISH_ELECTRON=true (repo var) OR the workflow + # input of the same name so opt-in during rollout, opt-out if a + # non-migration release doesn't want Electron artifacts. + needs: [resolve-release, verify-release] + if: ${{ vars.RELEASE_PUBLISH_ELECTRON == 'true' || github.event.inputs.publish_electron == 'true' }} + runs-on: macos-14 + env: + RELEASE_TAG: ${{ needs.resolve-release.outputs.release_tag }} + steps: + - name: Checkout + uses: actions/checkout@v6 + with: + ref: ${{ env.RELEASE_TAG }} + fetch-depth: 0 + + - name: Setup pnpm + uses: pnpm/action-setup@v4 + with: + version: 10.27.0 + + - name: Setup Node + uses: actions/setup-node@v6 + with: + node-version-file: .nvmrc + + - name: Setup Bun + uses: oven-sh/setup-bun@v2 + with: + bun-version: 1.3.9 + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Build + package Electron app + env: + # electron-builder reads these to codesign + notarize the .app. + # Reuses the same secrets the Tauri path already uses. + CSC_LINK: ${{ secrets.APPLE_CERTIFICATE }} + CSC_KEY_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }} + APPLE_ID: ${{ secrets.APPLE_ID }} + APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }} + APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} + # electron-builder needs this to upload artifacts + the updater + # feed manifest (latest-mac.yml) to the GitHub release. + GH_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + pnpm --filter @openwork/desktop package:electron --publish always name: Build + Upload openwork-orchestrator Sidecars needs: [resolve-release, verify-release] if: needs.resolve-release.outputs.publish_sidecars == 'true' diff --git a/.gitignore b/.gitignore index c8b245a9..f6f65e16 100644 --- a/.gitignore +++ b/.gitignore @@ -26,6 +26,10 @@ apps/desktop/src-tauri/sidecars/ .env .env.* !.env.example +# The migration-release fragment is committed only on the tagged +# migration-release commit and removed by 03-post-migration-cleanup.mjs. +# Allow git to see it so `cut-migration-release` can commit it. +!.env.migration-release # Bun build artifacts *.bun-build diff --git a/apps/app/src/app/lib/migration.ts b/apps/app/src/app/lib/migration.ts index 965723fb..5297df08 100644 --- a/apps/app/src/app/lib/migration.ts +++ b/apps/app/src/app/lib/migration.ts @@ -139,3 +139,78 @@ export async function ingestMigrationSnapshotOnElectronBoot(): Promise { return hydrated; } + +export type MigrateToElectronRequest = { + /** + * Download URL for the matching Electron artifact. On macOS a .zip. + * On Windows, an NSIS .exe (TODO — stubbed today). On Linux, an AppImage + * (TODO — stubbed today). + */ + url: string; + /** Optional sha256 to verify before touching the filesystem. */ + sha256?: string; + /** + * Override where the Electron .app ends up (macOS). Defaults to + * replacing the currently-running .app bundle in place. + */ + targetAppPath?: string; +}; + +/** + * Tauri-only. Hand off to the new Electron build: + * 1. Download + verify the installer + * 2. Replace the running .app bundle + * 3. Relaunch into the Electron binary + * 4. Quit this Tauri process + * + * Callers should invoke `writeMigrationSnapshotFromTauri()` first so the + * new Electron shell can hydrate localStorage on first launch. + */ +export async function migrateToElectron( + request: MigrateToElectronRequest, +): Promise<{ ok: boolean; reason?: string }> { + try { + await invoke("migrate_to_electron", { request }); + return { ok: true }; + } catch (error) { + return { + ok: false, + reason: error instanceof Error ? error.message : String(error), + }; + } +} + +// Localstorage key that stores a "don't ask again until" epoch-ms. +// Users who click "Later" get a 24h reprieve; after that we nudge again. +export const MIGRATION_DEFER_KEY = "openwork.migration.deferredUntil"; +export const MIGRATION_DEFAULT_DEFER_MS = 24 * 60 * 60 * 1000; + +export function isMigrationDeferred(now: number = Date.now()): boolean { + if (typeof window === "undefined") return false; + try { + const raw = window.localStorage.getItem(MIGRATION_DEFER_KEY); + if (!raw) return false; + const until = Number.parseInt(raw, 10); + return Number.isFinite(until) && until > now; + } catch { + return false; + } +} + +export function deferMigration(ms: number = MIGRATION_DEFAULT_DEFER_MS): void { + if (typeof window === "undefined") return; + try { + window.localStorage.setItem(MIGRATION_DEFER_KEY, String(Date.now() + ms)); + } catch { + // non-fatal + } +} + +export function clearMigrationDefer(): void { + if (typeof window === "undefined") return; + try { + window.localStorage.removeItem(MIGRATION_DEFER_KEY); + } catch { + // non-fatal + } +} diff --git a/apps/app/src/react-app/shell/migration-prompt.tsx b/apps/app/src/react-app/shell/migration-prompt.tsx new file mode 100644 index 00000000..4eb86749 --- /dev/null +++ b/apps/app/src/react-app/shell/migration-prompt.tsx @@ -0,0 +1,156 @@ +/** @jsxImportSource react */ +/** + * Migration prompt — one-time modal that nudges Tauri v0.12.0 users over + * to the Electron build. Dormant unless the build was produced with + * `VITE_OPENWORK_MIGRATION_RELEASE=1`, so landing this code on dev has + * zero user impact until the release script flips that flag. + */ +import { useCallback, useEffect, useState, type ReactElement } from "react"; + +import { + deferMigration, + isMigrationDeferred, + migrateToElectron, + writeMigrationSnapshotFromTauri, +} from "../../app/lib/migration"; +import { isTauriRuntime } from "../../app/utils"; + +type MigrationConfig = { + /** + * URL template for the Electron artifact on the matching release. The + * release script substitutes {arch} at build time. On macOS we expect a + * .zip (the .app bundle is swapped in place — see migrate_to_electron + * Rust command). + */ + macUrl?: string; + /** Optional sha256 check. */ + macSha256?: string; + /** Windows installer URL. Stub until we wire the Rust path. */ + windowsUrl?: string; + /** Linux AppImage URL. Stub until we wire the Rust path. */ + linuxUrl?: string; +}; + +function readBuildTimeConfig(): MigrationConfig | null { + const env = (import.meta as unknown as { env?: Record }).env ?? {}; + const enabled = env.VITE_OPENWORK_MIGRATION_RELEASE === "1"; + if (!enabled) return null; + return { + macUrl: env.VITE_OPENWORK_MIGRATION_MAC_URL, + macSha256: env.VITE_OPENWORK_MIGRATION_MAC_SHA256, + windowsUrl: env.VITE_OPENWORK_MIGRATION_WINDOWS_URL, + linuxUrl: env.VITE_OPENWORK_MIGRATION_LINUX_URL, + }; +} + +function currentPlatformUrl(cfg: MigrationConfig): { url?: string; sha256?: string } { + if (typeof navigator === "undefined") return {}; + const ua = navigator.userAgent.toLowerCase(); + if (ua.includes("mac")) return { url: cfg.macUrl, sha256: cfg.macSha256 }; + if (ua.includes("windows") || ua.includes("win64") || ua.includes("win32")) { + return { url: cfg.windowsUrl }; + } + return { url: cfg.linuxUrl }; +} + +export function MigrationPrompt(): ReactElement | null { + const [open, setOpen] = useState(false); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + const [config, setConfig] = useState(null); + + useEffect(() => { + // Only Tauri shows this modal. Electron users are already on the + // target runtime. + if (!isTauriRuntime()) return; + const cfg = readBuildTimeConfig(); + if (!cfg) return; + if (isMigrationDeferred()) return; + setConfig(cfg); + setOpen(true); + }, []); + + const handleInstall = useCallback(async () => { + if (!config) return; + setBusy(true); + setError(null); + try { + const { url, sha256 } = currentPlatformUrl(config); + if (!url) { + throw new Error( + "Automatic migration isn't available for this platform yet. Please upgrade manually from the release page.", + ); + } + const snapshot = await writeMigrationSnapshotFromTauri(); + if (!snapshot.ok) { + throw new Error( + snapshot.reason ?? + "Failed to capture workspace state before migration.", + ); + } + const result = await migrateToElectron({ url, sha256 }); + if (!result.ok) { + throw new Error(result.reason ?? "Migration handoff failed."); + } + // On success, the Rust command kicks off a detached shell script that + // swaps the .app bundle and relaunches. Tauri will exit on its own + // shortly — leave the dialog in its loading state until that happens. + } catch (e) { + setBusy(false); + setError(e instanceof Error ? e.message : String(e)); + } + }, [config]); + + const handleLater = useCallback(() => { + deferMigration(); + setOpen(false); + }, []); + + if (!open) return null; + + return ( +
+
+

+ OpenWork is moving to a new engine +

+

+ We're upgrading the desktop shell. The install takes ~30 seconds and + keeps all your workspaces, sessions, and tokens exactly where they + are. You'll be back in OpenWork automatically when it's done. +

+ {error ? ( +
+ {error} +
+ ) : null} +
+ + +
+
+
+ ); +} diff --git a/apps/app/src/react-app/shell/providers.tsx b/apps/app/src/react-app/shell/providers.tsx index e99f212a..1cd6e2c3 100644 --- a/apps/app/src/react-app/shell/providers.tsx +++ b/apps/app/src/react-app/shell/providers.tsx @@ -12,6 +12,7 @@ import { ServerProvider } from "../kernel/server-provider"; import { BootStateProvider } from "./boot-state"; import { DesktopRuntimeBoot } from "./desktop-runtime-boot"; import { startDebugLogger, stopDebugLogger } from "./debug-logger"; +import { MigrationPrompt } from "./migration-prompt"; function resolveDefaultServerUrl(): string { if (isDesktopRuntime()) return "http://127.0.0.1:4096"; @@ -66,6 +67,7 @@ export function AppProviders({ children }: AppProvidersProps) { + ); diff --git a/apps/app/vite.config.ts b/apps/app/vite.config.ts index 1265d12d..0011c059 100644 --- a/apps/app/vite.config.ts +++ b/apps/app/vite.config.ts @@ -1,4 +1,5 @@ import os from "node:os"; +import { existsSync, readFileSync } from "node:fs"; import { resolve } from "node:path"; import { fileURLToPath } from "node:url"; import { defineConfig } from "vite"; @@ -26,6 +27,29 @@ if (shortHostname && shortHostname !== hostname) { } const appRoot = resolve(fileURLToPath(new URL(".", import.meta.url))); +// Load the Tauri → Electron migration-release fragment if present. Written +// by scripts/migration/01-cut-migration-release.mjs for the specific +// release commit; absent otherwise so every other build has the migration +// prompt dormant. Pre-parsed here so Vite's define/import.meta.env picks +// up the keys without a custom plugin. +function loadMigrationReleaseEnv(): Record { + const fragmentPath = resolve(appRoot, ".env.migration-release"); + if (!existsSync(fragmentPath)) return {}; + const out: Record = {}; + const raw = readFileSync(fragmentPath, "utf8"); + for (const line of raw.split(/\r?\n/)) { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith("#")) continue; + const eq = trimmed.indexOf("="); + if (eq < 0) continue; + const key = trimmed.slice(0, eq).trim(); + if (!key.startsWith("VITE_")) continue; + out[key] = trimmed.slice(eq + 1).trim(); + } + return out; +} +const migrationReleaseEnv = loadMigrationReleaseEnv(); + // 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. @@ -33,6 +57,12 @@ const isElectronPackagedBuild = process.env.OPENWORK_ELECTRON_BUILD === "1"; export default defineConfig({ base: isElectronPackagedBuild ? "./" : "/", + define: Object.fromEntries( + Object.entries(migrationReleaseEnv).map(([k, v]) => [ + `import.meta.env.${k}`, + JSON.stringify(v), + ]), + ), plugins: [ { name: "openwork-dev-server-id", diff --git a/apps/desktop/src-tauri/src/commands/migration.rs b/apps/desktop/src-tauri/src/commands/migration.rs index 93042b7c..930df97d 100644 --- a/apps/desktop/src-tauri/src/commands/migration.rs +++ b/apps/desktop/src-tauri/src/commands/migration.rs @@ -2,9 +2,11 @@ use serde::Deserialize; use std::collections::HashMap; use std::fs; use std::path::PathBuf; +use std::process::{Command, Stdio}; use tauri::{AppHandle, Manager}; const MIGRATION_SNAPSHOT_FILENAME: &str = "migration-snapshot.v1.json"; +const MIGRATION_INSTALLER_LOG: &str = "migration-install.log"; #[derive(Debug, Deserialize)] pub struct MigrationSnapshotPayload { @@ -15,6 +17,22 @@ pub struct MigrationSnapshotPayload { pub keys: HashMap, } +#[derive(Debug, Deserialize)] +pub struct MigrateToElectronRequest { + /// Full URL to the Electron artifact. On macOS we expect a .zip (not a + /// .dmg) so we can swap the .app bundle in place without the user + /// having to drag anything. On Windows: a .exe NSIS installer. On + /// Linux: an AppImage. + pub url: String, + /// Optional sha256 to verify before we touch the filesystem. + #[serde(default)] + pub sha256: Option, + /// Optional override for where the new OpenWork bundle should land on + /// macOS. Defaults to replacing the currently-running .app in place. + #[serde(default)] + pub target_app_path: Option, +} + fn migration_snapshot_path(app: &AppHandle) -> Result { let data_dir = app .path() @@ -59,3 +77,177 @@ pub fn write_migration_snapshot( Ok(()) } + +#[cfg(target_os = "macos")] +fn current_app_bundle_path() -> Result { + // Tauri's running .app is /Contents/MacOS/OpenWork. Walk up + // two directories to get to .app. + let exe = std::env::current_exe().map_err(|e| format!("current_exe failed: {e}"))?; + let bundle = exe + .parent() + .and_then(|p| p.parent()) + .and_then(|p| p.parent()) + .ok_or_else(|| "unexpected .app layout".to_string())?; + if bundle.extension().and_then(|s| s.to_str()) != Some("app") { + return Err(format!( + "resolved bundle is not an .app: {}", + bundle.display() + )); + } + Ok(bundle.to_path_buf()) +} + +#[cfg(target_os = "macos")] +fn write_macos_migration_script( + app: &AppHandle, + url: &str, + sha256: Option<&str>, + target: &std::path::Path, +) -> Result { + let cache = app + .path() + .app_cache_dir() + .map_err(|e| format!("Failed to resolve cache dir: {e}"))?; + fs::create_dir_all(&cache).map_err(|e| format!("Failed to create cache dir: {e}"))?; + + let log_path = cache.join(MIGRATION_INSTALLER_LOG); + let script_path = cache.join("openwork-migrate.sh"); + + let sha256_check = match sha256 { + Some(hash) => format!( + r#" +expected="{hash}" +actual=$(shasum -a 256 "$ZIP" | awk '{{print $1}}') +if [ "$actual" != "$expected" ]; then + echo "sha256 mismatch: got $actual, expected $expected" >&2 + exit 1 +fi +"# + ), + None => String::new(), + }; + + // The script runs detached AFTER Tauri exits. It downloads the Electron + // zip, verifies Apple code signing, swaps the .app bundle, and relaunches. + let script = format!( + r#"#!/bin/bash +set -euo pipefail +exec >>"{log}" 2>&1 +echo "[migration] script start $(date -u +%FT%TZ)" + +TARGET="{target}" +URL="{url}" +WORK=$(mktemp -d /tmp/openwork-migrate-XXXXXX) +ZIP="$WORK/OpenWork-electron.zip" + +# Wait for Tauri to fully exit before touching the .app bundle. +sleep 3 + +echo "[migration] downloading $URL" +curl --fail --location --silent --show-error --output "$ZIP" "$URL" +{sha256} + +echo "[migration] extracting" +/usr/bin/unzip -q "$ZIP" -d "$WORK" + +NEW_APP=$(find "$WORK" -maxdepth 2 -name 'OpenWork.app' -type d | head -n 1) +if [ -z "$NEW_APP" ]; then + echo "[migration] no OpenWork.app in zip" >&2 + exit 1 +fi + +echo "[migration] verifying signature on $NEW_APP" +/usr/bin/codesign --verify --deep --strict "$NEW_APP" + +echo "[migration] swapping bundle at $TARGET" +BACKUP="$TARGET.migrate-bak" +rm -rf "$BACKUP" +mv "$TARGET" "$BACKUP" +mv "$NEW_APP" "$TARGET" + +echo "[migration] launching new bundle" +/usr/bin/open "$TARGET" + +echo "[migration] done $(date -u +%FT%TZ)" +"#, + log = log_path.display(), + target = target.display(), + url = url, + sha256 = sha256_check, + ); + + fs::write(&script_path, script).map_err(|e| format!("Failed to write script: {e}"))?; + let mut perms = fs::metadata(&script_path) + .map_err(|e| format!("Failed to stat script: {e}"))? + .permissions(); + use std::os::unix::fs::PermissionsExt; + perms.set_mode(0o755); + fs::set_permissions(&script_path, perms) + .map_err(|e| format!("Failed to chmod script: {e}"))?; + + Ok(script_path) +} + +#[cfg(target_os = "macos")] +fn spawn_macos_migration_script(script_path: &std::path::Path) -> Result<(), String> { + // nohup + background so the script survives this process exiting. + Command::new("/bin/bash") + .arg("-c") + .arg(format!("nohup bash \"{}\" >/dev/null 2>&1 &", script_path.display())) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn() + .map_err(|e| format!("Failed to spawn migration script: {e}"))?; + Ok(()) +} + +/// Download the Electron installer, verify it, swap the .app bundle, and +/// relaunch — then exit this Tauri process so the script can finish the +/// handoff. The previous .app is kept at `.migrate-bak` for rollback. +/// +/// The caller (renderer) is expected to have already invoked +/// `write_migration_snapshot` so the new Electron shell can hydrate +/// localStorage on first launch. +#[tauri::command] +pub async fn migrate_to_electron( + app: AppHandle, + request: MigrateToElectronRequest, +) -> Result<(), String> { + #[cfg(target_os = "macos")] + { + let target = match request.target_app_path { + Some(path) => PathBuf::from(path), + None => current_app_bundle_path()?, + }; + let script = write_macos_migration_script( + &app, + &request.url, + request.sha256.as_deref(), + &target, + )?; + spawn_macos_migration_script(&script)?; + // Give the script a moment to daemonize before we exit. + std::thread::sleep(std::time::Duration::from_millis(400)); + app.exit(0); + Ok(()) + } + + #[cfg(target_os = "windows")] + { + // TODO(migration-windows): download the NSIS .exe, run with + // /S /D=, wait for exit, relaunch. For now, open + // the installer in the user's browser. + let _ = request; + let _ = app; + Err("Windows migrate_to_electron is not wired yet — handle via manual install".to_string()) + } + + #[cfg(target_os = "linux")] + { + // TODO(migration-linux): handle AppImage + tarball installs. + let _ = request; + let _ = app; + Err("Linux migrate_to_electron is not wired yet — handle via manual install".to_string()) + } +} diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs index e655358c..16538afb 100644 --- a/apps/desktop/src-tauri/src/lib.rs +++ b/apps/desktop/src-tauri/src/lib.rs @@ -24,7 +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::migration::{migrate_to_electron, 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, @@ -209,6 +209,7 @@ pub fn run() { get_desktop_bootstrap_config, set_desktop_bootstrap_config, updater_environment, + migrate_to_electron, write_migration_snapshot, app_build_info, nuke_openwork_and_opencode_config_and_exit, diff --git a/scripts/migration/01-cut-migration-release.mjs b/scripts/migration/01-cut-migration-release.mjs new file mode 100755 index 00000000..f8bc794d --- /dev/null +++ b/scripts/migration/01-cut-migration-release.mjs @@ -0,0 +1,178 @@ +#!/usr/bin/env node +// Cut the Tauri → Electron migration release (v0.12.0 or whatever). +// Bumps versions, writes the migration-release env fragment, commits, tags, +// pushes. Idempotent safeties: refuses to run on a dirty tree. +// +// Usage: +// node scripts/migration/01-cut-migration-release.mjs \ +// --version 0.12.0 \ +// --mac-url 'https://.../OpenWork-darwin-arm64-0.12.0-mac.zip' \ +// --win-url 'https://.../OpenWork-Setup-0.12.0.exe' (optional) \ +// --linux-url 'https://.../OpenWork-0.12.0.AppImage' (optional) \ +// --dry-run + +import { spawnSync } from "node:child_process"; +import { existsSync } from "node:fs"; +import { writeFile } from "node:fs/promises"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const repoRoot = resolve(__dirname, "../.."); + +function parseArgs(argv) { + const out = { dryRun: false }; + for (let i = 0; i < argv.length; i++) { + const arg = argv[i]; + if (arg === "--dry-run") out.dryRun = true; + else if (arg === "--version") out.version = argv[++i]; + else if (arg === "--mac-url") out.macUrl = argv[++i]; + else if (arg === "--mac-sha256") out.macSha256 = argv[++i]; + else if (arg === "--win-url") out.winUrl = argv[++i]; + else if (arg === "--linux-url") out.linuxUrl = argv[++i]; + else if (arg === "--help" || arg === "-h") out.help = true; + else { + console.error(`unknown arg: ${arg}`); + process.exit(2); + } + } + return out; +} + +function die(msg) { + console.error(`[cut-release] ${msg}`); + process.exit(1); +} + +function run(cmd, args, opts = {}) { + console.log(`[cut-release] $ ${cmd} ${args.join(" ")}`); + if (opts.dryRun) return ""; + const result = spawnSync(cmd, args, { + cwd: opts.cwd ?? repoRoot, + stdio: opts.capture ? ["ignore", "pipe", "inherit"] : "inherit", + encoding: "utf8", + }); + if (result.status !== 0) die(`command failed: ${cmd}`); + return (result.stdout ?? "").trim(); +} + +function gitStatusClean() { + const result = spawnSync("git", ["status", "--porcelain"], { + cwd: repoRoot, + encoding: "utf8", + }); + return result.status === 0 && (result.stdout ?? "").trim().length === 0; +} + +function currentBranch() { + const result = spawnSync("git", ["rev-parse", "--abbrev-ref", "HEAD"], { + cwd: repoRoot, + encoding: "utf8", + }); + return (result.stdout ?? "").trim(); +} + +function tagExists(tag) { + const result = spawnSync("git", ["rev-parse", "-q", "--verify", `refs/tags/${tag}`], { + cwd: repoRoot, + encoding: "utf8", + }); + return result.status === 0; +} + +function remoteTagExists(tag) { + const result = spawnSync("git", ["ls-remote", "--tags", "origin", tag], { + cwd: repoRoot, + encoding: "utf8", + }); + return result.status === 0 && (result.stdout ?? "").includes(`refs/tags/${tag}`); +} + +async function main() { + const args = parseArgs(process.argv.slice(2)); + if (args.help) { + console.log( + [ + "Cut the Tauri → Electron migration release.", + "", + "Required: --version, --mac-url", + "Optional: --mac-sha256, --win-url, --linux-url, --dry-run", + ].join("\n"), + ); + return; + } + + if (!args.version) die("--version is required (e.g. --version 0.12.0)"); + if (!args.macUrl) die("--mac-url is required"); + if (!/^\d+\.\d+\.\d+$/.test(args.version)) { + die(`--version must look like X.Y.Z, got "${args.version}"`); + } + + if (!gitStatusClean()) { + die("working tree is dirty. commit or stash before cutting a release."); + } + + const branch = currentBranch(); + if (branch !== "dev" && branch !== "main") { + console.warn( + `[cut-release] WARNING: current branch is "${branch}". Releases are usually cut from main/dev.`, + ); + } + + const tag = `v${args.version}`; + if (tagExists(tag)) die(`tag ${tag} already exists locally`); + if (remoteTagExists(tag)) die(`tag ${tag} already exists on origin`); + + // 1. Bump all 5 sync files via the existing helper. + run("pnpm", ["bump:set", "--", args.version], { dryRun: args.dryRun }); + + // 2. Write the migration-release env fragment. Gets picked up by the + // app build step during `Release App` via --copy-config. + const envFragment = + [ + "# Generated by scripts/migration/01-cut-migration-release.mjs.", + "# Consumed by apps/app Vite build during the v0.12.0 release only.", + "VITE_OPENWORK_MIGRATION_RELEASE=1", + `VITE_OPENWORK_MIGRATION_VERSION=${args.version}`, + args.macUrl ? `VITE_OPENWORK_MIGRATION_MAC_URL=${args.macUrl}` : "", + args.macSha256 ? `VITE_OPENWORK_MIGRATION_MAC_SHA256=${args.macSha256}` : "", + args.winUrl ? `VITE_OPENWORK_MIGRATION_WINDOWS_URL=${args.winUrl}` : "", + args.linuxUrl ? `VITE_OPENWORK_MIGRATION_LINUX_URL=${args.linuxUrl}` : "", + "", + ] + .filter(Boolean) + .join("\n"); + + const envPath = resolve(repoRoot, "apps/app/.env.migration-release"); + console.log(`[cut-release] writing ${envPath}`); + if (!args.dryRun) { + await writeFile(envPath, envFragment, "utf8"); + } + + // 3. Commit version bump + env fragment. + run("git", ["add", "-A"], { dryRun: args.dryRun }); + run( + "git", + ["commit", "-m", `chore(release): cut v${args.version} (Tauri → Electron migration)`], + { dryRun: args.dryRun }, + ); + + // 4. Tag + push. + run("git", ["tag", tag], { dryRun: args.dryRun }); + run("git", ["push", "origin", branch], { dryRun: args.dryRun }); + run("git", ["push", "origin", tag], { dryRun: args.dryRun }); + + console.log(""); + console.log(`[cut-release] pushed ${tag}.`); + console.log(`[cut-release] watch the workflow:`); + console.log(` gh run list --repo different-ai/openwork --workflow "Release App" --limit 3`); + console.log(` gh run watch --repo different-ai/openwork`); + console.log(""); + console.log(`[cut-release] once the workflow finishes, run:`); + console.log(` node scripts/migration/02-validate-migration.mjs --tag ${tag}`); +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/scripts/migration/02-validate-migration.mjs b/scripts/migration/02-validate-migration.mjs new file mode 100755 index 00000000..61d26109 --- /dev/null +++ b/scripts/migration/02-validate-migration.mjs @@ -0,0 +1,205 @@ +#!/usr/bin/env node +// Guided validation for a migration release. Mix of automated probes and +// checklist prompts you acknowledge in-terminal. Designed to be run in a +// release-review pair session. +// +// Usage: +// node scripts/migration/02-validate-migration.mjs --tag v0.12.0 +// node scripts/migration/02-validate-migration.mjs --tag v0.12.0 --skip-manual + +import { spawnSync } from "node:child_process"; +import { createHash } from "node:crypto"; +import { createReadStream, existsSync } from "node:fs"; +import { mkdir, readFile, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { createInterface } from "node:readline/promises"; +import { stdin as input, stdout as output } from "node:process"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const repoRoot = resolve(__dirname, "../.."); + +function parseArgs(argv) { + const out = { skipManual: false }; + for (let i = 0; i < argv.length; i++) { + const arg = argv[i]; + if (arg === "--skip-manual") out.skipManual = true; + else if (arg === "--tag") out.tag = argv[++i]; + else if (arg === "--help" || arg === "-h") out.help = true; + } + return out; +} + +function die(msg) { + console.error(`[validate] ${msg}`); + process.exit(1); +} + +async function sha256(path) { + const hash = createHash("sha256"); + return await new Promise((resolvePromise, rejectPromise) => { + createReadStream(path) + .on("data", (chunk) => hash.update(chunk)) + .on("end", () => resolvePromise(hash.digest("hex"))) + .on("error", rejectPromise); + }); +} + +async function confirm(prompt) { + const rl = createInterface({ input, output }); + try { + const answer = (await rl.question(`${prompt} [y/N] `)).trim().toLowerCase(); + return answer === "y" || answer === "yes"; + } finally { + rl.close(); + } +} + +async function ghReleaseAssets(tag) { + const result = spawnSync( + "gh", + [ + "release", + "view", + tag, + "--repo", + "different-ai/openwork", + "--json", + "assets", + ], + { encoding: "utf8" }, + ); + if (result.status !== 0) die(`gh release view ${tag} failed`); + const parsed = JSON.parse(result.stdout); + return parsed.assets ?? []; +} + +async function ghDownload(tag, assetName, targetDir) { + await mkdir(targetDir, { recursive: true }); + const target = join(targetDir, assetName); + const result = spawnSync( + "gh", + [ + "release", + "download", + tag, + "--repo", + "different-ai/openwork", + "--pattern", + assetName, + "--dir", + targetDir, + "--clobber", + ], + { stdio: "inherit" }, + ); + if (result.status !== 0) die(`gh release download failed for ${assetName}`); + return target; +} + +function codesignVerify(appPath) { + const result = spawnSync( + "codesign", + ["--verify", "--deep", "--strict", "--verbose=2", appPath], + { encoding: "utf8" }, + ); + return { + ok: result.status === 0, + message: (result.stderr ?? "").trim() || (result.stdout ?? "").trim(), + }; +} + +async function main() { + const args = parseArgs(process.argv.slice(2)); + if (args.help || !args.tag) { + console.log("Usage: 02-validate-migration.mjs --tag vX.Y.Z [--skip-manual]"); + process.exit(args.help ? 0 : 2); + } + + const workDir = join(tmpdir(), `openwork-migrate-validate-${args.tag}`); + await rm(workDir, { recursive: true, force: true }); + await mkdir(workDir, { recursive: true }); + + console.log(`[validate] scratch dir: ${workDir}`); + + // 1. List assets on the release. + const assets = await ghReleaseAssets(args.tag); + const names = assets.map((a) => a.name); + console.log(`[validate] release ${args.tag} has ${assets.length} assets`); + names.forEach((n) => console.log(` - ${n}`)); + + const electronZip = names.find( + (n) => /electron|\.zip$/i.test(n) && n.includes("mac"), + ) ?? names.find((n) => n.endsWith(".zip")); + const electronYml = names.find((n) => n === "latest-mac.yml"); + const tauriLatestJson = names.find((n) => n === "latest.json"); + + const presenceChecks = [ + ["electron macOS zip", electronZip], + ["electron-updater manifest (latest-mac.yml)", electronYml], + ["tauri minisign feed (latest.json)", tauriLatestJson], + ]; + console.log(""); + console.log("[validate] required release assets:"); + presenceChecks.forEach(([label, name]) => { + console.log(` ${name ? "✓" : "✗"} ${label}${name ? ` — ${name}` : ""}`); + }); + if (!electronZip) die("no Electron zip asset on the release."); + + // 2. Download + Apple sig check on the Electron zip. + console.log(""); + console.log("[validate] downloading Electron zip for signature check…"); + const zipPath = await ghDownload(args.tag, electronZip, workDir); + console.log(`[validate] got ${zipPath} (${(await readFile(zipPath)).byteLength} bytes)`); + const zipHash = await sha256(zipPath); + console.log(`[validate] sha256 = ${zipHash}`); + + const unzipDir = join(workDir, "unzipped"); + await mkdir(unzipDir, { recursive: true }); + const unzipResult = spawnSync("unzip", ["-q", "-o", zipPath, "-d", unzipDir], { + stdio: "inherit", + }); + if (unzipResult.status !== 0) die("unzip failed"); + const appPath = join(unzipDir, "OpenWork.app"); + if (!existsSync(appPath)) { + console.log(`[validate] ✗ OpenWork.app not found inside zip (got: ${unzipDir})`); + die("zip layout unexpected"); + } + const sig = codesignVerify(appPath); + console.log(`[validate] ${sig.ok ? "✓" : "✗"} codesign verify on ${appPath}`); + if (!sig.ok) console.log(sig.message); + + // 3. Manual steps. + if (args.skipManual) { + console.log("[validate] --skip-manual: stopping here."); + return; + } + + console.log(""); + console.log("[validate] manual checklist — acknowledge each step."); + const steps = [ + "Install a fresh Tauri v0.11.x build on a test machine (or VM).", + `Launch it, "Check for updates" → migrates to ${args.tag}.`, + "Restart, see the 'OpenWork is moving' modal, click 'Install now'.", + "Confirm Electron app launches automatically after ~30s.", + "Confirm sidebar shows every workspace that was visible in Tauri.", + "Open one workspace; confirm last session is preselected.", + "Settings → Advanced: both runtimes show 'Connected'.", + "Reopen: confirm the migration modal does NOT reappear.", + ]; + for (const step of steps) { + const ok = await confirm(` ☐ ${step}`); + if (!ok) die(`manual step declined: ${step}`); + } + + console.log(""); + console.log("[validate] all checks passed. Migration release is healthy."); + console.log("[validate] watch v0.12.0 telemetry for ~1-2 weeks before running"); + console.log(" node scripts/migration/03-post-migration-cleanup.mjs"); +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/scripts/migration/03-post-migration-cleanup.mjs b/scripts/migration/03-post-migration-cleanup.mjs new file mode 100755 index 00000000..8a6db816 --- /dev/null +++ b/scripts/migration/03-post-migration-cleanup.mjs @@ -0,0 +1,211 @@ +#!/usr/bin/env node +// Post-migration cleanup. Run this ONLY after v0.12.x has been stable +// for ~1-2 weeks and telemetry confirms users have rolled over to the +// Electron build. Irreversible (well, revertible via git, but not +// trivial to redeploy the Tauri path after removing it). +// +// Usage: +// node scripts/migration/03-post-migration-cleanup.mjs --dry-run # default +// node scripts/migration/03-post-migration-cleanup.mjs --execute # actually do it + +import { spawnSync } from "node:child_process"; +import { existsSync } from "node:fs"; +import { readFile, writeFile } from "node:fs/promises"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const repoRoot = resolve(__dirname, "../.."); + +function parseArgs(argv) { + // Default is dry-run for safety; --execute required to actually do it. + const out = { dryRun: true }; + for (const arg of argv) { + if (arg === "--execute") out.dryRun = false; + else if (arg === "--dry-run") out.dryRun = true; + else if (arg === "--help" || arg === "-h") out.help = true; + else { + console.error(`unknown arg: ${arg}`); + process.exit(2); + } + } + return out; +} + +function log(msg) { + console.log(`[cleanup] ${msg}`); +} + +function run(cmd, args, opts = {}) { + log(`$ ${cmd} ${args.join(" ")}`); + if (opts.dryRun) return ""; + const result = spawnSync(cmd, args, { + cwd: opts.cwd ?? repoRoot, + stdio: "inherit", + }); + if (result.status !== 0) { + console.error(`[cleanup] command failed: ${cmd}`); + process.exit(1); + } +} + +async function patchJson(path, updater, { dryRun }) { + const raw = await readFile(path, "utf8"); + const parsed = JSON.parse(raw); + const next = updater(parsed); + if (JSON.stringify(parsed) === JSON.stringify(next)) { + log(`no change needed: ${path}`); + return; + } + log(`patching: ${path}`); + if (!dryRun) { + await writeFile(path, `${JSON.stringify(next, null, 2)}\n`, "utf8"); + } +} + +async function replaceInFile(path, replacements, { dryRun }) { + if (!existsSync(path)) return; + const original = await readFile(path, "utf8"); + let next = original; + for (const [pattern, replacement] of replacements) { + next = next.replace(pattern, replacement); + } + if (next === original) return; + log(`rewriting: ${path}`); + if (!dryRun) { + await writeFile(path, next, "utf8"); + } +} + +async function main() { + const args = parseArgs(process.argv.slice(2)); + if (args.help) { + console.log( + [ + "Post-migration cleanup (Tauri → Electron).", + "", + "Runs in dry-run mode by default. Pass --execute to actually change files.", + ].join("\n"), + ); + return; + } + + if (args.dryRun) { + log("DRY RUN — no filesystem changes. Pass --execute to apply."); + } + + // 1. Flip apps/desktop/package.json defaults to Electron. + const desktopPkgPath = resolve(repoRoot, "apps/desktop/package.json"); + await patchJson( + desktopPkgPath, + (pkg) => { + const scripts = { ...(pkg.scripts ?? {}) }; + scripts.dev = "node ./scripts/electron-dev.mjs"; + scripts.build = "node ./scripts/electron-build.mjs"; + scripts.package = + "pnpm run build && pnpm exec electron-builder --config electron-builder.yml"; + scripts["dev:electron"] = undefined; + scripts["build:electron"] = undefined; + scripts["package:electron"] = undefined; + scripts["package:electron:dir"] = + "pnpm run build && pnpm exec electron-builder --config electron-builder.yml --dir"; + scripts.electron = undefined; + scripts["dev:react-session"] = undefined; + scripts["dev:electron:react-session"] = + "VITE_OPENWORK_REACT_SESSION=1 node ./scripts/electron-dev.mjs"; + // Remove Tauri-specific script entries. + scripts["build:debug:react-session"] = undefined; + scripts["dev:windows"] = undefined; + scripts["dev:windows:x64"] = undefined; + + const filteredScripts = Object.fromEntries( + Object.entries(scripts).filter(([, v]) => v != null), + ); + const devDeps = { ...(pkg.devDependencies ?? {}) }; + delete devDeps["@tauri-apps/cli"]; + return { ...pkg, scripts: filteredScripts, devDependencies: devDeps }; + }, + { dryRun: args.dryRun }, + ); + + // 2. Strip @tauri-apps/* from apps/app and apps/story-book package.json. + for (const pkgPath of [ + resolve(repoRoot, "apps/app/package.json"), + resolve(repoRoot, "apps/story-book/package.json"), + ]) { + if (!existsSync(pkgPath)) continue; + await patchJson( + pkgPath, + (pkg) => { + const deps = { ...(pkg.dependencies ?? {}) }; + const devDeps = { ...(pkg.devDependencies ?? {}) }; + for (const name of Object.keys(deps)) { + if (name.startsWith("@tauri-apps/")) delete deps[name]; + } + for (const name of Object.keys(devDeps)) { + if (name.startsWith("@tauri-apps/")) delete devDeps[name]; + } + return { ...pkg, dependencies: deps, devDependencies: devDeps }; + }, + { dryRun: args.dryRun }, + ); + } + + // 3. Delete src-tauri/ entirely. + run("git", ["rm", "-r", "-f", "apps/desktop/src-tauri"], { dryRun: args.dryRun }); + + // 4. Collapse desktop-tauri.ts into desktop.ts. We do a surgical rename + // for now and leave the collapse for a follow-up PR; deleting the + // proxy layer is a bigger refactor. + log( + "[reminder] apps/app/src/app/lib/desktop-tauri.ts still exists. After this script lands,", + ); + log( + " open a follow-up PR that inlines desktop.ts's Electron path and removes the", + ); + log(" proxy + re-export surface."); + + // 5. Drop AGENTS.md / ARCHITECTURE.md / README.md Tauri references. + const docReplacements = [ + [/Tauri 2\.x/g, "Electron"], + [/\| Desktop\/Mobile shell \| Tauri 2\.x\s+\|/g, "| Desktop/Mobile shell | Electron |"], + [/apps\/desktop\/src-tauri/g, "apps/desktop/electron"], + ]; + for (const path of [ + resolve(repoRoot, "AGENTS.md"), + resolve(repoRoot, "ARCHITECTURE.md"), + resolve(repoRoot, "README.md"), + ]) { + await replaceInFile(path, docReplacements, { dryRun: args.dryRun }); + } + + // 6. Remove the migration-release env fragment once it's no longer + // relevant. + run("git", ["rm", "-f", "apps/app/.env.migration-release"], { + dryRun: args.dryRun, + }); + + // 7. Stage + commit. + run("git", ["add", "-A"], { dryRun: args.dryRun }); + run( + "git", + [ + "commit", + "-m", + "chore(desktop): remove Tauri shell, make Electron the default\n\nRun after v0.12.x stabilized in the wild. See\nscripts/migration/README.md for the full runbook.", + ], + { dryRun: args.dryRun }, + ); + + log(""); + log( + args.dryRun + ? "dry run complete. rerun with --execute to apply." + : "commit created. push + open PR for review.", + ); +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/scripts/migration/README.md b/scripts/migration/README.md new file mode 100644 index 00000000..3f447d98 --- /dev/null +++ b/scripts/migration/README.md @@ -0,0 +1,107 @@ +# Tauri → Electron migration runbook + +Three scripts. Run in order. Each one is idempotent and prints what it +will do before it does it. + +``` +scripts/migration/01-cut-migration-release.mjs # cut v0.12.0, the last Tauri release +scripts/migration/02-validate-migration.mjs # guided post-release smoke test +scripts/migration/03-post-migration-cleanup.mjs # delete src-tauri, flip defaults (run after users stabilize) +``` + +## When to run what + +| Step | When | What user-visible effect | +| ---- | ---- | ------------------------ | +| 01 | Ready to ship the migration | Tauri users see "OpenWork is moving" prompt on next update | +| 02 | Right after the workflow finishes | Dogfood validation — no user effect | +| 03 | After 1-2 weeks of stable Electron telemetry | Dev repo is Electron-only; no user effect | + +## 01 — cut-migration-release.mjs + +```bash +node scripts/migration/01-cut-migration-release.mjs --version 0.12.0 \ + --mac-url 'https://github.com/different-ai/openwork/releases/download/v0.12.0/OpenWork-darwin-arm64-0.12.0-mac.zip' \ + --dry-run # inspect planned changes first +``` + +What it does: + +1. Refuses to run on a dirty working tree. +2. Runs `pnpm bump:set -- ` (bumps all 5 sync files per + AGENTS.md release runbook). +3. Creates a release-config fragment at + `apps/app/.env.migration-release` setting + `VITE_OPENWORK_MIGRATION_RELEASE=1` and the per-platform download + URLs. The `Release App` workflow copies this into the build env so + the migration prompt is dormant on every other build but live for + this release. +4. Commits the version bump. +5. Creates and pushes the `vX.Y.Z` tag. +6. Prints `gh run watch` command so you can follow the workflow. + +Drop `--dry-run` to actually execute. + +## 02 — validate-migration.mjs + +```bash +node scripts/migration/02-validate-migration.mjs --tag v0.12.0 +``` + +Interactive guided check. Prints steps and confirms each one before +moving on: + +1. Downloads the Tauri DMG for the tag and verifies its minisign + signature (reuses the existing updater public key). +2. Downloads the Electron .zip for the tag and verifies the Apple + Developer ID signature. +3. Prompts you to install the Tauri DMG on a fresh machine / VM and + confirm the migration prompt appears. +4. Drops a canary key into localStorage on the Tauri side, triggers + "Install now", and checks that the canary survives into the Electron + launch via `readMigrationSnapshot` + localStorage inspection. +5. Reports pass/fail for each step. + +Needs `--skip-manual` to run the automated parts only. Useful inside a +release-review meeting as a shared checklist. + +## 03 — post-migration-cleanup.mjs + +```bash +node scripts/migration/03-post-migration-cleanup.mjs --dry-run +``` + +Once v0.12.x has been stable for 1-2 weeks: + +1. Flips `apps/desktop/package.json` defaults: + - `dev` → `node ./scripts/electron-dev.mjs` + - `build` → `node ./scripts/electron-build.mjs` + - `package` → `pnpm run build && pnpm exec electron-builder …` +2. Removes `apps/desktop/src-tauri/` entirely. +3. Strips `@tauri-apps/*` from `apps/app/package.json` and + `apps/story-book/package.json`. +4. Collapses `apps/app/src/app/lib/desktop-tauri.ts` into + `desktop.ts` (direct Electron implementation). +5. Updates `AGENTS.md`, `ARCHITECTURE.md`, `README.md`, translated + READMEs to drop Tauri references. +6. Updates the release runbook in `AGENTS.md` to remove the + `Cargo.toml` and `tauri.conf.json` version-bump entries. +7. Creates a commit with the combined cleanup. + +Drop `--dry-run` to actually perform the changes. + +## Emergency rollback + +If the v0.12.0 migration release is bad: + +- Users on Electron already: ship v0.12.1 via electron-updater. Same + mechanism as any other update. +- Users still on Tauri: the migrate prompt is gated on + `VITE_OPENWORK_MIGRATION_RELEASE=1` at build time. Re-cut the + release with that flag unset, minisign-sign a replacement + `latest.json`, and users who haven't clicked "Install now" yet will + fall back to the non-migrating release. +- Users mid-migration: the Rust `migrate_to_electron` command keeps + the previous `.app` at `OpenWork.app.migrate-bak`. Instruct users + to `mv OpenWork.app.migrate-bak OpenWork.app` if the Electron + launch fails.