mirror of
https://github.com/different-ai/openwork
synced 2026-04-25 17:15:34 +02:00
feat(desktop): Tauri → Electron migration release kit (#1526)
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 <benjamin@openworklabs.com>
This commit is contained in:
58
.github/workflows/release-macos-aarch64.yml
vendored
58
.github/workflows/release-macos-aarch64.yml
vendored
@@ -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'
|
||||
|
||||
4
.gitignore
vendored
4
.gitignore
vendored
@@ -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
|
||||
|
||||
@@ -139,3 +139,78 @@ export async function ingestMigrationSnapshotOnElectronBoot(): Promise<number> {
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
156
apps/app/src/react-app/shell/migration-prompt.tsx
Normal file
156
apps/app/src/react-app/shell/migration-prompt.tsx
Normal file
@@ -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<string, string> }).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<string | null>(null);
|
||||
const [config, setConfig] = useState<MigrationConfig | null>(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 (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-sm"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="openwork-migration-title"
|
||||
>
|
||||
<div className="w-[min(440px,90vw)] rounded-[24px] border border-dls-border bg-dls-surface p-6 shadow-xl">
|
||||
<h2
|
||||
id="openwork-migration-title"
|
||||
className="text-lg font-semibold text-gray-12"
|
||||
>
|
||||
OpenWork is moving to a new engine
|
||||
</h2>
|
||||
<p className="mt-3 text-sm leading-relaxed text-gray-11">
|
||||
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.
|
||||
</p>
|
||||
{error ? (
|
||||
<div className="mt-3 rounded-md border border-red-7/50 bg-red-3 px-3 py-2 text-xs text-red-11">
|
||||
{error}
|
||||
</div>
|
||||
) : null}
|
||||
<div className="mt-5 flex items-center justify-end gap-2">
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-full px-4 py-2 text-sm text-gray-11 hover:bg-gray-4"
|
||||
onClick={handleLater}
|
||||
disabled={busy}
|
||||
>
|
||||
Later
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-full bg-gray-12 px-4 py-2 text-sm font-medium text-gray-1 hover:bg-gray-12/90 disabled:opacity-60"
|
||||
onClick={handleInstall}
|
||||
disabled={busy}
|
||||
>
|
||||
{busy ? "Installing…" : "Install now"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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) {
|
||||
</RestrictionNoticeProvider>
|
||||
</DesktopConfigProvider>
|
||||
</DenAuthProvider>
|
||||
<MigrationPrompt />
|
||||
</ServerProvider>
|
||||
</BootStateProvider>
|
||||
);
|
||||
|
||||
@@ -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<string, string> {
|
||||
const fragmentPath = resolve(appRoot, ".env.migration-release");
|
||||
if (!existsSync(fragmentPath)) return {};
|
||||
const out: Record<string, string> = {};
|
||||
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",
|
||||
|
||||
@@ -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<String, String>,
|
||||
}
|
||||
|
||||
#[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<String>,
|
||||
/// 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<String>,
|
||||
}
|
||||
|
||||
fn migration_snapshot_path(app: &AppHandle) -> Result<PathBuf, String> {
|
||||
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<PathBuf, String> {
|
||||
// Tauri's running .app is <something>/Contents/MacOS/OpenWork. Walk up
|
||||
// two directories to get to <something>.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<PathBuf, String> {
|
||||
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 `<app>.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=<install-dir>, 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())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
178
scripts/migration/01-cut-migration-release.mjs
Executable file
178
scripts/migration/01-cut-migration-release.mjs
Executable file
@@ -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);
|
||||
});
|
||||
205
scripts/migration/02-validate-migration.mjs
Executable file
205
scripts/migration/02-validate-migration.mjs
Executable file
@@ -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);
|
||||
});
|
||||
211
scripts/migration/03-post-migration-cleanup.mjs
Executable file
211
scripts/migration/03-post-migration-cleanup.mjs
Executable file
@@ -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);
|
||||
});
|
||||
107
scripts/migration/README.md
Normal file
107
scripts/migration/README.md
Normal file
@@ -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 -- <version>` (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.
|
||||
Reference in New Issue
Block a user