Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
65 changes: 65 additions & 0 deletions scripts/desktop-dev-migration.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import { mkdtemp, readFile, rm } from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import { setTimeout as delay } from 'node:timers/promises';

const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;

async function readOptionalJson(file) {
try {
return JSON.parse(await readFile(file, 'utf8'));
} catch (error) {
if (error.code === 'ENOENT') return null;
throw error;
}
}

function isProcessAlive(pid) {
try {
process.kill(pid, 0);
return true;
} catch (error) {
if (error.code === 'ESRCH') return false;
throw error;
}
}

// Tauri stops its frontend server when Desktop hands off to Data Migrator.
// Keep the development supervisor alive and re-enter Tauri after the migrator
// requests a restart, restoring both Vite and the Rust watcher.
export async function runDesktopWithMigrationRestart(run, {
info = () => {},
isAlive = isProcessAlive,
wait = () => delay(250),
} = {}) {
let restartArgs = [];
for (;;) {
const directory = await mkdtemp(path.join(os.tmpdir(), 'openbitfun-dev-migration-'));
try {
let failure;
try {
await run(restartArgs, { OPENBITFUN_DEV_MIGRATION_DIR: directory });
} catch (error) {
failure = error;
}
const handoff = await readOptionalJson(path.join(directory, 'handoff.json'));
if (!handoff) {
if (failure) throw failure;
return;
}
if (!UUID.test(handoff.runId) || !Number.isSafeInteger(handoff.pid) || handoff.pid <= 0) {
throw new Error('Invalid development migration handoff');
}
info('Waiting for Data Migrator; Desktop will reopen automatically when it finishes');
while (isAlive(handoff.pid)) await wait();
const restart = await readOptionalJson(path.join(directory, 'restart.json'));
if (restart?.runId !== handoff.runId) {
throw new Error('Data Migrator exited without completing its restart handoff; run desktop:dev to retry');
}
restartArgs = ['--legacy-migration-run-id', handoff.runId];
info('Restarting Desktop through the development launcher');
} finally {
await rm(directory, { recursive: true, force: true });
}
}
}
60 changes: 60 additions & 0 deletions scripts/desktop-dev-migration.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import assert from 'node:assert/strict';
import { access, writeFile } from 'node:fs/promises';
import path from 'node:path';
import test from 'node:test';
import { runDesktopWithMigrationRestart } from './desktop-dev-migration.mjs';

const runId = '01234567-89ab-4cde-8fab-0123456789ab';
const write = (directory, file, value) => writeFile(path.join(directory, file), JSON.stringify(value));

test('normal exit does not restart and removes its temporary channel', async () => {
let directory;
let calls = 0;
await runDesktopWithMigrationRestart(async (args, env) => {
calls++;
assert.deepEqual(args, []);
directory = env.OPENBITFUN_DEV_MIGRATION_DIR;
});
assert.equal(calls, 1);
await assert.rejects(access(directory), { code: 'ENOENT' });
});

test('build failures remain failures when no migration was launched', async () => {
const failure = new Error('build failed');
await assert.rejects(runDesktopWithMigrationRestart(async () => { throw failure; }), failure);
});

test('migration completion waits for child exit then restores the development host with the run id', async () => {
let calls = 0;
let directory;
let running = true;
await runDesktopWithMigrationRestart(async (args, env) => {
calls++;
if (calls === 1) {
directory = env.OPENBITFUN_DEV_MIGRATION_DIR;
await write(directory, 'handoff.json', { runId, pid: 123 });
// A handoff may also make Tauri report its stopped frontend as a failure.
throw new Error('frontend stopped');
}
assert.equal(running, false);
assert.deepEqual(args, ['--legacy-migration-run-id', runId]);
assert.notEqual(env.OPENBITFUN_DEV_MIGRATION_DIR, directory);
}, {
isAlive: (pid) => { assert.equal(pid, 123); return running; },
wait: async () => {
await write(directory, 'restart.json', { runId });
running = false;
},
});
assert.equal(calls, 2);
await assert.rejects(access(directory), { code: 'ENOENT' });
});

test('crashed or mismatched migrators cannot silently restart Desktop', async () => {
for (const restart of [null, { runId: 'wrong-run' }]) {
await assert.rejects(runDesktopWithMigrationRestart(async (_, env) => {
await write(env.OPENBITFUN_DEV_MIGRATION_DIR, 'handoff.json', { runId, pid: 123 });
if (restart) await write(env.OPENBITFUN_DEV_MIGRATION_DIR, 'restart.json', restart);
}, { isAlive: () => false }), /without completing its restart handoff/);
}
});
85 changes: 48 additions & 37 deletions scripts/dev.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,7 @@ function spawnCommand(cmd, args, cwd = ROOT_DIR, envOverrides = {}, shell = fals
const child = spawn(cmd, args, {
cwd,
stdio: 'inherit',
windowsHide: true,
shell,
env: {
...process.env,
Expand Down Expand Up @@ -192,6 +193,7 @@ function spawnBackgroundCommand(cmd, args, cwd = ROOT_DIR, env = process.env) {
return spawn(cmd, args, {
cwd,
stdio: 'inherit',
windowsHide: true,
env,
});
}
Expand All @@ -208,6 +210,7 @@ function spawnWindowsCommandArgs(command, args, cwd = ROOT_DIR, env = process.en
return spawn(process.env.ComSpec || 'C:\\Windows\\System32\\cmd.exe', ['/d', '/s', '/c', command, ...args], {
cwd,
stdio: 'inherit',
windowsHide: true,
env,
});
}
Expand Down Expand Up @@ -554,30 +557,31 @@ async function startDesktopPreview() {

printInfo(`Launching debug desktop binary: ${desktopBinary}`);

appProcess = spawnBackgroundCommand(desktopBinary, [], ROOT_DIR, {
...process.env,
// Debug previews must upload the current workspace build. The adjacent
// target/debug resource tree is only a build-time copy and can lag behind
// mobile-web edits made while the desktop binary is being reused.
OPENBITFUN_MOBILE_WEB_DIR: path.join(ROOT_DIR, 'src/mobile-web/dist'),
});

appProcess.on('error', (error) => {
printError(`Desktop preview failed to start: ${error.message || String(error)}`);
void shutdown(1);
});

appProcess.on('exit', (code, signal) => {
if (!shuttingDown) {
printInfo(`Desktop preview exited (code=${code ?? 'null'}, signal=${signal ?? 'null'})`);
}
void shutdown(code ?? 0);
});

printSuccess('Desktop preview is running');
printInfo('Front-end edits continue to use Vite HMR; rebuild Rust only when desktop-side code changes');

await new Promise(() => {});
const { runDesktopWithMigrationRestart } = await import(
pathToFileURL(path.join(__dirname, 'desktop-dev-migration.mjs')).href
);
try {
await runDesktopWithMigrationRestart((restartArgs, migrationEnv) => new Promise((resolve, reject) => {
appProcess = spawnBackgroundCommand(desktopBinary, restartArgs, ROOT_DIR, {
...process.env,
...migrationEnv,
// Upload the current workspace mobile bundle instead of the staged copy.
OPENBITFUN_MOBILE_WEB_DIR: path.join(ROOT_DIR, 'src/mobile-web/dist'),
});
appProcess.on('error', reject);
appProcess.on('close', (code, signal) => {
appProcess = null;
if (code === 0) resolve();
else reject(new Error(`Desktop preview exited (code=${code}, signal=${signal})`));
});
printSuccess('Desktop preview is running');
printInfo('Front-end edits continue to use Vite HMR; rebuild Rust only when desktop-side code changes');
}), { info: printInfo });
await shutdown(0);
} catch (error) {
printError(error.message || String(error));
await shutdown(1);
}
}

/**
Expand Down Expand Up @@ -747,19 +751,26 @@ async function main() {
OPENBITFUN_MOBILE_WEB_DIR: path.join(ROOT_DIR, 'src/mobile-web/dist'),
};
try {
if (process.platform === 'win32') {
// Running the generated .cmd shim directly via spawn is flaky on Windows.
// Use cmd.exe with an explicit args array so the desktop app directory
// stays the Tauri project root without pnpm workspace path rewriting.
const tauriBin = path.join(ROOT_DIR, 'node_modules', '.bin', 'tauri.cmd');
await runWindowsCommandArgs(tauriBin, ['dev', '--config', tauriConfig], desktopDir, tauriDevEnv);
} else {
const tauriBin = path.join(ROOT_DIR, 'node_modules', '.bin', 'tauri');
await spawnCommand(tauriBin, ['dev', '--config', tauriConfig], desktopDir, {
CARGO_PROFILE_DEV_CODEGEN_UNITS: tauriDevEnv.CARGO_PROFILE_DEV_CODEGEN_UNITS,
OPENBITFUN_MOBILE_WEB_DIR: tauriDevEnv.OPENBITFUN_MOBILE_WEB_DIR,
});
}
const { runDesktopWithMigrationRestart } = await import(
pathToFileURL(path.join(__dirname, 'desktop-dev-migration.mjs')).href
);
await runDesktopWithMigrationRestart(async (restartArgs, migrationEnv) => {
const args = ['dev', '--config', tauriConfig, ...(restartArgs.length ? ['--', '--', ...restartArgs] : [])];
if (process.platform === 'win32') {
// Running the generated .cmd shim directly via spawn is flaky on Windows.
// Use cmd.exe with an explicit args array so the desktop app directory
// stays the Tauri project root without pnpm workspace path rewriting.
const tauriBin = path.join(ROOT_DIR, 'node_modules', '.bin', 'tauri.cmd');
await runWindowsCommandArgs(tauriBin, args, desktopDir, { ...tauriDevEnv, ...migrationEnv });
} else {
const tauriBin = path.join(ROOT_DIR, 'node_modules', '.bin', 'tauri');
await spawnCommand(tauriBin, args, desktopDir, {
CARGO_PROFILE_DEV_CODEGEN_UNITS: tauriDevEnv.CARGO_PROFILE_DEV_CODEGEN_UNITS,
OPENBITFUN_MOBILE_WEB_DIR: tauriDevEnv.OPENBITFUN_MOBILE_WEB_DIR,
...migrationEnv,
});
}
}, { info: printInfo });
} finally {
// Option B: prune only when the desktop:dev session ends, not on each rebuild.
await runDesktopTargetGc('debug');
Expand Down
7 changes: 7 additions & 0 deletions src/apps/data-migrator/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,8 +30,15 @@ must remain a separate executable and WebView identity from Desktop.
```bash
cargo test -p openbitfun-data-migrator
node --test scripts/data-migrator-tauri-build.test.mjs
node --test scripts/desktop-dev-migration.test.mjs
```

Completion always returns to Desktop. Debug builds launched through `desktop:dev`
or `desktop:preview:debug` ask that launcher to restart via its private temporary
handoff directory, preserving the frontend server and development lifecycle.
Builds without that channel restart the trusted sibling Desktop executable.
Keep this developer-only channel out of persisted migration and remote protocols.

Run `pnpm run check:core-boundaries` when dependencies or delivery-profile
selection change. Packaging, signing, and UI interaction are separate explicit
verification steps.
Loading
Loading