diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 08d85fe..a3ddf95 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -6,6 +6,7 @@ "main": "build/main/index.js", "scripts": { "dev": "vite", + "dev:app": "node ../../scripts/dev-desktop.mjs", "build:renderer": "vite build", "test": "vitest run", "test:coverage": "vitest run --coverage", diff --git a/package.json b/package.json index cc0d5f8..89fd49f 100644 --- a/package.json +++ b/package.json @@ -9,6 +9,7 @@ "scripts": { "test": "pnpm -r --if-present run test", "test:coverage": "pnpm -r --if-present run test:coverage", + "dev:desktop": "pnpm --filter @open-wiki/desktop run dev:app", "typecheck": "tsc --noEmit -p tsconfig.json && pnpm -r --if-present run typecheck", "lint": "eslint .", "lint:fix": "eslint . --fix", diff --git a/scripts/dev-desktop.mjs b/scripts/dev-desktop.mjs new file mode 100644 index 0000000..eb7b8fd --- /dev/null +++ b/scripts/dev-desktop.mjs @@ -0,0 +1,143 @@ +#!/usr/bin/env node +/** + * Launch the desktop app for visual development. + * + * The renderer is Vite's dev server; the main process is the esbuild bundle + * from scripts/build-main.mjs. Electron loads the renderer from + * `VITE_DEV_SERVER_URL` when that env var is set (see `src/main/index.ts`), so + * this script builds the main bundle, starts Vite on a fixed port, and only + * then spawns `electron .` pointed at it. + * + * pnpm --filter @open-wiki/desktop run dev:app + * pnpm --filter @open-wiki/desktop run dev:app -- --project C:\path\to\wiki + * + * Any args after `--` are forwarded to Electron, so `--project ` opens a + * specific wiki instead of the launcher (see `src/main/project.ts`). + * + * The main bundle is built once up front; editing `src/main/**` requires + * restarting this command. The renderer hot-reloads through Vite as usual. + */ +import { spawn } from "node:child_process"; +import { createRequire } from "node:module"; +import { fileURLToPath } from "node:url"; +import { dirname, resolve } from "node:path"; + +const here = dirname(fileURLToPath(import.meta.url)); +const root = resolve(here, ".."); +const desktop = resolve(root, "apps", "desktop"); +const PORT = 5173; +const DEV_URL = `http://localhost:${PORT}`; +const win32 = process.platform === "win32"; +const userArgs = process.argv.slice(2); + +// `require("electron")` outside the Electron runtime returns the path to the +// binary it ships, on every platform. Spawning that binary directly — rather +// than going through `pnpm exec electron` and a shell — keeps `userArgs` as a +// literal argv: a `--project C:\x&y` value is a path, not shell tokens. +const requireFromDesktop = createRequire(resolve(desktop, "package.json")); +const electronBin = requireFromDesktop("electron"); +if (typeof electronBin !== "string" || !electronBin) { + throw new Error( + "electron is not installed under @open-wiki/desktop — run `pnpm install` and retry.", + ); +} +// Vite's own CLI, resolved the same way, so it too is spawned directly rather +// than through `pnpm exec vite` + a shell. A direct `node` spawn has one +// concrete payoff: `vite.kill()` reaches the real Vite process instead of a +// `cmd.exe` wrapper, so the dev server does not outlive the launcher and hold +// port 5173 (which the next launch's --strictPort would then fail on). The +// `./bin/vite.js` subpath is not in vite's `exports`, so resolve the package +// root via its `package.json` (which is) and join the bin from there. +const viteCli = resolve( + dirname(requireFromDesktop.resolve("vite/package.json")), + "bin", + "vite.js", +); + +/** + * Spawn `pnpm` (a `.CMD` shim on Windows that only the shell resolves), + * inheriting stdio. Only ever called with trusted constant args — no + * developer-supplied input reaches here, so the shell-join is not an + * injection surface. Used only for the one-shot build step, which exits on + * its own before anything long-lived is started. + */ +function runPnpm(args, opts = {}) { + const full = ["pnpm", ...args].join(" "); + return spawn(win32 ? full : "pnpm", win32 ? [] : args, { + stdio: "inherit", + shell: win32, + ...opts, + }); +} + +// 1. Build the main process + preload. esbuild is fast, and a stale main +// bundle is the one failure a dev server cannot recover from. Run it +// through the pnpm script so workspace devDeps (esbuild) are on NODE_PATH. +await new Promise((res, rej) => { + const p = runPnpm(["--filter", "@open-wiki/desktop", "run", "build:main"]); + p.on("exit", (code) => (code === 0 ? res() : rej(new Error(`build-main exited ${code}`)))); +}); + +// 2. Start Vite on a fixed port so the dev URL is known up front; --strictPort +// fails loudly instead of silently landing on 5174 (which Electron would +// never be told about). Spawned directly as `node ` with the +// desktop as cwd so vite.config.ts is found and the process is killable. +const vite = spawn( + process.execPath, + [viteCli, "--port", String(PORT), "--strictPort"], + { stdio: ["inherit", "pipe", "inherit"], cwd: desktop, shell: false }, +); + +let electron = null; +let exiting = false; + +function launchElectron() { + // Direct spawn, no shell: `.` loads the desktop package (`main` → + // build/main/index.js), and `userArgs` ride as literal argv so a `--project` + // path with spaces or metacharacters is not shell-interpreted. + electron = spawn(electronBin, [".", ...userArgs], { + stdio: "inherit", + cwd: desktop, + env: { ...process.env, VITE_DEV_SERVER_URL: DEV_URL }, + }); + electron.on("exit", (code) => { + exiting = true; + vite.kill(); + // `null` means a signal killed it — not a clean exit. Report non-zero so a + // crashed Electron does not read as success in `pnpm`/CI. + process.exit(code ?? 128); + }); +} + +// Vite prints ` VITE vX.Y.Z ready in Nms` once the dev server is listening. +let ready = false; +vite.stdout.on("data", (chunk) => { + process.stdout.write(chunk); + if (!ready && /ready in/i.test(chunk.toString())) { + ready = true; + launchElectron(); + } +}); + +// Vite died on its own — crash, port conflict, or killed from another console. +// `exiting` is false only when nothing else already triggered shutdown, which +// means Electron may still be alive and pointing at a dead dev URL: kill it +// before leaving, or it is orphaned (a child is not signalled when its parent +// exits on Windows). +vite.on("exit", (code) => { + if (!exiting) { + electron?.kill(); + process.exit(code ?? 1); + } +}); + +// SIGINT is the only one of these Windows can deliver (Ctrl+C); SIGTERM is +// POSIX-only and is a no-op there, but harmless to register. +for (const sig of ["SIGINT", "SIGTERM"]) { + process.on(sig, () => { + exiting = true; + vite.kill(); + electron?.kill(); + process.exit(sig === "SIGINT" ? 130 : 143); + }); +} \ No newline at end of file