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
1 change: 1 addition & 0 deletions apps/desktop/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
143 changes: 143 additions & 0 deletions scripts/dev-desktop.mjs
Original file line number Diff line number Diff line change
@@ -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 <dir>` 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}`))));
});
Comment on lines +76 to +79

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -eu
printf '%s\n' '--- file ---'
cat -n scripts/dev-desktop.mjs
printf '%s\n' '--- spawn and lifecycle references ---'
rg -n -C 3 'runPnpm|spawn\\(|\\.on\\("(error|exit|close|spawn)"|vite\\.kill|electron\\.kill|process\\.exit' scripts/dev-desktop.mjs
printf '%s\n' '--- package/runtime context ---'
rg -n -C 2 '"(dev-desktop|build:main|vite|electron)"|dev-desktop' package.json pnpm-workspace.yaml scripts 2>/dev/null || true

Repository: protonspy/open-wiki

Length of output: 7153


🏁 Script executed:

#!/bin/bash
set -eu
node - <<'JS'
const { spawn } = require("node:child_process");

function probe(label, command, args, options = {}) {
  return new Promise((resolve) => {
    const child = spawn(command, args, options);
    const events = [];
    child.on("error", (err) => events.push(`error:${err.code}`));
    child.on("exit", (code, signal) => {
      events.push(`exit:${code}:${signal}`);
      resolve({ label, events });
    });
  });
}

(async () => {
  console.log(await probe("missing executable, shell:false", "__missing_spawn_probe__", [], {
    stdio: "ignore",
    shell: false,
  }));
  console.log(await probe("missing executable, shell:true", "__missing_spawn_probe__", [], {
    stdio: "ignore",
    shell: true,
  }));

  const child = spawn(process.execPath, ["-e", "process.exit(0)"], {
    stdio: "ignore",
    shell: false,
  });
  let errorListenerCount = 0;
  errorListenerCount += child.listenerCount("error");
  await new Promise((resolve) => child.once("exit", resolve));
  console.log({ successfulSpawnErrorListenersBeforeExit: errorListenerCount });
})();
JS
printf '%s\n' '--- relevant source lines ---'
sed -n '64,132p' scripts/dev-desktop.mjs

Repository: protonspy/open-wiki

Length of output: 2713


🏁 Script executed:

#!/bin/bash
set -eu
node - <<'JS'
const { spawn } = require("node:child_process");

async function withErrorHandler() {
  const child = spawn("__missing_spawn_probe__", [], { stdio: "ignore", shell: false });
  const events = [];
  child.on("error", (error) => events.push(`error:${error.code}`));
  child.on("exit", (code, signal) => events.push(`exit:${code}:${signal}`));
  await new Promise((resolve) => child.on("close", resolve));
  return events;
}

console.log(JSON.stringify(await withErrorHandler()));

const noHandler = spawn(
  process.execPath,
  ["-e", "require('node:child_process').spawn('__missing_spawn_probe__')"],
  { stdio: ["ignore", "pipe", "pipe"] },
);
let stderr = "";
noHandler.stderr.on("data", (chunk) => (stderr += chunk));
const result = await new Promise((resolve) =>
  noHandler.on("close", (code, signal) => resolve({ code, signal, stderr })),
);
console.log(JSON.stringify({
  unhandledErrorExit: result.code,
  unhandledErrorSignal: result.signal,
  mentionsErrorEvent: /Error|error/i.test(result.stderr),
}));

const source = require("node:fs").readFileSync("scripts/dev-desktop.mjs", "utf8");
for (const variable of ["p", "vite", "electron"]) {
  const declaration = source.indexOf(`const ${variable} =`);
  const next = source.indexOf("\n", declaration);
  const window = source.slice(declaration, declaration + 500);
  console.log(JSON.stringify({
    variable,
    hasErrorListenerNearDeclaration: /\.on\(["']error["']|\.once\(["']error["']/.test(window),
  }));
}
JS

Repository: protonspy/open-wiki

Length of output: 1001


🏁 Script executed:

#!/bin/bash
set -eu
node --input-type=module - <<'JS'
import { spawn } from "node:child_process";
import { readFileSync } from "node:fs";

async function withErrorHandler() {
  const child = spawn("__missing_spawn_probe__", [], { stdio: "ignore", shell: false });
  const events = [];
  child.on("error", (error) => events.push(`error:${error.code}`));
  child.on("exit", (code, signal) => events.push(`exit:${code}:${signal}`));
  await new Promise((resolve) => child.on("close", resolve));
  return events;
}

console.log(JSON.stringify(await withErrorHandler()));

const noHandler = spawn(
  process.execPath,
  ["-e", "require('node:child_process').spawn('__missing_spawn_probe__')"],
  { stdio: ["ignore", "pipe", "pipe"] },
);
let stderr = "";
noHandler.stderr.on("data", (chunk) => (stderr += chunk));
const result = await new Promise((resolve) =>
  noHandler.on("close", (code, signal) => resolve({ code, signal, stderr })),
);
console.log(JSON.stringify({
  unhandledErrorExit: result.code,
  unhandledErrorSignal: result.signal,
  mentionsErrorEvent: /Error|error/i.test(result.stderr),
}));

const source = readFileSync("scripts/dev-desktop.mjs", "utf8");
for (const variable of ["p", "vite", "electron"]) {
  const declaration = source.indexOf(`const ${variable} =`);
  const window = source.slice(declaration, declaration + 500);
  console.log(JSON.stringify({
    variable,
    hasErrorListenerNearDeclaration: /\.on\(["']error["']|\.once\(["']error["']/.test(window),
  }));
}
JS

Repository: protonspy/open-wiki

Length of output: 434


Handle ChildProcess spawn failures and clean up the peer process.

When a child emits "error" without a listener, the launcher exits. If Electron fails after Vite starts, Vite remains alive and can keep port 5173 occupied. Add error handlers that route failures through one non-zero shutdown path:

  • p: reject the build promise.
  • vite: terminate Electron if it started.
  • electron: terminate Vite.
📍 Affects 1 file
  • scripts/dev-desktop.mjs#L76-L79 (this comment)
  • scripts/dev-desktop.mjs#L85-L89
  • scripts/dev-desktop.mjs#L98-L109
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@scripts/dev-desktop.mjs` around lines 76 - 79, Handle spawn errors and
peer-process cleanup across scripts/dev-desktop.mjs: in the build promise around
runPnpm, add a p error listener that rejects through the existing non-zero
shutdown path; in the vite process flow, add an error handler that terminates
Electron if it has started; and in the electron process flow, add an error
handler that terminates Vite. Ensure all failures use one consistent non-zero
shutdown path and prevent Vite from remaining alive.


// 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 <vite-cli>` 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();
}
});
Comment on lines +112 to +120

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -eu
file=$(fd -t f '^dev-desktop\.mjs$' . | head -n 1)
printf 'FILE=%s\n' "$file"
ast-grep outline "$file"
printf '\n--- relevant source ---\n'
sed -n '1,180p' "$file"
printf '\n--- related references ---\n'
rg -n -C 3 'launchElectron|vite\.stdout|ready in|dev-desktop' . --glob '!node_modules'

Repository: protonspy/open-wiki

Length of output: 19065


🏁 Script executed:

#!/bin/bash
set -eu
printf '%s\n' '--- Vite declarations ---'
rg -n -C 2 '"vite"|vite:' package.json apps packages pnpm-lock.yaml --glob 'package.json' --glob 'pnpm-lock.yaml' | head -n 120

printf '%s\n' '--- split-chunk behavior ---'
node - <<'JS'
let launches = 0;
let ready = false;
const original = ["  VITE v6.0.0  ready", " in 12ms\n"];

for (const chunk of original) {
  if (!ready && /ready in/i.test(chunk.toString())) {
    ready = true;
    launches++;
  }
}
console.log({ handler: "current", chunks: original, ready, launches });

launches = 0;
ready = false;
let startupOutput = "";
for (const chunk of original) {
  if (!ready) {
    startupOutput += chunk.toString();
    if (/ready in/i.test(startupOutput)) {
      ready = true;
      launches++;
    }
  }
}
console.log({ handler: "buffered", chunks: original, ready, launches });
JS

Repository: protonspy/open-wiki

Length of output: 2884


Buffer Vite startup output before matching readiness.

"data" handlers receive arbitrary chunks. If ready in spans two chunks, launchElectron() never runs. Accumulate startup output before applying the readiness check.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@scripts/dev-desktop.mjs` around lines 112 - 120, Update the Vite stdout
handler around the ready flag and launchElectron so it accumulates startup
chunks in a buffer before testing for the readiness pattern. Preserve forwarding
each chunk to process.stdout, and call launchElectron once when the buffered
output contains the complete “ready in” message, including when that text spans
multiple chunks.


// 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);
});
}
Loading