create-opentray 0.27.1 → 0.27.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -11,8 +11,8 @@ import { execFile, spawn, spawnSync } from "node:child_process";
11
11
  import net from "node:net";
12
12
  import { fileURLToPath } from "node:url";
13
13
  import { getSvgPath } from "figma-squircle";
14
- import { resolveDefaultDarwinAppBundlePath, sanitizeAppBundleName } from "@opentray/packaging";
15
14
  import { promisify } from "node:util";
15
+ import { resolveDefaultDarwinAppBundlePath, sanitizeAppBundleName } from "@opentray/packaging";
16
16
  import { createServer } from "node:http";
17
17
  //#region packages/core/src/app-id.ts
18
18
  /** A token that looks like a command option, e.g. `--xx`, `-p`, or `--port=8080`. */
@@ -7517,7 +7517,7 @@ const killProcessTree$1 = async (child) => {
7517
7517
  child.kill("SIGTERM");
7518
7518
  }
7519
7519
  await sweepDescendants(pid, "SIGTERM");
7520
- await waitForExit(child, 3e3).then((exited) => {
7520
+ await waitForExit$1(child, 3e3).then((exited) => {
7521
7521
  if (!exited) {
7522
7522
  try {
7523
7523
  process.kill(-pid, "SIGKILL");
@@ -7555,7 +7555,7 @@ const sweepDescendants = async (pid, signal) => {
7555
7555
  }
7556
7556
  }
7557
7557
  };
7558
- const waitForExit = (child, timeoutMs) => new Promise((resolve) => {
7558
+ const waitForExit$1 = (child, timeoutMs) => new Promise((resolve) => {
7559
7559
  if (child.exitCode !== null || child.signalCode !== null) {
7560
7560
  resolve(true);
7561
7561
  return;
@@ -7581,6 +7581,8 @@ const runExecFileCapture = (command, args) => new Promise((resolve, reject) => {
7581
7581
  });
7582
7582
  //#endregion
7583
7583
  //#region packages/core/src/toolbar-carrier.ts
7584
+ /** Debounce window that collapses a reload's close burst into one rebuild. */
7585
+ const TOOLBAR_CHANNEL_REBUILD_DEBOUNCE_MS = 300;
7584
7586
  /**
7585
7587
  * The generated-entry helper source. The embedding template supplies
7586
7588
  * `column`/`fixed`/`grow` (imported from "@opentray/ext-webview" beside
@@ -7592,6 +7594,8 @@ const toolbarCarrierSource = () => `// Toolbar navigation carrier (add-webview-o
7592
7594
  // it through the create-private message channel. Channel failure is an
7593
7595
  // exposed bug (D12: no fallback path) — errors land in app.log through
7594
7596
  // options.log instead of being silently swallowed.
7597
+ const TOOLBAR_CHANNEL_REBUILD_DEBOUNCE_MS = ${TOOLBAR_CHANNEL_REBUILD_DEBOUNCE_MS};
7598
+
7595
7599
  const attachToolbarCarrier = async (shell, options) => {
7596
7600
  // Per-child bridge policy (D2): the toolbar page is trusted shell UI and
7597
7601
  // gets exactly the channel surface; the content webview gets NO policy, so
@@ -7605,11 +7609,21 @@ const attachToolbarCarrier = async (shell, options) => {
7605
7609
  // Declarative layered layout (D8 sugar): toolbar pinned at a fixed 44px
7606
7610
  // height, content filling the remainder; resize is recomputed natively.
7607
7611
  await shell.setLayout(column([fixed("toolbar", 44), grow("content")]));
7608
- const channel = await shell.createMessageChannel({ target: "toolbar" });
7609
7612
  const note = (error) => {
7610
7613
  const message = error instanceof Error ? error.message : String(error);
7611
7614
  void options.log("toolbar carrier: " + message);
7612
7615
  };
7616
+
7617
+ // Channel self-heal state (P1-3): the toolbar page's document owns its
7618
+ // endpoint half, so a document death (manual ⌘R reload) closes the channel
7619
+ // (D11 document_navigated semantics). The host half heals itself: debounced
7620
+ // recreate → reinstall command surface → re-seed the address bar. Posts
7621
+ // always flow through the CURRENT channel, so a dead document's last
7622
+ // in-flight pushes fail into app.log instead of a zombie endpoint.
7623
+ let channel = null;
7624
+ let stopped = false;
7625
+ let rebuildTimer = null;
7626
+
7613
7627
  // Address-bar truth (D13): the content webview's urlChange events ARE the
7614
7628
  // address bar's source of truth — pushed over the channel, never scraped
7615
7629
  // from a wrapper document.
@@ -7653,37 +7667,86 @@ const attachToolbarCarrier = async (shell, options) => {
7653
7667
  projectTitle(event.title);
7654
7668
  });
7655
7669
  }
7670
+
7656
7671
  // Command surface (D12): instructions and state queries arrive as channel
7657
7672
  // payloads; back/forward drive the content webview's NATIVE history and
7658
7673
  // reload re-navigates to the current URL through the native navigate
7659
- // command (never evaluate(location.reload()) on a wrapper).
7660
- channel.onMessage((payload) => {
7661
- const message = typeof payload === "object" && payload !== null ? payload : {};
7662
- const kind = message.kind;
7663
- if (kind === "navigate" && typeof message.url === "string" && message.url.length > 0) {
7664
- void content.navigate(message.url).catch(note);
7665
- return;
7666
- }
7667
- if (kind === "back") {
7668
- void content.back().catch(note);
7669
- return;
7670
- }
7671
- if (kind === "forward") {
7672
- void content.forward().catch(note);
7673
- return;
7674
- }
7675
- if (kind === "reload") {
7676
- void content.getUrl().then((state) => content.navigate(state.url)).catch(note);
7674
+ // command (never evaluate(location.reload()) on a wrapper). Installed as
7675
+ // one function so every rebuilt channel gets the identical surface.
7676
+ const installChannelCommandSurface = () => {
7677
+ channel.onMessage((payload) => {
7678
+ const message = typeof payload === "object" && payload !== null ? payload : {};
7679
+ const kind = message.kind;
7680
+ if (kind === "navigate" && typeof message.url === "string" && message.url.length > 0) {
7681
+ void content.navigate(message.url).catch(note);
7682
+ return;
7683
+ }
7684
+ if (kind === "back") {
7685
+ void content.back().catch(note);
7686
+ return;
7687
+ }
7688
+ if (kind === "forward") {
7689
+ void content.forward().catch(note);
7690
+ return;
7691
+ }
7692
+ if (kind === "reload") {
7693
+ void content.getUrl().then((state) => content.navigate(state.url)).catch(note);
7694
+ return;
7695
+ }
7696
+ if (kind === "get-url") {
7697
+ void content.getUrl().then((state) => pushUrl(state.url)).catch(note);
7698
+ }
7699
+ });
7700
+ };
7701
+ // Seed the address bar with the current truth (pre-subscription buffering
7702
+ // on both endpoints covers the race before the toolbar page registers).
7703
+ const seedAddressBar = () => {
7704
+ void content.getUrl().then((state) => pushUrl(state.url)).catch(note);
7705
+ };
7706
+
7707
+ // One channel lifecycle: create, install the surface, observe the single
7708
+ // onClose (D11), and drop the replaced channel's tombstone (destroy is
7709
+ // best-effort cleanup of already-closed state).
7710
+ const openChannel = async () => {
7711
+ const next = await shell.createMessageChannel({ target: "toolbar" });
7712
+ if (stopped) {
7713
+ try { await next.destroy(); } catch { /* raced teardown */ }
7677
7714
  return;
7678
7715
  }
7679
- if (kind === "get-url") {
7680
- void content.getUrl().then((state) => pushUrl(state.url)).catch(note);
7716
+ const replaced = channel;
7717
+ channel = next;
7718
+ installChannelCommandSurface();
7719
+ channel.onClose(() => scheduleChannelRebuild());
7720
+ if (replaced !== null) {
7721
+ try { await replaced.destroy(); } catch { /* already closed */ }
7681
7722
  }
7682
- });
7683
- // Seed the address bar with the current truth (pre-subscription buffering
7684
- // on both endpoints covers the race before the toolbar page registers).
7685
- void content.getUrl().then((state) => pushUrl(state.url)).catch(note);
7686
- return { content, channel };
7723
+ seedAddressBar();
7724
+ };
7725
+ // Debounced self-heal (P1-3): a reload can emit several closes (both
7726
+ // endpoints observe one each, plus repeated reloads); exactly one rebuild
7727
+ // serves the burst. After stop() the carrier never rebuilds — a teardown
7728
+ // close (window destroyed / session closed) must not resurrect a channel
7729
+ // against a dying session.
7730
+ const scheduleChannelRebuild = () => {
7731
+ if (stopped || rebuildTimer !== null) return;
7732
+ rebuildTimer = setTimeout(() => {
7733
+ rebuildTimer = null;
7734
+ if (stopped) return;
7735
+ void openChannel().catch(note);
7736
+ }, TOOLBAR_CHANNEL_REBUILD_DEBOUNCE_MS);
7737
+ };
7738
+
7739
+ await openChannel();
7740
+ return {
7741
+ content,
7742
+ stop: () => {
7743
+ stopped = true;
7744
+ if (rebuildTimer !== null) {
7745
+ clearTimeout(rebuildTimer);
7746
+ rebuildTimer = null;
7747
+ }
7748
+ },
7749
+ };
7687
7750
  };
7688
7751
  `;
7689
7752
  //#endregion
@@ -7879,29 +7942,48 @@ const main = async () => {
7879
7942
  ? { "darwin-icon-only": { type: "file", path: resolve(PROJECT_DIR, trayIcon.path), isTemplate: true } }
7880
7943
  : { "icon-only": { type: "file", path: resolve(PROJECT_DIR, trayIcon.path) } };
7881
7944
 
7882
- const tray = await createTray({
7883
- id: config.appId,
7884
- tooltip: { title: config.appName, description: \`\${config.appName} (OpenTray)\` },
7885
- icon: Object.keys(trayIconCandidates).length > 0
7886
- ? trayIconCandidates
7887
- : { "text-only": "${config.appName.replace(/['"\\]/gu, "").slice(0, 2) || "A"}" },
7888
- menu: { items: [
7889
- { type: "item", id: 1, title: \`Show \${config.appName}\`, primaryEvent: true },
7890
- { type: "separator" },
7891
- { type: "item", id: 2, title: "Quit" },
7892
- ] },
7893
- }, {
7894
- appId: config.appId,
7895
- appName: config.appName,
7896
- ...(appIcon === undefined ? {} : { appIcon }),
7897
- appLaunch: {
7898
- // The generated entry embeds a native PTY (@lydell/node-pty), which
7899
- // requires a Node host — cold launches must not inherit a Bun execPath.
7900
- command: nodeRuntime(),
7901
- args: [resolve(PROJECT_DIR, "main.mjs")],
7902
- cwd: PROJECT_DIR,
7903
- },
7904
- });
7945
+ const tray = await (async () => {
7946
+ try {
7947
+ return await createTray({
7948
+ id: config.appId,
7949
+ tooltip: { title: config.appName, description: \`\${config.appName} (OpenTray)\` },
7950
+ icon: Object.keys(trayIconCandidates).length > 0
7951
+ ? trayIconCandidates
7952
+ : { "text-only": "${config.appName.replace(/['"\\]/gu, "").slice(0, 2) || "A"}" },
7953
+ menu: { items: [
7954
+ { type: "item", id: 1, title: \`Show \${config.appName}\`, primaryEvent: true },
7955
+ { type: "separator" },
7956
+ { type: "item", id: 2, title: "Quit" },
7957
+ ] },
7958
+ }, {
7959
+ appId: config.appId,
7960
+ appName: config.appName,
7961
+ ...(appIcon === undefined ? {} : { appIcon }),
7962
+ appLaunch: {
7963
+ // The generated entry embeds a native PTY (@lydell/node-pty), which
7964
+ // requires a Node host — cold launches must not inherit a Bun execPath.
7965
+ command: nodeRuntime(),
7966
+ args: [resolve(PROJECT_DIR, "main.mjs")],
7967
+ cwd: PROJECT_DIR,
7968
+ },
7969
+ });
7970
+ } catch (error) {
7971
+ // Carrier-resurrection yield: a Dock-pinned carrier can cold-start this
7972
+ // entry while another instance already owns the broker session. The
7973
+ // owner keeps the app open; this launcher exits cleanly (no retry, no
7974
+ // empty tray shell, no orphaned command supervision) and the evidence
7975
+ // lands in app.log. The supervised command started BEFORE createTray,
7976
+ // so the yield takes it down (direct kill; the closing PTY master adds
7977
+ // SIGHUP) — the shell server dies with this process.
7978
+ const message = error instanceof Error ? error.message : String(error);
7979
+ if (message.includes("OPENTRAY_BROKER_SINGLE_SESSION")) {
7980
+ await logSink("[create-opentray] broker session already owned by another instance of this app; this cold-started launcher exits (yield)\\n", "utf8");
7981
+ try { command.killDirect(); } catch { /* already dead */ }
7982
+ process.exit(0);
7983
+ }
7984
+ throw error;
7985
+ }
7986
+ })();
7905
7987
 
7906
7988
  const baseTitle = config.appName;
7907
7989
  // v1 developerMode maps ONLY to per-window DevTools admission. When false it
@@ -7962,12 +8044,15 @@ const main = async () => {
7962
8044
  ...devtools,
7963
8045
  });
7964
8046
  await win.show().catch(() => {});
7965
- await attachToolbarCarrier(win, {
8047
+ const carrier = await attachToolbarCarrier(win, {
7966
8048
  toolbarUrl: \`http://127.0.0.1:\${shellPort}/toolbar.html\`,
7967
8049
  contentUrl: direct,
7968
8050
  titleFollows,
7969
8051
  log: logNote,
7970
8052
  });
8053
+ // P1-3 exit race: teardown closes the channel; the carrier must never
8054
+ // rebuild against a dying session. Stop before the window goes down.
8055
+ process.once("exit", carrier.stop);
7971
8056
  serviceWindows.set(port, { win, detached: false });
7972
8057
  return;
7973
8058
  }
@@ -8203,28 +8288,43 @@ ${toolbarCarrierSource()}const main = async () => {
8203
8288
  ? { "darwin-icon-only": { type: "file", path: resolve(PROJECT_DIR, trayIcon.path), isTemplate: true } }
8204
8289
  : { "icon-only": { type: "file", path: resolve(PROJECT_DIR, trayIcon.path) } };
8205
8290
 
8206
- const tray = await createTray({
8207
- id: config.appId,
8208
- tooltip: { title: config.appName, description: \`\${config.appName} (OpenTray)\` },
8209
- icon: Object.keys(trayIconCandidates).length > 0
8210
- ? trayIconCandidates
8211
- : { "text-only": "${config.appName.replace(/['"\\]/gu, "").slice(0, 2) || "A"}" },
8212
- menu: { items: [
8213
- { type: "item", id: 1, title: \`Show \${config.appName}\`, primaryEvent: true },
8214
- { type: "item", id: 3, title: "Reload" },
8215
- { type: "separator" },
8216
- { type: "item", id: 2, title: "Quit" },
8217
- ] },
8218
- }, {
8219
- appId: config.appId,
8220
- appName: config.appName,
8221
- ...(appIcon === undefined ? {} : { appIcon }),
8222
- appLaunch: {
8223
- command: nodeRuntime(),
8224
- args: [resolve(PROJECT_DIR, "main.mjs")],
8225
- cwd: PROJECT_DIR,
8226
- },
8227
- });
8291
+ const tray = await (async () => {
8292
+ try {
8293
+ return await createTray({
8294
+ id: config.appId,
8295
+ tooltip: { title: config.appName, description: \`\${config.appName} (OpenTray)\` },
8296
+ icon: Object.keys(trayIconCandidates).length > 0
8297
+ ? trayIconCandidates
8298
+ : { "text-only": "${config.appName.replace(/['"\\]/gu, "").slice(0, 2) || "A"}" },
8299
+ menu: { items: [
8300
+ { type: "item", id: 1, title: \`Show \${config.appName}\`, primaryEvent: true },
8301
+ { type: "item", id: 3, title: "Reload" },
8302
+ { type: "separator" },
8303
+ { type: "item", id: 2, title: "Quit" },
8304
+ ] },
8305
+ }, {
8306
+ appId: config.appId,
8307
+ appName: config.appName,
8308
+ ...(appIcon === undefined ? {} : { appIcon }),
8309
+ appLaunch: {
8310
+ command: nodeRuntime(),
8311
+ args: [resolve(PROJECT_DIR, "main.mjs")],
8312
+ cwd: PROJECT_DIR,
8313
+ },
8314
+ });
8315
+ } catch (error) {
8316
+ // Carrier-resurrection yield: a Dock-pinned carrier can cold-start this
8317
+ // entry while another instance already owns the broker session. The
8318
+ // owner keeps the app open; this launcher exits cleanly (no retry, no
8319
+ // empty tray shell) and the evidence lands in app.log.
8320
+ const message = error instanceof Error ? error.message : String(error);
8321
+ if (message.includes("OPENTRAY_BROKER_SINGLE_SESSION")) {
8322
+ await logSink("[create-opentray] broker session already owned by another instance of this app; this cold-started launcher exits (yield)\\n", "utf8");
8323
+ process.exit(0);
8324
+ }
8325
+ throw error;
8326
+ }
8327
+ })();
8228
8328
 
8229
8329
  // v1 developerMode maps ONLY to per-window DevTools admission. When false it
8230
8330
  // must not request devtools at all — default windows are not inspectable.
@@ -8265,12 +8365,16 @@ ${toolbarCarrierSource()}const main = async () => {
8265
8365
 
8266
8366
  let content = null;
8267
8367
  if (toolbarReady) {
8268
- ({ content } = await attachToolbarCarrier(window, {
8368
+ const carrier = await attachToolbarCarrier(window, {
8269
8369
  toolbarUrl: \`http://127.0.0.1:\${shellPort}/toolbar.html\`,
8270
8370
  contentUrl: config.url,
8271
8371
  titleFollows,
8272
8372
  log: logNote,
8273
- }));
8373
+ });
8374
+ ({ content } = carrier);
8375
+ // P1-3 exit race: teardown closes the channel; the carrier must never
8376
+ // rebuild against a dying session. Stop before the window goes down.
8377
+ process.once("exit", carrier.stop);
8274
8378
  }
8275
8379
 
8276
8380
  // Tray reload (D11): toolbar mode reloads the CONTENT webview natively
@@ -8831,6 +8935,182 @@ const runPackageManagerInstall = async (options) => {
8831
8935
  });
8832
8936
  };
8833
8937
  //#endregion
8938
+ //#region packages/core/src/errors.ts
8939
+ const ok = (value) => ({
8940
+ ok: true,
8941
+ value
8942
+ });
8943
+ const err = (code, message, details) => ({
8944
+ ok: false,
8945
+ error: {
8946
+ code,
8947
+ message,
8948
+ ...details === void 0 ? {} : { details }
8949
+ }
8950
+ });
8951
+ //#endregion
8952
+ //#region packages/core/src/runtime-record.ts
8953
+ const execFileAsync$1 = promisify(execFile);
8954
+ const RUNTIME_FILENAME = "runtime.json";
8955
+ const readRuntimeRecord = async (registrationDir) => {
8956
+ try {
8957
+ const raw = JSON.parse(await readFile(join(registrationDir, RUNTIME_FILENAME), "utf8"));
8958
+ if (typeof raw === "object" && raw !== null && typeof raw.pid === "number" && typeof raw.token === "string") {
8959
+ const record = raw;
8960
+ return {
8961
+ pid: record.pid,
8962
+ token: record.token,
8963
+ startedAt: typeof record.startedAt === "number" ? record.startedAt : null,
8964
+ launchedAt: typeof record.launchedAt === "number" ? record.launchedAt : Date.now()
8965
+ };
8966
+ }
8967
+ return;
8968
+ } catch {
8969
+ return;
8970
+ }
8971
+ };
8972
+ const clearRuntimeRecord = async (registrationDir) => {
8973
+ await rm(join(registrationDir, RUNTIME_FILENAME), { force: true });
8974
+ };
8975
+ const isPidAlive$1 = (pid) => {
8976
+ try {
8977
+ process.kill(pid, 0);
8978
+ return true;
8979
+ } catch (error) {
8980
+ return error.code === "EPERM";
8981
+ }
8982
+ };
8983
+ /** Linux: /proc/<pid>/stat field 22 (starttime in clock ticks since boot). */
8984
+ const linuxStartTicks = async (pid) => {
8985
+ try {
8986
+ const stat = await readFile(`/proc/${pid}/stat`, "utf8");
8987
+ const close = stat.lastIndexOf(")");
8988
+ if (close < 0) return void 0;
8989
+ const fields = stat.slice(close + 2).split(" ");
8990
+ const ticks = Number.parseInt(fields[19] ?? "", 10);
8991
+ return Number.isInteger(ticks) ? ticks : void 0;
8992
+ } catch {
8993
+ return;
8994
+ }
8995
+ };
8996
+ const bootEpochMs = async () => {
8997
+ try {
8998
+ const stat = await readFile("/proc/stat", "utf8");
8999
+ const btime = /btime (\d+)/u.exec(stat);
9000
+ return btime === null ? void 0 : Number.parseInt(btime[1], 10) * 1e3;
9001
+ } catch {
9002
+ return;
9003
+ }
9004
+ };
9005
+ /** macOS: `ps -o lstart= -p <pid>` prints the absolute start timestamp. */
9006
+ const macosStartEpochMs = async (pid) => {
9007
+ try {
9008
+ const { stdout } = await execFileAsync$1("ps", [
9009
+ "-o",
9010
+ "lstart=",
9011
+ "-p",
9012
+ String(pid)
9013
+ ], {
9014
+ timeout: 5e3,
9015
+ env: {
9016
+ ...process.env,
9017
+ LC_ALL: "C",
9018
+ LANG: "C"
9019
+ }
9020
+ });
9021
+ const parsed = Date.parse(stdout.trim());
9022
+ return Number.isNaN(parsed) ? void 0 : parsed;
9023
+ } catch {
9024
+ return;
9025
+ }
9026
+ };
9027
+ /** Windows: PowerShell Get-Process StartTime for the pid. */
9028
+ const windowsStartEpochMs = async (pid) => {
9029
+ try {
9030
+ const { stdout } = await execFileAsync$1("powershell.exe", [
9031
+ "-NoProfile",
9032
+ "-NonInteractive",
9033
+ "-Command",
9034
+ `(Get-CimInstance Win32_Process -Filter "ProcessId=${pid}").CreationDate`
9035
+ ], {
9036
+ timeout: 1e4,
9037
+ windowsHide: true
9038
+ });
9039
+ const trimmed = stdout.trim();
9040
+ const dmtf = /^(\d{14})(?:\.(\d+))?(?:([+-]\d+))?$/u.exec(trimmed);
9041
+ if (dmtf !== null) {
9042
+ const stamp = dmtf[1] ?? "";
9043
+ const fraction = dmtf[2];
9044
+ const offset = dmtf[3];
9045
+ const iso = `${stamp.slice(0, 4)}-${stamp.slice(4, 6)}-${stamp.slice(6, 8)}T${stamp.slice(8, 10)}:${stamp.slice(10, 12)}:${stamp.slice(12, 14)}${fraction === void 0 ? "" : `.${fraction.slice(0, 3)}`}${offset ?? "Z"}`;
9046
+ const parsed = Date.parse(iso);
9047
+ if (!Number.isNaN(parsed)) return parsed;
9048
+ }
9049
+ const parsed = Date.parse(trimmed);
9050
+ return Number.isNaN(parsed) ? void 0 : parsed;
9051
+ } catch {
9052
+ return;
9053
+ }
9054
+ };
9055
+ /**
9056
+ * Read a process's start fingerprint (epoch ms) on the current platform;
9057
+ * undefined when the platform cannot report it.
9058
+ */
9059
+ const readProcessStartEpochMs = async (pid, platform = process.platform) => {
9060
+ if (platform === "linux") {
9061
+ const [ticks, boot] = await Promise.all([linuxStartTicks(pid), bootEpochMs()]);
9062
+ if (ticks !== void 0 && boot !== void 0) return boot + Math.floor(ticks / 100 * 1e3);
9063
+ return;
9064
+ }
9065
+ if (platform === "darwin") return macosStartEpochMs(pid);
9066
+ if (platform === "win32") return windowsStartEpochMs(pid);
9067
+ };
9068
+ /**
9069
+ * Inspect a recorded PID against its recorded start fingerprint.
9070
+ *
9071
+ * - `dead`: the process does not exist (stale record).
9072
+ * - `reused`: a different process now owns the PID (start mismatch).
9073
+ * - `live`: alive and (when the platform reports start times) the same launch.
9074
+ * - `unverified`: alive but identity cannot be verified on this platform.
9075
+ */
9076
+ const inspectProcess = async (pid, expectedStartedAt, platform = process.platform) => {
9077
+ if (!isPidAlive$1(pid)) return "dead";
9078
+ if (expectedStartedAt === null) return "unverified";
9079
+ const toleranceMs = 2e3;
9080
+ const observed = await readProcessStartEpochMs(pid, platform);
9081
+ if (observed === void 0) return "unverified";
9082
+ return Math.abs(observed - expectedStartedAt) <= toleranceMs ? "live" : "reused";
9083
+ };
9084
+ /** Terminate a verified process tree (never called for unverified PIDs). */
9085
+ const killProcessTree = async (pid, platform = process.platform) => {
9086
+ try {
9087
+ if (platform === "win32") await execFileAsync$1("taskkill", [
9088
+ "/PID",
9089
+ String(pid),
9090
+ "/T",
9091
+ "/F"
9092
+ ], {
9093
+ timeout: 15e3,
9094
+ windowsHide: true
9095
+ });
9096
+ else {
9097
+ const { stdout } = await execFileAsync$1("pgrep", ["-P", String(pid)], { timeout: 5e3 }).catch(() => ({ stdout: "" }));
9098
+ const children = stdout.split("\n").map((line) => Number.parseInt(line.trim(), 10)).filter((value) => Number.isInteger(value) && value > 0);
9099
+ process.kill(pid, "SIGTERM");
9100
+ for (const child of children) try {
9101
+ process.kill(child, "SIGTERM");
9102
+ } catch {}
9103
+ await new Promise((resolve) => setTimeout(resolve, 300));
9104
+ for (const target of [pid, ...children]) if (isPidAlive$1(target)) try {
9105
+ process.kill(target, "SIGKILL");
9106
+ } catch {}
9107
+ }
9108
+ return ok({ pid });
9109
+ } catch (error) {
9110
+ return err("process_unverified", `failed to terminate process tree for pid ${pid}: ${error instanceof Error ? error.message : String(error)}`, { pid });
9111
+ }
9112
+ };
9113
+ //#endregion
8834
9114
  //#region packages/core/src/user-messages.ts
8835
9115
  const UI_LOCALES = [
8836
9116
  "zh-CN",
@@ -8957,6 +9237,7 @@ const userMessages = (locale) => CATALOGS[locale];
8957
9237
  const ptyUnavailableMessage = (code, locale) => code === "pty_bun_terminal_missing" ? CATALOGS[locale].ptyBunTerminalMissing : CATALOGS[locale].ptyNodePtyMissing;
8958
9238
  //#endregion
8959
9239
  //#region packages/core/src/open-app.ts
9240
+ const execFileAsync = promisify(execFile);
8960
9241
  /** The generated entry embeds a native PTY: it needs Node, never a Bun host. */
8961
9242
  const nodeExecutable = () => {
8962
9243
  if (process.versions.bun === void 0 && process.execPath.includes("node")) return process.execPath;
@@ -8997,14 +9278,100 @@ const spawnEntryCold = (projectDir) => {
8997
9278
  detail: `launched app entry (pid ${child.pid})`
8998
9279
  };
8999
9280
  };
9281
+ const isPidAlive = (pid) => {
9282
+ try {
9283
+ process.kill(pid, 0);
9284
+ return true;
9285
+ } catch (error) {
9286
+ return error.code === "EPERM";
9287
+ }
9288
+ };
9289
+ /**
9290
+ * Live entry pids for one payload by argv identity: the entry is always
9291
+ * `node <projectDir>/main.mjs` (the carrier's opentray-launch.json vector and
9292
+ * the cold-start spawn both use the absolute script path), so matching the
9293
+ * script path is exact identity — never a name-based guess. POSIX uses
9294
+ * `pgrep -f`; Windows a bounded CIM CommandLine query (best effort — a probe
9295
+ * failure reports no live instances instead of guessing).
9296
+ */
9297
+ const defaultFindEntryPids = async (projectDir, platform) => {
9298
+ const scriptPath = join(projectDir, "main.mjs");
9299
+ const own = process.pid;
9300
+ if (platform === "win32") try {
9301
+ const { stdout } = await execFileAsync("powershell.exe", [
9302
+ "-NoProfile",
9303
+ "-NonInteractive",
9304
+ "-Command",
9305
+ `Get-CimInstance Win32_Process | Where-Object { $_.CommandLine -like '*${scriptPath.replaceAll("'", "''")}*' } | Select-Object -ExpandProperty ProcessId`
9306
+ ], {
9307
+ timeout: 1e4,
9308
+ windowsHide: true
9309
+ });
9310
+ return stdout.split(/\r?\n/u).map((line) => Number.parseInt(line.trim(), 10)).filter((pid) => Number.isInteger(pid) && pid > 0 && pid !== own);
9311
+ } catch {
9312
+ return [];
9313
+ }
9314
+ try {
9315
+ const { stdout } = await execFileAsync("pgrep", ["-f", scriptPath], { timeout: 5e3 });
9316
+ return stdout.split("\n").map((line) => Number.parseInt(line.trim(), 10)).filter((pid) => Number.isInteger(pid) && pid > 0 && pid !== own);
9317
+ } catch {
9318
+ return [];
9319
+ }
9320
+ };
9321
+ /** Bounded wait for every stopped pid to actually exit (100ms cadence). */
9322
+ const waitForExit = async (pids, budgetMs) => {
9323
+ const deadline = Date.now() + budgetMs;
9324
+ for (;;) {
9325
+ if (!pids.some(isPidAlive)) return true;
9326
+ if (Date.now() >= deadline) return false;
9327
+ await new Promise((resolve) => {
9328
+ setTimeout(resolve, 100);
9329
+ });
9330
+ }
9331
+ };
9332
+ /**
9333
+ * Replace-semantics stop for one payload's live entry instances (Dev Launch
9334
+ * Law): probe by argv identity, terminate each tree, and wait bounded for the
9335
+ * pids (and behind them the broker session) to release before a new start may
9336
+ * claim the endpoint. A probe that finds nothing is a no-op.
9337
+ */
9338
+ const stopLiveAppInstances = async (projectDir, options = {}) => {
9339
+ const platform = options.platform ?? process.platform;
9340
+ const findPids = options.probe?.findPids ?? defaultFindEntryPids;
9341
+ const killTree = options.probe?.killTree ?? ((pid) => killProcessTree(pid, platform));
9342
+ const pids = [...new Set(await findPids(projectDir, platform))].filter(isPidAlive);
9343
+ if (pids.length === 0) return {
9344
+ stoppedPids: [],
9345
+ hung: false
9346
+ };
9347
+ for (const pid of pids) await killTree(pid);
9348
+ if (!await waitForExit(pids, options.waitBudgetMs ?? 5e3)) return {
9349
+ stoppedPids: pids,
9350
+ hung: true
9351
+ };
9352
+ await new Promise((resolve) => {
9353
+ setTimeout(resolve, 300);
9354
+ });
9355
+ return {
9356
+ stoppedPids: pids,
9357
+ hung: false
9358
+ };
9359
+ };
9000
9360
  const openMaterializedApp = async (input) => {
9001
- if ((input.platform ?? process.platform) === "darwin") {
9361
+ const platform = input.platform ?? process.platform;
9362
+ const replaced = await stopLiveAppInstances(input.projectDir, {
9363
+ platform,
9364
+ probe: input.probe,
9365
+ waitBudgetMs: input.stopWaitBudgetMs
9366
+ });
9367
+ const replacedNote = replaced.stoppedPids.length === 0 ? "" : `replaced live instance (pid ${replaced.stoppedPids.join(", ")})${replaced.hung ? " — WARNING: pid still alive after stop" : ""}; `;
9368
+ if (platform === "darwin") {
9002
9369
  const bundlePath = input.bundlePath ?? await expectedBundleForProject(input.projectDir);
9003
- if (bundlePath === void 0) return spawnEntryCold(input.projectDir);
9370
+ if (bundlePath === void 0) return withNote(spawnEntryCold(input.projectDir), replacedNote);
9004
9371
  try {
9005
9372
  await stat(bundlePath);
9006
9373
  } catch {
9007
- return spawnEntryCold(input.projectDir);
9374
+ return withNote(spawnEntryCold(input.projectDir), replacedNote);
9008
9375
  }
9009
9376
  const child = spawn("open", [bundlePath], {
9010
9377
  stdio: "ignore",
@@ -9016,14 +9383,18 @@ const openMaterializedApp = async (input) => {
9016
9383
  });
9017
9384
  if (status === 0) return {
9018
9385
  ok: true,
9019
- detail: `opened ${bundlePath}`
9386
+ detail: `${replacedNote}opened ${bundlePath}`
9020
9387
  };
9021
9388
  return {
9022
9389
  ok: false,
9023
- detail: `open ${bundlePath} failed with ${status ?? "spawn error"}`
9390
+ detail: `${replacedNote}open ${bundlePath} failed with ${status ?? "spawn error"}`
9024
9391
  };
9025
9392
  }
9026
- return spawnEntryCold(input.projectDir);
9393
+ return withNote(spawnEntryCold(input.projectDir), replacedNote);
9394
+ };
9395
+ const withNote = (result, note) => note === "" ? result : {
9396
+ ...result,
9397
+ detail: `${note}${result.detail}`
9027
9398
  };
9028
9399
  /**
9029
9400
  * Platform-truthful pinning hint; makes no persistence claim before first
@@ -9037,20 +9408,6 @@ const pinningHint = (platform = process.platform, locale = "zh-CN") => {
9037
9408
  return messages.pinHintOther;
9038
9409
  };
9039
9410
  //#endregion
9040
- //#region packages/core/src/errors.ts
9041
- const ok = (value) => ({
9042
- ok: true,
9043
- value
9044
- });
9045
- const err = (code, message, details) => ({
9046
- ok: false,
9047
- error: {
9048
- code,
9049
- message,
9050
- ...details === void 0 ? {} : { details }
9051
- }
9052
- });
9053
- //#endregion
9054
9411
  //#region ../../node_modules/.pnpm/zod@4.4.3/node_modules/zod/v4/core/core.js
9055
9412
  var _a$1;
9056
9413
  function $constructor(name, initializer, params) {
@@ -13602,168 +13959,6 @@ const listRegistrations = async (homeDir) => {
13602
13959
  return records;
13603
13960
  };
13604
13961
  //#endregion
13605
- //#region packages/core/src/runtime-record.ts
13606
- const execFileAsync = promisify(execFile);
13607
- const RUNTIME_FILENAME = "runtime.json";
13608
- const readRuntimeRecord = async (registrationDir) => {
13609
- try {
13610
- const raw = JSON.parse(await readFile(join(registrationDir, RUNTIME_FILENAME), "utf8"));
13611
- if (typeof raw === "object" && raw !== null && typeof raw.pid === "number" && typeof raw.token === "string") {
13612
- const record = raw;
13613
- return {
13614
- pid: record.pid,
13615
- token: record.token,
13616
- startedAt: typeof record.startedAt === "number" ? record.startedAt : null,
13617
- launchedAt: typeof record.launchedAt === "number" ? record.launchedAt : Date.now()
13618
- };
13619
- }
13620
- return;
13621
- } catch {
13622
- return;
13623
- }
13624
- };
13625
- const clearRuntimeRecord = async (registrationDir) => {
13626
- await rm(join(registrationDir, RUNTIME_FILENAME), { force: true });
13627
- };
13628
- const isPidAlive = (pid) => {
13629
- try {
13630
- process.kill(pid, 0);
13631
- return true;
13632
- } catch (error) {
13633
- return error.code === "EPERM";
13634
- }
13635
- };
13636
- /** Linux: /proc/<pid>/stat field 22 (starttime in clock ticks since boot). */
13637
- const linuxStartTicks = async (pid) => {
13638
- try {
13639
- const stat = await readFile(`/proc/${pid}/stat`, "utf8");
13640
- const close = stat.lastIndexOf(")");
13641
- if (close < 0) return void 0;
13642
- const fields = stat.slice(close + 2).split(" ");
13643
- const ticks = Number.parseInt(fields[19] ?? "", 10);
13644
- return Number.isInteger(ticks) ? ticks : void 0;
13645
- } catch {
13646
- return;
13647
- }
13648
- };
13649
- const bootEpochMs = async () => {
13650
- try {
13651
- const stat = await readFile("/proc/stat", "utf8");
13652
- const btime = /btime (\d+)/u.exec(stat);
13653
- return btime === null ? void 0 : Number.parseInt(btime[1], 10) * 1e3;
13654
- } catch {
13655
- return;
13656
- }
13657
- };
13658
- /** macOS: `ps -o lstart= -p <pid>` prints the absolute start timestamp. */
13659
- const macosStartEpochMs = async (pid) => {
13660
- try {
13661
- const { stdout } = await execFileAsync("ps", [
13662
- "-o",
13663
- "lstart=",
13664
- "-p",
13665
- String(pid)
13666
- ], {
13667
- timeout: 5e3,
13668
- env: {
13669
- ...process.env,
13670
- LC_ALL: "C",
13671
- LANG: "C"
13672
- }
13673
- });
13674
- const parsed = Date.parse(stdout.trim());
13675
- return Number.isNaN(parsed) ? void 0 : parsed;
13676
- } catch {
13677
- return;
13678
- }
13679
- };
13680
- /** Windows: PowerShell Get-Process StartTime for the pid. */
13681
- const windowsStartEpochMs = async (pid) => {
13682
- try {
13683
- const { stdout } = await execFileAsync("powershell.exe", [
13684
- "-NoProfile",
13685
- "-NonInteractive",
13686
- "-Command",
13687
- `(Get-CimInstance Win32_Process -Filter "ProcessId=${pid}").CreationDate`
13688
- ], {
13689
- timeout: 1e4,
13690
- windowsHide: true
13691
- });
13692
- const trimmed = stdout.trim();
13693
- const dmtf = /^(\d{14})(?:\.(\d+))?(?:([+-]\d+))?$/u.exec(trimmed);
13694
- if (dmtf !== null) {
13695
- const stamp = dmtf[1] ?? "";
13696
- const fraction = dmtf[2];
13697
- const offset = dmtf[3];
13698
- const iso = `${stamp.slice(0, 4)}-${stamp.slice(4, 6)}-${stamp.slice(6, 8)}T${stamp.slice(8, 10)}:${stamp.slice(10, 12)}:${stamp.slice(12, 14)}${fraction === void 0 ? "" : `.${fraction.slice(0, 3)}`}${offset ?? "Z"}`;
13699
- const parsed = Date.parse(iso);
13700
- if (!Number.isNaN(parsed)) return parsed;
13701
- }
13702
- const parsed = Date.parse(trimmed);
13703
- return Number.isNaN(parsed) ? void 0 : parsed;
13704
- } catch {
13705
- return;
13706
- }
13707
- };
13708
- /**
13709
- * Read a process's start fingerprint (epoch ms) on the current platform;
13710
- * undefined when the platform cannot report it.
13711
- */
13712
- const readProcessStartEpochMs = async (pid, platform = process.platform) => {
13713
- if (platform === "linux") {
13714
- const [ticks, boot] = await Promise.all([linuxStartTicks(pid), bootEpochMs()]);
13715
- if (ticks !== void 0 && boot !== void 0) return boot + Math.floor(ticks / 100 * 1e3);
13716
- return;
13717
- }
13718
- if (platform === "darwin") return macosStartEpochMs(pid);
13719
- if (platform === "win32") return windowsStartEpochMs(pid);
13720
- };
13721
- /**
13722
- * Inspect a recorded PID against its recorded start fingerprint.
13723
- *
13724
- * - `dead`: the process does not exist (stale record).
13725
- * - `reused`: a different process now owns the PID (start mismatch).
13726
- * - `live`: alive and (when the platform reports start times) the same launch.
13727
- * - `unverified`: alive but identity cannot be verified on this platform.
13728
- */
13729
- const inspectProcess = async (pid, expectedStartedAt, platform = process.platform) => {
13730
- if (!isPidAlive(pid)) return "dead";
13731
- if (expectedStartedAt === null) return "unverified";
13732
- const toleranceMs = 2e3;
13733
- const observed = await readProcessStartEpochMs(pid, platform);
13734
- if (observed === void 0) return "unverified";
13735
- return Math.abs(observed - expectedStartedAt) <= toleranceMs ? "live" : "reused";
13736
- };
13737
- /** Terminate a verified process tree (never called for unverified PIDs). */
13738
- const killProcessTree = async (pid, platform = process.platform) => {
13739
- try {
13740
- if (platform === "win32") await execFileAsync("taskkill", [
13741
- "/PID",
13742
- String(pid),
13743
- "/T",
13744
- "/F"
13745
- ], {
13746
- timeout: 15e3,
13747
- windowsHide: true
13748
- });
13749
- else {
13750
- const { stdout } = await execFileAsync("pgrep", ["-P", String(pid)], { timeout: 5e3 }).catch(() => ({ stdout: "" }));
13751
- const children = stdout.split("\n").map((line) => Number.parseInt(line.trim(), 10)).filter((value) => Number.isInteger(value) && value > 0);
13752
- process.kill(pid, "SIGTERM");
13753
- for (const child of children) try {
13754
- process.kill(child, "SIGTERM");
13755
- } catch {}
13756
- await new Promise((resolve) => setTimeout(resolve, 300));
13757
- for (const target of [pid, ...children]) if (isPidAlive(target)) try {
13758
- process.kill(target, "SIGKILL");
13759
- } catch {}
13760
- }
13761
- return ok({ pid });
13762
- } catch (error) {
13763
- return err("process_unverified", `failed to terminate process tree for pid ${pid}: ${error instanceof Error ? error.message : String(error)}`, { pid });
13764
- }
13765
- };
13766
- //#endregion
13767
13962
  //#region packages/core/src/scan.ts
13768
13963
  const exists$1 = async (path) => {
13769
13964
  try {
@@ -18421,4 +18616,4 @@ if (isMainModule()) main(process.argv.slice(2)).then((code) => {
18421
18616
  //#endregion
18422
18617
  export { resolveLaunchVector as A, parseLsofPorts as C, verifyHttpService as D, serviceUrl as E, isValidAppId as F, toProjectDirectoryName as I, tokenizeCommandLine as M, deriveDefaultAppId as N, waitForTcpPort as O, deriveDefaultAppName as P, createPortDiscovery as S, parsePowerShellPorts as T, extractTitle as _, createWizardServer as a, resolveFaviconUrl as b, createWizardSession as c, detectPackageManager as d, expectedDarwinBundlePath as f, extractFaviconCandidates as g, writeScaffold as h, parseWizardCli as i, resolveOnPath as j, parseShebangInterpreter as k, openMaterializedApp as l, materialize as m, normalizeDraftForm as n, isAuthorized as o, isDirectoryOccupied$1 as p, openBrowser as r, isLoopbackHost as s, main as t, pinningHint as u, faviconCandidateSize as v, parseNetstatPorts as w, scrapeService as x, rankFaviconCandidates as y };
18423
18618
 
18424
- //# sourceMappingURL=bin-2M6ud0DS.mjs.map
18619
+ //# sourceMappingURL=bin-DgadYsE1.mjs.map