local-operator-ui 0.23.2 → 0.23.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.
- package/bin/local-operator-ui.js +11 -1
- package/out/main/index.js +1389 -112
- package/out/preload/index.js +79 -0
- package/out/renderer/assets/{_basePickBy-BVR8NHkD.js → _basePickBy-BcgsHs9n.js} +1 -1
- package/out/renderer/assets/{_baseUniq-onZkstHq.js → _baseUniq-BpL7wRRP.js} +1 -1
- package/out/renderer/assets/{agent-details-page-DC13Jibh.js → agent-details-page-XeWCJu5r.js} +1 -1
- package/out/renderer/assets/{agent-hub-page-iulwy94G.js → agent-hub-page-CnCjDZpC.js} +1 -1
- package/out/renderer/assets/{agents-page-BSegINKc.js → agents-page-Bdno1dWD.js} +2 -2
- package/out/renderer/assets/{arc-DAb06JtB.js → arc-BoPsrKtE.js} +1 -1
- package/out/renderer/assets/{architectureDiagram-IEHRJDOE-sSe348HD.js → architectureDiagram-IEHRJDOE-C2WpBBPk.js} +1 -1
- package/out/renderer/assets/{blockDiagram-JOT3LUYC-DB0hhoP8.js → blockDiagram-JOT3LUYC-CGcW4B3x.js} +1 -1
- package/out/renderer/assets/{c4Diagram-VJAJSXHY-CcMWb5_5.js → c4Diagram-VJAJSXHY-o8n2wzDv.js} +1 -1
- package/out/renderer/assets/channel-urLqozUh.js +1 -0
- package/out/renderer/assets/{chunk-4BMEZGHF-SRkv1Dzw.js → chunk-4BMEZGHF-D-n0UX21.js} +1 -1
- package/out/renderer/assets/{chunk-A2AXSNBT-BBdcJhkk.js → chunk-A2AXSNBT-DfLH07_4.js} +1 -1
- package/out/renderer/assets/{chunk-AEK57VVT-BFLmAuIk.js → chunk-AEK57VVT-CL7wRm_N.js} +1 -1
- package/out/renderer/assets/{chunk-D6G4REZN--QHGDmI-.js → chunk-D6G4REZN-Cn9ko_6u.js} +1 -1
- package/out/renderer/assets/{chunk-RZ5BOZE2-Cy8U6l3Q.js → chunk-RZ5BOZE2-D2Yy6cfD.js} +1 -1
- package/out/renderer/assets/{chunk-XZIHB7SX-DFfpTr7O.js → chunk-XZIHB7SX-DADi_UCc.js} +1 -1
- package/out/renderer/assets/classDiagram-GIVACNV2-CdFb9UuH.js +1 -0
- package/out/renderer/assets/classDiagram-v2-COTLJTTW-CdFb9UuH.js +1 -0
- package/out/renderer/assets/clone-R16IxTog.js +1 -0
- package/out/renderer/assets/{compact-pagination-DERWPPOM.js → compact-pagination-BJGKcOBo.js} +1 -1
- package/out/renderer/assets/{dagre-OKDRZEBW-DfjKcX6M.js → dagre-OKDRZEBW-CTUL3pXh.js} +1 -1
- package/out/renderer/assets/{diagram-SSKATNLV-Bm1j7Y3r.js → diagram-SSKATNLV-DN8A3Gxk.js} +1 -1
- package/out/renderer/assets/{diagram-VNBRO52H-BRx5TiDX.js → diagram-VNBRO52H-DMnKnMe_.js} +1 -1
- package/out/renderer/assets/{erDiagram-Q7BY3M3F-BSxbIo11.js → erDiagram-Q7BY3M3F-D_QMDQ8A.js} +1 -1
- package/out/renderer/assets/{flowDiagram-4HSFHLVR-R2ILhUKR.js → flowDiagram-4HSFHLVR-C3lOP5SJ.js} +1 -1
- package/out/renderer/assets/{ganttDiagram-APWFNJXF-Gd7TZJFy.js → ganttDiagram-APWFNJXF-C_OA497D.js} +1 -1
- package/out/renderer/assets/{gitGraphDiagram-7IBYFJ6S-BAs2K2WV.js → gitGraphDiagram-7IBYFJ6S-CME4fotT.js} +1 -1
- package/out/renderer/assets/{graph-zm35SWuR.js → graph-StjGh6sY.js} +1 -1
- package/out/renderer/assets/icon-BgNKKXpS.css +1 -0
- package/out/renderer/assets/{icon-mKHwnRNs.js → icon-fm25vzsV.js} +1 -1
- package/out/renderer/assets/{index-DF2zdD6_.js → index-CrQVJ-Co.js} +365 -371
- package/out/renderer/assets/{index-DXx2j7ov.js → index-Dr1tzeVu.js} +1 -1
- package/out/renderer/assets/{index-CqIP2mt3.js → index-ir1HIEvx.js} +1 -1
- package/out/renderer/assets/{infoDiagram-PH2N3AL5-BcAWGJ1_.js → infoDiagram-PH2N3AL5-BDK-X71A.js} +1 -1
- package/out/renderer/assets/{installer-DcKqSMWJ.js → installer-BlFThduE.js} +1 -1
- package/out/renderer/assets/{journeyDiagram-U35MCT3I-VfDkAwaY.js → journeyDiagram-U35MCT3I-BjNamBAK.js} +1 -1
- package/out/renderer/assets/{kanban-definition-NDS4AKOZ-BVgW6OQK.js → kanban-definition-NDS4AKOZ-ggxQngdS.js} +1 -1
- package/out/renderer/assets/{layout-jfvNOQNg.js → layout-BwRA6JIM.js} +1 -1
- package/out/renderer/assets/{legacy-agents-page-yObswBd2.js → legacy-agents-page-6NVm9UCi.js} +4 -4
- package/out/renderer/assets/{mermaid.core-w7CiwIS9.js → mermaid.core-CfsR-egY.js} +5 -5
- package/out/renderer/assets/{mindmap-definition-ALO5MXBD-Ck5fBAvP.js → mindmap-definition-ALO5MXBD-DyShE6kV.js} +1 -1
- package/out/renderer/assets/{page-header-DmGvvMe3.js → page-header-DHRyHEm6.js} +1 -1
- package/out/renderer/assets/{parseISO-BfcDJeSX.js → parseISO-QkHFGs0Y.js} +1 -1
- package/out/renderer/assets/{pieDiagram-IB7DONF6-xuxf3Is5.js → pieDiagram-IB7DONF6-3wxJVvt5.js} +1 -1
- package/out/renderer/assets/{quadrantDiagram-7GDLP6J5-B4UU5d5n.js → quadrantDiagram-7GDLP6J5-iNEHdUXN.js} +1 -1
- package/out/renderer/assets/{radar-MK3ICKWK-BUD9nsvK.js → radar-MK3ICKWK-tbLyRbLt.js} +1 -1
- package/out/renderer/assets/radient-auth-buttons-CJi5xMEA.js +6 -0
- package/out/renderer/assets/{requirementDiagram-KVF5MWMF-0fsS7ohi.js → requirementDiagram-KVF5MWMF-CfXxmaaW.js} +1 -1
- package/out/renderer/assets/{sankeyDiagram-QLVOVGJD-CUjpA8gC.js → sankeyDiagram-QLVOVGJD-DaJoWNT6.js} +1 -1
- package/out/renderer/assets/{schedules-page-xXMGrdrg.js → schedules-page-j6YSAreu.js} +8 -8
- package/out/renderer/assets/{sequenceDiagram-X6HHIX6F-CX6mfjEg.js → sequenceDiagram-X6HHIX6F-WF0xhSxd.js} +1 -1
- package/out/renderer/assets/settings-page-xzYs88jD.js +97 -0
- package/out/renderer/assets/{square-pen-717nTWtU.js → square-pen-D_sBefzl.js} +1 -1
- package/out/renderer/assets/{stateDiagram-DGXRK772-DlOrLmRF.js → stateDiagram-DGXRK772-YdZ2xuTI.js} +1 -1
- package/out/renderer/assets/stateDiagram-v2-YXO3MK2T-CegpBrNz.js +1 -0
- package/out/renderer/assets/{timeline-definition-BDJGKUSR-B6_9nD67.js → timeline-definition-BDJGKUSR-DYaIE99w.js} +1 -1
- package/out/renderer/assets/{use-agent-like-mutation-Ckg2HQQI.js → use-agent-like-mutation-s4bRf1u_.js} +1 -1
- package/out/renderer/assets/{xychartDiagram-VJFVF3MP-B2JoquLl.js → xychartDiagram-VJFVF3MP-DYQwMal7.js} +1 -1
- package/out/renderer/index.html +3 -3
- package/out/renderer/installer.html +3 -3
- package/package.json +52 -20
- package/out/renderer/assets/channel-Dl22yt_O.js +0 -1
- package/out/renderer/assets/classDiagram-GIVACNV2-BguqLYlr.js +0 -1
- package/out/renderer/assets/classDiagram-v2-COTLJTTW-BguqLYlr.js +0 -1
- package/out/renderer/assets/clone-C0R1R8sa.js +0 -1
- package/out/renderer/assets/icon-DC3rStnR.css +0 -1
- package/out/renderer/assets/radient-auth-buttons-BtcEZuSY.js +0 -6
- package/out/renderer/assets/settings-page-PLBi7GEM.js +0 -91
- package/out/renderer/assets/stateDiagram-v2-YXO3MK2T-CsUzDnIT.js +0 -1
package/out/main/index.js
CHANGED
|
@@ -86,7 +86,16 @@ const teamFields = zod.z.object({
|
|
|
86
86
|
}).strict();
|
|
87
87
|
const chains = zod.z.record(zod.z.array(zod.z.string().max(1024)).max(100));
|
|
88
88
|
const scheduleUnit = zod.z.enum(["minutes", "hours", "days"]);
|
|
89
|
-
|
|
89
|
+
function isAddressableVariableKey(key) {
|
|
90
|
+
return !/^\.+$/.test(key);
|
|
91
|
+
}
|
|
92
|
+
const variableKey = zod.z.string().min(1).max(128).refine(
|
|
93
|
+
(key) => [...key].every(
|
|
94
|
+
(character) => character.charCodeAt(0) >= 32 && character.charCodeAt(0) !== 127 && character !== "/" && character !== "\\"
|
|
95
|
+
)
|
|
96
|
+
).refine(isAddressableVariableKey);
|
|
97
|
+
const variableType = zod.z.enum(["str", "int", "float", "bool", "list", "dict"]);
|
|
98
|
+
const variableValue = zod.z.string().max(16384);
|
|
90
99
|
const scheduleWrite = zod.z.object({
|
|
91
100
|
is_active: zod.z.boolean().nullish(),
|
|
92
101
|
one_time: zod.z.boolean().nullish(),
|
|
@@ -280,6 +289,89 @@ const desktopRequestSchema = zod.z.discriminatedUnion("op", [
|
|
|
280
289
|
// `desktopRequestByteBudget`. Firing it costs the control budget and a
|
|
281
290
|
// 2-byte body, which is what lets the renderer issue it from a keystroke.
|
|
282
291
|
zod.z.object({ op: zod.z.literal("sessions.warm"), sessionId }).strict(),
|
|
292
|
+
/*
|
|
293
|
+
* A session's code memory: the names the session's own cells have left in its
|
|
294
|
+
* eval namespace.
|
|
295
|
+
*
|
|
296
|
+
* Session-addressed, because the runtime is what holds a namespace and the
|
|
297
|
+
* kernel it lives in is keyed by session id. The legacy
|
|
298
|
+
* `/v1/agents/{id}/execution-variables` route answered through the agent
|
|
299
|
+
* registry instead, which resolves agent-directory UUIDs - so a canonical
|
|
300
|
+
* session id could only ever 404 there, and the panel that taught "code
|
|
301
|
+
* memory" never loaded once.
|
|
302
|
+
*/
|
|
303
|
+
zod.z.object({ op: zod.z.literal("sessions.variables.list"), sessionId }).strict(),
|
|
304
|
+
zod.z.object({
|
|
305
|
+
op: zod.z.literal("sessions.variables.create"),
|
|
306
|
+
sessionId,
|
|
307
|
+
key: variableKey,
|
|
308
|
+
value: variableValue,
|
|
309
|
+
type: variableType
|
|
310
|
+
}).strict(),
|
|
311
|
+
zod.z.object({
|
|
312
|
+
op: zod.z.literal("sessions.variables.update"),
|
|
313
|
+
sessionId,
|
|
314
|
+
key: variableKey,
|
|
315
|
+
value: variableValue,
|
|
316
|
+
type: variableType
|
|
317
|
+
}).strict(),
|
|
318
|
+
zod.z.object({
|
|
319
|
+
op: zod.z.literal("sessions.variables.delete"),
|
|
320
|
+
sessionId,
|
|
321
|
+
key: variableKey
|
|
322
|
+
}).strict(),
|
|
323
|
+
// Machine-wide desktop presence. NOT a watch lease and deliberately not
|
|
324
|
+
// shaped like one: it names no session, because the fact it carries is
|
|
325
|
+
// "somebody is at this machine's screen and this app can raise a banner",
|
|
326
|
+
// which is a property of the app rather than of any conversation. The
|
|
327
|
+
// backend reads it to decide whether a BACKGROUND completion is worth a
|
|
328
|
+
// banner here at all (a session runtime with no surface to present on
|
|
329
|
+
// otherwise toasts on its own host, where nobody may be).
|
|
330
|
+
//
|
|
331
|
+
// `subscription_id` is the LIVE feed subscription the presence belongs to,
|
|
332
|
+
// not a per-session watch subscription: the server holds the lease against
|
|
333
|
+
// that socket, so a dropped feed revokes the claim without waiting for a
|
|
334
|
+
// heartbeat to go stale. That is the whole reason presence is a route on the
|
|
335
|
+
// feed rather than a local file - a desktop paired to a backend on another
|
|
336
|
+
// host cannot write to that host's disk.
|
|
337
|
+
zod.z.object({
|
|
338
|
+
op: zod.z.literal("sessions.presence"),
|
|
339
|
+
subscriptionId: zod.z.string().regex(/^[a-f0-9]{1,64}$/),
|
|
340
|
+
canNotify: zod.z.boolean(),
|
|
341
|
+
/*
|
|
342
|
+
* WHY THE CLAIM CARRIES MORE THAN "a desktop is connected".
|
|
343
|
+
*
|
|
344
|
+
* The backend's delivery lease answers two separate questions from
|
|
345
|
+
* this one beat, and a claim that names neither is a claim that
|
|
346
|
+
* delivers NOTHING:
|
|
347
|
+
*
|
|
348
|
+
* - `can_notify_kinds` is what `delivers(kind)` reads. Empty (the
|
|
349
|
+
* default for a client that forgot) means rung 2 is never eligible
|
|
350
|
+
* for any completion, so rung 4 raises the runtime's own banner —
|
|
351
|
+
* earlier, and its claim advances the read watermark, so the
|
|
352
|
+
* clickable banner this app exists to raise never composes.
|
|
353
|
+
* The backend's own test for this is
|
|
354
|
+
* `test_a_claim_with_no_kinds_claims_nothing`: an app that does not
|
|
355
|
+
* advertise must not win the rung.
|
|
356
|
+
* - `window` (with `session_id`) is what `attended` reads. Without it
|
|
357
|
+
* this app counts as watching NOTHING, so a completion in the
|
|
358
|
+
* conversation on screen raises a banner — the inverse of rung 1,
|
|
359
|
+
* and a regression against the per-session flag it replaces.
|
|
360
|
+
*
|
|
361
|
+
* `window` is sent even when there is no window: a windowless app
|
|
362
|
+
* (macOS, alive in the dock) can still raise a banner but cannot be
|
|
363
|
+
* displaying anything, which is why `session_id` must be "" there
|
|
364
|
+
* rather than the last conversation the closed window held.
|
|
365
|
+
*/
|
|
366
|
+
canNotifyKinds: zod.z.array(zod.z.enum(["complete", "error"])).max(4),
|
|
367
|
+
sessionId: zod.z.string().regex(/^([a-f0-9]{12})?$/),
|
|
368
|
+
window: zod.z.object({
|
|
369
|
+
exists: zod.z.boolean(),
|
|
370
|
+
focused: zod.z.boolean(),
|
|
371
|
+
visible: zod.z.boolean(),
|
|
372
|
+
minimized: zod.z.boolean()
|
|
373
|
+
}).strict()
|
|
374
|
+
}).strict(),
|
|
283
375
|
// The legacy surface, reached through the same authenticated vocabulary as
|
|
284
376
|
// everything else. These routes are gated in managed mode (agent inventory,
|
|
285
377
|
// cwd paths, job history and conversation content are the same tenant's data
|
|
@@ -386,28 +478,6 @@ const desktopRequestSchema = zod.z.discriminatedUnion("op", [
|
|
|
386
478
|
systemPrompt: zod.z.string().max(DESKTOP_SYSTEM_PROMPT_MAX_CHARS)
|
|
387
479
|
}).strict(),
|
|
388
480
|
zod.z.object({ op: zod.z.literal("legacy.agent.download"), agentId: id$1 }).strict(),
|
|
389
|
-
zod.z.object({ op: zod.z.literal("legacy.agent.variables.list"), agentId: id$1 }).strict(),
|
|
390
|
-
zod.z.object({
|
|
391
|
-
op: zod.z.literal("legacy.agent.variables.create"),
|
|
392
|
-
agentId: id$1,
|
|
393
|
-
variable: zod.z.record(zod.z.unknown())
|
|
394
|
-
}).strict(),
|
|
395
|
-
zod.z.object({
|
|
396
|
-
op: zod.z.literal("legacy.agent.variables.get"),
|
|
397
|
-
agentId: id$1,
|
|
398
|
-
key: variableKey
|
|
399
|
-
}).strict(),
|
|
400
|
-
zod.z.object({
|
|
401
|
-
op: zod.z.literal("legacy.agent.variables.update"),
|
|
402
|
-
agentId: id$1,
|
|
403
|
-
key: variableKey,
|
|
404
|
-
variable: zod.z.record(zod.z.unknown())
|
|
405
|
-
}).strict(),
|
|
406
|
-
zod.z.object({
|
|
407
|
-
op: zod.z.literal("legacy.agent.variables.delete"),
|
|
408
|
-
agentId: id$1,
|
|
409
|
-
key: variableKey
|
|
410
|
-
}).strict(),
|
|
411
481
|
zod.z.object({ op: zod.z.literal("legacy.job.cancel"), jobId: id$1 }).strict(),
|
|
412
482
|
zod.z.object({ op: zod.z.literal("commands.list") }).strict(),
|
|
413
483
|
zod.z.object({
|
|
@@ -494,6 +564,25 @@ const desktopRequestSchema = zod.z.discriminatedUnion("op", [
|
|
|
494
564
|
asideId: requestId
|
|
495
565
|
}).strict(),
|
|
496
566
|
zod.z.object({ op: zod.z.literal("mcp.list"), sessionId }).strict(),
|
|
567
|
+
zod.z.object({
|
|
568
|
+
op: zod.z.literal("mcp.credentials.store"),
|
|
569
|
+
sessionId,
|
|
570
|
+
name: zod.z.string().min(1).max(256),
|
|
571
|
+
values: zod.z.record(zod.z.string().min(1).max(128), zod.z.string().min(1).max(32768)).refine(
|
|
572
|
+
(secrets) => Object.keys(secrets).length <= 32 && Object.values(secrets).reduce(
|
|
573
|
+
(total, value) => total + value.length,
|
|
574
|
+
0
|
|
575
|
+
) <= 65536,
|
|
576
|
+
// Mirrors the owner's own bound (`local_operator/mcp/credentials.py`),
|
|
577
|
+
// so an oversized paste is refused as a sentence rather than
|
|
578
|
+
// serialized into a request the control budget rejects as an
|
|
579
|
+
// opaque 413.
|
|
580
|
+
{
|
|
581
|
+
message: "Too many secret values, or too much secret text, for one MCP credential write."
|
|
582
|
+
}
|
|
583
|
+
),
|
|
584
|
+
confirmedReplace: zod.z.array(zod.z.string().min(1).max(128)).max(32)
|
|
585
|
+
}).strict(),
|
|
497
586
|
zod.z.object({
|
|
498
587
|
op: zod.z.literal("mcp.control"),
|
|
499
588
|
sessionId,
|
|
@@ -797,6 +886,27 @@ function desktopEndpoint(request) {
|
|
|
797
886
|
can_notify: request.canNotify
|
|
798
887
|
}
|
|
799
888
|
};
|
|
889
|
+
case "sessions.presence":
|
|
890
|
+
return {
|
|
891
|
+
path: "/v1/desktop/presence",
|
|
892
|
+
method: "POST",
|
|
893
|
+
body: {
|
|
894
|
+
subscription_id: request.subscriptionId,
|
|
895
|
+
// The three fields the delivery lease reads beside it. Sent
|
|
896
|
+
// snake_case like the route's own model, and always sent: a
|
|
897
|
+
// defaulted claim is what made this app ineligible.
|
|
898
|
+
can_notify_kinds: request.canNotifyKinds,
|
|
899
|
+
session_id: request.sessionId,
|
|
900
|
+
window: request.window,
|
|
901
|
+
// `can_notify` means "can ATTEMPT delivery", never "the user will
|
|
902
|
+
// be reached": `Notification.isSupported()` knows nothing about
|
|
903
|
+
// macOS Focus/DND, Windows Focus Assist or a denied permission.
|
|
904
|
+
// The backend's suppression reads it as eligibility to try, and a
|
|
905
|
+
// claim never advances the read watermark, so a suppressed banner
|
|
906
|
+
// still leaves the durable unseen mark intact.
|
|
907
|
+
can_notify: request.canNotify
|
|
908
|
+
}
|
|
909
|
+
};
|
|
800
910
|
case "sessions.warm":
|
|
801
911
|
return {
|
|
802
912
|
path: `/v1/desktop/sessions/${request.sessionId}/warm`,
|
|
@@ -807,6 +917,31 @@ function desktopEndpoint(request) {
|
|
|
807
917
|
// makes a legal call answer 422.
|
|
808
918
|
body: {}
|
|
809
919
|
};
|
|
920
|
+
case "sessions.variables.list":
|
|
921
|
+
return {
|
|
922
|
+
path: `/v1/desktop/sessions/${request.sessionId}/variables`,
|
|
923
|
+
method: "GET"
|
|
924
|
+
};
|
|
925
|
+
case "sessions.variables.create":
|
|
926
|
+
return {
|
|
927
|
+
path: `/v1/desktop/sessions/${request.sessionId}/variables`,
|
|
928
|
+
method: "POST",
|
|
929
|
+
// The route's own body shape, not the op's: `{key, value, type}` on
|
|
930
|
+
// create and `{value, type}` on update, because the key is in the path
|
|
931
|
+
// once the variable exists and is immutable after that.
|
|
932
|
+
body: { key: request.key, value: request.value, type: request.type }
|
|
933
|
+
};
|
|
934
|
+
case "sessions.variables.update":
|
|
935
|
+
return {
|
|
936
|
+
path: `/v1/desktop/sessions/${request.sessionId}/variables/${encodeURIComponent(request.key)}`,
|
|
937
|
+
method: "PATCH",
|
|
938
|
+
body: { value: request.value, type: request.type }
|
|
939
|
+
};
|
|
940
|
+
case "sessions.variables.delete":
|
|
941
|
+
return {
|
|
942
|
+
path: `/v1/desktop/sessions/${request.sessionId}/variables/${encodeURIComponent(request.key)}`,
|
|
943
|
+
method: "DELETE"
|
|
944
|
+
};
|
|
810
945
|
case "legacy.models": {
|
|
811
946
|
const query = new URLSearchParams();
|
|
812
947
|
if (request.provider) query.set("provider", request.provider);
|
|
@@ -920,33 +1055,6 @@ function desktopEndpoint(request) {
|
|
|
920
1055
|
};
|
|
921
1056
|
case "legacy.agent.download":
|
|
922
1057
|
return { path: `/v1/agents/${request.agentId}/download`, method: "GET" };
|
|
923
|
-
case "legacy.agent.variables.list":
|
|
924
|
-
return {
|
|
925
|
-
path: `/v1/agents/${request.agentId}/execution-variables`,
|
|
926
|
-
method: "GET"
|
|
927
|
-
};
|
|
928
|
-
case "legacy.agent.variables.create":
|
|
929
|
-
return {
|
|
930
|
-
path: `/v1/agents/${request.agentId}/execution-variables`,
|
|
931
|
-
method: "POST",
|
|
932
|
-
body: request.variable
|
|
933
|
-
};
|
|
934
|
-
case "legacy.agent.variables.get":
|
|
935
|
-
return {
|
|
936
|
-
path: `/v1/agents/${request.agentId}/execution-variables/${request.key}`,
|
|
937
|
-
method: "GET"
|
|
938
|
-
};
|
|
939
|
-
case "legacy.agent.variables.update":
|
|
940
|
-
return {
|
|
941
|
-
path: `/v1/agents/${request.agentId}/execution-variables/${request.key}`,
|
|
942
|
-
method: "PATCH",
|
|
943
|
-
body: request.variable
|
|
944
|
-
};
|
|
945
|
-
case "legacy.agent.variables.delete":
|
|
946
|
-
return {
|
|
947
|
-
path: `/v1/agents/${request.agentId}/execution-variables/${request.key}`,
|
|
948
|
-
method: "DELETE"
|
|
949
|
-
};
|
|
950
1058
|
case "legacy.job.cancel":
|
|
951
1059
|
return { path: `/v1/jobs/${request.jobId}`, method: "DELETE" };
|
|
952
1060
|
case "commands.list":
|
|
@@ -1057,6 +1165,16 @@ function desktopEndpoint(request) {
|
|
|
1057
1165
|
path: `/v1/desktop/sessions/${request.sessionId}/mcp`,
|
|
1058
1166
|
method: "GET"
|
|
1059
1167
|
};
|
|
1168
|
+
case "mcp.credentials.store":
|
|
1169
|
+
return {
|
|
1170
|
+
path: `/v1/desktop/sessions/${request.sessionId}/mcp/credentials`,
|
|
1171
|
+
method: "POST",
|
|
1172
|
+
body: {
|
|
1173
|
+
name: request.name,
|
|
1174
|
+
values: request.values,
|
|
1175
|
+
confirmed_replace: request.confirmedReplace
|
|
1176
|
+
}
|
|
1177
|
+
};
|
|
1060
1178
|
case "mcp.control":
|
|
1061
1179
|
return {
|
|
1062
1180
|
path: `/v1/desktop/sessions/${request.sessionId}/mcp`,
|
|
@@ -1139,6 +1257,341 @@ function desktopEndpoint(request) {
|
|
|
1139
1257
|
}
|
|
1140
1258
|
const MAX_PROBE_PATHS = 64;
|
|
1141
1259
|
const MAX_FILE_READ_BYTES = 64 * 1024 * 1024;
|
|
1260
|
+
const OPEN_SESSION_FLAG = "--open-session";
|
|
1261
|
+
const OPEN_CATALOGUE_FLAG = "--open-catalogue";
|
|
1262
|
+
const SESSION_ID$2 = /^[a-f0-9]{12}$/;
|
|
1263
|
+
function readOpenSessionArgv(argv) {
|
|
1264
|
+
let found = null;
|
|
1265
|
+
for (let index = 0; index < argv.length; index += 1) {
|
|
1266
|
+
const argument = argv[index];
|
|
1267
|
+
if (argument === OPEN_SESSION_FLAG) {
|
|
1268
|
+
const next = argv[index + 1];
|
|
1269
|
+
if (next !== void 0) found = next;
|
|
1270
|
+
continue;
|
|
1271
|
+
}
|
|
1272
|
+
if (argument.startsWith(`${OPEN_SESSION_FLAG}=`)) {
|
|
1273
|
+
found = argument.slice(OPEN_SESSION_FLAG.length + 1);
|
|
1274
|
+
}
|
|
1275
|
+
}
|
|
1276
|
+
return found !== null && SESSION_ID$2.test(found) ? found : null;
|
|
1277
|
+
}
|
|
1278
|
+
function readLaunchTarget(argv) {
|
|
1279
|
+
const sessionId2 = readOpenSessionArgv(argv);
|
|
1280
|
+
if (sessionId2) return { kind: "session", sessionId: sessionId2 };
|
|
1281
|
+
if (argv.includes(OPEN_CATALOGUE_FLAG)) return { kind: "catalogue" };
|
|
1282
|
+
return { kind: "restore" };
|
|
1283
|
+
}
|
|
1284
|
+
const FEED_NOTIFIABLE_KINDS = ["complete", "error"];
|
|
1285
|
+
const DEFAULT_HEARTBEAT_SECONDS = 15;
|
|
1286
|
+
const MIN_HEARTBEAT_SECONDS = 1;
|
|
1287
|
+
const MAX_HEARTBEAT_SECONDS = 60;
|
|
1288
|
+
const MISSED_HEARTBEATS = 3;
|
|
1289
|
+
const RECONNECT_MIN_MS = 1e3;
|
|
1290
|
+
const RECONNECT_MAX_MS = 3e4;
|
|
1291
|
+
const PRESENCE_INTERVAL_MS = 15e3;
|
|
1292
|
+
const WATCHDOG_TICK_MS$1 = 1e3;
|
|
1293
|
+
const SSE_DATA_PREFIX = /^ /;
|
|
1294
|
+
const MAX_FRAME_BYTES$2 = 8 * 1024 * 1024;
|
|
1295
|
+
class DesktopFeedRelay {
|
|
1296
|
+
constructor(backendUrl, token, options) {
|
|
1297
|
+
this.backendUrl = backendUrl;
|
|
1298
|
+
this.token = token;
|
|
1299
|
+
this.options = options;
|
|
1300
|
+
}
|
|
1301
|
+
controller = null;
|
|
1302
|
+
/**
|
|
1303
|
+
* The live body reader, so the watchdog can END the read rather than only
|
|
1304
|
+
* signal the fetch.
|
|
1305
|
+
*
|
|
1306
|
+
* Aborting the controller is not enough on its own to be sure of this: it
|
|
1307
|
+
* aborts a fetch in flight, and whether that also rejects a body read already
|
|
1308
|
+
* in progress is the platform's business (undici does; a test double need
|
|
1309
|
+
* not). `cancel()` is the reader's own guarantee — the pending `read()`
|
|
1310
|
+
* resolves `done` and the pump reconnects — so the watchdog does not depend
|
|
1311
|
+
* on a behaviour it cannot assert here.
|
|
1312
|
+
*/
|
|
1313
|
+
reader = null;
|
|
1314
|
+
running = false;
|
|
1315
|
+
stopped = false;
|
|
1316
|
+
connected = false;
|
|
1317
|
+
subscriptionId = null;
|
|
1318
|
+
heartbeatMs = DEFAULT_HEARTBEAT_SECONDS * 1e3;
|
|
1319
|
+
lastFrameAt = 0;
|
|
1320
|
+
watchdog = null;
|
|
1321
|
+
presence = null;
|
|
1322
|
+
reconnectTimer = null;
|
|
1323
|
+
/**
|
|
1324
|
+
* Settles the promise `pause()` is parked on.
|
|
1325
|
+
*
|
|
1326
|
+
* The timer IS the only thing that would resolve it, so a `stop()` that only
|
|
1327
|
+
* cleared the timer left `pump()` awaiting a promise nothing could settle — a
|
|
1328
|
+
* leak nobody would find later (the relay refuses to restart, so it had no
|
|
1329
|
+
* visible symptom). Held so teardown ends the wait explicitly.
|
|
1330
|
+
*/
|
|
1331
|
+
reconnectResolve = null;
|
|
1332
|
+
attempt = 0;
|
|
1333
|
+
/**
|
|
1334
|
+
* Single observer per kind, not a subscriber set.
|
|
1335
|
+
*
|
|
1336
|
+
* The relay has exactly two consumers and both are main's: one fan-out that
|
|
1337
|
+
* puts a frame in front of the notifier and (when a window exists) in front
|
|
1338
|
+
* of the renderer, and one that mirrors the connection state to the sidebar.
|
|
1339
|
+
* A set would suggest that consumers can come and go independently, and the
|
|
1340
|
+
* lifecycle here is the opposite of that — the observers are re-attached
|
|
1341
|
+
* whenever the relay is rebuilt for a rotated backend URL, which is a thing
|
|
1342
|
+
* this class cannot do for itself.
|
|
1343
|
+
*/
|
|
1344
|
+
frameObserver = null;
|
|
1345
|
+
stateObserver = null;
|
|
1346
|
+
get isConnected() {
|
|
1347
|
+
return this.connected;
|
|
1348
|
+
}
|
|
1349
|
+
/** True when this relay can authenticate at all. */
|
|
1350
|
+
get available() {
|
|
1351
|
+
return Boolean(this.token);
|
|
1352
|
+
}
|
|
1353
|
+
observe(observer) {
|
|
1354
|
+
this.frameObserver = observer;
|
|
1355
|
+
}
|
|
1356
|
+
watchState(observer) {
|
|
1357
|
+
this.stateObserver = observer;
|
|
1358
|
+
observer?.({ connected: this.connected });
|
|
1359
|
+
}
|
|
1360
|
+
/**
|
|
1361
|
+
* Open the feed if the backend supports it. Idempotent, and safe to call
|
|
1362
|
+
* again on every backend-ready transition: a restart or an
|
|
1363
|
+
* external-backend rotation re-runs the capability gate against the backend
|
|
1364
|
+
* that is actually there now.
|
|
1365
|
+
*/
|
|
1366
|
+
async start() {
|
|
1367
|
+
if (this.running || this.stopped) return;
|
|
1368
|
+
if (!this.token) {
|
|
1369
|
+
this.setConnected(false);
|
|
1370
|
+
return;
|
|
1371
|
+
}
|
|
1372
|
+
this.running = true;
|
|
1373
|
+
let features;
|
|
1374
|
+
try {
|
|
1375
|
+
const response = await this.options.request({ op: "capabilities" });
|
|
1376
|
+
const body = response.body;
|
|
1377
|
+
features = body?.result?.features;
|
|
1378
|
+
} catch {
|
|
1379
|
+
this.running = false;
|
|
1380
|
+
this.setConnected(false);
|
|
1381
|
+
return;
|
|
1382
|
+
}
|
|
1383
|
+
if (this.stopped) return;
|
|
1384
|
+
if (!(Number(features?.desktop_feed ?? 0) >= 1)) {
|
|
1385
|
+
this.running = false;
|
|
1386
|
+
this.setConnected(false);
|
|
1387
|
+
return;
|
|
1388
|
+
}
|
|
1389
|
+
void this.pump();
|
|
1390
|
+
}
|
|
1391
|
+
/** Tear everything down. Idempotent. */
|
|
1392
|
+
stop() {
|
|
1393
|
+
this.stopped = true;
|
|
1394
|
+
this.running = false;
|
|
1395
|
+
if (this.reconnectTimer) {
|
|
1396
|
+
clearTimeout(this.reconnectTimer);
|
|
1397
|
+
this.reconnectTimer = null;
|
|
1398
|
+
}
|
|
1399
|
+
this.reconnectResolve?.();
|
|
1400
|
+
this.reconnectResolve = null;
|
|
1401
|
+
this.endSocket();
|
|
1402
|
+
this.setConnected(false);
|
|
1403
|
+
}
|
|
1404
|
+
/** Abort the socket and its timers, leaving `stopped` alone. */
|
|
1405
|
+
endSocket() {
|
|
1406
|
+
if (this.watchdog) {
|
|
1407
|
+
clearInterval(this.watchdog);
|
|
1408
|
+
this.watchdog = null;
|
|
1409
|
+
}
|
|
1410
|
+
if (this.presence) {
|
|
1411
|
+
clearInterval(this.presence);
|
|
1412
|
+
this.presence = null;
|
|
1413
|
+
}
|
|
1414
|
+
this.subscriptionId = null;
|
|
1415
|
+
this.controller?.abort();
|
|
1416
|
+
this.controller = null;
|
|
1417
|
+
}
|
|
1418
|
+
/** Read until the socket ends, then pace a reconnect. */
|
|
1419
|
+
async pump() {
|
|
1420
|
+
while (!this.stopped) {
|
|
1421
|
+
await this.readOnce();
|
|
1422
|
+
if (this.stopped) return;
|
|
1423
|
+
this.setConnected(false);
|
|
1424
|
+
await this.pause();
|
|
1425
|
+
}
|
|
1426
|
+
}
|
|
1427
|
+
pause() {
|
|
1428
|
+
const delay = Math.min(
|
|
1429
|
+
RECONNECT_MAX_MS,
|
|
1430
|
+
RECONNECT_MIN_MS * 2 ** Math.min(this.attempt, 5)
|
|
1431
|
+
);
|
|
1432
|
+
this.attempt += 1;
|
|
1433
|
+
return new Promise((resolve) => {
|
|
1434
|
+
this.reconnectResolve = resolve;
|
|
1435
|
+
this.reconnectTimer = setTimeout(() => {
|
|
1436
|
+
this.reconnectTimer = null;
|
|
1437
|
+
this.reconnectResolve = null;
|
|
1438
|
+
resolve();
|
|
1439
|
+
}, delay);
|
|
1440
|
+
this.reconnectTimer.unref?.();
|
|
1441
|
+
});
|
|
1442
|
+
}
|
|
1443
|
+
async readOnce() {
|
|
1444
|
+
const controller = new AbortController();
|
|
1445
|
+
this.controller = controller;
|
|
1446
|
+
const url = new URL("/v1/desktop/events", this.backendUrl);
|
|
1447
|
+
try {
|
|
1448
|
+
const response = await fetch(url, {
|
|
1449
|
+
headers: {
|
|
1450
|
+
Accept: "text/event-stream",
|
|
1451
|
+
Authorization: `Bearer ${this.token}`
|
|
1452
|
+
},
|
|
1453
|
+
redirect: "error",
|
|
1454
|
+
signal: controller.signal
|
|
1455
|
+
});
|
|
1456
|
+
if (!response.ok || !response.body) {
|
|
1457
|
+
return;
|
|
1458
|
+
}
|
|
1459
|
+
this.attempt = 0;
|
|
1460
|
+
this.setConnected(true);
|
|
1461
|
+
this.startWatchdog();
|
|
1462
|
+
const reader = response.body.getReader();
|
|
1463
|
+
this.reader = reader;
|
|
1464
|
+
const decoder = new TextDecoder();
|
|
1465
|
+
let buffer = "";
|
|
1466
|
+
for (; ; ) {
|
|
1467
|
+
const { done, value } = await reader.read();
|
|
1468
|
+
if (done) break;
|
|
1469
|
+
this.lastFrameAt = Date.now();
|
|
1470
|
+
buffer += decoder.decode(value, { stream: true });
|
|
1471
|
+
for (; ; ) {
|
|
1472
|
+
const boundary = buffer.indexOf("\n\n");
|
|
1473
|
+
if (boundary < 0) {
|
|
1474
|
+
if (buffer.length > MAX_FRAME_BYTES$2) {
|
|
1475
|
+
reader.cancel().catch(() => void 0);
|
|
1476
|
+
return;
|
|
1477
|
+
}
|
|
1478
|
+
break;
|
|
1479
|
+
}
|
|
1480
|
+
const record = buffer.slice(0, boundary);
|
|
1481
|
+
buffer = buffer.slice(boundary + 2);
|
|
1482
|
+
for (const line of record.split("\n")) {
|
|
1483
|
+
if (!line.startsWith("data:")) continue;
|
|
1484
|
+
this.dispatch(line.slice(5).replace(SSE_DATA_PREFIX, ""));
|
|
1485
|
+
}
|
|
1486
|
+
}
|
|
1487
|
+
}
|
|
1488
|
+
} catch {
|
|
1489
|
+
} finally {
|
|
1490
|
+
if (this.controller === controller) this.controller = null;
|
|
1491
|
+
this.reader = null;
|
|
1492
|
+
if (this.watchdog) {
|
|
1493
|
+
clearInterval(this.watchdog);
|
|
1494
|
+
this.watchdog = null;
|
|
1495
|
+
}
|
|
1496
|
+
if (this.presence) {
|
|
1497
|
+
clearInterval(this.presence);
|
|
1498
|
+
this.presence = null;
|
|
1499
|
+
}
|
|
1500
|
+
this.subscriptionId = null;
|
|
1501
|
+
}
|
|
1502
|
+
}
|
|
1503
|
+
dispatch(data) {
|
|
1504
|
+
let frame;
|
|
1505
|
+
try {
|
|
1506
|
+
frame = JSON.parse(data);
|
|
1507
|
+
} catch {
|
|
1508
|
+
return;
|
|
1509
|
+
}
|
|
1510
|
+
if (frame.type === "open") {
|
|
1511
|
+
this.subscriptionId = frame.payload.subscription_id;
|
|
1512
|
+
if (typeof frame.payload.heartbeat_seconds === "number") {
|
|
1513
|
+
this.heartbeatMs = Math.min(
|
|
1514
|
+
MAX_HEARTBEAT_SECONDS,
|
|
1515
|
+
Math.max(
|
|
1516
|
+
this.options.heartbeatFloorSeconds ?? MIN_HEARTBEAT_SECONDS,
|
|
1517
|
+
frame.payload.heartbeat_seconds
|
|
1518
|
+
)
|
|
1519
|
+
) * 1e3;
|
|
1520
|
+
}
|
|
1521
|
+
this.startPresence();
|
|
1522
|
+
}
|
|
1523
|
+
try {
|
|
1524
|
+
this.frameObserver?.(frame);
|
|
1525
|
+
} catch {
|
|
1526
|
+
}
|
|
1527
|
+
}
|
|
1528
|
+
/**
|
|
1529
|
+
* Tear the socket down when it has gone quiet for three heartbeats.
|
|
1530
|
+
*
|
|
1531
|
+
* A half-open socket reports no error, no end and no data: the reader simply
|
|
1532
|
+
* never resolves. Without this the app would look connected while no
|
|
1533
|
+
* completion could reach it, which is the silent-failure state this whole
|
|
1534
|
+
* change exists to remove.
|
|
1535
|
+
*/
|
|
1536
|
+
startWatchdog() {
|
|
1537
|
+
this.lastFrameAt = Date.now();
|
|
1538
|
+
if (this.watchdog) clearInterval(this.watchdog);
|
|
1539
|
+
this.watchdog = setInterval(() => {
|
|
1540
|
+
if (Date.now() - this.lastFrameAt < this.heartbeatMs * MISSED_HEARTBEATS)
|
|
1541
|
+
return;
|
|
1542
|
+
void this.reader?.cancel().catch(() => void 0);
|
|
1543
|
+
this.controller?.abort();
|
|
1544
|
+
}, WATCHDOG_TICK_MS$1);
|
|
1545
|
+
this.watchdog.unref?.();
|
|
1546
|
+
}
|
|
1547
|
+
/**
|
|
1548
|
+
* Renew the presence lease for as long as this subscription is live.
|
|
1549
|
+
*
|
|
1550
|
+
* The first beat is immediate: the backend holds the lease against the
|
|
1551
|
+
* subscription, so waiting a full interval would leave a window in which the
|
|
1552
|
+
* app can deliver a banner while the backend still believes nobody is there
|
|
1553
|
+
* and toasts on its own host instead.
|
|
1554
|
+
*/
|
|
1555
|
+
startPresence() {
|
|
1556
|
+
if (this.presence) clearInterval(this.presence);
|
|
1557
|
+
const beat = () => {
|
|
1558
|
+
const subscriptionId = this.subscriptionId;
|
|
1559
|
+
if (!subscriptionId) return;
|
|
1560
|
+
const context = this.options.presenceContext?.() ?? {
|
|
1561
|
+
sessionId: "",
|
|
1562
|
+
window: {
|
|
1563
|
+
exists: false,
|
|
1564
|
+
focused: false,
|
|
1565
|
+
visible: false,
|
|
1566
|
+
minimized: false
|
|
1567
|
+
}
|
|
1568
|
+
};
|
|
1569
|
+
void this.options.beatPresence({
|
|
1570
|
+
subscriptionId,
|
|
1571
|
+
canNotify: true,
|
|
1572
|
+
// Always the FEED's kinds, never a window's: this app delivers a
|
|
1573
|
+
// completion window or no window, which is why the feed exists.
|
|
1574
|
+
canNotifyKinds: FEED_NOTIFIABLE_KINDS,
|
|
1575
|
+
sessionId: context.sessionId,
|
|
1576
|
+
window: context.window
|
|
1577
|
+
}).catch(() => void 0);
|
|
1578
|
+
};
|
|
1579
|
+
beat();
|
|
1580
|
+
this.presence = setInterval(
|
|
1581
|
+
beat,
|
|
1582
|
+
this.options.presenceIntervalMs ?? PRESENCE_INTERVAL_MS
|
|
1583
|
+
);
|
|
1584
|
+
this.presence.unref?.();
|
|
1585
|
+
}
|
|
1586
|
+
setConnected(connected) {
|
|
1587
|
+
if (this.connected === connected) return;
|
|
1588
|
+
this.connected = connected;
|
|
1589
|
+
try {
|
|
1590
|
+
this.stateObserver?.({ connected });
|
|
1591
|
+
} catch {
|
|
1592
|
+
}
|
|
1593
|
+
}
|
|
1594
|
+
}
|
|
1142
1595
|
const id = zod.z.string().min(1).max(128).regex(/^[a-zA-Z0-9_-]+$/);
|
|
1143
1596
|
const MAX_UPLOAD_BYTES = 16 * 1024 * 1024;
|
|
1144
1597
|
const MAX_DOWNLOAD_BYTES = 32 * 1024 * 1024;
|
|
@@ -1335,7 +1788,7 @@ const DESKTOP_STREAM_DETAIL = {
|
|
|
1335
1788
|
*/
|
|
1336
1789
|
refusedWithoutStatus: "The event stream was refused."
|
|
1337
1790
|
};
|
|
1338
|
-
const SESSION_ID = /^[a-f0-9]{12}$/;
|
|
1791
|
+
const SESSION_ID$1 = /^[a-f0-9]{12}$/;
|
|
1339
1792
|
const SAFE_ID = /^[a-zA-Z0-9_-]{1,128}$/;
|
|
1340
1793
|
function transportFailureDetail(error) {
|
|
1341
1794
|
if (error instanceof Error && error.name === "AbortError") {
|
|
@@ -1345,11 +1798,14 @@ function transportFailureDetail(error) {
|
|
|
1345
1798
|
if (code === "ECONNREFUSED") return DESKTOP_STREAM_DETAIL.serverDown;
|
|
1346
1799
|
return DESKTOP_STREAM_DETAIL.connectionFailed;
|
|
1347
1800
|
}
|
|
1348
|
-
const
|
|
1801
|
+
const SILENCE_TIMEOUT_MS = 45e3;
|
|
1802
|
+
const WATCHDOG_TICK_MS = 1e3;
|
|
1803
|
+
const MAX_FRAME_BYTES$1 = 8 * 1024 * 1024;
|
|
1349
1804
|
class DesktopStreamRelay {
|
|
1350
|
-
constructor(backendUrl, token) {
|
|
1805
|
+
constructor(backendUrl, token, timings = {}) {
|
|
1351
1806
|
this.backendUrl = backendUrl;
|
|
1352
1807
|
this.token = token;
|
|
1808
|
+
this.timings = timings;
|
|
1353
1809
|
}
|
|
1354
1810
|
streams = /* @__PURE__ */ new Map();
|
|
1355
1811
|
/** Frame observer for main-owned side effects (notifications). It sees
|
|
@@ -1364,7 +1820,7 @@ class DesktopStreamRelay {
|
|
|
1364
1820
|
return Boolean(this.token);
|
|
1365
1821
|
}
|
|
1366
1822
|
subscribe(args, emit) {
|
|
1367
|
-
if (!SESSION_ID.test(args.sessionId)) {
|
|
1823
|
+
if (!SESSION_ID$1.test(args.sessionId)) {
|
|
1368
1824
|
throw new Error("Invalid session for streaming.");
|
|
1369
1825
|
}
|
|
1370
1826
|
if (args.epoch !== void 0 && !SAFE_ID.test(args.epoch)) {
|
|
@@ -1421,41 +1877,63 @@ class DesktopStreamRelay {
|
|
|
1421
1877
|
emit({
|
|
1422
1878
|
streamId,
|
|
1423
1879
|
kind: "error",
|
|
1424
|
-
detail: DESKTOP_STREAM_DETAIL.refused(response.status)
|
|
1880
|
+
detail: DESKTOP_STREAM_DETAIL.refused(response.status),
|
|
1881
|
+
// Carried so the renderer can tell "this conversation is gone"
|
|
1882
|
+
// from "this transport is down". Both are refusals; only one of
|
|
1883
|
+
// them is about the session, and the two need different words.
|
|
1884
|
+
status: response.status
|
|
1425
1885
|
});
|
|
1426
1886
|
return;
|
|
1427
1887
|
}
|
|
1428
1888
|
const reader = response.body.getReader();
|
|
1429
1889
|
const decoder = new TextDecoder();
|
|
1430
1890
|
let buffer = "";
|
|
1431
|
-
|
|
1432
|
-
|
|
1433
|
-
|
|
1434
|
-
|
|
1891
|
+
let lastActivity = Date.now();
|
|
1892
|
+
const silenceTimeoutMs = this.timings.silenceTimeoutMs ?? SILENCE_TIMEOUT_MS;
|
|
1893
|
+
const watchdog = setInterval(() => {
|
|
1894
|
+
if (Date.now() - lastActivity < silenceTimeoutMs) return;
|
|
1895
|
+
clearInterval(watchdog);
|
|
1896
|
+
emit({
|
|
1897
|
+
streamId,
|
|
1898
|
+
kind: "error",
|
|
1899
|
+
detail: DESKTOP_STREAM_DETAIL.ended
|
|
1900
|
+
});
|
|
1901
|
+
void reader.cancel().catch(() => void 0);
|
|
1902
|
+
}, this.timings.watchdogTickMs ?? WATCHDOG_TICK_MS);
|
|
1903
|
+
watchdog.unref?.();
|
|
1904
|
+
try {
|
|
1435
1905
|
for (; ; ) {
|
|
1436
|
-
const
|
|
1437
|
-
if (
|
|
1438
|
-
|
|
1439
|
-
|
|
1440
|
-
|
|
1441
|
-
|
|
1442
|
-
|
|
1443
|
-
|
|
1444
|
-
|
|
1445
|
-
|
|
1906
|
+
const { done, value } = await reader.read();
|
|
1907
|
+
if (done) break;
|
|
1908
|
+
lastActivity = Date.now();
|
|
1909
|
+
buffer += decoder.decode(value, { stream: true });
|
|
1910
|
+
for (; ; ) {
|
|
1911
|
+
const boundary = buffer.indexOf("\n\n");
|
|
1912
|
+
if (boundary < 0) {
|
|
1913
|
+
if (buffer.length > MAX_FRAME_BYTES$1) {
|
|
1914
|
+
emit({
|
|
1915
|
+
streamId,
|
|
1916
|
+
kind: "error",
|
|
1917
|
+
detail: DESKTOP_STREAM_DETAIL.frameBudget
|
|
1918
|
+
});
|
|
1919
|
+
reader.cancel().catch(() => void 0);
|
|
1920
|
+
return;
|
|
1921
|
+
}
|
|
1922
|
+
break;
|
|
1446
1923
|
}
|
|
1447
|
-
|
|
1448
|
-
|
|
1449
|
-
|
|
1450
|
-
|
|
1451
|
-
|
|
1452
|
-
|
|
1453
|
-
|
|
1454
|
-
|
|
1455
|
-
emit({ streamId, kind: "data", data });
|
|
1924
|
+
const record = buffer.slice(0, boundary);
|
|
1925
|
+
buffer = buffer.slice(boundary + 2);
|
|
1926
|
+
for (const line of record.split("\n")) {
|
|
1927
|
+
if (line.startsWith("data:")) {
|
|
1928
|
+
const data = line.slice(5).replace(/^ /, "");
|
|
1929
|
+
this.observer?.(sessionId2, data);
|
|
1930
|
+
emit({ streamId, kind: "data", data });
|
|
1931
|
+
}
|
|
1456
1932
|
}
|
|
1457
1933
|
}
|
|
1458
1934
|
}
|
|
1935
|
+
} finally {
|
|
1936
|
+
clearInterval(watchdog);
|
|
1459
1937
|
}
|
|
1460
1938
|
emit({ streamId, kind: "end" });
|
|
1461
1939
|
} catch (error) {
|
|
@@ -2835,6 +3313,27 @@ class BackendServiceManager {
|
|
|
2835
3313
|
/** Survives relay recreation so notifications never silently detach
|
|
2836
3314
|
* when the backend URL rotates. */
|
|
2837
3315
|
streamObserver = null;
|
|
3316
|
+
/**
|
|
3317
|
+
* The machine-wide desktop feed relay, rebuilt when the backend URL rotates
|
|
3318
|
+
* for the same reason the stream relay is: a subscription must never pin a
|
|
3319
|
+
* stale origin. Held HERE rather than in `index.ts` because the desktop
|
|
3320
|
+
* bearer lives in this class and is never exposed — the feed needs it to
|
|
3321
|
+
* carry an SSE connection and to beat the presence lease.
|
|
3322
|
+
*/
|
|
3323
|
+
feedRelay = null;
|
|
3324
|
+
feedRelayUrl = "";
|
|
3325
|
+
/** Both survive relay recreation, so a URL rotation cannot silently detach
|
|
3326
|
+
* the banner path or leave the sidebar reading a stale connection state. */
|
|
3327
|
+
feedFrameObserver = null;
|
|
3328
|
+
feedStateObserver = null;
|
|
3329
|
+
/**
|
|
3330
|
+
* The window/focus/displayed-session answers the presence claim carries.
|
|
3331
|
+
*
|
|
3332
|
+
* Set by main once a window can exist — before that a windowless app is the
|
|
3333
|
+
* honest answer, which is also the reference's own default for a client that
|
|
3334
|
+
* has not said anything yet.
|
|
3335
|
+
*/
|
|
3336
|
+
presenceContext = null;
|
|
2838
3337
|
/**
|
|
2839
3338
|
* Called every time the backend becomes reachable and authenticated.
|
|
2840
3339
|
*
|
|
@@ -2895,6 +3394,60 @@ class BackendServiceManager {
|
|
|
2895
3394
|
this.streamObserver = observer;
|
|
2896
3395
|
this.streamRelay?.observe(observer);
|
|
2897
3396
|
}
|
|
3397
|
+
/**
|
|
3398
|
+
* The machine-wide feed, rebuilt when the backend URL rotates.
|
|
3399
|
+
*
|
|
3400
|
+
* Lazily constructed for the same reason `getStreamRelay` is: the desktop
|
|
3401
|
+
* token is minted inside `start()`, so a relay built at `app.whenReady()`
|
|
3402
|
+
* would capture `null` and never authenticate. Callers reach for it from the
|
|
3403
|
+
* backend-ready hook, which is the first moment the token exists.
|
|
3404
|
+
*/
|
|
3405
|
+
getDesktopFeedRelay() {
|
|
3406
|
+
if (!this.feedRelay || this.feedRelayUrl !== this.backendUrl) {
|
|
3407
|
+
this.feedRelay?.stop();
|
|
3408
|
+
this.feedRelay = new DesktopFeedRelay(
|
|
3409
|
+
this.backendUrl,
|
|
3410
|
+
this.desktopToken,
|
|
3411
|
+
{
|
|
3412
|
+
request: (input) => this.requestDesktop(input),
|
|
3413
|
+
// The presence beat is a contract op, so it travels the same
|
|
3414
|
+
// authenticated transport as every other control and the token
|
|
3415
|
+
// never leaves this class.
|
|
3416
|
+
beatPresence: (presence) => this.requestDesktop({
|
|
3417
|
+
op: "sessions.presence",
|
|
3418
|
+
...presence
|
|
3419
|
+
}),
|
|
3420
|
+
// What this app can say about its own window. Supplied by main
|
|
3421
|
+
// (it owns the window and the notifier's displayed session) and
|
|
3422
|
+
// read at each beat rather than captured, so a window that opens
|
|
3423
|
+
// or closes mid-lease is reported without a new relay.
|
|
3424
|
+
presenceContext: () => this.presenceContext?.() ?? null
|
|
3425
|
+
}
|
|
3426
|
+
);
|
|
3427
|
+
this.feedRelay.observe(this.feedFrameObserver);
|
|
3428
|
+
this.feedRelay.watchState(this.feedStateObserver);
|
|
3429
|
+
this.feedRelayUrl = this.backendUrl;
|
|
3430
|
+
}
|
|
3431
|
+
return this.feedRelay;
|
|
3432
|
+
}
|
|
3433
|
+
/**
|
|
3434
|
+
* Supply the presence claim's window state.
|
|
3435
|
+
*
|
|
3436
|
+
* Separate from `observeDesktopFeed` because it is a different question
|
|
3437
|
+
* asked of a different owner: the observers are consumers of frames, and
|
|
3438
|
+
* this is main's answer about itself. Applied to a relay that already
|
|
3439
|
+
* exists, so the ordering between "start the feed" and "make a window" does
|
|
3440
|
+
* not decide whether the claim is complete.
|
|
3441
|
+
*/
|
|
3442
|
+
providePresenceContext(context) {
|
|
3443
|
+
this.presenceContext = context;
|
|
3444
|
+
}
|
|
3445
|
+
observeDesktopFeed(frameObserver, stateObserver) {
|
|
3446
|
+
this.feedFrameObserver = frameObserver;
|
|
3447
|
+
this.feedStateObserver = stateObserver;
|
|
3448
|
+
this.feedRelay?.observe(frameObserver);
|
|
3449
|
+
this.feedRelay?.watchState(stateObserver);
|
|
3450
|
+
}
|
|
2898
3451
|
requestDesktop(input) {
|
|
2899
3452
|
return requestDesktop(input, this.backendUrl, this.desktopToken);
|
|
2900
3453
|
}
|
|
@@ -8422,7 +8975,7 @@ function relayedBytes(value) {
|
|
|
8422
8975
|
if (!(buffer instanceof ArrayBuffer)) return null;
|
|
8423
8976
|
return new Uint8Array(buffer, byteOffset, byteLength);
|
|
8424
8977
|
}
|
|
8425
|
-
function registerDesktopIPC(window, expectedUrl, request, streams, media, notifier) {
|
|
8978
|
+
function registerDesktopIPC(window, expectedUrl, request, streams, media, notifier, noteLeftSession) {
|
|
8426
8979
|
const opened = /* @__PURE__ */ new Map();
|
|
8427
8980
|
function authorize(event) {
|
|
8428
8981
|
const owner = window();
|
|
@@ -8457,6 +9010,15 @@ function registerDesktopIPC(window, expectedUrl, request, streams, media, notifi
|
|
|
8457
9010
|
focused: args.focused
|
|
8458
9011
|
});
|
|
8459
9012
|
});
|
|
9013
|
+
electron.ipcMain.handle("desktop-watch-release", (event, input) => {
|
|
9014
|
+
authorize(event);
|
|
9015
|
+
const args = input;
|
|
9016
|
+
if (typeof args?.sessionId !== "string") {
|
|
9017
|
+
throw new Error("Invalid watch release.");
|
|
9018
|
+
}
|
|
9019
|
+
notifier.releaseWatch(event.sender.id, args.sessionId);
|
|
9020
|
+
noteLeftSession?.(args.sessionId);
|
|
9021
|
+
});
|
|
8460
9022
|
}
|
|
8461
9023
|
if (media) {
|
|
8462
9024
|
electron.ipcMain.handle("desktop-media", (event, input, bytes) => {
|
|
@@ -8557,11 +9119,26 @@ function gateBody(kind, gateTitle, gateDetail) {
|
|
|
8557
9119
|
}
|
|
8558
9120
|
return subject || GATE_BODIES[kind] || GATE_BODIES.approval;
|
|
8559
9121
|
}
|
|
9122
|
+
function isBurstDigest(n) {
|
|
9123
|
+
return (n.burst_count ?? 0) > 1 || (n.session_ids?.length ?? 0) > 1;
|
|
9124
|
+
}
|
|
9125
|
+
function digestMemberCount(n) {
|
|
9126
|
+
return (n.member_tokens ?? []).filter(
|
|
9127
|
+
(member) => !!member?.session_id && !!member?.completion_token
|
|
9128
|
+
).length;
|
|
9129
|
+
}
|
|
9130
|
+
const SILENT_HOST = {
|
|
9131
|
+
windowAlive: () => true,
|
|
9132
|
+
noteDisplayed: () => void 0,
|
|
9133
|
+
reopen: () => void 0
|
|
9134
|
+
};
|
|
9135
|
+
const RENDERER_REPORT_TTL_MS = 45e3;
|
|
8560
9136
|
class DesktopNotifier {
|
|
8561
|
-
constructor(window, request, windowRaise = "focus") {
|
|
9137
|
+
constructor(window, request, windowRaise = "focus", host = SILENT_HOST) {
|
|
8562
9138
|
this.window = window;
|
|
8563
9139
|
this.request = request;
|
|
8564
9140
|
this.windowRaise = windowRaise;
|
|
9141
|
+
this.host = host;
|
|
8565
9142
|
}
|
|
8566
9143
|
delivered = /* @__PURE__ */ new Map();
|
|
8567
9144
|
/** Last known frontend epoch per session, to key dedupe on owner epoch. */
|
|
@@ -8730,12 +9307,24 @@ class DesktopNotifier {
|
|
|
8730
9307
|
* renderer's active subscription; `can_notify` is asserted only when main
|
|
8731
9308
|
* can really deliver. A focused window is interactive; a visible-but-
|
|
8732
9309
|
* unfocused one still watches but is not; a hidden one is neither.
|
|
9310
|
+
*
|
|
9311
|
+
* ``windowId`` IS THE WEB CONTENTS ID (``event.sender.id``, from the IPC
|
|
9312
|
+
* handler), and the map is keyed on it because that is what a close cleanup can
|
|
9313
|
+
* still name after the window is gone. Every reader must use the SAME id — see
|
|
9314
|
+
* `presence()`, where reading the ``BrowserWindow`` id instead made the
|
|
9315
|
+
* machine-wide presence report an empty session id for a window it was
|
|
9316
|
+
* claiming was focused on a conversation (review round 2, R2-3).
|
|
8733
9317
|
*/
|
|
8734
9318
|
async heartbeat(windowId, args) {
|
|
8735
9319
|
this.windows.set(windowId, {
|
|
8736
9320
|
visible: args.visible,
|
|
8737
|
-
focused: args.focused
|
|
9321
|
+
focused: args.focused,
|
|
9322
|
+
session: args.sessionId,
|
|
9323
|
+
// THE REPORT'S AGE, part of the record rather than a separate map
|
|
9324
|
+
// (R2-4): a state that cannot expire is a state that outlives its pane.
|
|
9325
|
+
seenAt: Date.now()
|
|
8738
9326
|
});
|
|
9327
|
+
this.host.noteDisplayed(args.sessionId);
|
|
8739
9328
|
return this.request({
|
|
8740
9329
|
op: "sessions.watch",
|
|
8741
9330
|
sessionId: args.sessionId,
|
|
@@ -8744,10 +9333,52 @@ class DesktopNotifier {
|
|
|
8744
9333
|
canNotify: this.canNotify
|
|
8745
9334
|
});
|
|
8746
9335
|
}
|
|
9336
|
+
/**
|
|
9337
|
+
* Forget a window's watch state on close.
|
|
9338
|
+
*
|
|
9339
|
+
* This had NO CALLER while `mainWindow.on("closed")` nulled the window
|
|
9340
|
+
* without telling the notifier, and `webContents` ids are not reused — so
|
|
9341
|
+
* after one window close the map held `{visible: true, focused: true}` for
|
|
9342
|
+
* the life of the process and every `when_unfocused` completion was
|
|
9343
|
+
* suppressed for good. That is the strongest candidate for the operator's
|
|
9344
|
+
* "I don't reliably get notified", and the fix is this call plus the
|
|
9345
|
+
* liveness read in `anyDisplayingFocused`.
|
|
9346
|
+
*/
|
|
8747
9347
|
forgetWindow(windowId) {
|
|
8748
9348
|
this.windows.delete(windowId);
|
|
8749
9349
|
}
|
|
8750
|
-
/**
|
|
9350
|
+
/**
|
|
9351
|
+
* Withdraw the DISPLAYED conversation when the pane that reported it leaves.
|
|
9352
|
+
*
|
|
9353
|
+
* THE NAVIGATION HALF OF R2-4. `forgetWindow` runs when a window closes, and
|
|
9354
|
+
* the report now expires on its own after `RENDERER_REPORT_TTL_MS` — but
|
|
9355
|
+
* navigating from a conversation to the catalogue or Settings stops the watch
|
|
9356
|
+
* and unsubscribes its stream while leaving the window, and therefore the map
|
|
9357
|
+
* entry, exactly where it was. Main's own feed then kept renewing "showing A"
|
|
9358
|
+
* every beat and the backend, which treats fresh presence as authoritative,
|
|
9359
|
+
* suppressed A's banner while no pane displayed it.
|
|
9360
|
+
*
|
|
9361
|
+
* IDENTITY-SAFE BY CONSTRUCTION, which is the part a naive clear gets wrong: a
|
|
9362
|
+
* cleanup runs asynchronously after the pane has already changed, so an
|
|
9363
|
+
* unconditional clear would wipe the session a DEEPER pane has since
|
|
9364
|
+
* subscribed to. This withdraws only the report that names the conversation
|
|
9365
|
+
* being left.
|
|
9366
|
+
*/
|
|
9367
|
+
releaseWatch(windowId, sessionId2) {
|
|
9368
|
+
const state = this.windows.get(windowId);
|
|
9369
|
+
if (!state) return;
|
|
9370
|
+
if (!sessionId2 || state.session !== sessionId2) return;
|
|
9371
|
+
this.windows.set(windowId, { ...state, session: "" });
|
|
9372
|
+
}
|
|
9373
|
+
/**
|
|
9374
|
+
* Called by the stream relay for every parsed frame it forwards, and by the
|
|
9375
|
+
* machine-wide feed for the frames it carries.
|
|
9376
|
+
*
|
|
9377
|
+
* ONE method for both sources on purpose. A `notification` frame's payload is
|
|
9378
|
+
* byte-identical whichever way it arrived — same `dedupe_key`, minted by the
|
|
9379
|
+
* backend — so two entry points would be two places to keep that true, and
|
|
9380
|
+
* the pair would eventually be delivered twice instead of once.
|
|
9381
|
+
*/
|
|
8751
9382
|
observe(sessionId2, frame) {
|
|
8752
9383
|
if (!this.canNotify) return;
|
|
8753
9384
|
if (this.windowRaise === "never") return;
|
|
@@ -8808,16 +9439,47 @@ class DesktopNotifier {
|
|
|
8808
9439
|
*/
|
|
8809
9440
|
async composed(sessionId2, n) {
|
|
8810
9441
|
if (!this.claim(n.dedupe_key)) return;
|
|
8811
|
-
if (n.focus_policy === "when_unfocused" && this.
|
|
9442
|
+
if (n.focus_policy === "when_unfocused" && this.anyDisplayingFocused(sessionId2))
|
|
9443
|
+
return;
|
|
8812
9444
|
if (n.completion_token) {
|
|
8813
9445
|
const outcome = await this.claimDelivery(sessionId2, n.completion_token);
|
|
8814
9446
|
if (outcome !== "won") {
|
|
8815
9447
|
if (outcome === "failed") this.delivered.delete(n.dedupe_key);
|
|
8816
9448
|
return;
|
|
8817
9449
|
}
|
|
9450
|
+
} else if (digestMemberCount(n) > 0 || isBurstDigest(n)) {
|
|
9451
|
+
const won = await this.claimDigestMembers(n);
|
|
9452
|
+
if (won === 0 && digestMemberCount(n) > 0) {
|
|
9453
|
+
this.delivered.delete(n.dedupe_key);
|
|
9454
|
+
return;
|
|
9455
|
+
}
|
|
8818
9456
|
}
|
|
8819
9457
|
this.show(
|
|
8820
|
-
|
|
9458
|
+
/*
|
|
9459
|
+
* A BURST DIGEST is not about one conversation (R1-2). The backend
|
|
9460
|
+
* caps per-tick banners and publishes ONE frame for the remainder,
|
|
9461
|
+
* carrying `burst_count` and `session_ids` and setting `session_id`
|
|
9462
|
+
* to the LAST overflow member — it must name something for the frame
|
|
9463
|
+
* to be a valid session frame at all. Routing its click to that id
|
|
9464
|
+
* opened one arbitrary member of the burst, which is the "the click
|
|
9465
|
+
* did not land where I expected" defect this change exists to
|
|
9466
|
+
* remove, so a digest opens the catalogue instead.
|
|
9467
|
+
*
|
|
9468
|
+
* Detected on the fields themselves rather than on a title string:
|
|
9469
|
+
* the strings are the backend's to word, and a surface that matched
|
|
9470
|
+
* on them would route a re-worded digest to a random conversation.
|
|
9471
|
+
*
|
|
9472
|
+
* A ONE-MEMBER DIGEST LANDS ON ITS OWN CONVERSATION, and that is a
|
|
9473
|
+
* decision rather than a fall-through (review round 3, R3-1). The frame
|
|
9474
|
+
* above is the case the claim branch now recognises and this one does
|
|
9475
|
+
* not: `burst_count` is 1 and it names exactly one conversation, so the
|
|
9476
|
+
* banner says one thing and the click goes there — the operator's own
|
|
9477
|
+
* request ("a click landing on the exact conversation") read literally.
|
|
9478
|
+
* Folding the member marker into `isBurstDigest` would send it to the
|
|
9479
|
+
* catalogue and lose that navigation for no gain in correctness, since
|
|
9480
|
+
* it is the CLAIM that has to happen for every member, not the routing.
|
|
9481
|
+
*/
|
|
9482
|
+
isBurstDigest(n) ? null : sessionId2,
|
|
8821
9483
|
n.title,
|
|
8822
9484
|
n.status,
|
|
8823
9485
|
n.body,
|
|
@@ -8825,6 +9487,36 @@ class DesktopNotifier {
|
|
|
8825
9487
|
n.body_is_failure ?? false
|
|
8826
9488
|
);
|
|
8827
9489
|
}
|
|
9490
|
+
/**
|
|
9491
|
+
* Claim a burst digest's members, and report how many this surface won.
|
|
9492
|
+
*
|
|
9493
|
+
* One `sessions.notified` per member, through the SAME atomic claim a single
|
|
9494
|
+
* frame uses — that is the point of the contract: the arbitration lives in one
|
|
9495
|
+
* place (the backend's `claim_delivery`), so a digest and an individual banner
|
|
9496
|
+
* for one of its members cannot both win.
|
|
9497
|
+
*
|
|
9498
|
+
* Members are claimed SEQUENTIALLY rather than in parallel, deliberately: this
|
|
9499
|
+
* runs on the delivery path with a banner's worth of latency behind it, and a
|
|
9500
|
+
* burst is capped at a handful of members by the backend's own ceiling.
|
|
9501
|
+
*
|
|
9502
|
+
* A `failed` claim is treated as NOT won, which makes the whole digest fail
|
|
9503
|
+
* closed when the backend is unreachable — the same choice the single-frame
|
|
9504
|
+
* path makes, and for the same reason: showing the banner anyway would risk a
|
|
9505
|
+
* second banner for completions another surface may also have reached.
|
|
9506
|
+
*/
|
|
9507
|
+
async claimDigestMembers(n) {
|
|
9508
|
+
const members = n.member_tokens ?? [];
|
|
9509
|
+
let won = 0;
|
|
9510
|
+
for (const member of members) {
|
|
9511
|
+
if (!member?.session_id || !member?.completion_token) continue;
|
|
9512
|
+
const outcome = await this.claimDelivery(
|
|
9513
|
+
member.session_id,
|
|
9514
|
+
member.completion_token
|
|
9515
|
+
);
|
|
9516
|
+
if (outcome === "won") won += 1;
|
|
9517
|
+
}
|
|
9518
|
+
return won;
|
|
9519
|
+
}
|
|
8828
9520
|
/**
|
|
8829
9521
|
* Ask the backend whether this surface owns this completion's toast.
|
|
8830
9522
|
*
|
|
@@ -8852,12 +9544,91 @@ class DesktopNotifier {
|
|
|
8852
9544
|
return "failed";
|
|
8853
9545
|
}
|
|
8854
9546
|
}
|
|
8855
|
-
|
|
8856
|
-
|
|
8857
|
-
|
|
9547
|
+
/**
|
|
9548
|
+
* Whether a LIVE window is displaying `sessionId` in the foreground.
|
|
9549
|
+
*
|
|
9550
|
+
* THE completion gate, and the predicate is deliberately two facts rather
|
|
9551
|
+
* than one. `anyFocused()` alone answers "is the app in front", which is not
|
|
9552
|
+
* the question: with the app focused on session A, a completion in session B
|
|
9553
|
+
* was suppressed and announced to nobody — and rung 3 had already deferred
|
|
9554
|
+
* to rung 2, so nothing else could raise it either. That is the operator's
|
|
9555
|
+
* reported symptom, made permanent by the presence mechanism itself (B1).
|
|
9556
|
+
*
|
|
9557
|
+
* Only a window showing THIS conversation is a reason not to banner, because
|
|
9558
|
+
* only then is the completion already on screen.
|
|
9559
|
+
*
|
|
9560
|
+
* Dead windows are filtered rather than assumed gone. `forgetWindow` runs on
|
|
9561
|
+
* `closed`, but a renderer process that dies takes its `webContents` id with
|
|
9562
|
+
* it and no `closed` event reaches this map, and `webContents` ids are not
|
|
9563
|
+
* reused — so an unfiltered stale `{visible: true, focused: true}` entry
|
|
9564
|
+
* suppresses every completion for the life of the process.
|
|
9565
|
+
*/
|
|
9566
|
+
anyDisplayingFocused(sessionId2) {
|
|
9567
|
+
for (const [id2, state] of this.windows) {
|
|
9568
|
+
if (!state.visible || !state.focused) continue;
|
|
9569
|
+
if (state.session !== sessionId2) continue;
|
|
9570
|
+
if (!this.host.windowAlive(id2)) continue;
|
|
9571
|
+
if (Date.now() - state.seenAt > RENDERER_REPORT_TTL_MS) continue;
|
|
9572
|
+
return true;
|
|
8858
9573
|
}
|
|
8859
9574
|
return false;
|
|
8860
9575
|
}
|
|
9576
|
+
/**
|
|
9577
|
+
* What this app can say about itself for the machine-wide presence claim.
|
|
9578
|
+
*
|
|
9579
|
+
* The claim is not "a desktop is connected" - the backend already knows that
|
|
9580
|
+
* from the socket. It is the two answers only the app has, and both are
|
|
9581
|
+
* read by the delivery ladder: `session_id` + `window` decide `attended`
|
|
9582
|
+
* (rung 1's "a watching surface already has it"), and a MISSING window is not
|
|
9583
|
+
* a missing value - it means this app is watching nothing, which is the
|
|
9584
|
+
* correct answer for the operator's own windowless case and the wrong answer
|
|
9585
|
+
* for a window that is merely minimised.
|
|
9586
|
+
*
|
|
9587
|
+
* `sessionId` comes from this notifier's own heartbeat map rather than from
|
|
9588
|
+
* the renderer, so it cannot disagree with the completion gate above: the
|
|
9589
|
+
* same value that suppresses a banner is the value that tells the backend
|
|
9590
|
+
* not to raise someone else's. An app that names a conversation it is not
|
|
9591
|
+
* showing suppresses the banner for a conversation nobody can see.
|
|
9592
|
+
*
|
|
9593
|
+
* Dead windows answer "no window" rather than "last known window": a
|
|
9594
|
+
* destroyed window has no `is*()` to read, and reporting its stale state
|
|
9595
|
+
* would advertise a surface that cannot display anything.
|
|
9596
|
+
*
|
|
9597
|
+
* THE SESSION IS READ BY THE ID THE WRITER USED (review round 2, R2-3).
|
|
9598
|
+
* `heartbeat` keys this map on `event.sender.id` — the WEB CONTENTS id —
|
|
9599
|
+
* because that is what the IPC event carries and what the close cleanup
|
|
9600
|
+
* captures. This accessor read `window.id`, the BrowserWindow id, which merely
|
|
9601
|
+
* happens to be equal early in a session and drifts as soon as anything else
|
|
9602
|
+
* is created (the browser host's own webContents, a second window). It then
|
|
9603
|
+
* reported a focused, visible window with `session_id: ""`, so the backend
|
|
9604
|
+
* could not recognise the conversation actually on screen and bannered it as
|
|
9605
|
+
* background.
|
|
9606
|
+
*/
|
|
9607
|
+
presence() {
|
|
9608
|
+
const window = this.window();
|
|
9609
|
+
if (!window || window.isDestroyed()) {
|
|
9610
|
+
return {
|
|
9611
|
+
sessionId: "",
|
|
9612
|
+
window: {
|
|
9613
|
+
exists: false,
|
|
9614
|
+
focused: false,
|
|
9615
|
+
visible: false,
|
|
9616
|
+
minimized: false
|
|
9617
|
+
}
|
|
9618
|
+
};
|
|
9619
|
+
}
|
|
9620
|
+
const reported = this.windows.get(window.webContents.id);
|
|
9621
|
+
const current = reported && Date.now() - reported.seenAt <= RENDERER_REPORT_TTL_MS;
|
|
9622
|
+
return {
|
|
9623
|
+
sessionId: current ? reported.session : "",
|
|
9624
|
+
window: {
|
|
9625
|
+
exists: true,
|
|
9626
|
+
focused: window.isFocused(),
|
|
9627
|
+
visible: window.isVisible(),
|
|
9628
|
+
minimized: window.isMinimized()
|
|
9629
|
+
}
|
|
9630
|
+
};
|
|
9631
|
+
}
|
|
8861
9632
|
/**
|
|
8862
9633
|
* Raise the banner for a pending approval or question.
|
|
8863
9634
|
*
|
|
@@ -8909,7 +9680,7 @@ class DesktopNotifier {
|
|
|
8909
9680
|
turn(sessionId2, seq) {
|
|
8910
9681
|
const key = `turn:${sessionId2}:${this.epochs.get(sessionId2) ?? ""}:${seq}`;
|
|
8911
9682
|
if (!this.claim(key)) return;
|
|
8912
|
-
if (this.
|
|
9683
|
+
if (this.anyDisplayingFocused(sessionId2)) return;
|
|
8913
9684
|
this.show(sessionId2, "Turn complete", "", "The agent finished its turn.");
|
|
8914
9685
|
}
|
|
8915
9686
|
claim(key) {
|
|
@@ -8973,9 +9744,12 @@ class DesktopNotifier {
|
|
|
8973
9744
|
});
|
|
8974
9745
|
notification.on("click", () => {
|
|
8975
9746
|
const target2 = this.window();
|
|
8976
|
-
if (!target2)
|
|
8977
|
-
|
|
9747
|
+
if (!target2 || target2.isDestroyed()) {
|
|
9748
|
+
this.host.reopen(sessionId2);
|
|
9749
|
+
return;
|
|
9750
|
+
}
|
|
8978
9751
|
target2.webContents.send("desktop-open-conversation", { sessionId: sessionId2 });
|
|
9752
|
+
raiseWindow(target2, this.windowRaise);
|
|
8979
9753
|
});
|
|
8980
9754
|
notification.show();
|
|
8981
9755
|
}
|
|
@@ -12728,6 +13502,311 @@ ${pipRun.stderr.trim()}`,
|
|
|
12728
13502
|
}
|
|
12729
13503
|
}
|
|
12730
13504
|
}
|
|
13505
|
+
const AUTH_TIMEOUT_MS = 5e3;
|
|
13506
|
+
const MAX_FRAME_BYTES = 64 * 1024;
|
|
13507
|
+
const SESSION_ID = /^[a-f0-9]{12}$/;
|
|
13508
|
+
class ViewerEndpoint {
|
|
13509
|
+
constructor(host, key) {
|
|
13510
|
+
this.host = host;
|
|
13511
|
+
this.key = key;
|
|
13512
|
+
}
|
|
13513
|
+
server = null;
|
|
13514
|
+
port = 0;
|
|
13515
|
+
get boundPort() {
|
|
13516
|
+
return this.port;
|
|
13517
|
+
}
|
|
13518
|
+
/**
|
|
13519
|
+
* Bind on loopback and answer the port.
|
|
13520
|
+
*
|
|
13521
|
+
* IDEMPOTENT, and that is load-bearing: a window can be recreated, and the
|
|
13522
|
+
* app's own `activate` path runs again after every close. A second bind
|
|
13523
|
+
* would leak a listener, and a leaked listener is worse than a leaked
|
|
13524
|
+
* source because the record then advertises a port nothing reads.
|
|
13525
|
+
*/
|
|
13526
|
+
start() {
|
|
13527
|
+
if (this.server) return Promise.resolve(this.port);
|
|
13528
|
+
return new Promise((resolve, reject) => {
|
|
13529
|
+
const server = node_net.createServer((socket) => this.onConnection(socket));
|
|
13530
|
+
server.on("error", reject);
|
|
13531
|
+
server.listen(0, "127.0.0.1", () => {
|
|
13532
|
+
const address = server.address();
|
|
13533
|
+
if (address === null || typeof address === "string") {
|
|
13534
|
+
server.close();
|
|
13535
|
+
reject(new Error("The viewer endpoint could not resolve its port."));
|
|
13536
|
+
return;
|
|
13537
|
+
}
|
|
13538
|
+
this.server = server;
|
|
13539
|
+
this.port = address.port;
|
|
13540
|
+
server.removeAllListeners("error");
|
|
13541
|
+
server.on("error", () => void 0);
|
|
13542
|
+
resolve(this.port);
|
|
13543
|
+
});
|
|
13544
|
+
});
|
|
13545
|
+
}
|
|
13546
|
+
/** Stop serving. Idempotent, and safe on a listener that never bound. */
|
|
13547
|
+
close() {
|
|
13548
|
+
const server = this.server;
|
|
13549
|
+
this.server = null;
|
|
13550
|
+
this.port = 0;
|
|
13551
|
+
if (!server) return;
|
|
13552
|
+
try {
|
|
13553
|
+
server.close();
|
|
13554
|
+
} catch {
|
|
13555
|
+
}
|
|
13556
|
+
}
|
|
13557
|
+
onConnection(socket) {
|
|
13558
|
+
let authenticated = false;
|
|
13559
|
+
let buffer = "";
|
|
13560
|
+
let closed = false;
|
|
13561
|
+
let chain = Promise.resolve();
|
|
13562
|
+
const finish = () => {
|
|
13563
|
+
closed = true;
|
|
13564
|
+
socket.destroy();
|
|
13565
|
+
};
|
|
13566
|
+
const authTimer = setTimeout(() => {
|
|
13567
|
+
if (!authenticated) finish();
|
|
13568
|
+
}, AUTH_TIMEOUT_MS);
|
|
13569
|
+
socket.setEncoding("utf8");
|
|
13570
|
+
socket.on("error", finish);
|
|
13571
|
+
socket.on("close", () => clearTimeout(authTimer));
|
|
13572
|
+
socket.on("data", (chunk) => {
|
|
13573
|
+
if (closed) return;
|
|
13574
|
+
buffer += chunk;
|
|
13575
|
+
if (buffer.length > MAX_FRAME_BYTES) {
|
|
13576
|
+
finish();
|
|
13577
|
+
return;
|
|
13578
|
+
}
|
|
13579
|
+
for (; ; ) {
|
|
13580
|
+
const newline = buffer.indexOf("\n");
|
|
13581
|
+
if (newline < 0) break;
|
|
13582
|
+
const line = buffer.slice(0, newline);
|
|
13583
|
+
buffer = buffer.slice(newline + 1);
|
|
13584
|
+
chain = chain.then(
|
|
13585
|
+
() => this.onLine(line, socket, {
|
|
13586
|
+
isAuthenticated: () => authenticated,
|
|
13587
|
+
markAuthenticated: () => {
|
|
13588
|
+
authenticated = true;
|
|
13589
|
+
clearTimeout(authTimer);
|
|
13590
|
+
},
|
|
13591
|
+
finish
|
|
13592
|
+
}).catch(() => void 0)
|
|
13593
|
+
);
|
|
13594
|
+
}
|
|
13595
|
+
});
|
|
13596
|
+
}
|
|
13597
|
+
async onLine(line, socket, state) {
|
|
13598
|
+
let frame;
|
|
13599
|
+
try {
|
|
13600
|
+
frame = JSON.parse(line);
|
|
13601
|
+
} catch {
|
|
13602
|
+
return;
|
|
13603
|
+
}
|
|
13604
|
+
if (typeof frame !== "object" || frame === null) return;
|
|
13605
|
+
const record = frame;
|
|
13606
|
+
if (!state.isAuthenticated()) {
|
|
13607
|
+
const key = typeof record.key === "string" ? record.key : "";
|
|
13608
|
+
if (!constantTimeEqual(key, this.key)) {
|
|
13609
|
+
state.finish();
|
|
13610
|
+
return;
|
|
13611
|
+
}
|
|
13612
|
+
state.markAuthenticated();
|
|
13613
|
+
return;
|
|
13614
|
+
}
|
|
13615
|
+
const op = typeof record.op === "string" ? record.op : "";
|
|
13616
|
+
const req = record.req;
|
|
13617
|
+
let reply;
|
|
13618
|
+
try {
|
|
13619
|
+
reply = { op: "ack", req, detail: await this.apply(op, record) };
|
|
13620
|
+
} catch (error) {
|
|
13621
|
+
reply = {
|
|
13622
|
+
op: "error",
|
|
13623
|
+
req,
|
|
13624
|
+
detail: error instanceof Error ? error.message : "refused"
|
|
13625
|
+
};
|
|
13626
|
+
}
|
|
13627
|
+
try {
|
|
13628
|
+
socket.write(`${JSON.stringify(reply)}
|
|
13629
|
+
`);
|
|
13630
|
+
} catch {
|
|
13631
|
+
state.finish();
|
|
13632
|
+
}
|
|
13633
|
+
}
|
|
13634
|
+
async apply(op, frame) {
|
|
13635
|
+
if (op === "resume_session") {
|
|
13636
|
+
const sessionId2 = String(frame.session_id ?? "");
|
|
13637
|
+
if (!SESSION_ID.test(sessionId2)) {
|
|
13638
|
+
throw new Error("resume_session needs a session id");
|
|
13639
|
+
}
|
|
13640
|
+
return await this.host.resumeSession(sessionId2);
|
|
13641
|
+
}
|
|
13642
|
+
if (op === "focus_window") {
|
|
13643
|
+
return await this.host.focusWindow();
|
|
13644
|
+
}
|
|
13645
|
+
throw new Error(`unknown viewer op: ${op || "(none)"}`);
|
|
13646
|
+
}
|
|
13647
|
+
}
|
|
13648
|
+
function constantTimeEqual(actual, expected) {
|
|
13649
|
+
const a = Buffer.from(actual, "utf8");
|
|
13650
|
+
const b = Buffer.from(expected, "utf8");
|
|
13651
|
+
if (a.length !== b.length) {
|
|
13652
|
+
node_crypto.timingSafeEqual(b, b);
|
|
13653
|
+
return false;
|
|
13654
|
+
}
|
|
13655
|
+
return node_crypto.timingSafeEqual(a, b);
|
|
13656
|
+
}
|
|
13657
|
+
const VIEWER_RUN_DIRNAME = "run/viewers";
|
|
13658
|
+
const VIEWER_PROTOCOL = 1;
|
|
13659
|
+
const FOCUS_WINDOW_CAPABILITY = "focus-window-v1";
|
|
13660
|
+
const DESKTOP_NOTIFY_CAPABILITY = "desktop-notify-v1";
|
|
13661
|
+
const VIEWER_HEARTBEAT_INTERVAL_MS = 15e3;
|
|
13662
|
+
function viewerRunDir(env = process.env) {
|
|
13663
|
+
const root = env.LOCAL_OPERATOR_CONFIG_DIR || path$1.join(os.homedir(), ".local-operator");
|
|
13664
|
+
return path$1.join(root, VIEWER_RUN_DIRNAME);
|
|
13665
|
+
}
|
|
13666
|
+
function publishViewerRecord(record, dir = viewerRunDir()) {
|
|
13667
|
+
fs.mkdirSync(dir, { recursive: true, mode: 448 });
|
|
13668
|
+
fs.chmodSync(dir, 448);
|
|
13669
|
+
const target2 = path$1.join(dir, `${record.pid}.json`);
|
|
13670
|
+
const temporary = path$1.join(
|
|
13671
|
+
dir,
|
|
13672
|
+
`.${record.pid}.${node_crypto.randomBytes(6).toString("hex")}.tmp`
|
|
13673
|
+
);
|
|
13674
|
+
fs.writeFileSync(temporary, JSON.stringify(record), { mode: 384 });
|
|
13675
|
+
fs.renameSync(temporary, target2);
|
|
13676
|
+
return target2;
|
|
13677
|
+
}
|
|
13678
|
+
function unpublishViewerRecord(pid, dir = viewerRunDir()) {
|
|
13679
|
+
try {
|
|
13680
|
+
fs.unlinkSync(path$1.join(dir, `${pid}.json`));
|
|
13681
|
+
} catch {
|
|
13682
|
+
}
|
|
13683
|
+
}
|
|
13684
|
+
class ViewerRecordPublisher {
|
|
13685
|
+
constructor(dir = viewerRunDir(), pid = process.pid) {
|
|
13686
|
+
this.dir = dir;
|
|
13687
|
+
this.record = {
|
|
13688
|
+
pid,
|
|
13689
|
+
surface: "desktop",
|
|
13690
|
+
// 0 until the listener binds. A record advertising port 0 is not
|
|
13691
|
+
// dialable, which is the honest answer during the gap and the reason
|
|
13692
|
+
// the endpoint stamps the real port before anything else.
|
|
13693
|
+
control_port: 0,
|
|
13694
|
+
// The whole authorization story: 32 random bytes, 0600 on disk.
|
|
13695
|
+
control_key: node_crypto.randomBytes(32).toString("hex"),
|
|
13696
|
+
current_session: "",
|
|
13697
|
+
// True even with no window: `resume_session` RECREATES the window and
|
|
13698
|
+
// then navigates (B3), so routing a click here is still the cheapest
|
|
13699
|
+
// landing site on a machine where the app is alive in the dock. What
|
|
13700
|
+
// the record must not claim while no window exists is a CONVERSATION
|
|
13701
|
+
// (`current_session` stays `""`), because that field is what lets a
|
|
13702
|
+
// click skip the switch.
|
|
13703
|
+
can_switch: true,
|
|
13704
|
+
focused_at: 0,
|
|
13705
|
+
protocol: VIEWER_PROTOCOL,
|
|
13706
|
+
started_at: Date.now() / 1e3,
|
|
13707
|
+
heartbeat_at: Date.now() / 1e3,
|
|
13708
|
+
capabilities: [FOCUS_WINDOW_CAPABILITY, DESKTOP_NOTIFY_CAPABILITY]
|
|
13709
|
+
};
|
|
13710
|
+
}
|
|
13711
|
+
record;
|
|
13712
|
+
timer = null;
|
|
13713
|
+
stopped = false;
|
|
13714
|
+
/**
|
|
13715
|
+
* Whether anything has been written yet.
|
|
13716
|
+
*
|
|
13717
|
+
* `setControlPort` lands BEFORE `start` in the app's own ordering (the port
|
|
13718
|
+
* only exists once the listener has bound, and the listener is what the
|
|
13719
|
+
* record advertises), so a publisher that wrote on every setter would put a
|
|
13720
|
+
* port-0 record in the directory for a moment — and a reader that dialled it
|
|
13721
|
+
* would spend its whole dial timeout on nothing. Mutations accumulate; the
|
|
13722
|
+
* first write is `start`'s.
|
|
13723
|
+
*/
|
|
13724
|
+
started = false;
|
|
13725
|
+
get controlKey() {
|
|
13726
|
+
return this.record.control_key;
|
|
13727
|
+
}
|
|
13728
|
+
/** Record the port the control listener actually bound, and publish. */
|
|
13729
|
+
setControlPort(port) {
|
|
13730
|
+
this.record.control_port = port;
|
|
13731
|
+
this.publish();
|
|
13732
|
+
}
|
|
13733
|
+
/**
|
|
13734
|
+
* Record which conversation is on screen now.
|
|
13735
|
+
*
|
|
13736
|
+
* An early return on an unchanged value keeps the 15 s beat from being a
|
|
13737
|
+
* disk write at navigation cadence: a re-render that re-asserts the same
|
|
13738
|
+
* session costs nothing.
|
|
13739
|
+
*/
|
|
13740
|
+
noteSession(sessionId2) {
|
|
13741
|
+
if (this.record.current_session === sessionId2) return;
|
|
13742
|
+
this.record.current_session = sessionId2;
|
|
13743
|
+
this.publish();
|
|
13744
|
+
}
|
|
13745
|
+
/**
|
|
13746
|
+
* Withdraw the report that THIS conversation is on screen, because the pane
|
|
13747
|
+
* that made it has left it (QA round 2, Q1).
|
|
13748
|
+
*
|
|
13749
|
+
* `current_session` is the record's answer to "which conversation should a
|
|
13750
|
+
* banner click land on", so leaving it naming a conversation the user has
|
|
13751
|
+
* navigated away from is not a stale label — it is a wrong destination the
|
|
13752
|
+
* backend then confirms: `needs_switch` is false for a match, so the click is
|
|
13753
|
+
* answered with a raise, that branch reports success, and no later rung of the
|
|
13754
|
+
* click ladder is tried. The user gets whatever the window happens to show
|
|
13755
|
+
* (the catalogue, Settings) instead of the conversation they were called back
|
|
13756
|
+
* to, and this branch's own presence withdrawal is what makes that click
|
|
13757
|
+
* `focus_policy: "always"` — the case the feature exists for.
|
|
13758
|
+
*
|
|
13759
|
+
* IDENTITY-SAFE, like the notifier's own `releaseWatch` and for the same
|
|
13760
|
+
* reason: the release runs from a renderer cleanup, after the pane has already
|
|
13761
|
+
* changed, so an unconditional clear would wipe a session a DEEPER pane has
|
|
13762
|
+
* since reported. An EMPTY id withdraws nothing — `""` is this record's own
|
|
13763
|
+
* "nothing is on screen" value, so a caller sending it is not naming a
|
|
13764
|
+
* conversation to leave.
|
|
13765
|
+
*
|
|
13766
|
+
* Losing the report is self-healing rather than lossy: a pane that still holds
|
|
13767
|
+
* the conversation re-asserts it on its next heartbeat, which is the same
|
|
13768
|
+
* 15 s beat that wrote it.
|
|
13769
|
+
*/
|
|
13770
|
+
releaseSession(sessionId2) {
|
|
13771
|
+
if (!sessionId2 || this.record.current_session !== sessionId2) return;
|
|
13772
|
+
this.record.current_session = "";
|
|
13773
|
+
this.publish();
|
|
13774
|
+
}
|
|
13775
|
+
/** Stamp the focus GAIN edge only. A blur carries no routing information. */
|
|
13776
|
+
noteFocused() {
|
|
13777
|
+
this.record.focused_at = Date.now() / 1e3;
|
|
13778
|
+
this.publish();
|
|
13779
|
+
}
|
|
13780
|
+
/** Begin the beat, and write the record for the first time. Idempotent,
|
|
13781
|
+
* because a window can be recreated. */
|
|
13782
|
+
start() {
|
|
13783
|
+
if (this.timer || this.stopped) return;
|
|
13784
|
+
this.started = true;
|
|
13785
|
+
this.publish();
|
|
13786
|
+
this.timer = setInterval(
|
|
13787
|
+
() => this.publish(),
|
|
13788
|
+
VIEWER_HEARTBEAT_INTERVAL_MS
|
|
13789
|
+
);
|
|
13790
|
+
this.timer.unref?.();
|
|
13791
|
+
}
|
|
13792
|
+
/** Stop beating and remove the record. Safe to call twice. */
|
|
13793
|
+
stop() {
|
|
13794
|
+
this.stopped = true;
|
|
13795
|
+
if (this.timer) {
|
|
13796
|
+
clearInterval(this.timer);
|
|
13797
|
+
this.timer = null;
|
|
13798
|
+
}
|
|
13799
|
+
unpublishViewerRecord(this.record.pid, this.dir);
|
|
13800
|
+
}
|
|
13801
|
+
publish() {
|
|
13802
|
+
if (this.stopped || !this.started) return;
|
|
13803
|
+
this.record.heartbeat_at = Date.now() / 1e3;
|
|
13804
|
+
try {
|
|
13805
|
+
publishViewerRecord(this.record, this.dir);
|
|
13806
|
+
} catch {
|
|
13807
|
+
}
|
|
13808
|
+
}
|
|
13809
|
+
}
|
|
12731
13810
|
const WINDOW_MODES = ["normal", "headless", "inactive"];
|
|
12732
13811
|
const WINDOW_MODE_ENV = "LOCAL_OPERATOR_UI_WINDOW_MODE";
|
|
12733
13812
|
const WINDOW_SIZE_ENV = "LOCAL_OPERATOR_UI_WINDOW_SIZE";
|
|
@@ -12852,16 +13931,16 @@ function createApplicationMenu() {
|
|
|
12852
13931
|
const isDev = Boolean(process.env.ELECTRON_RENDERER_URL);
|
|
12853
13932
|
const zoomInHandler = () => {
|
|
12854
13933
|
if (mainWindow) {
|
|
12855
|
-
const
|
|
12856
|
-
const currentZoom =
|
|
12857
|
-
|
|
13934
|
+
const webContents2 = mainWindow.webContents;
|
|
13935
|
+
const currentZoom = webContents2.getZoomFactor();
|
|
13936
|
+
webContents2.setZoomFactor(currentZoom + 0.1);
|
|
12858
13937
|
}
|
|
12859
13938
|
};
|
|
12860
13939
|
const zoomOutHandler = () => {
|
|
12861
13940
|
if (mainWindow) {
|
|
12862
|
-
const
|
|
12863
|
-
const currentZoom =
|
|
12864
|
-
|
|
13941
|
+
const webContents2 = mainWindow.webContents;
|
|
13942
|
+
const currentZoom = webContents2.getZoomFactor();
|
|
13943
|
+
webContents2.setZoomFactor(currentZoom - 0.1);
|
|
12865
13944
|
}
|
|
12866
13945
|
};
|
|
12867
13946
|
const actualSizeHandler = () => {
|
|
@@ -12986,7 +14065,7 @@ function createApplicationMenu() {
|
|
|
12986
14065
|
const menu = electron.Menu.buildFromTemplate(template);
|
|
12987
14066
|
electron.Menu.setApplicationMenu(menu);
|
|
12988
14067
|
}
|
|
12989
|
-
function createWindow() {
|
|
14068
|
+
function createWindow(initialSession = null, openCatalogue = false) {
|
|
12990
14069
|
const mainWindow2 = new electron.BrowserWindow({
|
|
12991
14070
|
width: windowLaunch.width,
|
|
12992
14071
|
height: windowLaunch.height,
|
|
@@ -13033,16 +14112,33 @@ function createWindow() {
|
|
|
13033
14112
|
// than from anything this app controls, so hold the page at full
|
|
13034
14113
|
// rate explicitly instead of trusting the platform. `normal`
|
|
13035
14114
|
// keeps Electron's own default.
|
|
13036
|
-
backgroundThrottling: windowLaunch.backgroundThrottling
|
|
14115
|
+
backgroundThrottling: windowLaunch.backgroundThrottling,
|
|
14116
|
+
/*
|
|
14117
|
+
* The conversation this window is being created FOR, in the renderer
|
|
14118
|
+
* process's own argv so preload can read it before the first paint (B3).
|
|
14119
|
+
*
|
|
14120
|
+
* Omitted entirely rather than set to an empty value on an ordinary
|
|
14121
|
+
* launch: the preload parses a flag out of argv, and an empty one would
|
|
14122
|
+
* be a value it then has to decide means "no session".
|
|
14123
|
+
*
|
|
14124
|
+
* `openCatalogue` is the THIRD intent (R2-1): a burst digest's click has
|
|
14125
|
+
* no single conversation to name, and "no flag at all" already means
|
|
14126
|
+
* "restore what you had open" — so it needs a flag of its own rather than
|
|
14127
|
+
* sharing that absence.
|
|
14128
|
+
*/
|
|
14129
|
+
...launchArgumentFlags(initialSession, openCatalogue)
|
|
13037
14130
|
}
|
|
13038
14131
|
});
|
|
14132
|
+
if (initialSession) holdPresentUntilConversation(mainWindow2, initialSession);
|
|
13039
14133
|
mainWindow2.webContents.on("console-message", (_event, level, message) => {
|
|
13040
14134
|
if (level < 2) return;
|
|
13041
14135
|
const write = level >= 3 ? logger.error : logger.warn;
|
|
13042
14136
|
write.call(logger, `[renderer] ${message}`, LogFileType.BACKEND);
|
|
13043
14137
|
});
|
|
13044
14138
|
mainWindow2.on("ready-to-show", () => {
|
|
13045
|
-
|
|
14139
|
+
if (!heldForConversation.has(mainWindow2.id)) {
|
|
14140
|
+
presentWindow(mainWindow2, windowLaunch.show);
|
|
14141
|
+
}
|
|
13046
14142
|
if (windowLaunch.mode === "normal") return;
|
|
13047
14143
|
setTimeout(() => {
|
|
13048
14144
|
if (!mainWindow2 || mainWindow2.isDestroyed()) return;
|
|
@@ -13118,6 +14214,7 @@ function createWindow() {
|
|
|
13118
14214
|
} else {
|
|
13119
14215
|
mainWindow2.loadFile(path$1.join(__dirname, "../renderer/index.html"));
|
|
13120
14216
|
}
|
|
14217
|
+
attachBrowserHostToWindow?.(mainWindow2);
|
|
13121
14218
|
return mainWindow2;
|
|
13122
14219
|
}
|
|
13123
14220
|
const backendService = new BackendServiceManager();
|
|
@@ -13145,18 +14242,57 @@ if (windowLaunch.mode !== "normal") {
|
|
|
13145
14242
|
}
|
|
13146
14243
|
let mainWindow = null;
|
|
13147
14244
|
let activeUpdateService = null;
|
|
14245
|
+
const launchTarget = readLaunchTarget(process.argv);
|
|
14246
|
+
const launchSession = launchTarget.kind === "session" ? launchTarget.sessionId : null;
|
|
14247
|
+
const launchCatalogue = launchTarget.kind === "catalogue";
|
|
14248
|
+
function launchArgumentFlags(initialSession, openCatalogue) {
|
|
14249
|
+
if (initialSession) {
|
|
14250
|
+
return { additionalArguments: [`${OPEN_SESSION_FLAG}=${initialSession}`] };
|
|
14251
|
+
}
|
|
14252
|
+
if (openCatalogue) return { additionalArguments: [OPEN_CATALOGUE_FLAG] };
|
|
14253
|
+
return {};
|
|
14254
|
+
}
|
|
14255
|
+
let queuedSession = null;
|
|
14256
|
+
let viewerRecord = null;
|
|
14257
|
+
let viewerEndpoint = null;
|
|
14258
|
+
let openConversationInWindow = null;
|
|
14259
|
+
let attachBrowserHostToWindow = null;
|
|
14260
|
+
const CONVERSATION_PAINT_FALLBACK_MS = 3e3;
|
|
14261
|
+
const heldForConversation = /* @__PURE__ */ new Map();
|
|
14262
|
+
function holdPresentUntilConversation(window, session) {
|
|
14263
|
+
const timer = setTimeout(() => {
|
|
14264
|
+
releaseHeldWindow(window.id);
|
|
14265
|
+
}, CONVERSATION_PAINT_FALLBACK_MS);
|
|
14266
|
+
timer.unref?.();
|
|
14267
|
+
heldForConversation.set(window.id, { session, timer });
|
|
14268
|
+
}
|
|
14269
|
+
function releaseHeldWindow(windowId) {
|
|
14270
|
+
const held = heldForConversation.get(windowId);
|
|
14271
|
+
if (!held) return false;
|
|
14272
|
+
clearTimeout(held.timer);
|
|
14273
|
+
heldForConversation.delete(windowId);
|
|
14274
|
+
const window = electron.BrowserWindow.fromId(windowId);
|
|
14275
|
+
if (window && !window.isDestroyed()) presentWindow(window, windowLaunch.show);
|
|
14276
|
+
return true;
|
|
14277
|
+
}
|
|
14278
|
+
function releaseHeldWindowFor(session) {
|
|
14279
|
+
for (const [windowId, held] of heldForConversation) {
|
|
14280
|
+
if (held.session !== session) continue;
|
|
14281
|
+
releaseHeldWindow(windowId);
|
|
14282
|
+
}
|
|
14283
|
+
}
|
|
13148
14284
|
const zoomInFromEvent = () => {
|
|
13149
14285
|
if (mainWindow) {
|
|
13150
|
-
const
|
|
13151
|
-
const currentZoom =
|
|
13152
|
-
|
|
14286
|
+
const webContents2 = mainWindow.webContents;
|
|
14287
|
+
const currentZoom = webContents2.getZoomFactor();
|
|
14288
|
+
webContents2.setZoomFactor(currentZoom + 0.1);
|
|
13153
14289
|
}
|
|
13154
14290
|
};
|
|
13155
14291
|
const zoomOutFromEvent = () => {
|
|
13156
14292
|
if (mainWindow) {
|
|
13157
|
-
const
|
|
13158
|
-
const currentZoom =
|
|
13159
|
-
|
|
14293
|
+
const webContents2 = mainWindow.webContents;
|
|
14294
|
+
const currentZoom = webContents2.getZoomFactor();
|
|
14295
|
+
webContents2.setZoomFactor(currentZoom - 0.1);
|
|
13160
14296
|
}
|
|
13161
14297
|
};
|
|
13162
14298
|
const actualSizeFromEvent = () => {
|
|
@@ -13170,6 +14306,12 @@ if (!gotTheLock) {
|
|
|
13170
14306
|
electron.app.quit();
|
|
13171
14307
|
} else {
|
|
13172
14308
|
electron.app.on("second-instance", (_event, commandLine) => {
|
|
14309
|
+
const requested = readOpenSessionArgv(commandLine);
|
|
14310
|
+
if (requested) {
|
|
14311
|
+
if (openConversationInWindow) openConversationInWindow(requested);
|
|
14312
|
+
else queuedSession = requested;
|
|
14313
|
+
return;
|
|
14314
|
+
}
|
|
13173
14315
|
if (mainWindow) {
|
|
13174
14316
|
raiseWindow(mainWindow, windowLaunch.show);
|
|
13175
14317
|
}
|
|
@@ -13194,10 +14336,45 @@ electron.app.whenReady().then(async () => {
|
|
|
13194
14336
|
const desktopNotifier = new DesktopNotifier(
|
|
13195
14337
|
() => mainWindow,
|
|
13196
14338
|
sendDesktop,
|
|
13197
|
-
windowLaunch.show
|
|
14339
|
+
windowLaunch.show,
|
|
14340
|
+
{
|
|
14341
|
+
/*
|
|
14342
|
+
* Whether a window we have a heartbeat from is still there.
|
|
14343
|
+
*
|
|
14344
|
+
* `forgetWindow` is called on `closed`, which covers every ordinary
|
|
14345
|
+
* close — but a renderer process that dies takes its `webContents` id
|
|
14346
|
+
* with it and no `closed` event reaches the notifier, and those ids
|
|
14347
|
+
* are not reused. Filtering on liveness is what makes a stale
|
|
14348
|
+
* `{visible: true, focused: true}` entry unable to suppress every
|
|
14349
|
+
* completion for the rest of the run.
|
|
14350
|
+
*/
|
|
14351
|
+
windowAlive: (windowId) => {
|
|
14352
|
+
const contents = electron.webContents.fromId(windowId);
|
|
14353
|
+
return Boolean(contents && !contents.isDestroyed());
|
|
14354
|
+
},
|
|
14355
|
+
// A banner click with no window recreates one and navigates it. The
|
|
14356
|
+
// queue lives in `openSessionInWindow` because window creation does.
|
|
14357
|
+
reopen: (sessionId2) => openSessionInWindow(sessionId2),
|
|
14358
|
+
/*
|
|
14359
|
+
* The renderer's report that this conversation is on screen. Two
|
|
14360
|
+
* consumers, and neither is delivery:
|
|
14361
|
+
*
|
|
14362
|
+
* - the viewer record's `current_session`, which is how a later click
|
|
14363
|
+
* routes here and how it skips a redundant switch;
|
|
14364
|
+
* - the held first present of a click-created window, where this report
|
|
14365
|
+
* IS the "conversation applied" milestone `ready-to-show` is not
|
|
14366
|
+
* (B3).
|
|
14367
|
+
*/
|
|
14368
|
+
noteDisplayed: (sessionId2) => {
|
|
14369
|
+
viewerRecord?.noteSession(sessionId2);
|
|
14370
|
+
releaseHeldWindowFor(sessionId2);
|
|
14371
|
+
}
|
|
14372
|
+
}
|
|
13198
14373
|
);
|
|
13199
14374
|
backendService.onBackendReady(() => {
|
|
13200
14375
|
void desktopNotifier.refreshNotificationContract();
|
|
14376
|
+
void backendService.getDesktopFeedRelay().start();
|
|
14377
|
+
void publishViewerPresence();
|
|
13201
14378
|
});
|
|
13202
14379
|
backendService.observeStream((sessionId2, data) => {
|
|
13203
14380
|
try {
|
|
@@ -13206,13 +14383,85 @@ electron.app.whenReady().then(async () => {
|
|
|
13206
14383
|
}
|
|
13207
14384
|
});
|
|
13208
14385
|
const rendererUrl = process.env.ELECTRON_RENDERER_URL || node_url.pathToFileURL(path$1.join(__dirname, "../renderer/index.html")).href;
|
|
14386
|
+
backendService.observeDesktopFeed(
|
|
14387
|
+
(frame) => {
|
|
14388
|
+
if (frame.type === "notification") {
|
|
14389
|
+
desktopNotifier.observe(frame.session_id, frame);
|
|
14390
|
+
return;
|
|
14391
|
+
}
|
|
14392
|
+
const window = mainWindow;
|
|
14393
|
+
if (!window || window.isDestroyed()) return;
|
|
14394
|
+
window.webContents.send("desktop-feed-frame", frame);
|
|
14395
|
+
},
|
|
14396
|
+
(state) => {
|
|
14397
|
+
const window = mainWindow;
|
|
14398
|
+
if (!window || window.isDestroyed()) return;
|
|
14399
|
+
window.webContents.send("desktop-feed-state", state);
|
|
14400
|
+
}
|
|
14401
|
+
);
|
|
14402
|
+
backendService.providePresenceContext(() => desktopNotifier.presence());
|
|
14403
|
+
electron.ipcMain.on("desktop-feed-watch", () => {
|
|
14404
|
+
const window = mainWindow;
|
|
14405
|
+
if (!window || window.isDestroyed()) return;
|
|
14406
|
+
window.webContents.send("desktop-feed-state", {
|
|
14407
|
+
connected: backendService.getDesktopFeedRelay().isConnected
|
|
14408
|
+
});
|
|
14409
|
+
});
|
|
14410
|
+
const record = new ViewerRecordPublisher();
|
|
14411
|
+
const endpoint2 = new ViewerEndpoint(
|
|
14412
|
+
{
|
|
14413
|
+
resumeSession: async (sessionId2) => {
|
|
14414
|
+
openSessionInWindow(sessionId2);
|
|
14415
|
+
return `showing ${sessionId2}`;
|
|
14416
|
+
},
|
|
14417
|
+
focusWindow: async () => {
|
|
14418
|
+
const window = mainWindow;
|
|
14419
|
+
if (window && !window.isDestroyed()) {
|
|
14420
|
+
raiseWindow(window, windowLaunch.show);
|
|
14421
|
+
return "raised the window";
|
|
14422
|
+
}
|
|
14423
|
+
setupMainWindowWithUpdateService();
|
|
14424
|
+
return "opened a window";
|
|
14425
|
+
}
|
|
14426
|
+
},
|
|
14427
|
+
record.controlKey
|
|
14428
|
+
);
|
|
14429
|
+
viewerRecord = record;
|
|
14430
|
+
viewerEndpoint = endpoint2;
|
|
14431
|
+
let viewerPublished = false;
|
|
14432
|
+
async function publishViewerPresence() {
|
|
14433
|
+
if (viewerPublished) return;
|
|
14434
|
+
let features;
|
|
14435
|
+
try {
|
|
14436
|
+
const response = await backendService.requestDesktop({
|
|
14437
|
+
op: "capabilities"
|
|
14438
|
+
});
|
|
14439
|
+
const body = response.body;
|
|
14440
|
+
features = body?.result?.features;
|
|
14441
|
+
} catch {
|
|
14442
|
+
return;
|
|
14443
|
+
}
|
|
14444
|
+
if (!(Number(features?.desktop_presence ?? 0) >= 1)) return;
|
|
14445
|
+
try {
|
|
14446
|
+
const port = await endpoint2.start();
|
|
14447
|
+
record.setControlPort(port);
|
|
14448
|
+
record.start();
|
|
14449
|
+
viewerPublished = true;
|
|
14450
|
+
} catch (error) {
|
|
14451
|
+
logger.warn(
|
|
14452
|
+
`Viewer endpoint did not bind, so no record is published: ${String(error)}`,
|
|
14453
|
+
LogFileType.BACKEND
|
|
14454
|
+
);
|
|
14455
|
+
}
|
|
14456
|
+
}
|
|
13209
14457
|
registerDesktopIPC(
|
|
13210
14458
|
() => mainWindow,
|
|
13211
14459
|
rendererUrl,
|
|
13212
14460
|
sendDesktop,
|
|
13213
14461
|
() => backendService.getStreamRelay(),
|
|
13214
14462
|
(input, bytes) => backendService.requestDesktopMedia(input, bytes),
|
|
13215
|
-
desktopNotifier
|
|
14463
|
+
desktopNotifier,
|
|
14464
|
+
(sessionId2) => viewerRecord?.releaseSession(sessionId2)
|
|
13216
14465
|
);
|
|
13217
14466
|
electron.ipcMain.handle("open-file", async (_, filePath) => {
|
|
13218
14467
|
try {
|
|
@@ -13522,8 +14771,9 @@ electron.app.whenReady().then(async () => {
|
|
|
13522
14771
|
}
|
|
13523
14772
|
createApplicationMenu();
|
|
13524
14773
|
let updateService = null;
|
|
13525
|
-
function setupMainWindowWithUpdateService() {
|
|
13526
|
-
mainWindow = createWindow();
|
|
14774
|
+
function setupMainWindowWithUpdateService(initialSession = null, openCatalogue = false) {
|
|
14775
|
+
mainWindow = createWindow(initialSession, openCatalogue);
|
|
14776
|
+
const windowId = mainWindow.webContents.id;
|
|
13527
14777
|
if (mainWindow) {
|
|
13528
14778
|
mainWindow.webContents.on("before-input-event", (event, input) => {
|
|
13529
14779
|
const isCmdOrCtrl = input.control || input.meta;
|
|
@@ -13552,8 +14802,17 @@ electron.app.whenReady().then(async () => {
|
|
|
13552
14802
|
updateService.dispose();
|
|
13553
14803
|
updateService = null;
|
|
13554
14804
|
}
|
|
14805
|
+
desktopNotifier.forgetWindow(windowId);
|
|
14806
|
+
if (electron.BrowserWindow.getAllWindows().length === 0)
|
|
14807
|
+
viewerRecord?.noteSession("");
|
|
14808
|
+
if (heldForConversation.has(windowId)) {
|
|
14809
|
+
const held = heldForConversation.get(windowId);
|
|
14810
|
+
if (held) clearTimeout(held.timer);
|
|
14811
|
+
heldForConversation.delete(windowId);
|
|
14812
|
+
}
|
|
13555
14813
|
mainWindow = null;
|
|
13556
14814
|
});
|
|
14815
|
+
mainWindow.on("focus", () => viewerRecord?.noteFocused());
|
|
13557
14816
|
updateService.setupIpcHandlers();
|
|
13558
14817
|
updateService.handlePlatformSpecifics();
|
|
13559
14818
|
setTimeout(() => {
|
|
@@ -13599,12 +14858,28 @@ electron.app.whenReady().then(async () => {
|
|
|
13599
14858
|
);
|
|
13600
14859
|
}
|
|
13601
14860
|
}
|
|
13602
|
-
|
|
13603
|
-
|
|
14861
|
+
function openSessionInWindow(sessionId2) {
|
|
14862
|
+
const window = mainWindow;
|
|
14863
|
+
if (window && !window.isDestroyed()) {
|
|
14864
|
+
window.webContents.send("desktop-open-conversation", { sessionId: sessionId2 });
|
|
14865
|
+
raiseWindow(window, windowLaunch.show);
|
|
14866
|
+
return;
|
|
14867
|
+
}
|
|
14868
|
+
setupMainWindowWithUpdateService(sessionId2, sessionId2 === null);
|
|
14869
|
+
}
|
|
14870
|
+
openConversationInWindow = openSessionInWindow;
|
|
14871
|
+
attachBrowserHostToWindow = (window) => {
|
|
14872
|
+
void startBrowserHostForWindow(window);
|
|
14873
|
+
};
|
|
14874
|
+
setupMainWindowWithUpdateService(launchSession, launchCatalogue);
|
|
14875
|
+
if (queuedSession) {
|
|
14876
|
+
const queued = queuedSession;
|
|
14877
|
+
queuedSession = null;
|
|
14878
|
+
openSessionInWindow(queued);
|
|
14879
|
+
}
|
|
13604
14880
|
electron.app.on("activate", () => {
|
|
13605
14881
|
if (electron.BrowserWindow.getAllWindows().length === 0) {
|
|
13606
14882
|
setupMainWindowWithUpdateService();
|
|
13607
|
-
if (mainWindow) void startBrowserHostForWindow(mainWindow);
|
|
13608
14883
|
}
|
|
13609
14884
|
});
|
|
13610
14885
|
}).catch((error) => {
|
|
@@ -13640,6 +14915,8 @@ const QUIT_FAILSAFE_MARGIN_MS = 5e3;
|
|
|
13640
14915
|
const QUIT_CLEANUP_FAILSAFE_MS = CONSOLE_RESOLUTION_WORST_MS + INTERPRETER_RESOLUTION_WORST_MS + OWNED_STOP_WORST_MS + READINESS_POLL_INTERVAL_MS + QUIT_FAILSAFE_MARGIN_MS;
|
|
13641
14916
|
let backendQuitPending = false;
|
|
13642
14917
|
electron.app.on("will-quit", (event) => {
|
|
14918
|
+
viewerEndpoint?.close();
|
|
14919
|
+
viewerRecord?.stop();
|
|
13643
14920
|
if (backendService.isOwnedCleanupComplete()) return;
|
|
13644
14921
|
event.preventDefault();
|
|
13645
14922
|
if (backendQuitPending) return;
|