create-opentray 0.27.4 → 0.28.0
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.
|
@@ -7587,7 +7587,9 @@ const TOOLBAR_CHANNEL_REBUILD_DEBOUNCE_MS = 300;
|
|
|
7587
7587
|
* The generated-entry helper source. The embedding template supplies
|
|
7588
7588
|
* `column`/`fixed`/`grow` (imported from "@opentray/ext-webview" beside
|
|
7589
7589
|
* `WebviewExt`) — the carrier deliberately performs no imports of its own so
|
|
7590
|
-
* both templates stay single-import consumers of the facade.
|
|
7590
|
+
* both templates stay single-import consumers of the facade. The template
|
|
7591
|
+
* also injects `options.log` (runtime notes) and `options.event` (structured
|
|
7592
|
+
* bootstrap-milestone sink into app.log, harden-lifecycle-ownership D5).
|
|
7591
7593
|
*/
|
|
7592
7594
|
const toolbarCarrierSource = () => `// Toolbar navigation carrier (add-webview-orchestration plan D12/D13):
|
|
7593
7595
|
// composes the native navigation toolbar over one content webview and drives
|
|
@@ -7597,18 +7599,42 @@ const toolbarCarrierSource = () => `// Toolbar navigation carrier (add-webview-o
|
|
|
7597
7599
|
const TOOLBAR_CHANNEL_REBUILD_DEBOUNCE_MS = ${TOOLBAR_CHANNEL_REBUILD_DEBOUNCE_MS};
|
|
7598
7600
|
|
|
7599
7601
|
const attachToolbarCarrier = async (shell, options) => {
|
|
7602
|
+
// Bootstrap milestones (harden-lifecycle-ownership D5): one structured
|
|
7603
|
+
// record per carrier step through the embedding template's event sink
|
|
7604
|
+
// (app.log). Failure records are awaited so the failed step is durable
|
|
7605
|
+
// before the error aborts the bootstrap; ok records are best-effort but
|
|
7606
|
+
// still ordered — the sink is the template's serial append queue (Codex R2
|
|
7607
|
+
// P1), whose submission is synchronous, so a void call enters the same
|
|
7608
|
+
// happens-before chain and lands after every earlier record. Keep the ok
|
|
7609
|
+
// path void: the carrier's awaited per-step bootstrap structure is
|
|
7610
|
+
// unchanged and the queue owns the ordering.
|
|
7611
|
+
const event = typeof options.event === "function" ? options.event : () => {};
|
|
7612
|
+
const milestone = async (step, run) => {
|
|
7613
|
+
try {
|
|
7614
|
+
const value = await run();
|
|
7615
|
+
void event({ step, status: "ok" });
|
|
7616
|
+
return value;
|
|
7617
|
+
} catch (error) {
|
|
7618
|
+
await event({
|
|
7619
|
+
step,
|
|
7620
|
+
status: "failed",
|
|
7621
|
+
error: error instanceof Error ? error.message : String(error),
|
|
7622
|
+
});
|
|
7623
|
+
throw error;
|
|
7624
|
+
}
|
|
7625
|
+
};
|
|
7600
7626
|
// Per-child bridge policy (D2): the toolbar page is trusted shell UI and
|
|
7601
7627
|
// gets exactly the channel surface; the content webview gets NO policy, so
|
|
7602
7628
|
// it is bridgeless regardless of what it loads.
|
|
7603
|
-
await shell.createWebview({
|
|
7629
|
+
await milestone("createWebviewToolbar", () => shell.createWebview({
|
|
7604
7630
|
id: "toolbar",
|
|
7605
7631
|
url: options.toolbarUrl,
|
|
7606
7632
|
bridge: { webviewId: true, messageChannels: true },
|
|
7607
|
-
});
|
|
7608
|
-
const content = await shell.createWebview({ id: "content", url: options.contentUrl });
|
|
7633
|
+
}));
|
|
7634
|
+
const content = await milestone("createWebviewContent", () => shell.createWebview({ id: "content", url: options.contentUrl }));
|
|
7609
7635
|
// Declarative layered layout (D8 sugar): toolbar pinned at a fixed 44px
|
|
7610
7636
|
// height, content filling the remainder; resize is recomputed natively.
|
|
7611
|
-
await shell.setLayout(column([fixed("toolbar", 44), grow("content")]));
|
|
7637
|
+
await milestone("setLayout", () => shell.setLayout(column([fixed("toolbar", 44), grow("content")])));
|
|
7612
7638
|
const note = (error) => {
|
|
7613
7639
|
const message = error instanceof Error ? error.message : String(error);
|
|
7614
7640
|
void options.log("toolbar carrier: " + message);
|
|
@@ -7736,7 +7762,9 @@ const attachToolbarCarrier = async (shell, options) => {
|
|
|
7736
7762
|
}, TOOLBAR_CHANNEL_REBUILD_DEBOUNCE_MS);
|
|
7737
7763
|
};
|
|
7738
7764
|
|
|
7739
|
-
|
|
7765
|
+
// The bootstrap channel gets the milestone record; self-heal rebuilds above
|
|
7766
|
+
// stay unrecorded (P1-3 steady-state, not bootstrap).
|
|
7767
|
+
await milestone("openChannel", openChannel);
|
|
7740
7768
|
return {
|
|
7741
7769
|
content,
|
|
7742
7770
|
stop: () => {
|
|
@@ -7750,6 +7778,197 @@ const attachToolbarCarrier = async (shell, options) => {
|
|
|
7750
7778
|
};
|
|
7751
7779
|
`;
|
|
7752
7780
|
//#endregion
|
|
7781
|
+
//#region packages/core/src/log-queue.ts
|
|
7782
|
+
/** The generated-entry app.log append queues. The milestone chain replaces
|
|
7783
|
+
* the old fire-and-forget `appendFile.bind(undefined, appLogPath)` logSink:
|
|
7784
|
+
* submission order becomes disk order and exits can await the drain. The
|
|
7785
|
+
* bounded output channel keeps a supervised command's stream from costing
|
|
7786
|
+
* unbounded memory or exit latency — lossy-by-design, with every loss
|
|
7787
|
+
* counted and reported (Codex R4). */
|
|
7788
|
+
const logQueueSource = () => `// App.log append queues (Codex R2 P1 + R3 + R4, 2026-09-15): appendFile
|
|
7789
|
+
// completions have no happens-before, so milestone records must never race,
|
|
7790
|
+
// and child output must never share that chain — a noisy child would grow an
|
|
7791
|
+
// unbounded happens-before list, drag later milestones, and make the exit
|
|
7792
|
+
// flush unbounded. Milestones keep one strictly serial chain; output records
|
|
7793
|
+
// flow through their own chain, coalesced and byte-capped.
|
|
7794
|
+
const appendLogLine = process.env.OPENTRAY_TEST_LOG_JITTER === "1"
|
|
7795
|
+
? async (text) => {
|
|
7796
|
+
// Test seam only: random 0-30 ms per-append delay.
|
|
7797
|
+
await new Promise((resolveDelay) => { setTimeout(resolveDelay, Math.random() * 30); });
|
|
7798
|
+
await appendFile(appLogPath, text, "utf8");
|
|
7799
|
+
}
|
|
7800
|
+
: (text) => appendFile(appLogPath, text, "utf8");
|
|
7801
|
+
let logQueueTail = Promise.resolve();
|
|
7802
|
+
const logSink = (text) => {
|
|
7803
|
+
const write = logQueueTail.then(() => appendLogLine(text));
|
|
7804
|
+
// A failed append must not kill the chain; the write itself still rejects
|
|
7805
|
+
// for callers that await durability (the tail catch also keeps void-ed
|
|
7806
|
+
// submissions from becoming unhandled rejections).
|
|
7807
|
+
logQueueTail = write.catch(() => {});
|
|
7808
|
+
return write;
|
|
7809
|
+
};
|
|
7810
|
+
|
|
7811
|
+
// Bounded child-output channel (Codex R3, hardened R4). Two chains coexist:
|
|
7812
|
+
// milestones stay strictly ordered among themselves, output records among
|
|
7813
|
+
// themselves; cross-channel disk order is unspecified (each appendFile is one
|
|
7814
|
+
// O_APPEND write, so records interleave at line boundaries but never corrupt).
|
|
7815
|
+
//
|
|
7816
|
+
// LOSS CONTRACT (Codex R4): child output in app.log is lossy-by-design
|
|
7817
|
+
// diagnostic data; every loss is counted and reported. Losses happen in
|
|
7818
|
+
// exactly three counted ways: (1) whole-chunk drops at admission or commit
|
|
7819
|
+
// trim that hold the cap, (2) an output append that rejects or exceeds
|
|
7820
|
+
// OUTPUT_APPEND_TIMEOUT_MS — its raw bytes re-enter the pending count, and a
|
|
7821
|
+
// late success after a timeout is conservative over-counting, never
|
|
7822
|
+
// under-counting, and (3) chunks absorbed after beginOutputShutdown() that no
|
|
7823
|
+
// bounded flush round managed to carry. Every counted loss resurfaces as one
|
|
7824
|
+
// readable "[log-queue] dropped N output bytes" line on the next carrier that
|
|
7825
|
+
// lands.
|
|
7826
|
+
const OUTPUT_COMMIT_BYTES = 8192;
|
|
7827
|
+
const OUTPUT_COMMIT_IDLE_MS = 50;
|
|
7828
|
+
const OUTPUT_CAP_BYTES = 262144;
|
|
7829
|
+
// Output-append wall-clock bound: a hung appendFile cannot hang the exit
|
|
7830
|
+
// flush. OPENTRAY_TEST_LOG_APPEND_TIMEOUT_MS is the test seam that shrinks it.
|
|
7831
|
+
const OUTPUT_APPEND_TIMEOUT_MS = Number(process.env.OPENTRAY_TEST_LOG_APPEND_TIMEOUT_MS) > 0
|
|
7832
|
+
? Number(process.env.OPENTRAY_TEST_LOG_APPEND_TIMEOUT_MS)
|
|
7833
|
+
: 5000;
|
|
7834
|
+
const FLUSH_MAX_DRAIN_ROUNDS = 8;
|
|
7835
|
+
const outputChunks = [];
|
|
7836
|
+
let outputBufferedBytes = 0;
|
|
7837
|
+
let outputInFlightBytes = 0;
|
|
7838
|
+
// Drop accounting (Codex R4 P1): pending = counted losses no carrier has
|
|
7839
|
+
// taken yet; the in-flight figure = losses a submitted carrier is reporting
|
|
7840
|
+
// right now. A commit MOVES pending into its carrier's reservation, and only
|
|
7841
|
+
// that carrier's own completion retires or returns it, so concurrent commits
|
|
7842
|
+
// can never double-carry an episode or drive the accounting negative (the
|
|
7843
|
+
// old single global counter subtracted stale snapshots and suppressed later
|
|
7844
|
+
// markers).
|
|
7845
|
+
let outputDroppedBytesPending = 0;
|
|
7846
|
+
let outputInFlightDropBytes = 0;
|
|
7847
|
+
let outputShutdown = false;
|
|
7848
|
+
let outputTimer = null;
|
|
7849
|
+
let outputTail = Promise.resolve();
|
|
7850
|
+
// One output append, wall-clock bounded. Resolves true when landed, false
|
|
7851
|
+
// when rejected or timed out (the bytes then count as lost).
|
|
7852
|
+
const appendOutputBounded = (payload) => new Promise((resolveWrite) => {
|
|
7853
|
+
let settled = false;
|
|
7854
|
+
const timer = setTimeout(() => {
|
|
7855
|
+
if (settled) return;
|
|
7856
|
+
settled = true;
|
|
7857
|
+
resolveWrite(false);
|
|
7858
|
+
}, OUTPUT_APPEND_TIMEOUT_MS);
|
|
7859
|
+
appendLogLine(payload).then(
|
|
7860
|
+
() => { if (!settled) { settled = true; clearTimeout(timer); resolveWrite(true); } },
|
|
7861
|
+
() => { if (!settled) { settled = true; clearTimeout(timer); resolveWrite(false); } },
|
|
7862
|
+
);
|
|
7863
|
+
});
|
|
7864
|
+
const commitOutputBuffer = () => {
|
|
7865
|
+
if (outputTimer !== null) { clearTimeout(outputTimer); outputTimer = null; }
|
|
7866
|
+
if (outputChunks.length === 0 && outputDroppedBytesPending === 0) return;
|
|
7867
|
+
const carriedDrops = outputDroppedBytesPending;
|
|
7868
|
+
outputDroppedBytesPending = 0;
|
|
7869
|
+
outputInFlightDropBytes += carriedDrops;
|
|
7870
|
+
const marker = carriedDrops > 0
|
|
7871
|
+
? "[log-queue] dropped " + carriedDrops + " output bytes\\n"
|
|
7872
|
+
: "";
|
|
7873
|
+
// Marker bytes count inside the cap (Codex R4 P2): the whole submitted
|
|
7874
|
+
// payload — marker included — must fit the budget not yet taken by
|
|
7875
|
+
// in-flight appends. Whole oldest chunks that do not fit drop now and join
|
|
7876
|
+
// the NEXT carrier's count (one chunk is the drop granularity everywhere).
|
|
7877
|
+
if (Buffer.byteLength(marker) > OUTPUT_CAP_BYTES - outputInFlightBytes) {
|
|
7878
|
+
// Even the marker alone does not fit (in-flight holds the full cap):
|
|
7879
|
+
// return the episode to pending and keep the chunks buffered; the flush
|
|
7880
|
+
// drain loop retries once the held appends land.
|
|
7881
|
+
outputDroppedBytesPending += carriedDrops;
|
|
7882
|
+
outputInFlightDropBytes -= carriedDrops;
|
|
7883
|
+
if (outputBufferedBytes > 0) { outputTimer = setTimeout(commitOutputBuffer, OUTPUT_COMMIT_IDLE_MS); }
|
|
7884
|
+
return;
|
|
7885
|
+
}
|
|
7886
|
+
while (outputChunks.length > 0
|
|
7887
|
+
&& Buffer.byteLength(marker) + outputBufferedBytes > OUTPUT_CAP_BYTES - outputInFlightBytes) {
|
|
7888
|
+
const oldest = outputChunks.shift();
|
|
7889
|
+
const size = Buffer.byteLength(oldest);
|
|
7890
|
+
outputBufferedBytes -= size;
|
|
7891
|
+
outputDroppedBytesPending += size;
|
|
7892
|
+
}
|
|
7893
|
+
const text = outputChunks.join("");
|
|
7894
|
+
outputChunks.length = 0;
|
|
7895
|
+
outputBufferedBytes = 0;
|
|
7896
|
+
const rawBytes = Buffer.byteLength(text);
|
|
7897
|
+
const payloadBytes = Buffer.byteLength(marker) + rawBytes;
|
|
7898
|
+
outputInFlightBytes += payloadBytes;
|
|
7899
|
+
const write = outputTail.then(() => appendOutputBounded(marker + text));
|
|
7900
|
+
write.then((landed) => {
|
|
7901
|
+
outputInFlightBytes -= payloadBytes;
|
|
7902
|
+
if (landed) {
|
|
7903
|
+
// The drop record is durable only now that its carrier append landed.
|
|
7904
|
+
outputInFlightDropBytes -= carriedDrops;
|
|
7905
|
+
} else {
|
|
7906
|
+
// Lossy-by-design (Codex R4): a rejected or timed-out carrier returns
|
|
7907
|
+
// its episode to pending and counts its own raw bytes as dropped, so
|
|
7908
|
+
// the next successful carrier re-reports the full total.
|
|
7909
|
+
outputInFlightDropBytes -= carriedDrops;
|
|
7910
|
+
outputDroppedBytesPending += carriedDrops + rawBytes;
|
|
7911
|
+
}
|
|
7912
|
+
});
|
|
7913
|
+
outputTail = write.then(() => {});
|
|
7914
|
+
};
|
|
7915
|
+
const beginOutputShutdown = () => { outputShutdown = true; };
|
|
7916
|
+
const logOutputChunk = (text) => {
|
|
7917
|
+
const size = Buffer.byteLength(text);
|
|
7918
|
+
if (outputShutdown) {
|
|
7919
|
+
// Quiescence barrier (Codex R4): after shutdown begins, chunks never
|
|
7920
|
+
// enter the chain and never schedule commits — their bytes join the
|
|
7921
|
+
// count the final flush marker carries.
|
|
7922
|
+
outputDroppedBytesPending += size;
|
|
7923
|
+
return;
|
|
7924
|
+
}
|
|
7925
|
+
outputChunks.push(text);
|
|
7926
|
+
outputBufferedBytes += size;
|
|
7927
|
+
// Hard cap BEFORE the coalescing thresholds: over-cap data must drop, not
|
|
7928
|
+
// graduate into an in-flight append that only the disk can drain. One
|
|
7929
|
+
// whole chunk is the granularity: a single chunk larger than the cap drops
|
|
7930
|
+
// whole (its byte count lands in the marker), and the transient memory
|
|
7931
|
+
// bound is cap + largest single chunk.
|
|
7932
|
+
while (outputBufferedBytes + outputInFlightBytes > OUTPUT_CAP_BYTES && outputChunks.length > 0) {
|
|
7933
|
+
const oldest = outputChunks.shift();
|
|
7934
|
+
const droppedSize = Buffer.byteLength(oldest);
|
|
7935
|
+
outputBufferedBytes -= droppedSize;
|
|
7936
|
+
outputDroppedBytesPending += droppedSize;
|
|
7937
|
+
}
|
|
7938
|
+
if (outputTimer !== null) { clearTimeout(outputTimer); outputTimer = null; }
|
|
7939
|
+
if (outputBufferedBytes >= OUTPUT_COMMIT_BYTES) {
|
|
7940
|
+
commitOutputBuffer();
|
|
7941
|
+
} else if (outputBufferedBytes > 0) {
|
|
7942
|
+
outputTimer = setTimeout(commitOutputBuffer, OUTPUT_COMMIT_IDLE_MS);
|
|
7943
|
+
}
|
|
7944
|
+
};
|
|
7945
|
+
const flushLogQueue = async () => {
|
|
7946
|
+
// Bounded drain (Codex R4): each round commits what fits and awaits BOTH
|
|
7947
|
+
// chain snapshots; if anything new was submitted — or drops are still
|
|
7948
|
+
// uncarried — the next round picks it up, for at most FLUSH_MAX_DRAIN_ROUNDS
|
|
7949
|
+
// rounds. Flush is a barrier with a budget: it always resolves, even over a
|
|
7950
|
+
// rejecting or hung appendFile (each output append is wall-clock bounded
|
|
7951
|
+
// and its loss is counted). Milestone awaits stay bounded by appendFile
|
|
7952
|
+
// itself, which remains the durability contract for milestone records.
|
|
7953
|
+
for (let round = 0; round < FLUSH_MAX_DRAIN_ROUNDS; round += 1) {
|
|
7954
|
+
const logBefore = logQueueTail;
|
|
7955
|
+
const outBefore = outputTail;
|
|
7956
|
+
commitOutputBuffer();
|
|
7957
|
+
await logQueueTail;
|
|
7958
|
+
await outputTail;
|
|
7959
|
+
if (logQueueTail === logBefore && outputTail === outBefore
|
|
7960
|
+
&& outputChunks.length === 0 && outputDroppedBytesPending === 0) {
|
|
7961
|
+
return;
|
|
7962
|
+
}
|
|
7963
|
+
}
|
|
7964
|
+
// Round budget exhausted while output was still arriving: give the final
|
|
7965
|
+
// counted total one last bounded carrier instead of dropping it silently.
|
|
7966
|
+
commitOutputBuffer();
|
|
7967
|
+
await logQueueTail;
|
|
7968
|
+
await outputTail;
|
|
7969
|
+
};
|
|
7970
|
+
`;
|
|
7971
|
+
//#endregion
|
|
7753
7972
|
//#region packages/core/src/entry-template.ts
|
|
7754
7973
|
/** The generated app entry: supervises the command, owns tray + window. */
|
|
7755
7974
|
const createEntrySource = (config) => {
|
|
@@ -7785,8 +8004,13 @@ const config = ${JSON.stringify(config, null, 2)};
|
|
|
7785
8004
|
|
|
7786
8005
|
const appLogPath = resolve(PROJECT_DIR, "app.log");
|
|
7787
8006
|
await mkdir(dirname(appLogPath), { recursive: true });
|
|
7788
|
-
|
|
8007
|
+
${logQueueSource()}
|
|
7789
8008
|
const logNote = (message) => { void logSink("[create-opentray] " + message + "\\n", "utf8"); };
|
|
8009
|
+
// Bootstrap milestones (harden-lifecycle-ownership D5): one structured JSON
|
|
8010
|
+
// record per startup step — the operator reads session health from app.log
|
|
8011
|
+
// alone, and a failed step names itself instead of needing archaeology.
|
|
8012
|
+
const errorText = (error) => error instanceof Error ? error.message : String(error);
|
|
8013
|
+
const logEvent = (record) => logSink(JSON.stringify({ time: new Date().toISOString(), event: "bootstrap", ...record }) + "\\n", "utf8");
|
|
7790
8014
|
const sleep = (ms) => new Promise((resolvePromise) => setTimeout(resolvePromise, ms));
|
|
7791
8015
|
|
|
7792
8016
|
const shellOptions = ${JSON.stringify(config.shell ?? null)};
|
|
@@ -7875,6 +8099,11 @@ const main = async () => {
|
|
|
7875
8099
|
// from the same host (its navigation surface stays channel-only, D12).
|
|
7876
8100
|
const shellApi = await import("./app-shell-server.mjs");
|
|
7877
8101
|
const shellPort = await shellApi.listenShell();
|
|
8102
|
+
if (shellPort === null) {
|
|
8103
|
+
await logEvent({ step: "listenShell", status: "failed" });
|
|
8104
|
+
} else {
|
|
8105
|
+
void logEvent({ step: "listenShell", status: "ok", port: shellPort });
|
|
8106
|
+
}
|
|
7878
8107
|
|
|
7879
8108
|
// Command supervision state shared by the exit note and teardown.
|
|
7880
8109
|
let commandExited = false;
|
|
@@ -7900,7 +8129,9 @@ const main = async () => {
|
|
|
7900
8129
|
});
|
|
7901
8130
|
shellApi.registerPty(pty);
|
|
7902
8131
|
pty.onData((chunk) => {
|
|
7903
|
-
|
|
8132
|
+
// Child output is bounded-channel data (Codex R3), never a milestone
|
|
8133
|
+
// record — a noisy command must not tax the serial chain.
|
|
8134
|
+
logOutputChunk(chunk);
|
|
7904
8135
|
shellApi.pushOutput(chunk);
|
|
7905
8136
|
});
|
|
7906
8137
|
const exited = new Promise((resolvePromise) => {
|
|
@@ -7923,8 +8154,8 @@ const main = async () => {
|
|
|
7923
8154
|
});
|
|
7924
8155
|
child.stdout.setEncoding("utf8");
|
|
7925
8156
|
child.stderr.setEncoding("utf8");
|
|
7926
|
-
child.stdout.on("data", (chunk) => {
|
|
7927
|
-
child.stderr.on("data", (chunk) => {
|
|
8157
|
+
child.stdout.on("data", (chunk) => { logOutputChunk(chunk); shellApi.pushOutput(chunk); });
|
|
8158
|
+
child.stderr.on("data", (chunk) => { logOutputChunk(chunk); shellApi.pushOutput(chunk); });
|
|
7928
8159
|
const exited = new Promise((resolvePromise) => {
|
|
7929
8160
|
child.once("exit", (code, signal) => resolvePromise({ code, signal }));
|
|
7930
8161
|
});
|
|
@@ -7944,7 +8175,7 @@ const main = async () => {
|
|
|
7944
8175
|
|
|
7945
8176
|
const tray = await (async () => {
|
|
7946
8177
|
try {
|
|
7947
|
-
|
|
8178
|
+
const created = await createTray({
|
|
7948
8179
|
id: config.appId,
|
|
7949
8180
|
tooltip: { title: config.appName, description: \`\${config.appName} (OpenTray)\` },
|
|
7950
8181
|
icon: Object.keys(trayIconCandidates).length > 0
|
|
@@ -7967,6 +8198,8 @@ const main = async () => {
|
|
|
7967
8198
|
cwd: PROJECT_DIR,
|
|
7968
8199
|
},
|
|
7969
8200
|
});
|
|
8201
|
+
void logEvent({ step: "createTray", status: "ok", appId: config.appId });
|
|
8202
|
+
return created;
|
|
7970
8203
|
} catch (error) {
|
|
7971
8204
|
// Carrier-resurrection yield: a Dock-pinned carrier can cold-start this
|
|
7972
8205
|
// entry while another instance already owns the broker session. The
|
|
@@ -7979,12 +8212,29 @@ const main = async () => {
|
|
|
7979
8212
|
if (message.includes("OPENTRAY_BROKER_SINGLE_SESSION")) {
|
|
7980
8213
|
await logSink("[create-opentray] broker session already owned by another instance of this app; this cold-started launcher exits (yield)\\n", "utf8");
|
|
7981
8214
|
try { command.killDirect(); } catch { /* already dead */ }
|
|
8215
|
+
await flushLogQueue();
|
|
7982
8216
|
process.exit(0);
|
|
7983
8217
|
}
|
|
7984
8218
|
throw error;
|
|
7985
8219
|
}
|
|
7986
8220
|
})();
|
|
7987
8221
|
|
|
8222
|
+
// harden-lifecycle-ownership D3: broker connection death is a terminal
|
|
8223
|
+
// state. The entry records the milestone and exits non-zero — the
|
|
8224
|
+
// supervised command dies with its supervisor instead of outliving a dead
|
|
8225
|
+
// backend. The quitting flag keeps the graceful quit path (whose transport
|
|
8226
|
+
// close also reaches this terminal state) on its exit(0) course.
|
|
8227
|
+
let quitting = false;
|
|
8228
|
+
tray.onConnectionDead?.((error) => {
|
|
8229
|
+
if (quitting) return;
|
|
8230
|
+
quitting = true;
|
|
8231
|
+
void (async () => {
|
|
8232
|
+
await logEvent({ step: "connectionDead", status: "failed", error: errorText(error) });
|
|
8233
|
+
await flushLogQueue();
|
|
8234
|
+
process.exit(1);
|
|
8235
|
+
})();
|
|
8236
|
+
});
|
|
8237
|
+
|
|
7988
8238
|
const baseTitle = config.appName;
|
|
7989
8239
|
// v1 developerMode maps ONLY to per-window DevTools admission. When false it
|
|
7990
8240
|
// must not request devtools at all — default windows are not inspectable.
|
|
@@ -7997,11 +8247,15 @@ const main = async () => {
|
|
|
7997
8247
|
// window = its own window session), so dedicated windows never collide on
|
|
7998
8248
|
// content.
|
|
7999
8249
|
const serviceWindows = new Map();
|
|
8250
|
+
// harden-lifecycle-ownership D5: a port whose window bootstrap failed is
|
|
8251
|
+
// recorded once (app.log names the failed milestone) and not retried every
|
|
8252
|
+
// monitor tick — a zombie retry loop would bury the failure record.
|
|
8253
|
+
const failedServiceWindows = new Set();
|
|
8000
8254
|
let terminalWindow = null;
|
|
8001
8255
|
|
|
8002
8256
|
const ensureTerminalWindow = async () => {
|
|
8003
8257
|
if (terminalWindow !== null || shellPort === null) return terminalWindow;
|
|
8004
|
-
|
|
8258
|
+
const candidate = tray.extend(WebviewExt).createWebviewWindow({
|
|
8005
8259
|
url: \`http://127.0.0.1:\${shellPort}/terminal.html\`,
|
|
8006
8260
|
width: 900,
|
|
8007
8261
|
height: 560,
|
|
@@ -8009,7 +8263,18 @@ const main = async () => {
|
|
|
8009
8263
|
style: { appMode: true, autoHide: false, keepOnTop: false },
|
|
8010
8264
|
...devtools,
|
|
8011
8265
|
});
|
|
8012
|
-
|
|
8266
|
+
// D5: a failed show leaves no session — record it and keep
|
|
8267
|
+
// terminalWindow null so a later abnormal-exit reveal can retry this
|
|
8268
|
+
// surface against a fresh window.
|
|
8269
|
+
try {
|
|
8270
|
+
await candidate.show();
|
|
8271
|
+
void logEvent({ step: "showWindow", status: "ok", target: "terminal" });
|
|
8272
|
+
} catch (error) {
|
|
8273
|
+
await logEvent({ step: "showWindow", status: "failed", target: "terminal", error: errorText(error) });
|
|
8274
|
+
await candidate.destroy().catch(() => {});
|
|
8275
|
+
return null;
|
|
8276
|
+
}
|
|
8277
|
+
terminalWindow = candidate;
|
|
8013
8278
|
return terminalWindow;
|
|
8014
8279
|
};
|
|
8015
8280
|
|
|
@@ -8026,7 +8291,7 @@ const main = async () => {
|
|
|
8026
8291
|
};
|
|
8027
8292
|
|
|
8028
8293
|
const ensureServiceWindow = async (port) => {
|
|
8029
|
-
if (serviceWindows.has(port)) return;
|
|
8294
|
+
if (serviceWindows.has(port) || failedServiceWindows.has(port)) return;
|
|
8030
8295
|
const direct = \`http://127.0.0.1:\${port}\`;
|
|
8031
8296
|
// Toolbar service windows (D13): the SAME native carrier URL applications
|
|
8032
8297
|
// use — a windowOnly session whose toolbar webview (shell-served
|
|
@@ -8043,16 +8308,38 @@ const main = async () => {
|
|
|
8043
8308
|
style: { appMode: true, autoHide: false, keepOnTop: false },
|
|
8044
8309
|
...devtools,
|
|
8045
8310
|
});
|
|
8046
|
-
|
|
8047
|
-
|
|
8048
|
-
|
|
8049
|
-
|
|
8050
|
-
|
|
8051
|
-
|
|
8052
|
-
|
|
8053
|
-
|
|
8054
|
-
|
|
8055
|
-
|
|
8311
|
+
// harden-lifecycle-ownership D5: the show() before the carrier is a
|
|
8312
|
+
// bootstrap gate — a failure aborts (the carrier never attaches child
|
|
8313
|
+
// webviews against a session that never came up) and the structured
|
|
8314
|
+
// record names the failed milestone in app.log.
|
|
8315
|
+
try {
|
|
8316
|
+
await win.show();
|
|
8317
|
+
void logEvent({ step: "showWindow", status: "ok", target: "service", port });
|
|
8318
|
+
} catch (error) {
|
|
8319
|
+
await logEvent({ step: "showWindow", status: "failed", target: "service", port, error: errorText(error) });
|
|
8320
|
+
failedServiceWindows.add(port);
|
|
8321
|
+
await win.destroy().catch(() => {});
|
|
8322
|
+
return;
|
|
8323
|
+
}
|
|
8324
|
+
try {
|
|
8325
|
+
const carrier = await attachToolbarCarrier(win, {
|
|
8326
|
+
toolbarUrl: \`http://127.0.0.1:\${shellPort}/toolbar.html\`,
|
|
8327
|
+
contentUrl: direct,
|
|
8328
|
+
titleFollows,
|
|
8329
|
+
log: logNote,
|
|
8330
|
+
event: logEvent,
|
|
8331
|
+
});
|
|
8332
|
+
// P1-3 exit race: teardown closes the channel; the carrier must never
|
|
8333
|
+
// rebuild against a dying session. Stop before the window goes down.
|
|
8334
|
+
process.once("exit", carrier.stop);
|
|
8335
|
+
} catch {
|
|
8336
|
+
// D5: the carrier already recorded its failed milestone; this window
|
|
8337
|
+
// is dead, the port is not retried, and the supervised command keeps
|
|
8338
|
+
// running (its terminal remains the abnormal-exit surface).
|
|
8339
|
+
failedServiceWindows.add(port);
|
|
8340
|
+
await win.destroy().catch(() => {});
|
|
8341
|
+
return;
|
|
8342
|
+
}
|
|
8056
8343
|
serviceWindows.set(port, { win, detached: false });
|
|
8057
8344
|
return;
|
|
8058
8345
|
}
|
|
@@ -8066,8 +8353,16 @@ const main = async () => {
|
|
|
8066
8353
|
...(!titleFollows ? {} : { titleSync: { documentToWindow: true } }),
|
|
8067
8354
|
...(!iconFollows ? {} : { iconSync: { faviconToWindow: true } }),
|
|
8068
8355
|
});
|
|
8356
|
+
try {
|
|
8357
|
+
await win.show();
|
|
8358
|
+
void logEvent({ step: "showWindow", status: "ok", target: "service", port });
|
|
8359
|
+
} catch (error) {
|
|
8360
|
+
await logEvent({ step: "showWindow", status: "failed", target: "service", port, error: errorText(error) });
|
|
8361
|
+
failedServiceWindows.add(port);
|
|
8362
|
+
await win.destroy().catch(() => {});
|
|
8363
|
+
return;
|
|
8364
|
+
}
|
|
8069
8365
|
serviceWindows.set(port, { win, detached: false });
|
|
8070
|
-
await win.show().catch(() => {});
|
|
8071
8366
|
};
|
|
8072
8367
|
|
|
8073
8368
|
if (showTerminal) {
|
|
@@ -8182,6 +8477,7 @@ const main = async () => {
|
|
|
8182
8477
|
}
|
|
8183
8478
|
|
|
8184
8479
|
const quit = async () => {
|
|
8480
|
+
quitting = true;
|
|
8185
8481
|
for (const { win } of serviceWindows.values()) {
|
|
8186
8482
|
try { await win.destroy(); } catch {}
|
|
8187
8483
|
}
|
|
@@ -8190,7 +8486,13 @@ const main = async () => {
|
|
|
8190
8486
|
try { await terminalWindow.destroy(); } catch {}
|
|
8191
8487
|
}
|
|
8192
8488
|
await tray.destroy();
|
|
8489
|
+
// Codex R4 quiescence barrier: from here the output channel can only
|
|
8490
|
+
// drain. Chunks the dying child emits during the kill sweep are counted
|
|
8491
|
+
// into the final drop marker instead of scheduling post-flush commits a
|
|
8492
|
+
// fast process.exit would orphan.
|
|
8493
|
+
beginOutputShutdown();
|
|
8193
8494
|
await killCommand();
|
|
8495
|
+
await flushLogQueue();
|
|
8194
8496
|
process.exit(0);
|
|
8195
8497
|
};
|
|
8196
8498
|
|
|
@@ -8221,6 +8523,7 @@ main().catch(async (error) => {
|
|
|
8221
8523
|
await logSink(\`[create-opentray] startup failed: \${error instanceof Error ? error.stack ?? error.message : String(error)}\\n\`, "utf8");
|
|
8222
8524
|
} catch { /* app.log unwritable; stderr below is the remaining surface */ }
|
|
8223
8525
|
console.error(error);
|
|
8526
|
+
await flushLogQueue();
|
|
8224
8527
|
process.exit(1);
|
|
8225
8528
|
});
|
|
8226
8529
|
`;
|
|
@@ -8262,8 +8565,13 @@ const iconFollows = config.window.iconFollowsDocument === true;
|
|
|
8262
8565
|
|
|
8263
8566
|
const appLogPath = resolve(PROJECT_DIR, "app.log");
|
|
8264
8567
|
await mkdir(dirname(appLogPath), { recursive: true });
|
|
8265
|
-
|
|
8568
|
+
${logQueueSource()}
|
|
8266
8569
|
const logNote = (message) => { void logSink("[create-opentray] " + message + "\\n", "utf8"); };
|
|
8570
|
+
// Bootstrap milestones (harden-lifecycle-ownership D5): one structured JSON
|
|
8571
|
+
// record per startup step — the operator reads session health from app.log
|
|
8572
|
+
// alone, and a failed step names itself instead of needing archaeology.
|
|
8573
|
+
const errorText = (error) => error instanceof Error ? error.message : String(error);
|
|
8574
|
+
const logEvent = (record) => logSink(JSON.stringify({ time: new Date().toISOString(), event: "bootstrap", ...record }) + "\\n", "utf8");
|
|
8267
8575
|
|
|
8268
8576
|
${toolbarCarrierSource()}const main = async () => {
|
|
8269
8577
|
let appIcon;
|
|
@@ -8290,7 +8598,7 @@ ${toolbarCarrierSource()}const main = async () => {
|
|
|
8290
8598
|
|
|
8291
8599
|
const tray = await (async () => {
|
|
8292
8600
|
try {
|
|
8293
|
-
|
|
8601
|
+
const created = await createTray({
|
|
8294
8602
|
id: config.appId,
|
|
8295
8603
|
tooltip: { title: config.appName, description: \`\${config.appName} (OpenTray)\` },
|
|
8296
8604
|
icon: Object.keys(trayIconCandidates).length > 0
|
|
@@ -8312,6 +8620,8 @@ ${toolbarCarrierSource()}const main = async () => {
|
|
|
8312
8620
|
cwd: PROJECT_DIR,
|
|
8313
8621
|
},
|
|
8314
8622
|
});
|
|
8623
|
+
void logEvent({ step: "createTray", status: "ok", appId: config.appId });
|
|
8624
|
+
return created;
|
|
8315
8625
|
} catch (error) {
|
|
8316
8626
|
// Carrier-resurrection yield: a Dock-pinned carrier can cold-start this
|
|
8317
8627
|
// entry while another instance already owns the broker session. The
|
|
@@ -8320,12 +8630,29 @@ ${toolbarCarrierSource()}const main = async () => {
|
|
|
8320
8630
|
const message = error instanceof Error ? error.message : String(error);
|
|
8321
8631
|
if (message.includes("OPENTRAY_BROKER_SINGLE_SESSION")) {
|
|
8322
8632
|
await logSink("[create-opentray] broker session already owned by another instance of this app; this cold-started launcher exits (yield)\\n", "utf8");
|
|
8633
|
+
await flushLogQueue();
|
|
8323
8634
|
process.exit(0);
|
|
8324
8635
|
}
|
|
8325
8636
|
throw error;
|
|
8326
8637
|
}
|
|
8327
8638
|
})();
|
|
8328
8639
|
|
|
8640
|
+
// harden-lifecycle-ownership D3: broker connection death is a terminal
|
|
8641
|
+
// state. The entry records the milestone and exits non-zero — it never
|
|
8642
|
+
// keeps a live shell server over a dead backend (zombie entry). The
|
|
8643
|
+
// quitting flag keeps the graceful quit path (whose transport close also
|
|
8644
|
+
// reaches this terminal state) on its exit(0) course.
|
|
8645
|
+
let quitting = false;
|
|
8646
|
+
tray.onConnectionDead?.((error) => {
|
|
8647
|
+
if (quitting) return;
|
|
8648
|
+
quitting = true;
|
|
8649
|
+
void (async () => {
|
|
8650
|
+
await logEvent({ step: "connectionDead", status: "failed", error: errorText(error) });
|
|
8651
|
+
await flushLogQueue();
|
|
8652
|
+
process.exit(1);
|
|
8653
|
+
})();
|
|
8654
|
+
});
|
|
8655
|
+
|
|
8329
8656
|
// v1 developerMode maps ONLY to per-window DevTools admission. When false it
|
|
8330
8657
|
// must not request devtools at all — default windows are not inspectable.
|
|
8331
8658
|
const devtools = config.developerMode === true ? { devtools: true } : {};
|
|
@@ -8340,7 +8667,10 @@ ${toolbarCarrierSource()}const main = async () => {
|
|
|
8340
8667
|
const shellApi = await import("./app-shell-server.mjs");
|
|
8341
8668
|
shellPort = await shellApi.listenShell();
|
|
8342
8669
|
if (shellPort === null) {
|
|
8670
|
+
await logEvent({ step: "listenShell", status: "failed" });
|
|
8343
8671
|
await logSink("[create-opentray] toolbar shell host failed to listen; opening the direct window\\n", "utf8");
|
|
8672
|
+
} else {
|
|
8673
|
+
void logEvent({ step: "listenShell", status: "ok", port: shellPort });
|
|
8344
8674
|
}
|
|
8345
8675
|
}
|
|
8346
8676
|
const toolbarReady = toolbarMode && shellPort !== null;
|
|
@@ -8361,7 +8691,18 @@ ${toolbarCarrierSource()}const main = async () => {
|
|
|
8361
8691
|
...(iconFollows ? { iconSync: { faviconToWindow: true } } : {}),
|
|
8362
8692
|
}),
|
|
8363
8693
|
});
|
|
8364
|
-
|
|
8694
|
+
// harden-lifecycle-ownership D5: the initial show() is a bootstrap gate. A
|
|
8695
|
+
// failure aborts the carrier (no child webviews attach against a session
|
|
8696
|
+
// that never came up) and reaches the startup boundary — app.log records
|
|
8697
|
+
// the failed milestone, then the top-level handler persists the stack and
|
|
8698
|
+
// exits non-zero. Never swallowed.
|
|
8699
|
+
try {
|
|
8700
|
+
await window.show();
|
|
8701
|
+
void logEvent({ step: "showWindow", status: "ok", mode: toolbarReady ? "toolbar" : "direct" });
|
|
8702
|
+
} catch (error) {
|
|
8703
|
+
await logEvent({ step: "showWindow", status: "failed", mode: toolbarReady ? "toolbar" : "direct", error: errorText(error) });
|
|
8704
|
+
throw error;
|
|
8705
|
+
}
|
|
8365
8706
|
|
|
8366
8707
|
let content = null;
|
|
8367
8708
|
if (toolbarReady) {
|
|
@@ -8370,6 +8711,7 @@ ${toolbarCarrierSource()}const main = async () => {
|
|
|
8370
8711
|
contentUrl: config.url,
|
|
8371
8712
|
titleFollows,
|
|
8372
8713
|
log: logNote,
|
|
8714
|
+
event: logEvent,
|
|
8373
8715
|
});
|
|
8374
8716
|
({ content } = carrier);
|
|
8375
8717
|
// P1-3 exit race: teardown closes the channel; the carrier must never
|
|
@@ -8392,8 +8734,10 @@ ${toolbarCarrierSource()}const main = async () => {
|
|
|
8392
8734
|
};
|
|
8393
8735
|
|
|
8394
8736
|
const quit = async () => {
|
|
8737
|
+
quitting = true;
|
|
8395
8738
|
try { await window.destroy(); } catch {}
|
|
8396
8739
|
await tray.destroy();
|
|
8740
|
+
await flushLogQueue();
|
|
8397
8741
|
process.exit(0);
|
|
8398
8742
|
};
|
|
8399
8743
|
|
|
@@ -8419,6 +8763,7 @@ main().catch(async (error) => {
|
|
|
8419
8763
|
await logSink(\`[create-opentray] startup failed: \${error instanceof Error ? error.stack ?? error.message : String(error)}\\n\`, "utf8");
|
|
8420
8764
|
} catch { /* app.log unwritable; stderr below is the remaining surface */ }
|
|
8421
8765
|
console.error(error);
|
|
8766
|
+
await flushLogQueue();
|
|
8422
8767
|
process.exit(1);
|
|
8423
8768
|
});
|
|
8424
8769
|
|
|
@@ -8508,6 +8853,19 @@ const CT = {
|
|
|
8508
8853
|
".json": "application/json",
|
|
8509
8854
|
};
|
|
8510
8855
|
|
|
8856
|
+
// Toolbar context-menu guard (user walkthrough finding, 2026-09-15): the
|
|
8857
|
+
// trusted shell UI never shows the engine's native context menu except on
|
|
8858
|
+
// text-entry elements (inputs keep copy/paste/etc). Injected at serve time
|
|
8859
|
+
// into toolbar.html because the macOS native suppression hook stays off by
|
|
8860
|
+
// law — the isa-swizzle approach crashed the 0.27.3 broker, and the page
|
|
8861
|
+
// layer's own preventDefault is the sanctioned suppression point.
|
|
8862
|
+
const TOOLBAR_GUARD =
|
|
8863
|
+
'<script>document.addEventListener("contextmenu",function(e){var t=e.target;' +
|
|
8864
|
+
'if(t instanceof HTMLElement&&t.closest("input,textarea,[contenteditable]:not([contenteditable=\\"false\\"])")){return;}' +
|
|
8865
|
+
'e.preventDefault();},true);<\/script>';
|
|
8866
|
+
const injectToolbarGuard = (html) =>
|
|
8867
|
+
html.includes("</head>") ? html.replace("</head>", TOOLBAR_GUARD + "</head>") : TOOLBAR_GUARD + html;
|
|
8868
|
+
|
|
8511
8869
|
const server = createServer(async (req, res) => {
|
|
8512
8870
|
const url = new URL(req.url ?? "/", "http://127.0.0.1");
|
|
8513
8871
|
if (url.pathname === "/api/events") {
|
|
@@ -8561,6 +8919,12 @@ const server = createServer(async (req, res) => {
|
|
|
8561
8919
|
.end(index);
|
|
8562
8920
|
return;
|
|
8563
8921
|
}
|
|
8922
|
+
if (target === join(SHELL_DIR, "toolbar.html")) {
|
|
8923
|
+
res
|
|
8924
|
+
.writeHead(200, { "content-type": CT[".html"], "cache-control": "no-store" })
|
|
8925
|
+
.end(injectToolbarGuard(bytes.toString("utf8")));
|
|
8926
|
+
return;
|
|
8927
|
+
}
|
|
8564
8928
|
res
|
|
8565
8929
|
.writeHead(200, {
|
|
8566
8930
|
"content-type": CT[extname(target).toLowerCase()] ?? "application/octet-stream",
|
|
@@ -18616,4 +18980,4 @@ if (isMainModule()) main(process.argv.slice(2)).then((code) => {
|
|
|
18616
18980
|
//#endregion
|
|
18617
18981
|
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 };
|
|
18618
18982
|
|
|
18619
|
-
//# sourceMappingURL=bin-
|
|
18983
|
+
//# sourceMappingURL=bin-BEqHk5dQ.mjs.map
|