Skip to content

Commit b4809fa

Browse files
authored
Merge pull request #2880 from bobleer/bob/fix-macos-migration-desktop-restart
fix(migration): restore macOS startup and desktop return
2 parents 0582b0b + 0cf30ee commit b4809fa

9 files changed

Lines changed: 455 additions & 68 deletions

File tree

‎scripts/desktop-dev-migration.mjs‎

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
import { mkdtemp, readFile, rm } from 'node:fs/promises';
2+
import os from 'node:os';
3+
import path from 'node:path';
4+
import { setTimeout as delay } from 'node:timers/promises';
5+
6+
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
7+
8+
async function readOptionalJson(file) {
9+
try {
10+
return JSON.parse(await readFile(file, 'utf8'));
11+
} catch (error) {
12+
if (error.code === 'ENOENT') return null;
13+
throw error;
14+
}
15+
}
16+
17+
function isProcessAlive(pid) {
18+
try {
19+
process.kill(pid, 0);
20+
return true;
21+
} catch (error) {
22+
if (error.code === 'ESRCH') return false;
23+
throw error;
24+
}
25+
}
26+
27+
// Tauri stops its frontend server when Desktop hands off to Data Migrator.
28+
// Keep the development supervisor alive and re-enter Tauri after the migrator
29+
// requests a restart, restoring both Vite and the Rust watcher.
30+
export async function runDesktopWithMigrationRestart(run, {
31+
info = () => {},
32+
isAlive = isProcessAlive,
33+
wait = () => delay(250),
34+
} = {}) {
35+
let restartArgs = [];
36+
for (;;) {
37+
const directory = await mkdtemp(path.join(os.tmpdir(), 'openbitfun-dev-migration-'));
38+
try {
39+
let failure;
40+
try {
41+
await run(restartArgs, { OPENBITFUN_DEV_MIGRATION_DIR: directory });
42+
} catch (error) {
43+
failure = error;
44+
}
45+
const handoff = await readOptionalJson(path.join(directory, 'handoff.json'));
46+
if (!handoff) {
47+
if (failure) throw failure;
48+
return;
49+
}
50+
if (!UUID.test(handoff.runId) || !Number.isSafeInteger(handoff.pid) || handoff.pid <= 0) {
51+
throw new Error('Invalid development migration handoff');
52+
}
53+
info('Waiting for Data Migrator; Desktop will reopen automatically when it finishes');
54+
while (isAlive(handoff.pid)) await wait();
55+
const restart = await readOptionalJson(path.join(directory, 'restart.json'));
56+
if (restart?.runId !== handoff.runId) {
57+
throw new Error('Data Migrator exited without completing its restart handoff; run desktop:dev to retry');
58+
}
59+
restartArgs = ['--legacy-migration-run-id', handoff.runId];
60+
info('Restarting Desktop through the development launcher');
61+
} finally {
62+
await rm(directory, { recursive: true, force: true });
63+
}
64+
}
65+
}
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
import assert from 'node:assert/strict';
2+
import { access, writeFile } from 'node:fs/promises';
3+
import path from 'node:path';
4+
import test from 'node:test';
5+
import { runDesktopWithMigrationRestart } from './desktop-dev-migration.mjs';
6+
7+
const runId = '01234567-89ab-4cde-8fab-0123456789ab';
8+
const write = (directory, file, value) => writeFile(path.join(directory, file), JSON.stringify(value));
9+
10+
test('normal exit does not restart and removes its temporary channel', async () => {
11+
let directory;
12+
let calls = 0;
13+
await runDesktopWithMigrationRestart(async (args, env) => {
14+
calls++;
15+
assert.deepEqual(args, []);
16+
directory = env.OPENBITFUN_DEV_MIGRATION_DIR;
17+
});
18+
assert.equal(calls, 1);
19+
await assert.rejects(access(directory), { code: 'ENOENT' });
20+
});
21+
22+
test('build failures remain failures when no migration was launched', async () => {
23+
const failure = new Error('build failed');
24+
await assert.rejects(runDesktopWithMigrationRestart(async () => { throw failure; }), failure);
25+
});
26+
27+
test('migration completion waits for child exit then restores the development host with the run id', async () => {
28+
let calls = 0;
29+
let directory;
30+
let running = true;
31+
await runDesktopWithMigrationRestart(async (args, env) => {
32+
calls++;
33+
if (calls === 1) {
34+
directory = env.OPENBITFUN_DEV_MIGRATION_DIR;
35+
await write(directory, 'handoff.json', { runId, pid: 123 });
36+
// A handoff may also make Tauri report its stopped frontend as a failure.
37+
throw new Error('frontend stopped');
38+
}
39+
assert.equal(running, false);
40+
assert.deepEqual(args, ['--legacy-migration-run-id', runId]);
41+
assert.notEqual(env.OPENBITFUN_DEV_MIGRATION_DIR, directory);
42+
}, {
43+
isAlive: (pid) => { assert.equal(pid, 123); return running; },
44+
wait: async () => {
45+
await write(directory, 'restart.json', { runId });
46+
running = false;
47+
},
48+
});
49+
assert.equal(calls, 2);
50+
await assert.rejects(access(directory), { code: 'ENOENT' });
51+
});
52+
53+
test('crashed or mismatched migrators cannot silently restart Desktop', async () => {
54+
for (const restart of [null, { runId: 'wrong-run' }]) {
55+
await assert.rejects(runDesktopWithMigrationRestart(async (_, env) => {
56+
await write(env.OPENBITFUN_DEV_MIGRATION_DIR, 'handoff.json', { runId, pid: 123 });
57+
if (restart) await write(env.OPENBITFUN_DEV_MIGRATION_DIR, 'restart.json', restart);
58+
}, { isAlive: () => false }), /without completing its restart handoff/);
59+
}
60+
});

‎scripts/dev.cjs‎

Lines changed: 48 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,7 @@ function spawnCommand(cmd, args, cwd = ROOT_DIR, envOverrides = {}, shell = fals
124124
const child = spawn(cmd, args, {
125125
cwd,
126126
stdio: 'inherit',
127+
windowsHide: true,
127128
shell,
128129
env: {
129130
...process.env,
@@ -192,6 +193,7 @@ function spawnBackgroundCommand(cmd, args, cwd = ROOT_DIR, env = process.env) {
192193
return spawn(cmd, args, {
193194
cwd,
194195
stdio: 'inherit',
196+
windowsHide: true,
195197
env,
196198
});
197199
}
@@ -208,6 +210,7 @@ function spawnWindowsCommandArgs(command, args, cwd = ROOT_DIR, env = process.en
208210
return spawn(process.env.ComSpec || 'C:\\Windows\\System32\\cmd.exe', ['/d', '/s', '/c', command, ...args], {
209211
cwd,
210212
stdio: 'inherit',
213+
windowsHide: true,
211214
env,
212215
});
213216
}
@@ -554,30 +557,31 @@ async function startDesktopPreview() {
554557

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

557-
appProcess = spawnBackgroundCommand(desktopBinary, [], ROOT_DIR, {
558-
...process.env,
559-
// Debug previews must upload the current workspace build. The adjacent
560-
// target/debug resource tree is only a build-time copy and can lag behind
561-
// mobile-web edits made while the desktop binary is being reused.
562-
OPENBITFUN_MOBILE_WEB_DIR: path.join(ROOT_DIR, 'src/mobile-web/dist'),
563-
});
564-
565-
appProcess.on('error', (error) => {
566-
printError(`Desktop preview failed to start: ${error.message || String(error)}`);
567-
void shutdown(1);
568-
});
569-
570-
appProcess.on('exit', (code, signal) => {
571-
if (!shuttingDown) {
572-
printInfo(`Desktop preview exited (code=${code ?? 'null'}, signal=${signal ?? 'null'})`);
573-
}
574-
void shutdown(code ?? 0);
575-
});
576-
577-
printSuccess('Desktop preview is running');
578-
printInfo('Front-end edits continue to use Vite HMR; rebuild Rust only when desktop-side code changes');
579-
580-
await new Promise(() => {});
560+
const { runDesktopWithMigrationRestart } = await import(
561+
pathToFileURL(path.join(__dirname, 'desktop-dev-migration.mjs')).href
562+
);
563+
try {
564+
await runDesktopWithMigrationRestart((restartArgs, migrationEnv) => new Promise((resolve, reject) => {
565+
appProcess = spawnBackgroundCommand(desktopBinary, restartArgs, ROOT_DIR, {
566+
...process.env,
567+
...migrationEnv,
568+
// Upload the current workspace mobile bundle instead of the staged copy.
569+
OPENBITFUN_MOBILE_WEB_DIR: path.join(ROOT_DIR, 'src/mobile-web/dist'),
570+
});
571+
appProcess.on('error', reject);
572+
appProcess.on('close', (code, signal) => {
573+
appProcess = null;
574+
if (code === 0) resolve();
575+
else reject(new Error(`Desktop preview exited (code=${code}, signal=${signal})`));
576+
});
577+
printSuccess('Desktop preview is running');
578+
printInfo('Front-end edits continue to use Vite HMR; rebuild Rust only when desktop-side code changes');
579+
}), { info: printInfo });
580+
await shutdown(0);
581+
} catch (error) {
582+
printError(error.message || String(error));
583+
await shutdown(1);
584+
}
581585
}
582586

583587
/**
@@ -747,19 +751,26 @@ async function main() {
747751
OPENBITFUN_MOBILE_WEB_DIR: path.join(ROOT_DIR, 'src/mobile-web/dist'),
748752
};
749753
try {
750-
if (process.platform === 'win32') {
751-
// Running the generated .cmd shim directly via spawn is flaky on Windows.
752-
// Use cmd.exe with an explicit args array so the desktop app directory
753-
// stays the Tauri project root without pnpm workspace path rewriting.
754-
const tauriBin = path.join(ROOT_DIR, 'node_modules', '.bin', 'tauri.cmd');
755-
await runWindowsCommandArgs(tauriBin, ['dev', '--config', tauriConfig], desktopDir, tauriDevEnv);
756-
} else {
757-
const tauriBin = path.join(ROOT_DIR, 'node_modules', '.bin', 'tauri');
758-
await spawnCommand(tauriBin, ['dev', '--config', tauriConfig], desktopDir, {
759-
CARGO_PROFILE_DEV_CODEGEN_UNITS: tauriDevEnv.CARGO_PROFILE_DEV_CODEGEN_UNITS,
760-
OPENBITFUN_MOBILE_WEB_DIR: tauriDevEnv.OPENBITFUN_MOBILE_WEB_DIR,
761-
});
762-
}
754+
const { runDesktopWithMigrationRestart } = await import(
755+
pathToFileURL(path.join(__dirname, 'desktop-dev-migration.mjs')).href
756+
);
757+
await runDesktopWithMigrationRestart(async (restartArgs, migrationEnv) => {
758+
const args = ['dev', '--config', tauriConfig, ...(restartArgs.length ? ['--', '--', ...restartArgs] : [])];
759+
if (process.platform === 'win32') {
760+
// Running the generated .cmd shim directly via spawn is flaky on Windows.
761+
// Use cmd.exe with an explicit args array so the desktop app directory
762+
// stays the Tauri project root without pnpm workspace path rewriting.
763+
const tauriBin = path.join(ROOT_DIR, 'node_modules', '.bin', 'tauri.cmd');
764+
await runWindowsCommandArgs(tauriBin, args, desktopDir, { ...tauriDevEnv, ...migrationEnv });
765+
} else {
766+
const tauriBin = path.join(ROOT_DIR, 'node_modules', '.bin', 'tauri');
767+
await spawnCommand(tauriBin, args, desktopDir, {
768+
CARGO_PROFILE_DEV_CODEGEN_UNITS: tauriDevEnv.CARGO_PROFILE_DEV_CODEGEN_UNITS,
769+
OPENBITFUN_MOBILE_WEB_DIR: tauriDevEnv.OPENBITFUN_MOBILE_WEB_DIR,
770+
...migrationEnv,
771+
});
772+
}
773+
}, { info: printInfo });
763774
} finally {
764775
// Option B: prune only when the desktop:dev session ends, not on each rebuild.
765776
await runDesktopTargetGc('debug');

‎src/apps/data-migrator/AGENTS.md‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,8 +30,15 @@ must remain a separate executable and WebView identity from Desktop.
3030
```bash
3131
cargo test -p openbitfun-data-migrator
3232
node --test scripts/data-migrator-tauri-build.test.mjs
33+
node --test scripts/desktop-dev-migration.test.mjs
3334
```
3435

36+
Completion always returns to Desktop. Debug builds launched through `desktop:dev`
37+
or `desktop:preview:debug` ask that launcher to restart via its private temporary
38+
handoff directory, preserving the frontend server and development lifecycle.
39+
Builds without that channel restart the trusted sibling Desktop executable.
40+
Keep this developer-only channel out of persisted migration and remote protocols.
41+
3542
Run `pnpm run check:core-boundaries` when dependencies or delivery-profile
3643
selection change. Packaging, signing, and UI interaction are separate explicit
3744
verification steps.

0 commit comments

Comments
 (0)