Two independent causes of a blank app window. Never SHIP a blank (the prod hole). v0.3.22 shipped a black screen: a minifier temporal-dead-zone reorder threw before React mounted, leaving an empty #root (#1178). It reached users because every existing check — vitest, node:test, and the whole Playwright e2e suite — runs the UN-MINIFIED dev server, so a bug living only in the minified bundle passes them all. playwright.prod.config.ts + e2e-prod/ close that hole: build the real bundle, serve dist/ via vite preview, and assert the app actually mounts (#root has children, renders visible text, no pageerror). The core assertion is deliberately structural — "did anything mount?" — because that is what a pre-render crash always breaks, whatever its cause. retries: 0, so a blank screen can never be flaky-passed away. Never DISPLAY a blank in dev (the collision). Running `bun desktop` while one is already up does not fail politely, it cascades into a blank window. Reproduced deterministically and measured over CDP: the healthy main window has #root childElementCount 1; after a second launch it is 0. The new launch's port grab makes the running instance's dev:api exit, and `concurrently --kill-others-on-fail` then tears down that instance's whole stack including its Vite server — leaving its window open, pointed at a dev URL that no longer answers. desktop-dev.mjs now clears a leftover dev app first, loudly. The safety boundary for that cleanup is `isDevAppProcess` in desktop-common.mjs: it matches the cargo dev binary (`omnivoice-studio`) ONLY, never the installed release app (`OmniVoice Studio`) — killing a user's real app would be far worse than the bug being fixed. Unit-tested both ways. Also makes the gate runnable off Linux: the dev e2e config hardcodes /usr/bin/chromium, which doesn't exist on Windows/macOS. The new config falls back to Playwright's own browser so a contributor can run the gate before a release. The ci.yml step that runs this gate is NOT in this commit — pushing workflow changes needs a token scope this session lacks. It is provided separately for the maintainer to apply. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
128 lines
5.8 KiB
JavaScript
128 lines
5.8 KiB
JavaScript
#!/usr/bin/env bun
|
|
// ──────────────────────────────────────────────────────────────────────────
|
|
// desktop-dev.mjs — launch `tauri dev` with a self-healing PATH.
|
|
//
|
|
// Why this exists: `tauri dev` shells out to `cargo`. On Windows especially, a
|
|
// terminal opened *before* rustup was installed keeps a stale PATH snapshot
|
|
// that lacks `~/.cargo/bin`, so `bun desktop` dies with
|
|
// failed to run 'cargo metadata' command … program not found
|
|
// even though cargo IS installed and IS on the persisted User PATH — a brand
|
|
// new terminal would find it. Rather than make every contributor remember to
|
|
// reopen their shell, prepend the standard rustup bin dir here when cargo isn't
|
|
// already resolvable. Cross-platform (`~/.cargo/bin` on macOS/Linux/Windows)
|
|
// and a complete no-op when cargo is already on PATH, so nothing changes for
|
|
// anyone whose environment is already correct.
|
|
//
|
|
// Invoked as the frontend `desktop` script (`bun ../scripts/desktop-dev.mjs`),
|
|
// so cwd is frontend/ and `bun run tauri dev` resolves the workspace-local
|
|
// @tauri-apps/cli. All extra args are forwarded untouched.
|
|
// ──────────────────────────────────────────────────────────────────────────
|
|
import { spawnSync } from "node:child_process";
|
|
import { existsSync } from "node:fs";
|
|
import { join, delimiter } from "node:path";
|
|
import { homedir } from "node:os";
|
|
import process from "node:process";
|
|
import { DEV_APP_PROCESS_NAME } from "./desktop-common.mjs";
|
|
|
|
/** The env's PATH key — Windows uses "Path", others "PATH"; match case-insensitively. */
|
|
function pathKeyOf(env) {
|
|
return Object.keys(env).find((k) => k.toLowerCase() === "path") ?? "PATH";
|
|
}
|
|
|
|
/** Is `cargo` resolvable via the given env's PATH? Uses a child that searches
|
|
* its own PATH (`cmd`/`sh`), which mirrors how the Tauri CLI's Rust resolves
|
|
* `cargo` downstream — unlike Bun's own launcher resolution, which snapshots
|
|
* PATH and would give a false negative after we heal it. */
|
|
function cargoResolvable(env) {
|
|
const probe =
|
|
process.platform === "win32"
|
|
? spawnSync("cmd", ["/c", "cargo --version"], { env, stdio: "ignore" })
|
|
: spawnSync("sh", ["-c", "command -v cargo"], { env, stdio: "ignore" });
|
|
return probe.status === 0;
|
|
}
|
|
|
|
/**
|
|
* Take down a leftover dev app before starting a new one.
|
|
*
|
|
* Launching a second `bun desktop` while one is already running does NOT just
|
|
* fail politely — it cascades into a BLANK WINDOW. Reproduced deterministically:
|
|
* the new launch's port grab makes the running instance's `dev:api` exit, and
|
|
* `concurrently --kill-others-on-fail` then tears down that instance's whole
|
|
* stack *including its Vite server* — leaving its window open, pointed at a
|
|
* dev URL that no longer answers, with an empty #root. The user sees a black
|
|
* app and nothing explains why.
|
|
*
|
|
* So: clear the previous dev instance first, loudly. Deliberately matches ONLY
|
|
* the cargo-built dev binary (`omnivoice-studio`); the installed release app is
|
|
* `OmniVoice Studio` and is never touched.
|
|
*/
|
|
function killStaleDevApp() {
|
|
const NAME = DEV_APP_PROCESS_NAME;
|
|
try {
|
|
if (process.platform === "win32") {
|
|
const list = spawnSync("tasklist", ["/FI", `IMAGENAME eq ${NAME}.exe`, "/NH"], {
|
|
encoding: "utf8",
|
|
});
|
|
if (!list.stdout || !list.stdout.toLowerCase().includes(`${NAME}.exe`)) return;
|
|
spawnSync("taskkill", ["/F", "/T", "/IM", `${NAME}.exe`], { stdio: "ignore" });
|
|
} else {
|
|
// -f matches the full path so we hit target/debug/omnivoice-studio only.
|
|
const found = spawnSync("pgrep", ["-f", `${NAME}$`], { encoding: "utf8" });
|
|
if (found.status !== 0) return;
|
|
spawnSync("pkill", ["-f", `${NAME}$`], { stdio: "ignore" });
|
|
}
|
|
console.log(
|
|
"[desktop-dev] closed a previous dev app instance - two instances fight over the " +
|
|
"dev server and leave one window blank. Starting a clean one.",
|
|
);
|
|
} catch {
|
|
// Best-effort: never block a launch because cleanup failed.
|
|
}
|
|
}
|
|
|
|
killStaleDevApp();
|
|
|
|
// Start from the real environment; heal a stale PATH into a *copy* (mutating
|
|
// process.env doesn't reliably propagate to children under Bun).
|
|
const childEnv = { ...process.env };
|
|
const key = pathKeyOf(childEnv);
|
|
|
|
if (!cargoResolvable(childEnv)) {
|
|
const cargoBin = join(homedir(), ".cargo", "bin");
|
|
const cargoExe = join(cargoBin, process.platform === "win32" ? "cargo.exe" : "cargo");
|
|
if (existsSync(cargoExe)) {
|
|
childEnv[key] = cargoBin + delimiter + (childEnv[key] ?? "");
|
|
console.log(
|
|
`[desktop-dev] added '${cargoBin}' to PATH for this run - cargo is installed but wasn't visible to ` +
|
|
`this terminal (a stale PATH from before rustup). Open a new terminal to make it permanent.`,
|
|
);
|
|
} else {
|
|
console.error(
|
|
[
|
|
"",
|
|
"❌ `tauri dev` needs Rust/cargo, and none was found.",
|
|
"",
|
|
" Install the Rust toolchain, then reopen your terminal:",
|
|
" Windows: winget install Rust.Rustup",
|
|
" macOS/Linux: https://rustup.rs",
|
|
"",
|
|
" Or download a prebuilt installer from the Releases page (no toolchain needed).",
|
|
"",
|
|
].join("\n"),
|
|
);
|
|
process.exit(1);
|
|
}
|
|
}
|
|
|
|
// Run the workspace-local Tauri CLI in dev mode (cwd is already frontend/),
|
|
// handing it the healed env so its `cargo` spawns inherit the fixed PATH.
|
|
const res = spawnSync("bun", ["run", "tauri", "dev", ...process.argv.slice(2)], {
|
|
stdio: "inherit",
|
|
env: childEnv,
|
|
});
|
|
if (res.error) {
|
|
console.error(`❌ failed to launch tauri dev: ${res.error.message}`);
|
|
process.exit(1);
|
|
}
|
|
process.exit(res.status ?? 1);
|