@soederpop/luca 0.0.25 → 0.0.28
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/docs/examples/assistant-with-process-manager.md +84 -0
- package/docs/examples/websocket-ask-and-reply-example.md +128 -0
- package/docs/window-manager-fix.md +249 -0
- package/package.json +1 -1
- package/src/agi/features/assistant.ts +75 -13
- package/src/agi/features/docs-reader.ts +25 -1
- package/src/bootstrap/generated.ts +215 -1
- package/src/cli/build-info.ts +2 -2
- package/src/clients/websocket.ts +76 -1
- package/src/command.ts +75 -0
- package/src/commands/describe.ts +29 -1089
- package/src/container-describer.ts +1098 -0
- package/src/container.ts +11 -0
- package/src/helper.ts +29 -2
- package/src/introspection/generated.agi.ts +1315 -611
- package/src/introspection/generated.node.ts +1168 -552
- package/src/introspection/generated.web.ts +9 -1
- package/src/node/features/content-db.ts +17 -0
- package/src/node/features/fs.ts +18 -0
- package/src/node/features/ipc-socket.ts +370 -180
- package/src/node/features/process-manager.ts +316 -49
- package/src/node/features/window-manager.ts +843 -235
- package/src/scaffolds/generated.ts +1 -1
- package/src/server.ts +40 -0
- package/src/servers/express.ts +2 -0
- package/src/servers/mcp.ts +1 -0
- package/src/servers/socket.ts +89 -0
- package/src/web/clients/socket.ts +22 -6
- package/test/websocket-ask.test.ts +101 -0
|
@@ -16,6 +16,10 @@ const DEFAULT_SOCKET_PATH = join(
|
|
|
16
16
|
'ipc-window.sock'
|
|
17
17
|
)
|
|
18
18
|
|
|
19
|
+
function controlPathFor(socketPath: string): string {
|
|
20
|
+
return socketPath.replace(/\.sock$/, '-control.sock')
|
|
21
|
+
}
|
|
22
|
+
|
|
19
23
|
const ErrorCodes = ['BadRequest', 'NotFound', 'EvalFailed', 'Internal', 'Timeout', 'Disconnected', 'NoClient'] as const
|
|
20
24
|
type WindowManagerErrorCode = typeof ErrorCodes[number]
|
|
21
25
|
|
|
@@ -45,6 +49,24 @@ export const WindowManagerOptionsSchema = FeatureOptionsSchema.extend({
|
|
|
45
49
|
})
|
|
46
50
|
export type WindowManagerOptions = z.infer<typeof WindowManagerOptionsSchema>
|
|
47
51
|
|
|
52
|
+
/** One window as tracked in feature state (keys are canonical lowercase ids). */
|
|
53
|
+
export const WindowTrackedEntrySchema = z.object({
|
|
54
|
+
windowId: z.string().describe('Canonical id (lowercase)'),
|
|
55
|
+
nativeWindowId: z.string().optional().describe('Last id string from the native app (may differ in casing)'),
|
|
56
|
+
openedAt: z.number().optional().describe('Epoch ms when this process first recorded the window'),
|
|
57
|
+
lastAck: z.any().optional().describe('JSON-serializable snapshot of the last relevant ack payload'),
|
|
58
|
+
kind: z.enum(['browser', 'terminal', 'unknown']).optional().describe('How the window was opened, if known'),
|
|
59
|
+
})
|
|
60
|
+
export type WindowTrackedEntry = z.infer<typeof WindowTrackedEntrySchema>
|
|
61
|
+
|
|
62
|
+
/** In-flight window IPC op (the Promise + timer remain internal to the feature). */
|
|
63
|
+
export const WindowPendingOperationSchema = z.object({
|
|
64
|
+
requestId: z.string(),
|
|
65
|
+
action: z.string().optional(),
|
|
66
|
+
startedAt: z.number(),
|
|
67
|
+
})
|
|
68
|
+
export type WindowPendingOperation = z.infer<typeof WindowPendingOperationSchema>
|
|
69
|
+
|
|
48
70
|
export const WindowManagerStateSchema = FeatureStateSchema.extend({
|
|
49
71
|
listening: z.boolean().default(false)
|
|
50
72
|
.describe('Whether the IPC server is listening'),
|
|
@@ -53,9 +75,17 @@ export const WindowManagerStateSchema = FeatureStateSchema.extend({
|
|
|
53
75
|
socketPath: z.string().optional()
|
|
54
76
|
.describe('The socket path in use'),
|
|
55
77
|
windowCount: z.number().default(0)
|
|
56
|
-
.describe('Number of tracked windows'),
|
|
78
|
+
.describe('Number of tracked windows (mirrors keys of `windows`)'),
|
|
79
|
+
windows: z.record(z.string(), WindowTrackedEntrySchema).default({})
|
|
80
|
+
.describe('Open windows keyed by canonical id'),
|
|
81
|
+
pendingOperations: z.array(WindowPendingOperationSchema).default([])
|
|
82
|
+
.describe('Window commands awaiting windowAck'),
|
|
83
|
+
producerCount: z.number().default(0)
|
|
84
|
+
.describe('Producer sockets connected to this broker (broker mode only)'),
|
|
57
85
|
lastError: z.string().optional()
|
|
58
86
|
.describe('Last error message'),
|
|
87
|
+
mode: z.enum(['broker', 'producer']).optional()
|
|
88
|
+
.describe('Whether this instance is the broker (owns app socket) or a producer (routes through broker)'),
|
|
59
89
|
})
|
|
60
90
|
export type WindowManagerState = z.infer<typeof WindowManagerStateSchema>
|
|
61
91
|
|
|
@@ -65,8 +95,9 @@ export const WindowManagerEventsSchema = FeatureEventsSchema.extend({
|
|
|
65
95
|
clientDisconnected: z.tuple([]).describe('Emitted when the native app disconnects'),
|
|
66
96
|
message: z.tuple([z.any().describe('The parsed message object')]).describe('Emitted for any incoming message that is not a windowAck'),
|
|
67
97
|
windowAck: z.tuple([z.any().describe('The window ack payload')]).describe('Emitted when a window ack is received from the app'),
|
|
68
|
-
windowClosed: z.tuple([z.any().describe('
|
|
98
|
+
windowClosed: z.tuple([z.any().describe('Lifecycle payload; includes canonical lowercase `windowId` when the closed window can be inferred (from `windowId`, `id`, nested fields, etc.)')]).describe('Emitted when the native app reports a window closed event'),
|
|
69
99
|
terminalExited: z.tuple([z.any().describe('Terminal lifecycle payload emitted when a terminal process exits')]).describe('Emitted when the native app reports a terminal process exit event'),
|
|
100
|
+
windowFocus: z.tuple([z.any().describe('Focus payload with windowId, kind, focused (boolean), and frame {x, y, width, height}')]).describe('Emitted when a window gains or loses focus'),
|
|
70
101
|
error: z.tuple([z.any().describe('The error')]).describe('Emitted on error'),
|
|
71
102
|
})
|
|
72
103
|
|
|
@@ -180,6 +211,8 @@ export type LayoutEntry =
|
|
|
180
211
|
interface WindowHandleEvents {
|
|
181
212
|
close: [msg: any]
|
|
182
213
|
terminalExited: [msg: any]
|
|
214
|
+
focus: [msg: any]
|
|
215
|
+
blur: [msg: any]
|
|
183
216
|
}
|
|
184
217
|
|
|
185
218
|
// --- WindowHandle ---
|
|
@@ -221,6 +254,10 @@ export class WindowHandle {
|
|
|
221
254
|
this._events.off(event, listener)
|
|
222
255
|
return this
|
|
223
256
|
}
|
|
257
|
+
|
|
258
|
+
async waitFor(event: string) {
|
|
259
|
+
return this._events.waitFor(event as any)
|
|
260
|
+
}
|
|
224
261
|
|
|
225
262
|
/** Register a one-time listener for a lifecycle event. */
|
|
226
263
|
once<E extends keyof WindowHandleEvents>(event: E, listener: (...args: WindowHandleEvents[E]) => void): this {
|
|
@@ -282,8 +319,17 @@ interface ClientConnection {
|
|
|
282
319
|
/**
|
|
283
320
|
* WindowManager Feature — Native window control via LucaVoiceLauncher
|
|
284
321
|
*
|
|
285
|
-
*
|
|
286
|
-
*
|
|
322
|
+
* Uses a broker/producer architecture so multiple luca processes can trigger
|
|
323
|
+
* window operations without competing for the same Unix socket.
|
|
324
|
+
*
|
|
325
|
+
* **Architecture:**
|
|
326
|
+
* - The first process to call `listen()` becomes the **broker**. It owns
|
|
327
|
+
* the app-facing socket (`ipc-window.sock`) and a control socket
|
|
328
|
+
* (`ipc-window-control.sock`).
|
|
329
|
+
* - Subsequent processes detect the broker and become **producers**. They
|
|
330
|
+
* connect to the control socket and route commands through the broker.
|
|
331
|
+
* - The broker forwards producer commands to the native app and routes
|
|
332
|
+
* acks and lifecycle events back to the originating producer.
|
|
287
333
|
*
|
|
288
334
|
* **Protocol:**
|
|
289
335
|
* - Bun listens on a Unix domain socket; the native app connects as a client
|
|
@@ -295,7 +341,16 @@ interface ClientConnection {
|
|
|
295
341
|
* **Capabilities:**
|
|
296
342
|
* - Spawn native browser windows with configurable chrome
|
|
297
343
|
* - Navigate, focus, close, and eval JavaScript in windows
|
|
298
|
-
* -
|
|
344
|
+
* - Multiple luca processes can trigger window operations simultaneously
|
|
345
|
+
* - Automatic broker detection and producer fallback
|
|
346
|
+
*
|
|
347
|
+
* Observable state includes `windows` (open window metadata), `pendingOperations`
|
|
348
|
+
* (in-flight command ids), and `producerCount` (broker). Sockets, promises, and
|
|
349
|
+
* `WindowHandle` instances stay internal.
|
|
350
|
+
*
|
|
351
|
+
* **Producer state:** The broker pushes `windowStateSync` on the control socket when a
|
|
352
|
+
* producer connects and whenever the window roster changes, so every process sees the
|
|
353
|
+
* same `windows` / `windowCount` / `clientConnected` as the broker (not only its own acks).
|
|
299
354
|
*
|
|
300
355
|
* @example
|
|
301
356
|
* ```typescript
|
|
@@ -321,59 +376,97 @@ export class WindowManager extends Feature<WindowManagerState, WindowManagerOpti
|
|
|
321
376
|
static override eventsSchema = WindowManagerEventsSchema
|
|
322
377
|
static { Feature.register(this, 'windowManager') }
|
|
323
378
|
|
|
324
|
-
|
|
325
|
-
private _client?: ClientConnection
|
|
379
|
+
// --- Shared state ---
|
|
326
380
|
private _pending = new Map<string, PendingRequest>()
|
|
327
|
-
private _trackedWindows = new Set<string>()
|
|
328
381
|
private _handles = new Map<string, WindowHandle>()
|
|
382
|
+
private _mode: 'broker' | 'producer' | null = null
|
|
383
|
+
|
|
384
|
+
// --- Broker-only state ---
|
|
385
|
+
private _server?: NetServer // app-facing socket server
|
|
386
|
+
private _controlServer?: NetServer // producer-facing socket server
|
|
387
|
+
private _client?: ClientConnection // the connected native app
|
|
388
|
+
private _producers = new Map<string, ClientConnection>() // connected producer processes
|
|
389
|
+
private _requestOrigins = new Map<string, Socket>() // requestId → producer socket
|
|
390
|
+
|
|
391
|
+
// --- Producer-only state ---
|
|
392
|
+
private _controlClient?: ClientConnection // connection to broker's control socket
|
|
329
393
|
|
|
330
394
|
private normalizeRequestId(value: unknown): string | undefined {
|
|
331
395
|
if (typeof value !== 'string') return undefined
|
|
332
396
|
return value.toLowerCase()
|
|
333
397
|
}
|
|
334
398
|
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
if (
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
let settled = false
|
|
399
|
+
/** Lowercase map key for `_handles` so lookups match regardless of native id casing. */
|
|
400
|
+
private handleMapKey(value: unknown): string | undefined {
|
|
401
|
+
if (typeof value === 'number' && !Number.isNaN(value)) return String(value).toLowerCase()
|
|
402
|
+
return this.normalizeRequestId(value)
|
|
403
|
+
}
|
|
341
404
|
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
405
|
+
/**
|
|
406
|
+
* Best-effort window id from a native lifecycle message (field names differ by app version).
|
|
407
|
+
*/
|
|
408
|
+
private extractLifecycleWindowId(msg: any): string | undefined {
|
|
409
|
+
if (!msg || typeof msg !== 'object') return undefined
|
|
410
|
+
const candidates = [
|
|
411
|
+
msg.windowId,
|
|
412
|
+
msg.window_id,
|
|
413
|
+
msg.windowID,
|
|
414
|
+
msg.window?.id,
|
|
415
|
+
msg.window?.windowId,
|
|
416
|
+
msg.payload?.windowId,
|
|
417
|
+
msg.result?.windowId,
|
|
418
|
+
msg.id,
|
|
419
|
+
]
|
|
420
|
+
for (const c of candidates) {
|
|
421
|
+
if (typeof c === 'string' && c.length > 0) return c
|
|
422
|
+
if (typeof c === 'number' && !Number.isNaN(c)) return String(c)
|
|
423
|
+
}
|
|
424
|
+
return undefined
|
|
425
|
+
}
|
|
346
426
|
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
427
|
+
/** Copy lifecycle message and set `windowId` to a canonical lowercase string when inferable. */
|
|
428
|
+
private enrichLifecycleWithCanonicalWindowId(msg: any): any {
|
|
429
|
+
const raw = this.extractLifecycleWindowId(msg)
|
|
430
|
+
if (raw === undefined) return msg
|
|
431
|
+
const key = this.handleMapKey(raw)
|
|
432
|
+
if (!key) return msg
|
|
433
|
+
return { ...msg, windowId: key }
|
|
434
|
+
}
|
|
353
435
|
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
436
|
+
/** Structured clone via JSON for stashing ack snapshots in observable state. */
|
|
437
|
+
private snapshotForState(value: unknown): any {
|
|
438
|
+
if (value === undefined) return undefined
|
|
439
|
+
try {
|
|
440
|
+
return JSON.parse(JSON.stringify(value))
|
|
441
|
+
} catch {
|
|
442
|
+
return undefined
|
|
443
|
+
}
|
|
444
|
+
}
|
|
361
445
|
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
clearTimeout(timer)
|
|
366
|
-
cleanup()
|
|
367
|
-
resolve('bridge')
|
|
368
|
-
}
|
|
446
|
+
private syncProducerCount(): void {
|
|
447
|
+
this.setState({ producerCount: this._producers.size })
|
|
448
|
+
}
|
|
369
449
|
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
450
|
+
private registerPendingOperation(id: string, action: string | undefined, entry: PendingRequest): void {
|
|
451
|
+
this._pending.set(id, entry)
|
|
452
|
+
this.setState((cur) => ({
|
|
453
|
+
pendingOperations: [
|
|
454
|
+
...(cur.pendingOperations ?? []),
|
|
455
|
+
{ requestId: id, action, startedAt: Date.now() },
|
|
456
|
+
],
|
|
457
|
+
}))
|
|
373
458
|
}
|
|
374
459
|
|
|
375
|
-
|
|
376
|
-
|
|
460
|
+
/** Clears timer, drops from `_pending` and from `pendingOperations` state. */
|
|
461
|
+
private takePendingRequest(id: string): PendingRequest | undefined {
|
|
462
|
+
const pending = this._pending.get(id)
|
|
463
|
+
if (!pending) return undefined
|
|
464
|
+
clearTimeout(pending.timer)
|
|
465
|
+
this._pending.delete(id)
|
|
466
|
+
this.setState((cur) => ({
|
|
467
|
+
pendingOperations: (cur.pendingOperations ?? []).filter((o) => o.requestId !== id),
|
|
468
|
+
}))
|
|
469
|
+
return pending
|
|
377
470
|
}
|
|
378
471
|
|
|
379
472
|
/** Default state: not listening, no client connected, zero windows tracked. */
|
|
@@ -383,15 +476,28 @@ export class WindowManager extends Feature<WindowManagerState, WindowManagerOpti
|
|
|
383
476
|
listening: false,
|
|
384
477
|
clientConnected: false,
|
|
385
478
|
windowCount: 0,
|
|
479
|
+
windows: {},
|
|
480
|
+
pendingOperations: [],
|
|
481
|
+
producerCount: 0,
|
|
386
482
|
}
|
|
387
483
|
}
|
|
388
484
|
|
|
389
|
-
/** Whether
|
|
485
|
+
/** Whether this instance is acting as the broker. */
|
|
486
|
+
get isBroker(): boolean {
|
|
487
|
+
return this._mode === 'broker'
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
/** Whether this instance is acting as a producer. */
|
|
491
|
+
get isProducer(): boolean {
|
|
492
|
+
return this._mode === 'producer'
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
/** Whether the IPC server is currently listening (broker) or connected to broker (producer). */
|
|
390
496
|
get isListening(): boolean {
|
|
391
497
|
return this.state.get('listening') || false
|
|
392
498
|
}
|
|
393
499
|
|
|
394
|
-
/** Whether the native app client is currently connected. */
|
|
500
|
+
/** Whether the native app client is currently connected (only meaningful for broker). */
|
|
395
501
|
get isClientConnected(): boolean {
|
|
396
502
|
return this.state.get('clientConnected') || false
|
|
397
503
|
}
|
|
@@ -400,24 +506,29 @@ export class WindowManager extends Feature<WindowManagerState, WindowManagerOpti
|
|
|
400
506
|
await super.enable(options)
|
|
401
507
|
|
|
402
508
|
if (this.options.autoListen) {
|
|
403
|
-
this.listen()
|
|
509
|
+
await this.listen()
|
|
404
510
|
}
|
|
405
511
|
|
|
406
512
|
return this
|
|
407
513
|
}
|
|
408
514
|
|
|
409
515
|
/**
|
|
410
|
-
* Start
|
|
411
|
-
*
|
|
412
|
-
* until the native app connects; does nothing visible if it never does.
|
|
516
|
+
* Start the window manager. Automatically detects whether a broker already
|
|
517
|
+
* exists and either becomes the broker or connects as a producer.
|
|
413
518
|
*
|
|
414
|
-
*
|
|
519
|
+
* - If no broker is running: becomes the broker, binds the app socket and
|
|
520
|
+
* a control socket for producers.
|
|
521
|
+
* - If a broker is already running: connects as a producer through the
|
|
522
|
+
* control socket.
|
|
523
|
+
*
|
|
524
|
+
* @param socketPath - Override the configured app socket path
|
|
415
525
|
* @returns This feature instance for chaining
|
|
416
526
|
*/
|
|
417
|
-
listen(socketPath?: string): this {
|
|
418
|
-
if (this.
|
|
527
|
+
async listen(socketPath?: string): Promise<this> {
|
|
528
|
+
if (this._mode) return this
|
|
419
529
|
|
|
420
530
|
socketPath = socketPath || this.options.socketPath || DEFAULT_SOCKET_PATH
|
|
531
|
+
const controlPath = controlPathFor(socketPath)
|
|
421
532
|
|
|
422
533
|
// Ensure the directory exists
|
|
423
534
|
const dir = dirname(socketPath)
|
|
@@ -430,57 +541,80 @@ export class WindowManager extends Feature<WindowManagerState, WindowManagerOpti
|
|
|
430
541
|
}
|
|
431
542
|
}
|
|
432
543
|
|
|
433
|
-
//
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
} catch (error: any) {
|
|
438
|
-
this.setState({ lastError: `Failed to remove stale socket at ${socketPath}: ${error?.message || String(error)}` })
|
|
439
|
-
return this
|
|
440
|
-
}
|
|
544
|
+
// Try to connect to an existing broker's control socket
|
|
545
|
+
const brokerAlive = await this.probeSocket(controlPath)
|
|
546
|
+
if (brokerAlive) {
|
|
547
|
+
return this.connectAsProducer(controlPath, socketPath)
|
|
441
548
|
}
|
|
442
549
|
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
// Set immediately to prevent parallel calls from creating duplicate servers
|
|
448
|
-
this._server = server
|
|
449
|
-
|
|
450
|
-
server.on('error', (err) => {
|
|
451
|
-
this.setState({ lastError: err.message })
|
|
452
|
-
})
|
|
453
|
-
|
|
454
|
-
const finalPath = socketPath
|
|
455
|
-
server.listen(finalPath, () => {
|
|
456
|
-
this.setState({ listening: true, socketPath: finalPath })
|
|
457
|
-
this.emit('listening')
|
|
458
|
-
})
|
|
550
|
+
// No broker — we become the broker
|
|
551
|
+
return this.becomeBroker(socketPath, controlPath)
|
|
552
|
+
}
|
|
459
553
|
|
|
460
|
-
|
|
554
|
+
/**
|
|
555
|
+
* Remove stale socket files without starting or stopping the server.
|
|
556
|
+
* Useful when a previous process crashed and left dead sockets behind.
|
|
557
|
+
* Will not remove sockets that have live listeners.
|
|
558
|
+
*
|
|
559
|
+
* @param socketPath - Override the configured socket path
|
|
560
|
+
* @returns true if a stale socket was removed
|
|
561
|
+
*/
|
|
562
|
+
async cleanupSocket(socketPath?: string): Promise<boolean> {
|
|
563
|
+
socketPath = socketPath || this.options.socketPath || DEFAULT_SOCKET_PATH
|
|
564
|
+
if (this._server) return false
|
|
565
|
+
if (!existsSync(socketPath)) return false
|
|
566
|
+
const isAlive = await this.probeSocket(socketPath)
|
|
567
|
+
if (isAlive) return false
|
|
568
|
+
try {
|
|
569
|
+
unlinkSync(socketPath)
|
|
570
|
+
return true
|
|
571
|
+
} catch {
|
|
572
|
+
return false
|
|
573
|
+
}
|
|
461
574
|
}
|
|
462
575
|
|
|
463
576
|
/**
|
|
464
|
-
* Stop the
|
|
577
|
+
* Stop the window manager and clean up all connections.
|
|
465
578
|
* Rejects any pending window operation requests.
|
|
466
579
|
*
|
|
467
580
|
* @returns This feature instance for chaining
|
|
468
581
|
*/
|
|
469
582
|
async stop(): Promise<this> {
|
|
583
|
+
// Resolve all pending requests
|
|
470
584
|
for (const [, pending] of this._pending) {
|
|
471
585
|
clearTimeout(pending.timer)
|
|
472
586
|
pending.resolve({ ok: false, error: 'Server stopping' })
|
|
473
587
|
}
|
|
474
588
|
this._pending.clear()
|
|
475
|
-
this._trackedWindows.clear()
|
|
476
589
|
this._handles.clear()
|
|
477
590
|
|
|
591
|
+
// --- Producer cleanup ---
|
|
592
|
+
if (this._controlClient) {
|
|
593
|
+
this._controlClient.socket.destroy()
|
|
594
|
+
this._controlClient = undefined
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
// --- Broker cleanup ---
|
|
478
598
|
if (this._client) {
|
|
479
599
|
this._client.socket.destroy()
|
|
480
600
|
this._client = undefined
|
|
481
601
|
}
|
|
482
602
|
|
|
603
|
+
for (const [, producer] of this._producers) {
|
|
604
|
+
producer.socket.destroy()
|
|
605
|
+
}
|
|
606
|
+
this._producers.clear()
|
|
607
|
+
this._requestOrigins.clear()
|
|
608
|
+
|
|
483
609
|
const socketPath = this.state.get('socketPath')
|
|
610
|
+
const controlPath = socketPath ? controlPathFor(socketPath) : undefined
|
|
611
|
+
|
|
612
|
+
if (this._controlServer) {
|
|
613
|
+
await new Promise<void>((resolve) => {
|
|
614
|
+
this._controlServer!.close(() => resolve())
|
|
615
|
+
})
|
|
616
|
+
this._controlServer = undefined
|
|
617
|
+
}
|
|
484
618
|
|
|
485
619
|
if (this._server) {
|
|
486
620
|
await new Promise<void>((resolve) => {
|
|
@@ -489,12 +623,27 @@ export class WindowManager extends Feature<WindowManagerState, WindowManagerOpti
|
|
|
489
623
|
this._server = undefined
|
|
490
624
|
}
|
|
491
625
|
|
|
492
|
-
// Clean up the
|
|
493
|
-
if (
|
|
494
|
-
|
|
626
|
+
// Clean up socket files (only if we were the broker)
|
|
627
|
+
if (this._mode === 'broker') {
|
|
628
|
+
if (socketPath && existsSync(socketPath)) {
|
|
629
|
+
try { unlinkSync(socketPath) } catch { /* ignore */ }
|
|
630
|
+
}
|
|
631
|
+
if (controlPath && existsSync(controlPath)) {
|
|
632
|
+
try { unlinkSync(controlPath) } catch { /* ignore */ }
|
|
633
|
+
}
|
|
495
634
|
}
|
|
496
635
|
|
|
497
|
-
this.
|
|
636
|
+
this._mode = null
|
|
637
|
+
this.setState({
|
|
638
|
+
listening: false,
|
|
639
|
+
clientConnected: false,
|
|
640
|
+
socketPath: undefined,
|
|
641
|
+
windowCount: 0,
|
|
642
|
+
windows: {},
|
|
643
|
+
pendingOperations: [],
|
|
644
|
+
producerCount: 0,
|
|
645
|
+
mode: undefined,
|
|
646
|
+
})
|
|
498
647
|
return this
|
|
499
648
|
}
|
|
500
649
|
|
|
@@ -528,7 +677,7 @@ export class WindowManager extends Feature<WindowManagerState, WindowManagerOpti
|
|
|
528
677
|
})
|
|
529
678
|
}
|
|
530
679
|
|
|
531
|
-
return this.getOrCreateHandle(ackResult.windowId, ackResult)
|
|
680
|
+
return this.getOrCreateHandle(ackResult.windowId, ackResult, 'browser')
|
|
532
681
|
}
|
|
533
682
|
|
|
534
683
|
/**
|
|
@@ -557,7 +706,7 @@ export class WindowManager extends Feature<WindowManagerState, WindowManagerOpti
|
|
|
557
706
|
})
|
|
558
707
|
}
|
|
559
708
|
|
|
560
|
-
return this.getOrCreateHandle(ackResult.windowId, ackResult)
|
|
709
|
+
return this.getOrCreateHandle(ackResult.windowId, ackResult, 'terminal')
|
|
561
710
|
}
|
|
562
711
|
|
|
563
712
|
/**
|
|
@@ -712,68 +861,132 @@ export class WindowManager extends Feature<WindowManagerState, WindowManagerOpti
|
|
|
712
861
|
return results
|
|
713
862
|
}
|
|
714
863
|
|
|
864
|
+
/**
|
|
865
|
+
* Write an NDJSON message to the connected app client.
|
|
866
|
+
* In producer mode, routes through the broker.
|
|
867
|
+
* Public so other features can send arbitrary protocol messages over the same socket.
|
|
868
|
+
*
|
|
869
|
+
* @param msg - The message object to send (will be JSON-serialized + newline)
|
|
870
|
+
* @returns True if the message was written, false if no connection is available
|
|
871
|
+
*/
|
|
872
|
+
send(msg: Record<string, any>): boolean {
|
|
873
|
+
if (this._mode === 'producer' && this._controlClient) {
|
|
874
|
+
this._controlClient.socket.write(JSON.stringify(msg) + '\n')
|
|
875
|
+
return true
|
|
876
|
+
}
|
|
877
|
+
if (!this._client) return false
|
|
878
|
+
this._client.socket.write(JSON.stringify(msg) + '\n')
|
|
879
|
+
return true
|
|
880
|
+
}
|
|
881
|
+
|
|
882
|
+
// =====================================================================
|
|
883
|
+
// Private — Broker / Producer lifecycle
|
|
884
|
+
// =====================================================================
|
|
885
|
+
|
|
715
886
|
/** Get or create a tracked WindowHandle for a given windowId. */
|
|
716
|
-
private getOrCreateHandle(
|
|
717
|
-
|
|
718
|
-
|
|
887
|
+
private getOrCreateHandle(
|
|
888
|
+
windowId: string | undefined,
|
|
889
|
+
result?: WindowAckResult,
|
|
890
|
+
kind: 'browser' | 'terminal' | 'unknown' = 'unknown',
|
|
891
|
+
): WindowHandle {
|
|
892
|
+
const id = windowId ?? result?.windowId ?? randomUUID()
|
|
893
|
+
const mapKey = this.handleMapKey(id) ?? id
|
|
894
|
+
let handle = this._handles.get(mapKey)
|
|
719
895
|
if (!handle) {
|
|
720
896
|
handle = new WindowHandle(id, this, result)
|
|
721
|
-
this._handles.set(
|
|
897
|
+
this._handles.set(mapKey, handle)
|
|
722
898
|
} else if (result) {
|
|
723
899
|
handle.result = result
|
|
724
900
|
}
|
|
901
|
+
this.trackWindowOpened(id, { kind: kind !== 'unknown' ? kind : undefined })
|
|
725
902
|
return handle
|
|
726
903
|
}
|
|
727
904
|
|
|
728
|
-
// --- Private internals ---
|
|
729
|
-
|
|
730
|
-
private _displayCache: { width: number; height: number } | null = null
|
|
731
|
-
|
|
732
905
|
/**
|
|
733
|
-
*
|
|
906
|
+
* Probe an existing socket to see if a live listener is behind it.
|
|
907
|
+
* Attempts a quick connect — if it succeeds, someone is listening.
|
|
734
908
|
*/
|
|
735
|
-
private
|
|
736
|
-
if (
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
}
|
|
909
|
+
private probeSocket(socketPath: string): Promise<boolean> {
|
|
910
|
+
if (!existsSync(socketPath)) return Promise.resolve(false)
|
|
911
|
+
return new Promise<boolean>((resolve) => {
|
|
912
|
+
const probe = new Socket()
|
|
913
|
+
const timer = setTimeout(() => {
|
|
914
|
+
probe.destroy()
|
|
915
|
+
resolve(false)
|
|
916
|
+
}, 500)
|
|
744
917
|
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
const keys = ['width', 'height', 'x', 'y'] as const
|
|
751
|
-
const hasPercentage = keys.some(k => typeof opts[k] === 'string' && (opts[k] as string).endsWith('%'))
|
|
752
|
-
if (!hasPercentage) return opts
|
|
918
|
+
probe.once('connect', () => {
|
|
919
|
+
clearTimeout(timer)
|
|
920
|
+
probe.destroy()
|
|
921
|
+
resolve(true)
|
|
922
|
+
})
|
|
753
923
|
|
|
754
|
-
|
|
755
|
-
|
|
924
|
+
probe.once('error', () => {
|
|
925
|
+
clearTimeout(timer)
|
|
926
|
+
probe.destroy()
|
|
927
|
+
resolve(false)
|
|
928
|
+
})
|
|
756
929
|
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
930
|
+
probe.connect(socketPath)
|
|
931
|
+
})
|
|
932
|
+
}
|
|
933
|
+
|
|
934
|
+
// =====================================================================
|
|
935
|
+
// Broker mode — owns the app socket and the control socket
|
|
936
|
+
// =====================================================================
|
|
937
|
+
|
|
938
|
+
private async becomeBroker(socketPath: string, controlPath: string): Promise<this> {
|
|
939
|
+
// Clean up stale app socket
|
|
940
|
+
if (existsSync(socketPath)) {
|
|
941
|
+
try { unlinkSync(socketPath) } catch { /* ignore */ }
|
|
942
|
+
}
|
|
943
|
+
// Clean up stale control socket
|
|
944
|
+
if (existsSync(controlPath)) {
|
|
945
|
+
try { unlinkSync(controlPath) } catch { /* ignore */ }
|
|
764
946
|
}
|
|
765
947
|
|
|
766
|
-
|
|
948
|
+
// Bind the app-facing socket (native launcher connects here)
|
|
949
|
+
const server = new NetServer((socket) => {
|
|
950
|
+
this.handleAppClientConnect(socket)
|
|
951
|
+
})
|
|
952
|
+
this._server = server
|
|
953
|
+
|
|
954
|
+
server.on('error', (err) => {
|
|
955
|
+
this.setState({ lastError: err.message })
|
|
956
|
+
})
|
|
957
|
+
|
|
958
|
+
await new Promise<void>((resolve) => {
|
|
959
|
+
server.listen(socketPath, () => resolve())
|
|
960
|
+
})
|
|
961
|
+
|
|
962
|
+
// Bind the control socket (producer processes connect here)
|
|
963
|
+
const controlServer = new NetServer((socket) => {
|
|
964
|
+
this.handleProducerConnect(socket)
|
|
965
|
+
})
|
|
966
|
+
this._controlServer = controlServer
|
|
967
|
+
|
|
968
|
+
controlServer.on('error', (err) => {
|
|
969
|
+
this.setState({ lastError: `Control socket error: ${err.message}` })
|
|
970
|
+
})
|
|
971
|
+
|
|
972
|
+
await new Promise<void>((resolve) => {
|
|
973
|
+
controlServer.listen(controlPath, () => resolve())
|
|
974
|
+
})
|
|
975
|
+
|
|
976
|
+
this._mode = 'broker'
|
|
977
|
+
this.setState({ listening: true, socketPath, mode: 'broker' })
|
|
978
|
+
this.emit('listening')
|
|
979
|
+
|
|
980
|
+
return this
|
|
767
981
|
}
|
|
768
982
|
|
|
769
983
|
/**
|
|
770
|
-
* Handle a new client connection from the native
|
|
984
|
+
* Handle a new app client connection from the native launcher.
|
|
771
985
|
* Sets up NDJSON buffering and event forwarding.
|
|
772
986
|
*/
|
|
773
|
-
private
|
|
987
|
+
private handleAppClientConnect(socket: Socket): void {
|
|
774
988
|
const client: ClientConnection = { socket, buffer: '' }
|
|
775
989
|
|
|
776
|
-
// Replace any existing client (single-client model)
|
|
777
990
|
if (this._client) {
|
|
778
991
|
this._client.socket.destroy()
|
|
779
992
|
}
|
|
@@ -787,24 +1000,42 @@ export class WindowManager extends Feature<WindowManagerState, WindowManagerOpti
|
|
|
787
1000
|
const lines = client.buffer.split('\n')
|
|
788
1001
|
client.buffer = lines.pop() || ''
|
|
789
1002
|
for (const line of lines) {
|
|
790
|
-
if (line.trim()) this.
|
|
1003
|
+
if (line.trim()) this.processAppMessage(line)
|
|
791
1004
|
}
|
|
792
1005
|
})
|
|
793
1006
|
|
|
794
1007
|
socket.on('close', () => {
|
|
795
1008
|
if (this._client === client) {
|
|
796
1009
|
this._client = undefined
|
|
797
|
-
this._trackedWindows.clear()
|
|
798
1010
|
this._handles.clear()
|
|
799
|
-
this.setState({
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
1011
|
+
this.setState({
|
|
1012
|
+
clientConnected: false,
|
|
1013
|
+
windowCount: 0,
|
|
1014
|
+
windows: {},
|
|
1015
|
+
pendingOperations: [],
|
|
1016
|
+
})
|
|
1017
|
+
this.broadcastWindowStateSync()
|
|
1018
|
+
|
|
1019
|
+
// Resolve all pending requests — the app is gone
|
|
1020
|
+
for (const [, pending] of this._pending) {
|
|
803
1021
|
clearTimeout(pending.timer)
|
|
804
1022
|
pending.resolve({ ok: false, error: 'Client disconnected' })
|
|
805
1023
|
}
|
|
806
1024
|
this._pending.clear()
|
|
807
1025
|
|
|
1026
|
+
// Notify producers that app disconnected — resolve their in-flight requests
|
|
1027
|
+
for (const [reqId, producerSocket] of this._requestOrigins) {
|
|
1028
|
+
try {
|
|
1029
|
+
producerSocket.write(JSON.stringify({
|
|
1030
|
+
type: 'windowAck',
|
|
1031
|
+
id: reqId,
|
|
1032
|
+
success: false,
|
|
1033
|
+
error: 'App client disconnected',
|
|
1034
|
+
}) + '\n')
|
|
1035
|
+
} catch { /* producer gone */ }
|
|
1036
|
+
}
|
|
1037
|
+
this._requestOrigins.clear()
|
|
1038
|
+
|
|
808
1039
|
this.emit('clientDisconnected')
|
|
809
1040
|
}
|
|
810
1041
|
})
|
|
@@ -815,149 +1046,431 @@ export class WindowManager extends Feature<WindowManagerState, WindowManagerOpti
|
|
|
815
1046
|
}
|
|
816
1047
|
|
|
817
1048
|
/**
|
|
818
|
-
*
|
|
819
|
-
*
|
|
1049
|
+
* Handle a new producer connection on the control socket.
|
|
1050
|
+
* Producers send window commands; broker forwards to app and routes acks back.
|
|
1051
|
+
*/
|
|
1052
|
+
private handleProducerConnect(socket: Socket): void {
|
|
1053
|
+
const id = randomUUID()
|
|
1054
|
+
const client: ClientConnection = { socket, buffer: '' }
|
|
1055
|
+
this._producers.set(id, client)
|
|
1056
|
+
this.syncProducerCount()
|
|
1057
|
+
this.sendWindowStateSyncToSocket(socket)
|
|
1058
|
+
|
|
1059
|
+
socket.on('data', (chunk) => {
|
|
1060
|
+
client.buffer += chunk.toString()
|
|
1061
|
+
const lines = client.buffer.split('\n')
|
|
1062
|
+
client.buffer = lines.pop() || ''
|
|
1063
|
+
for (const line of lines) {
|
|
1064
|
+
if (line.trim()) this.processProducerMessage(line, socket)
|
|
1065
|
+
}
|
|
1066
|
+
})
|
|
1067
|
+
|
|
1068
|
+
socket.on('close', () => {
|
|
1069
|
+
this._producers.delete(id)
|
|
1070
|
+
this.syncProducerCount()
|
|
1071
|
+
// Clean up request origins for this producer
|
|
1072
|
+
for (const [reqId, sock] of this._requestOrigins) {
|
|
1073
|
+
if (sock === socket) this._requestOrigins.delete(reqId)
|
|
1074
|
+
}
|
|
1075
|
+
})
|
|
1076
|
+
|
|
1077
|
+
socket.on('error', () => {
|
|
1078
|
+
this._producers.delete(id)
|
|
1079
|
+
this.syncProducerCount()
|
|
1080
|
+
})
|
|
1081
|
+
}
|
|
1082
|
+
|
|
1083
|
+
/**
|
|
1084
|
+
* Process a message from a producer. Records the origin so acks can be
|
|
1085
|
+
* routed back, then forwards the command to the app client.
|
|
820
1086
|
*/
|
|
821
|
-
private
|
|
1087
|
+
private processProducerMessage(line: string, producerSocket: Socket): void {
|
|
822
1088
|
let msg: any
|
|
823
|
-
try {
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
1089
|
+
try { msg = JSON.parse(line) } catch { return }
|
|
1090
|
+
|
|
1091
|
+
const requestId = this.normalizeRequestId(msg.id)
|
|
1092
|
+
if (requestId) {
|
|
1093
|
+
this._requestOrigins.set(requestId, producerSocket)
|
|
827
1094
|
}
|
|
828
1095
|
|
|
829
|
-
//
|
|
1096
|
+
// Forward to the native app client
|
|
1097
|
+
if (!this._client) {
|
|
1098
|
+
// No app connected — send error back to producer immediately
|
|
1099
|
+
if (requestId) {
|
|
1100
|
+
this._requestOrigins.delete(requestId)
|
|
1101
|
+
try {
|
|
1102
|
+
producerSocket.write(JSON.stringify({
|
|
1103
|
+
type: 'windowAck',
|
|
1104
|
+
id: msg.id,
|
|
1105
|
+
success: false,
|
|
1106
|
+
error: 'No native launcher client connected',
|
|
1107
|
+
}) + '\n')
|
|
1108
|
+
} catch { /* producer gone */ }
|
|
1109
|
+
}
|
|
1110
|
+
return
|
|
1111
|
+
}
|
|
1112
|
+
|
|
1113
|
+
this._client.socket.write(line + '\n')
|
|
1114
|
+
}
|
|
1115
|
+
|
|
1116
|
+
/**
|
|
1117
|
+
* Process a message from the native app (broker mode).
|
|
1118
|
+
* Routes windowAck back to the originating producer or resolves locally.
|
|
1119
|
+
* Broadcasts lifecycle events to all producers.
|
|
1120
|
+
*/
|
|
1121
|
+
private processAppMessage(line: string): void {
|
|
1122
|
+
let msg: any
|
|
1123
|
+
try { msg = JSON.parse(line) } catch { return }
|
|
1124
|
+
|
|
830
1125
|
if (msg.type === 'windowAck') {
|
|
831
1126
|
this.emit('windowAck', msg)
|
|
832
|
-
this.updateTrackedWindowsFromAck(msg)
|
|
1127
|
+
const rosterChanged = this.updateTrackedWindowsFromAck(msg)
|
|
833
1128
|
|
|
834
1129
|
const ackId = this.normalizeRequestId(msg.id)
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
}
|
|
843
|
-
|
|
1130
|
+
|
|
1131
|
+
// Route to originating producer if this was a proxied request
|
|
1132
|
+
if (ackId && this._requestOrigins.has(ackId)) {
|
|
1133
|
+
const producerSocket = this._requestOrigins.get(ackId)!
|
|
1134
|
+
this._requestOrigins.delete(ackId)
|
|
1135
|
+
try {
|
|
1136
|
+
producerSocket.write(line + '\n')
|
|
1137
|
+
} catch { /* producer disconnected */ }
|
|
1138
|
+
if (rosterChanged) this.broadcastWindowStateSync()
|
|
1139
|
+
return
|
|
1140
|
+
}
|
|
1141
|
+
|
|
1142
|
+
// Otherwise resolve locally (broker's own request)
|
|
1143
|
+
if (ackId) {
|
|
1144
|
+
const pending = this.takePendingRequest(ackId)
|
|
1145
|
+
if (pending) {
|
|
1146
|
+
if (msg.success) {
|
|
1147
|
+
pending.resolve(msg.result ?? msg)
|
|
1148
|
+
} else {
|
|
1149
|
+
pending.resolve({ ok: false, error: msg.error || 'Window operation failed' })
|
|
1150
|
+
}
|
|
844
1151
|
}
|
|
845
1152
|
}
|
|
1153
|
+
if (rosterChanged) this.broadcastWindowStateSync()
|
|
846
1154
|
return
|
|
847
1155
|
}
|
|
848
1156
|
|
|
1157
|
+
let messageOut = msg
|
|
1158
|
+
|
|
849
1159
|
if (msg.type === 'windowClosed') {
|
|
850
|
-
this.
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
1160
|
+
messageOut = this.enrichLifecycleWithCanonicalWindowId(msg)
|
|
1161
|
+
this.trackWindowClosed(messageOut.windowId)
|
|
1162
|
+
const key = this.handleMapKey(messageOut.windowId)
|
|
1163
|
+
const handle = key ? this._handles.get(key) : undefined
|
|
1164
|
+
if (handle && key) {
|
|
1165
|
+
handle.emit('close', messageOut)
|
|
1166
|
+
this._handles.delete(key)
|
|
855
1167
|
}
|
|
856
|
-
this.emit('windowClosed',
|
|
1168
|
+
this.emit('windowClosed', messageOut)
|
|
1169
|
+
// Broadcast to all producers
|
|
1170
|
+
this.broadcastToProducers(JSON.stringify(messageOut))
|
|
1171
|
+
this.broadcastWindowStateSync()
|
|
857
1172
|
}
|
|
858
1173
|
|
|
859
1174
|
if (msg.type === 'terminalExited') {
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
this.
|
|
1175
|
+
messageOut = this.enrichLifecycleWithCanonicalWindowId(msg)
|
|
1176
|
+
const key = this.handleMapKey(messageOut.windowId)
|
|
1177
|
+
const handle = key ? this._handles.get(key) : undefined
|
|
1178
|
+
if (handle) handle.emit('terminalExited', messageOut)
|
|
1179
|
+
this.emit('terminalExited', messageOut)
|
|
1180
|
+
// Broadcast to all producers
|
|
1181
|
+
this.broadcastToProducers(JSON.stringify(messageOut))
|
|
863
1182
|
}
|
|
864
1183
|
|
|
865
|
-
|
|
866
|
-
|
|
1184
|
+
if (msg.type === 'windowFocus') {
|
|
1185
|
+
messageOut = this.enrichLifecycleWithCanonicalWindowId(msg)
|
|
1186
|
+
const key = this.handleMapKey(messageOut.windowId)
|
|
1187
|
+
const handle = key ? this._handles.get(key) : undefined
|
|
1188
|
+
if (handle) handle.emit(messageOut.focused ? 'focus' : 'blur', messageOut)
|
|
1189
|
+
this.emit('windowFocus', messageOut)
|
|
1190
|
+
// Broadcast to all producers
|
|
1191
|
+
this.broadcastToProducers(JSON.stringify(messageOut))
|
|
1192
|
+
}
|
|
1193
|
+
|
|
1194
|
+
this.emit('message', messageOut)
|
|
867
1195
|
}
|
|
868
1196
|
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
1197
|
+
/** Send an NDJSON line to all connected producers. */
|
|
1198
|
+
private broadcastToProducers(line: string): void {
|
|
1199
|
+
for (const [, producer] of this._producers) {
|
|
1200
|
+
try {
|
|
1201
|
+
producer.socket.write(line + '\n')
|
|
1202
|
+
} catch { /* producer gone */ }
|
|
1203
|
+
}
|
|
1204
|
+
}
|
|
872
1205
|
|
|
873
|
-
|
|
874
|
-
|
|
1206
|
+
/** Broker: snapshot of roster + app connectivity for producers (internal protocol). */
|
|
1207
|
+
private buildWindowStateSyncPayload(): Record<string, unknown> {
|
|
1208
|
+
return {
|
|
1209
|
+
type: 'windowStateSync',
|
|
1210
|
+
windows: this.state.get('windows') ?? {},
|
|
1211
|
+
windowCount: this.state.get('windowCount') ?? 0,
|
|
1212
|
+
clientConnected: this.state.get('clientConnected') ?? false,
|
|
1213
|
+
}
|
|
1214
|
+
}
|
|
875
1215
|
|
|
876
|
-
|
|
877
|
-
|
|
1216
|
+
/** Broker: push current window roster to one producer control socket. */
|
|
1217
|
+
private sendWindowStateSyncToSocket(socket: Socket): void {
|
|
1218
|
+
if (this._mode !== 'broker') return
|
|
1219
|
+
try {
|
|
1220
|
+
socket.write(JSON.stringify(this.buildWindowStateSyncPayload()) + '\n')
|
|
1221
|
+
} catch { /* gone */ }
|
|
1222
|
+
}
|
|
1223
|
+
|
|
1224
|
+
/** Broker: fan out roster snapshot so all producers converge with broker state. */
|
|
1225
|
+
private broadcastWindowStateSync(): void {
|
|
1226
|
+
if (this._mode !== 'broker' || this._producers.size === 0) return
|
|
1227
|
+
const line = JSON.stringify(this.buildWindowStateSyncPayload())
|
|
1228
|
+
this.broadcastToProducers(line)
|
|
1229
|
+
}
|
|
1230
|
+
|
|
1231
|
+
// =====================================================================
|
|
1232
|
+
// Producer mode — routes commands through the broker
|
|
1233
|
+
// =====================================================================
|
|
1234
|
+
|
|
1235
|
+
private connectAsProducer(controlPath: string, socketPath: string): Promise<this> {
|
|
1236
|
+
return new Promise<this>((resolve, reject) => {
|
|
1237
|
+
const socket = new Socket()
|
|
1238
|
+
const client: ClientConnection = { socket, buffer: '' }
|
|
1239
|
+
|
|
1240
|
+
const timer = setTimeout(() => {
|
|
1241
|
+
socket.destroy()
|
|
1242
|
+
this.setState({ lastError: `Timed out connecting to broker at ${controlPath}` })
|
|
1243
|
+
reject(new WindowManagerError(`Timed out connecting to broker`, 'Timeout'))
|
|
1244
|
+
}, 3000)
|
|
1245
|
+
|
|
1246
|
+
socket.connect(controlPath, () => {
|
|
1247
|
+
clearTimeout(timer)
|
|
1248
|
+
this._mode = 'producer'
|
|
1249
|
+
this._controlClient = client
|
|
1250
|
+
this.setState({ listening: true, socketPath, mode: 'producer' })
|
|
1251
|
+
this.emit('listening')
|
|
1252
|
+
resolve(this)
|
|
1253
|
+
})
|
|
1254
|
+
|
|
1255
|
+
socket.on('data', (chunk) => {
|
|
1256
|
+
client.buffer += chunk.toString()
|
|
1257
|
+
const lines = client.buffer.split('\n')
|
|
1258
|
+
client.buffer = lines.pop() || ''
|
|
1259
|
+
for (const line of lines) {
|
|
1260
|
+
if (line.trim()) this.processProducerIncoming(line)
|
|
1261
|
+
}
|
|
1262
|
+
})
|
|
1263
|
+
|
|
1264
|
+
socket.on('close', () => {
|
|
1265
|
+
this._controlClient = undefined
|
|
1266
|
+
this._mode = null
|
|
1267
|
+
for (const [mapKey, handle] of [...this._handles.entries()]) {
|
|
1268
|
+
handle.emit('close', {
|
|
1269
|
+
type: 'windowClosed',
|
|
1270
|
+
windowId: mapKey,
|
|
1271
|
+
reason: 'brokerDisconnected',
|
|
1272
|
+
})
|
|
1273
|
+
}
|
|
1274
|
+
this._handles.clear()
|
|
1275
|
+
this.setState({
|
|
1276
|
+
listening: false,
|
|
1277
|
+
mode: undefined,
|
|
1278
|
+
pendingOperations: [],
|
|
1279
|
+
windows: {},
|
|
1280
|
+
windowCount: 0,
|
|
1281
|
+
})
|
|
1282
|
+
|
|
1283
|
+
// Resolve all pending requests — broker is gone
|
|
1284
|
+
for (const [, pending] of this._pending) {
|
|
1285
|
+
clearTimeout(pending.timer)
|
|
1286
|
+
pending.resolve({ ok: false, error: 'Broker disconnected' })
|
|
1287
|
+
}
|
|
1288
|
+
this._pending.clear()
|
|
1289
|
+
})
|
|
1290
|
+
|
|
1291
|
+
socket.on('error', (err) => {
|
|
1292
|
+
clearTimeout(timer)
|
|
1293
|
+
this.setState({ lastError: `Broker connection error: ${err.message}` })
|
|
1294
|
+
})
|
|
1295
|
+
})
|
|
1296
|
+
}
|
|
1297
|
+
|
|
1298
|
+
/**
|
|
1299
|
+
* Process a message received from the broker (producer mode).
|
|
1300
|
+
* Handles windowAck (resolves pending requests) and lifecycle events.
|
|
1301
|
+
*/
|
|
1302
|
+
private processProducerIncoming(line: string): void {
|
|
1303
|
+
let msg: any
|
|
1304
|
+
try { msg = JSON.parse(line) } catch { return }
|
|
1305
|
+
|
|
1306
|
+
if (msg.type === 'windowStateSync') {
|
|
1307
|
+
this.applyProducerWindowStateSync(msg)
|
|
878
1308
|
return
|
|
879
1309
|
}
|
|
880
1310
|
|
|
881
|
-
if (
|
|
882
|
-
this.
|
|
1311
|
+
if (msg.type === 'windowAck') {
|
|
1312
|
+
this.emit('windowAck', msg)
|
|
1313
|
+
this.updateTrackedWindowsFromAck(msg)
|
|
1314
|
+
|
|
1315
|
+
const ackId = this.normalizeRequestId(msg.id)
|
|
1316
|
+
if (ackId) {
|
|
1317
|
+
const pending = this.takePendingRequest(ackId)
|
|
1318
|
+
if (pending) {
|
|
1319
|
+
if (msg.success) {
|
|
1320
|
+
pending.resolve(msg.result ?? msg)
|
|
1321
|
+
} else {
|
|
1322
|
+
pending.resolve({ ok: false, error: msg.error || 'Window operation failed' })
|
|
1323
|
+
}
|
|
1324
|
+
}
|
|
1325
|
+
}
|
|
1326
|
+
return
|
|
1327
|
+
}
|
|
1328
|
+
|
|
1329
|
+
let messageOut = msg
|
|
1330
|
+
|
|
1331
|
+
if (msg.type === 'windowClosed') {
|
|
1332
|
+
messageOut = this.enrichLifecycleWithCanonicalWindowId(msg)
|
|
1333
|
+
this.trackWindowClosed(messageOut.windowId)
|
|
1334
|
+
const key = this.handleMapKey(messageOut.windowId)
|
|
1335
|
+
const handle = key ? this._handles.get(key) : undefined
|
|
1336
|
+
if (handle && key) {
|
|
1337
|
+
handle.emit('close', messageOut)
|
|
1338
|
+
this._handles.delete(key)
|
|
1339
|
+
}
|
|
1340
|
+
this.emit('windowClosed', messageOut)
|
|
1341
|
+
}
|
|
1342
|
+
|
|
1343
|
+
if (msg.type === 'terminalExited') {
|
|
1344
|
+
messageOut = this.enrichLifecycleWithCanonicalWindowId(msg)
|
|
1345
|
+
const key = this.handleMapKey(messageOut.windowId)
|
|
1346
|
+
const handle = key ? this._handles.get(key) : undefined
|
|
1347
|
+
if (handle) handle.emit('terminalExited', messageOut)
|
|
1348
|
+
this.emit('terminalExited', messageOut)
|
|
1349
|
+
}
|
|
1350
|
+
|
|
1351
|
+
if (msg.type === 'windowFocus') {
|
|
1352
|
+
messageOut = this.enrichLifecycleWithCanonicalWindowId(msg)
|
|
1353
|
+
const key = this.handleMapKey(messageOut.windowId)
|
|
1354
|
+
const handle = key ? this._handles.get(key) : undefined
|
|
1355
|
+
if (handle) handle.emit(messageOut.focused ? 'focus' : 'blur', messageOut)
|
|
1356
|
+
this.emit('windowFocus', messageOut)
|
|
883
1357
|
}
|
|
1358
|
+
|
|
1359
|
+
this.emit('message', messageOut)
|
|
884
1360
|
}
|
|
885
1361
|
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
1362
|
+
// =====================================================================
|
|
1363
|
+
// Shared internals
|
|
1364
|
+
// =====================================================================
|
|
1365
|
+
|
|
1366
|
+
private _displayCache: { width: number; height: number } | null = null
|
|
1367
|
+
|
|
1368
|
+
/**
|
|
1369
|
+
* Get the primary display resolution, cached for the lifetime of the feature.
|
|
1370
|
+
*/
|
|
1371
|
+
private getPrimaryDisplay(): { width: number; height: number } {
|
|
1372
|
+
if (this._displayCache) return this._displayCache
|
|
1373
|
+
const osFeature = this.container.feature('os')
|
|
1374
|
+
const displays = osFeature.getDisplayInfo()
|
|
1375
|
+
const primary = displays.find(d => d.main) ?? displays[0]
|
|
1376
|
+
if (!primary) throw new Error('No displays found')
|
|
1377
|
+
this._displayCache = { width: primary.resolution.width, height: primary.resolution.height }
|
|
1378
|
+
return this._displayCache
|
|
891
1379
|
}
|
|
892
1380
|
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
1381
|
+
/**
|
|
1382
|
+
* Resolve percentage-based dimension values to absolute points using the primary display.
|
|
1383
|
+
* Passes through absolute numbers unchanged. Only fetches display info if percentages are present.
|
|
1384
|
+
*/
|
|
1385
|
+
private resolveDimensions<T extends Record<string, any>>(opts: T): T {
|
|
1386
|
+
const keys = ['width', 'height', 'x', 'y'] as const
|
|
1387
|
+
const hasPercentage = keys.some(k => typeof opts[k] === 'string' && (opts[k] as string).endsWith('%'))
|
|
1388
|
+
if (!hasPercentage) return opts
|
|
1389
|
+
|
|
1390
|
+
const display = this.getPrimaryDisplay()
|
|
1391
|
+
const resolved = { ...opts }
|
|
1392
|
+
|
|
1393
|
+
for (const key of keys) {
|
|
1394
|
+
const val = opts[key]
|
|
1395
|
+
if (typeof val === 'string' && val.endsWith('%')) {
|
|
1396
|
+
const pct = parseFloat(val) / 100
|
|
1397
|
+
const ref = (key === 'width' || key === 'x') ? display.width : display.height
|
|
1398
|
+
;(resolved as any)[key] = Math.round(pct * ref)
|
|
1399
|
+
}
|
|
1400
|
+
}
|
|
1401
|
+
|
|
1402
|
+
return resolved
|
|
898
1403
|
}
|
|
899
1404
|
|
|
900
1405
|
/**
|
|
901
|
-
* Send a window dispatch command
|
|
902
|
-
*
|
|
1406
|
+
* Send a window dispatch command and wait for the ack.
|
|
1407
|
+
* In broker mode, sends directly to the app client.
|
|
1408
|
+
* In producer mode, routes through the broker via the control socket.
|
|
1409
|
+
* If not yet started, calls listen() to auto-detect mode.
|
|
903
1410
|
*/
|
|
904
1411
|
private sendWindowCommand(windowPayload: Record<string, any>): Promise<WindowAckResult> {
|
|
905
1412
|
return new Promise<WindowAckResult>(async (resolve, reject) => {
|
|
906
1413
|
const timeoutMs = this.options.requestTimeoutMs || 10000
|
|
907
|
-
const bridge = this.getBridgeListener()
|
|
908
1414
|
|
|
909
|
-
//
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
this.listen()
|
|
1415
|
+
// Auto-start if needed
|
|
1416
|
+
if (!this._mode) {
|
|
1417
|
+
await this.listen()
|
|
913
1418
|
}
|
|
914
1419
|
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
this.
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
1420
|
+
// In producer mode, we don't wait for app client — the broker handles that.
|
|
1421
|
+
// We just need our control connection to be alive.
|
|
1422
|
+
if (this._mode === 'producer') {
|
|
1423
|
+
if (!this._controlClient) {
|
|
1424
|
+
resolve({ ok: false, error: 'Not connected to broker', code: 'Disconnected' })
|
|
1425
|
+
return
|
|
1426
|
+
}
|
|
922
1427
|
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
const usingBridge = connectionMode === 'bridge'
|
|
1428
|
+
const rawId = randomUUID()
|
|
1429
|
+
const id = this.normalizeRequestId(rawId) || rawId
|
|
926
1430
|
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
}
|
|
933
|
-
|
|
1431
|
+
const timer = setTimeout(() => {
|
|
1432
|
+
const p = this.takePendingRequest(id)
|
|
1433
|
+
if (p) {
|
|
1434
|
+
p.resolve({ ok: false, error: `Window ${windowPayload.action} timed out after ${timeoutMs}ms`, code: 'Timeout' })
|
|
1435
|
+
}
|
|
1436
|
+
}, timeoutMs)
|
|
1437
|
+
|
|
1438
|
+
this.registerPendingOperation(id, windowPayload.action, { resolve, reject, timer })
|
|
934
1439
|
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
this.processLine(JSON.stringify(msg))
|
|
1440
|
+
const payload = {
|
|
1441
|
+
id,
|
|
1442
|
+
status: 'processing',
|
|
1443
|
+
window: windowPayload,
|
|
1444
|
+
timestamp: new Date().toISOString(),
|
|
941
1445
|
}
|
|
942
|
-
bridge.on?.('message', bridgeMessageListener)
|
|
943
|
-
}
|
|
944
1446
|
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
resolve(value)
|
|
1447
|
+
this._controlClient.socket.write(JSON.stringify(payload) + '\n')
|
|
1448
|
+
return
|
|
948
1449
|
}
|
|
949
1450
|
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
1451
|
+
// Broker mode — send directly to app client
|
|
1452
|
+
if (!this._client) {
|
|
1453
|
+
// Wait for app client to connect
|
|
1454
|
+
const connected = await this.waitForAppClient(timeoutMs)
|
|
1455
|
+
if (!connected) {
|
|
1456
|
+
const error = `No native launcher client connected on socket ${this.state.get('socketPath') || this.options.socketPath}`
|
|
1457
|
+
this.setState({ lastError: error })
|
|
1458
|
+
resolve({ ok: false, error, code: 'NoClient' })
|
|
1459
|
+
return
|
|
1460
|
+
}
|
|
953
1461
|
}
|
|
954
1462
|
|
|
1463
|
+
const rawId = randomUUID()
|
|
1464
|
+
const id = this.normalizeRequestId(rawId) || rawId
|
|
1465
|
+
|
|
955
1466
|
const timer = setTimeout(() => {
|
|
956
|
-
this.
|
|
957
|
-
|
|
1467
|
+
const p = this.takePendingRequest(id)
|
|
1468
|
+
if (p) {
|
|
1469
|
+
p.resolve({ ok: false, error: `Window ${windowPayload.action} timed out after ${timeoutMs}ms`, code: 'Timeout' })
|
|
1470
|
+
}
|
|
958
1471
|
}, timeoutMs)
|
|
959
1472
|
|
|
960
|
-
this.
|
|
1473
|
+
this.registerPendingOperation(id, windowPayload.action, { resolve, reject, timer })
|
|
961
1474
|
|
|
962
1475
|
const payload = {
|
|
963
1476
|
id,
|
|
@@ -965,30 +1478,125 @@ export class WindowManager extends Feature<WindowManagerState, WindowManagerOpti
|
|
|
965
1478
|
window: windowPayload,
|
|
966
1479
|
timestamp: new Date().toISOString(),
|
|
967
1480
|
}
|
|
968
|
-
const sent =
|
|
1481
|
+
const sent = this.send(payload)
|
|
969
1482
|
|
|
970
1483
|
if (!sent) {
|
|
971
|
-
|
|
972
|
-
this._pending.delete(id)
|
|
973
|
-
cleanupBridgeListener()
|
|
1484
|
+
const p = this.takePendingRequest(id)
|
|
974
1485
|
const error = `Failed to send window ${windowPayload.action}: client disconnected`
|
|
975
1486
|
this.setState({ lastError: error })
|
|
976
|
-
|
|
1487
|
+
if (p) {
|
|
1488
|
+
p.resolve({ ok: false, error, code: 'Disconnected' })
|
|
1489
|
+
}
|
|
977
1490
|
}
|
|
978
1491
|
})
|
|
979
1492
|
}
|
|
980
1493
|
|
|
981
|
-
/**
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
1494
|
+
/** Wait for the native app to connect (broker mode only). */
|
|
1495
|
+
private waitForAppClient(timeoutMs: number): Promise<boolean> {
|
|
1496
|
+
if (this._client) return Promise.resolve(true)
|
|
1497
|
+
|
|
1498
|
+
return new Promise<boolean>((resolve) => {
|
|
1499
|
+
const timer = setTimeout(() => {
|
|
1500
|
+
this.off('clientConnected', onConnected)
|
|
1501
|
+
resolve(false)
|
|
1502
|
+
}, timeoutMs)
|
|
1503
|
+
|
|
1504
|
+
const onConnected = () => {
|
|
1505
|
+
clearTimeout(timer)
|
|
1506
|
+
resolve(true)
|
|
1507
|
+
}
|
|
1508
|
+
|
|
1509
|
+
this.once('clientConnected', onConnected)
|
|
1510
|
+
})
|
|
1511
|
+
}
|
|
1512
|
+
|
|
1513
|
+
/** Returns true if the open-window roster changed (broker should fan out `windowStateSync`). */
|
|
1514
|
+
private updateTrackedWindowsFromAck(msg: any): boolean {
|
|
1515
|
+
if (!msg?.success) return false
|
|
1516
|
+
if (typeof msg?.action !== 'string') return false
|
|
1517
|
+
|
|
1518
|
+
const action = msg.action.toLowerCase()
|
|
1519
|
+
const resultWindowId = msg?.result?.windowId
|
|
1520
|
+
|
|
1521
|
+
if (action === 'open' || action === 'spawn' || action === 'terminal') {
|
|
1522
|
+
const before = Object.keys(this.state.get('windows') ?? {}).length
|
|
1523
|
+
this.trackWindowOpened(resultWindowId, {
|
|
1524
|
+
kind: action === 'terminal' ? 'terminal' : 'browser',
|
|
1525
|
+
lastAck: msg,
|
|
1526
|
+
})
|
|
1527
|
+
return Object.keys(this.state.get('windows') ?? {}).length !== before
|
|
1528
|
+
}
|
|
1529
|
+
|
|
1530
|
+
if (action === 'close') {
|
|
1531
|
+
const before = Object.keys(this.state.get('windows') ?? {}).length
|
|
1532
|
+
this.trackWindowClosed(resultWindowId)
|
|
1533
|
+
return Object.keys(this.state.get('windows') ?? {}).length !== before
|
|
1534
|
+
}
|
|
1535
|
+
|
|
1536
|
+
return false
|
|
1537
|
+
}
|
|
1538
|
+
|
|
1539
|
+
/** Producer: apply broker snapshot; drop local handles for windows no longer in the roster. */
|
|
1540
|
+
private applyProducerWindowStateSync(msg: any): void {
|
|
1541
|
+
const raw = msg?.windows
|
|
1542
|
+
const windows =
|
|
1543
|
+
raw && typeof raw === 'object' && !Array.isArray(raw)
|
|
1544
|
+
? (raw as Record<string, WindowTrackedEntry>)
|
|
1545
|
+
: {}
|
|
1546
|
+
const windowCount =
|
|
1547
|
+
typeof msg?.windowCount === 'number' ? msg.windowCount : Object.keys(windows).length
|
|
1548
|
+
const patch: Partial<WindowManagerState> = { windows, windowCount }
|
|
1549
|
+
if (typeof msg?.clientConnected === 'boolean') {
|
|
1550
|
+
patch.clientConnected = msg.clientConnected
|
|
1551
|
+
}
|
|
1552
|
+
this.setState(patch)
|
|
1553
|
+
this.pruneHandlesMissingFromRoster(windows)
|
|
1554
|
+
}
|
|
1555
|
+
|
|
1556
|
+
private pruneHandlesMissingFromRoster(windows: Record<string, WindowTrackedEntry>): void {
|
|
1557
|
+
const allowed = new Set(Object.keys(windows))
|
|
1558
|
+
for (const [mapKey, handle] of [...this._handles.entries()]) {
|
|
1559
|
+
if (!allowed.has(mapKey)) {
|
|
1560
|
+
handle.emit('close', {
|
|
1561
|
+
type: 'windowClosed',
|
|
1562
|
+
windowId: mapKey,
|
|
1563
|
+
reason: 'windowStateSync',
|
|
1564
|
+
})
|
|
1565
|
+
this._handles.delete(mapKey)
|
|
1566
|
+
}
|
|
1567
|
+
}
|
|
1568
|
+
}
|
|
1569
|
+
|
|
1570
|
+
private trackWindowOpened(
|
|
1571
|
+
windowId: unknown,
|
|
1572
|
+
extra?: { kind?: 'browser' | 'terminal' | 'unknown'; lastAck?: unknown },
|
|
1573
|
+
): void {
|
|
1574
|
+
const normalized = this.normalizeRequestId(windowId)
|
|
1575
|
+
if (!normalized) return
|
|
1576
|
+
const native = typeof windowId === 'string' ? windowId : normalized
|
|
1577
|
+
this.setState((cur) => {
|
|
1578
|
+
const windows = { ...(cur.windows ?? {}) }
|
|
1579
|
+
const prev = windows[normalized]
|
|
1580
|
+
windows[normalized] = {
|
|
1581
|
+
windowId: normalized,
|
|
1582
|
+
nativeWindowId: prev?.nativeWindowId ?? native,
|
|
1583
|
+
openedAt: prev?.openedAt ?? Date.now(),
|
|
1584
|
+
lastAck:
|
|
1585
|
+
extra?.lastAck !== undefined ? this.snapshotForState(extra.lastAck) : prev?.lastAck,
|
|
1586
|
+
kind: extra?.kind ?? prev?.kind ?? 'unknown',
|
|
1587
|
+
}
|
|
1588
|
+
return { windows, windowCount: Object.keys(windows).length }
|
|
1589
|
+
})
|
|
1590
|
+
}
|
|
1591
|
+
|
|
1592
|
+
private trackWindowClosed(windowId: unknown): void {
|
|
1593
|
+
const normalized = this.normalizeRequestId(windowId)
|
|
1594
|
+
if (!normalized) return
|
|
1595
|
+
this.setState((cur) => {
|
|
1596
|
+
const windows = { ...(cur.windows ?? {}) }
|
|
1597
|
+
delete windows[normalized]
|
|
1598
|
+
return { windows, windowCount: Object.keys(windows).length }
|
|
1599
|
+
})
|
|
992
1600
|
}
|
|
993
1601
|
}
|
|
994
1602
|
|