@soederpop/luca 0.0.28 → 0.0.30
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/commands/try-all-challenges.ts +1 -1
- package/docs/TABLE-OF-CONTENTS.md +0 -3
- package/docs/examples/structured-output-with-assistants.md +144 -0
- package/docs/tutorials/20-browser-esm.md +234 -0
- package/package.json +1 -1
- package/src/agi/container.server.ts +4 -0
- package/src/agi/features/assistant.ts +132 -2
- package/src/agi/features/browser-use.ts +623 -0
- package/src/agi/features/conversation.ts +135 -45
- package/src/agi/lib/interceptor-chain.ts +79 -0
- package/src/bootstrap/generated.ts +381 -308
- package/src/cli/build-info.ts +2 -2
- package/src/clients/rest.ts +7 -7
- package/src/commands/chat.ts +22 -0
- package/src/commands/describe.ts +67 -2
- package/src/commands/prompt.ts +23 -3
- package/src/container.ts +411 -113
- package/src/helper.ts +189 -5
- package/src/introspection/generated.agi.ts +17664 -11568
- package/src/introspection/generated.node.ts +4891 -1860
- package/src/introspection/generated.web.ts +379 -291
- package/src/introspection/index.ts +7 -0
- package/src/introspection/scan.ts +224 -7
- package/src/node/container.ts +31 -10
- package/src/node/features/content-db.ts +7 -7
- package/src/node/features/disk-cache.ts +11 -11
- package/src/node/features/esbuild.ts +3 -3
- package/src/node/features/file-manager.ts +37 -16
- package/src/node/features/fs.ts +64 -25
- package/src/node/features/git.ts +10 -10
- package/src/node/features/helpers.ts +25 -18
- package/src/node/features/ink.ts +13 -13
- package/src/node/features/ipc-socket.ts +8 -8
- package/src/node/features/networking.ts +3 -3
- package/src/node/features/os.ts +7 -7
- package/src/node/features/package-finder.ts +15 -15
- package/src/node/features/proc.ts +1 -1
- package/src/node/features/ui.ts +13 -13
- package/src/node/features/vm.ts +4 -4
- package/src/scaffolds/generated.ts +1 -1
- package/src/servers/express.ts +6 -6
- package/src/servers/mcp.ts +4 -4
- package/src/servers/socket.ts +6 -6
- package/test/interceptor-chain.test.ts +61 -0
- package/docs/apis/features/node/window-manager.md +0 -445
- package/docs/examples/window-manager-layouts.md +0 -180
- package/docs/examples/window-manager.md +0 -125
- package/docs/window-manager-fix.md +0 -249
- package/scripts/test-window-manager-lifecycle.ts +0 -86
- package/scripts/test-window-manager.ts +0 -43
- package/src/node/features/window-manager.ts +0 -1603
|
@@ -1,1603 +0,0 @@
|
|
|
1
|
-
import { z } from 'zod'
|
|
2
|
-
import { FeatureStateSchema, FeatureOptionsSchema, FeatureEventsSchema } from '../../schemas/base.js'
|
|
3
|
-
import { Feature } from '../feature.js'
|
|
4
|
-
import { Bus } from '../../bus.js'
|
|
5
|
-
import { Server as NetServer, Socket } from 'net'
|
|
6
|
-
import { homedir } from 'os'
|
|
7
|
-
import { join, dirname } from 'path'
|
|
8
|
-
import { randomUUID } from 'crypto'
|
|
9
|
-
import { existsSync, unlinkSync, mkdirSync } from 'fs'
|
|
10
|
-
|
|
11
|
-
const DEFAULT_SOCKET_PATH = join(
|
|
12
|
-
homedir(),
|
|
13
|
-
'Library',
|
|
14
|
-
'Application Support',
|
|
15
|
-
'LucaVoiceLauncher',
|
|
16
|
-
'ipc-window.sock'
|
|
17
|
-
)
|
|
18
|
-
|
|
19
|
-
function controlPathFor(socketPath: string): string {
|
|
20
|
-
return socketPath.replace(/\.sock$/, '-control.sock')
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
const ErrorCodes = ['BadRequest', 'NotFound', 'EvalFailed', 'Internal', 'Timeout', 'Disconnected', 'NoClient'] as const
|
|
24
|
-
type WindowManagerErrorCode = typeof ErrorCodes[number]
|
|
25
|
-
|
|
26
|
-
/**
|
|
27
|
-
* Custom error class for WindowManager operations.
|
|
28
|
-
* Carries a `code` property for programmatic error handling.
|
|
29
|
-
*/
|
|
30
|
-
export class WindowManagerError extends Error {
|
|
31
|
-
code: WindowManagerErrorCode
|
|
32
|
-
|
|
33
|
-
constructor(message: string, code: WindowManagerErrorCode = 'Internal') {
|
|
34
|
-
super(message)
|
|
35
|
-
this.name = 'WindowManagerError'
|
|
36
|
-
this.code = code
|
|
37
|
-
}
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
// --- Schemas ---
|
|
41
|
-
|
|
42
|
-
export const WindowManagerOptionsSchema = FeatureOptionsSchema.extend({
|
|
43
|
-
socketPath: z.string().default(DEFAULT_SOCKET_PATH)
|
|
44
|
-
.describe('Path to the Unix domain socket the server listens on'),
|
|
45
|
-
autoListen: z.boolean().optional()
|
|
46
|
-
.describe('Automatically start listening when the feature is enabled'),
|
|
47
|
-
requestTimeoutMs: z.number().default(10000)
|
|
48
|
-
.describe('Per-request timeout in milliseconds for window operations'),
|
|
49
|
-
})
|
|
50
|
-
export type WindowManagerOptions = z.infer<typeof WindowManagerOptionsSchema>
|
|
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
|
-
|
|
70
|
-
export const WindowManagerStateSchema = FeatureStateSchema.extend({
|
|
71
|
-
listening: z.boolean().default(false)
|
|
72
|
-
.describe('Whether the IPC server is listening'),
|
|
73
|
-
clientConnected: z.boolean().default(false)
|
|
74
|
-
.describe('Whether the native launcher app is connected'),
|
|
75
|
-
socketPath: z.string().optional()
|
|
76
|
-
.describe('The socket path in use'),
|
|
77
|
-
windowCount: z.number().default(0)
|
|
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)'),
|
|
85
|
-
lastError: z.string().optional()
|
|
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)'),
|
|
89
|
-
})
|
|
90
|
-
export type WindowManagerState = z.infer<typeof WindowManagerStateSchema>
|
|
91
|
-
|
|
92
|
-
export const WindowManagerEventsSchema = FeatureEventsSchema.extend({
|
|
93
|
-
listening: z.tuple([]).describe('Emitted when the IPC server starts listening'),
|
|
94
|
-
clientConnected: z.tuple([z.any().describe('The client socket')]).describe('Emitted when the native app connects'),
|
|
95
|
-
clientDisconnected: z.tuple([]).describe('Emitted when the native app disconnects'),
|
|
96
|
-
message: z.tuple([z.any().describe('The parsed message object')]).describe('Emitted for any incoming message that is not a windowAck'),
|
|
97
|
-
windowAck: z.tuple([z.any().describe('The window ack payload')]).describe('Emitted when a window ack is received from the app'),
|
|
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'),
|
|
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'),
|
|
101
|
-
error: z.tuple([z.any().describe('The error')]).describe('Emitted on error'),
|
|
102
|
-
})
|
|
103
|
-
|
|
104
|
-
// --- Types ---
|
|
105
|
-
|
|
106
|
-
/** A dimension value — either absolute points or a percentage string like `"50%"`. */
|
|
107
|
-
export type DimensionValue = number | `${number}%`
|
|
108
|
-
|
|
109
|
-
/**
|
|
110
|
-
* Options for spawning a new native browser window.
|
|
111
|
-
* Dimensions and positions accept absolute points or percentage strings (e.g. `"50%"`)
|
|
112
|
-
* resolved against the primary display.
|
|
113
|
-
*/
|
|
114
|
-
export interface SpawnOptions {
|
|
115
|
-
url?: string
|
|
116
|
-
width?: DimensionValue
|
|
117
|
-
height?: DimensionValue
|
|
118
|
-
x?: DimensionValue
|
|
119
|
-
y?: DimensionValue
|
|
120
|
-
alwaysOnTop?: boolean
|
|
121
|
-
window?: {
|
|
122
|
-
decorations?: 'normal' | 'hiddenTitleBar' | 'none'
|
|
123
|
-
transparent?: boolean
|
|
124
|
-
shadow?: boolean
|
|
125
|
-
alwaysOnTop?: boolean
|
|
126
|
-
opacity?: number
|
|
127
|
-
clickThrough?: boolean
|
|
128
|
-
}
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
/**
|
|
132
|
-
* Options for spawning a native terminal window.
|
|
133
|
-
* Dimensions and positions accept absolute points or percentage strings (e.g. `"50%"`)
|
|
134
|
-
* resolved against the primary display.
|
|
135
|
-
*/
|
|
136
|
-
export interface SpawnTTYOptions {
|
|
137
|
-
/** Executable name or path (required). */
|
|
138
|
-
command: string
|
|
139
|
-
/** Arguments passed after the command. */
|
|
140
|
-
args?: string[]
|
|
141
|
-
/** Working directory for the process. */
|
|
142
|
-
cwd?: string
|
|
143
|
-
/** Environment variable overrides. */
|
|
144
|
-
env?: Record<string, string>
|
|
145
|
-
/** Initial terminal columns. */
|
|
146
|
-
cols?: number
|
|
147
|
-
/** Initial terminal rows. */
|
|
148
|
-
rows?: number
|
|
149
|
-
/** Window title. */
|
|
150
|
-
title?: string
|
|
151
|
-
/** Window width in points. */
|
|
152
|
-
width?: DimensionValue
|
|
153
|
-
/** Window height in points. */
|
|
154
|
-
height?: DimensionValue
|
|
155
|
-
/** Window x position. */
|
|
156
|
-
x?: DimensionValue
|
|
157
|
-
/** Window y position. */
|
|
158
|
-
y?: DimensionValue
|
|
159
|
-
/** Chrome options (decorations, alwaysOnTop, etc.) */
|
|
160
|
-
window?: SpawnOptions['window']
|
|
161
|
-
}
|
|
162
|
-
|
|
163
|
-
/**
|
|
164
|
-
* Options for capturing a screenshot from a native window.
|
|
165
|
-
*/
|
|
166
|
-
export interface WindowScreenGrabOptions {
|
|
167
|
-
/** Window ID. If omitted, the launcher uses the most recent window. */
|
|
168
|
-
windowId?: string
|
|
169
|
-
/** Output file path for the PNG image. */
|
|
170
|
-
path: string
|
|
171
|
-
}
|
|
172
|
-
|
|
173
|
-
/**
|
|
174
|
-
* Options for recording video from a native window.
|
|
175
|
-
*/
|
|
176
|
-
export interface WindowVideoOptions {
|
|
177
|
-
/** Window ID. If omitted, the launcher uses the most recent window. */
|
|
178
|
-
windowId?: string
|
|
179
|
-
/** Output file path for the video file. */
|
|
180
|
-
path: string
|
|
181
|
-
/** Recording duration in milliseconds. */
|
|
182
|
-
durationMs?: number
|
|
183
|
-
}
|
|
184
|
-
|
|
185
|
-
/**
|
|
186
|
-
* The result returned from a window ack.
|
|
187
|
-
*/
|
|
188
|
-
export interface WindowAckResult {
|
|
189
|
-
ok?: boolean
|
|
190
|
-
windowId?: string
|
|
191
|
-
value?: string
|
|
192
|
-
json?: any
|
|
193
|
-
[key: string]: any
|
|
194
|
-
}
|
|
195
|
-
|
|
196
|
-
// --- Layout Types ---
|
|
197
|
-
|
|
198
|
-
/**
|
|
199
|
-
* A single entry in a layout configuration.
|
|
200
|
-
* Use `type: 'tty'` for terminal windows, `type: 'window'` (or omit) for browser windows.
|
|
201
|
-
* If `type` is omitted, entries with a `command` field are treated as TTY, otherwise as window.
|
|
202
|
-
*/
|
|
203
|
-
export type LayoutEntry =
|
|
204
|
-
| ({ type: 'window' } & SpawnOptions)
|
|
205
|
-
| ({ type: 'tty' } & SpawnTTYOptions)
|
|
206
|
-
| ({ type?: undefined } & SpawnOptions)
|
|
207
|
-
| ({ type?: undefined; command: string } & SpawnTTYOptions)
|
|
208
|
-
|
|
209
|
-
// --- WindowHandle Events ---
|
|
210
|
-
|
|
211
|
-
interface WindowHandleEvents {
|
|
212
|
-
close: [msg: any]
|
|
213
|
-
terminalExited: [msg: any]
|
|
214
|
-
focus: [msg: any]
|
|
215
|
-
blur: [msg: any]
|
|
216
|
-
}
|
|
217
|
-
|
|
218
|
-
// --- WindowHandle ---
|
|
219
|
-
|
|
220
|
-
/**
|
|
221
|
-
* A lightweight handle to a single native window.
|
|
222
|
-
* Delegates all operations back to the WindowManager instance.
|
|
223
|
-
* Emits lifecycle events (`close`, `terminalExited`) when the native app reports them.
|
|
224
|
-
*
|
|
225
|
-
* @example
|
|
226
|
-
* ```typescript
|
|
227
|
-
* const handle = await windowManager.spawn({ url: 'https://example.com' })
|
|
228
|
-
* handle.on('close', (msg) => console.log('window closed', msg))
|
|
229
|
-
* handle.on('terminalExited', (info) => console.log('process exited', info))
|
|
230
|
-
* ```
|
|
231
|
-
*/
|
|
232
|
-
export class WindowHandle {
|
|
233
|
-
private _events = new Bus<WindowHandleEvents>()
|
|
234
|
-
|
|
235
|
-
/** The original ack result from spawning this window. */
|
|
236
|
-
public result: WindowAckResult
|
|
237
|
-
|
|
238
|
-
constructor(
|
|
239
|
-
public readonly windowId: string,
|
|
240
|
-
private manager: WindowManager,
|
|
241
|
-
result?: WindowAckResult
|
|
242
|
-
) {
|
|
243
|
-
this.result = result ?? {}
|
|
244
|
-
}
|
|
245
|
-
|
|
246
|
-
/** Register a listener for a lifecycle event. */
|
|
247
|
-
on<E extends keyof WindowHandleEvents>(event: E, listener: (...args: WindowHandleEvents[E]) => void): this {
|
|
248
|
-
this._events.on(event, listener)
|
|
249
|
-
return this
|
|
250
|
-
}
|
|
251
|
-
|
|
252
|
-
/** Remove a listener for a lifecycle event. */
|
|
253
|
-
off<E extends keyof WindowHandleEvents>(event: E, listener?: (...args: WindowHandleEvents[E]) => void): this {
|
|
254
|
-
this._events.off(event, listener)
|
|
255
|
-
return this
|
|
256
|
-
}
|
|
257
|
-
|
|
258
|
-
async waitFor(event: string) {
|
|
259
|
-
return this._events.waitFor(event as any)
|
|
260
|
-
}
|
|
261
|
-
|
|
262
|
-
/** Register a one-time listener for a lifecycle event. */
|
|
263
|
-
once<E extends keyof WindowHandleEvents>(event: E, listener: (...args: WindowHandleEvents[E]) => void): this {
|
|
264
|
-
this._events.once(event, listener)
|
|
265
|
-
return this
|
|
266
|
-
}
|
|
267
|
-
|
|
268
|
-
/** Emit a lifecycle event on this handle. */
|
|
269
|
-
emit<E extends keyof WindowHandleEvents>(event: E, ...args: WindowHandleEvents[E]): void {
|
|
270
|
-
this._events.emit(event, ...args)
|
|
271
|
-
}
|
|
272
|
-
|
|
273
|
-
/** Bring this window to the front. */
|
|
274
|
-
async focus(): Promise<WindowAckResult> {
|
|
275
|
-
return this.manager.focus(this.windowId)
|
|
276
|
-
}
|
|
277
|
-
|
|
278
|
-
/** Close this window. */
|
|
279
|
-
async close(): Promise<WindowAckResult> {
|
|
280
|
-
return this.manager.close(this.windowId)
|
|
281
|
-
}
|
|
282
|
-
|
|
283
|
-
/** Navigate this window to a URL. */
|
|
284
|
-
async navigate(url: string): Promise<WindowAckResult> {
|
|
285
|
-
return this.manager.navigate(this.windowId, url)
|
|
286
|
-
}
|
|
287
|
-
|
|
288
|
-
/** Evaluate JavaScript in this window's web view. */
|
|
289
|
-
async eval(code: string, opts?: { timeoutMs?: number; returnJson?: boolean }): Promise<WindowAckResult> {
|
|
290
|
-
return this.manager.eval(this.windowId, code, opts)
|
|
291
|
-
}
|
|
292
|
-
|
|
293
|
-
/** Capture a PNG screenshot of this window. */
|
|
294
|
-
async screengrab(path: string): Promise<WindowAckResult> {
|
|
295
|
-
return this.manager.screengrab({ windowId: this.windowId, path })
|
|
296
|
-
}
|
|
297
|
-
|
|
298
|
-
/** Record a video of this window to disk. */
|
|
299
|
-
async video(path: string, opts?: { durationMs?: number }): Promise<WindowAckResult> {
|
|
300
|
-
return this.manager.video({ windowId: this.windowId, path, durationMs: opts?.durationMs })
|
|
301
|
-
}
|
|
302
|
-
}
|
|
303
|
-
|
|
304
|
-
// --- Private types ---
|
|
305
|
-
|
|
306
|
-
type PendingRequest = {
|
|
307
|
-
resolve: (value: any) => void
|
|
308
|
-
reject: (reason: any) => void
|
|
309
|
-
timer: ReturnType<typeof setTimeout>
|
|
310
|
-
}
|
|
311
|
-
|
|
312
|
-
interface ClientConnection {
|
|
313
|
-
socket: Socket
|
|
314
|
-
buffer: string
|
|
315
|
-
}
|
|
316
|
-
|
|
317
|
-
// --- Feature ---
|
|
318
|
-
|
|
319
|
-
/**
|
|
320
|
-
* WindowManager Feature — Native window control via LucaVoiceLauncher
|
|
321
|
-
*
|
|
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.
|
|
333
|
-
*
|
|
334
|
-
* **Protocol:**
|
|
335
|
-
* - Bun listens on a Unix domain socket; the native app connects as a client
|
|
336
|
-
* - Window dispatch commands are sent as NDJSON with a `window` field
|
|
337
|
-
* - The app executes window commands and sends back `windowAck` messages
|
|
338
|
-
* - Any non-windowAck message from the app is emitted as a `message` event
|
|
339
|
-
* - Other features can use `send()` to write arbitrary NDJSON to the app
|
|
340
|
-
*
|
|
341
|
-
* **Capabilities:**
|
|
342
|
-
* - Spawn native browser windows with configurable chrome
|
|
343
|
-
* - Navigate, focus, close, and eval JavaScript in windows
|
|
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).
|
|
354
|
-
*
|
|
355
|
-
* @example
|
|
356
|
-
* ```typescript
|
|
357
|
-
* const wm = container.feature('windowManager', { enable: true, autoListen: true })
|
|
358
|
-
*
|
|
359
|
-
* const handle = await wm.spawn({ url: 'https://google.com', width: 800, height: 600 })
|
|
360
|
-
* handle.on('close', (msg) => console.log('window closed'))
|
|
361
|
-
* await handle.navigate('https://news.ycombinator.com')
|
|
362
|
-
* const title = await handle.eval('document.title')
|
|
363
|
-
* await handle.close()
|
|
364
|
-
*
|
|
365
|
-
* // Other features can listen for non-window messages
|
|
366
|
-
* wm.on('message', (msg) => console.log('App says:', msg))
|
|
367
|
-
*
|
|
368
|
-
* // Other features can write raw NDJSON to the app
|
|
369
|
-
* wm.send({ id: 'abc', status: 'processing', speech: 'Working on it' })
|
|
370
|
-
* ```
|
|
371
|
-
*/
|
|
372
|
-
export class WindowManager extends Feature<WindowManagerState, WindowManagerOptions> {
|
|
373
|
-
static override shortcut = 'features.windowManager' as const
|
|
374
|
-
static override stateSchema = WindowManagerStateSchema
|
|
375
|
-
static override optionsSchema = WindowManagerOptionsSchema
|
|
376
|
-
static override eventsSchema = WindowManagerEventsSchema
|
|
377
|
-
static { Feature.register(this, 'windowManager') }
|
|
378
|
-
|
|
379
|
-
// --- Shared state ---
|
|
380
|
-
private _pending = new Map<string, PendingRequest>()
|
|
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
|
|
393
|
-
|
|
394
|
-
private normalizeRequestId(value: unknown): string | undefined {
|
|
395
|
-
if (typeof value !== 'string') return undefined
|
|
396
|
-
return value.toLowerCase()
|
|
397
|
-
}
|
|
398
|
-
|
|
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
|
-
}
|
|
404
|
-
|
|
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
|
-
}
|
|
426
|
-
|
|
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
|
-
}
|
|
435
|
-
|
|
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
|
-
}
|
|
445
|
-
|
|
446
|
-
private syncProducerCount(): void {
|
|
447
|
-
this.setState({ producerCount: this._producers.size })
|
|
448
|
-
}
|
|
449
|
-
|
|
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
|
-
}))
|
|
458
|
-
}
|
|
459
|
-
|
|
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
|
|
470
|
-
}
|
|
471
|
-
|
|
472
|
-
/** Default state: not listening, no client connected, zero windows tracked. */
|
|
473
|
-
override get initialState(): WindowManagerState {
|
|
474
|
-
return {
|
|
475
|
-
...super.initialState,
|
|
476
|
-
listening: false,
|
|
477
|
-
clientConnected: false,
|
|
478
|
-
windowCount: 0,
|
|
479
|
-
windows: {},
|
|
480
|
-
pendingOperations: [],
|
|
481
|
-
producerCount: 0,
|
|
482
|
-
}
|
|
483
|
-
}
|
|
484
|
-
|
|
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). */
|
|
496
|
-
get isListening(): boolean {
|
|
497
|
-
return this.state.get('listening') || false
|
|
498
|
-
}
|
|
499
|
-
|
|
500
|
-
/** Whether the native app client is currently connected (only meaningful for broker). */
|
|
501
|
-
get isClientConnected(): boolean {
|
|
502
|
-
return this.state.get('clientConnected') || false
|
|
503
|
-
}
|
|
504
|
-
|
|
505
|
-
override async enable(options: any = {}): Promise<this> {
|
|
506
|
-
await super.enable(options)
|
|
507
|
-
|
|
508
|
-
if (this.options.autoListen) {
|
|
509
|
-
await this.listen()
|
|
510
|
-
}
|
|
511
|
-
|
|
512
|
-
return this
|
|
513
|
-
}
|
|
514
|
-
|
|
515
|
-
/**
|
|
516
|
-
* Start the window manager. Automatically detects whether a broker already
|
|
517
|
-
* exists and either becomes the broker or connects as a producer.
|
|
518
|
-
*
|
|
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
|
|
525
|
-
* @returns This feature instance for chaining
|
|
526
|
-
*/
|
|
527
|
-
async listen(socketPath?: string): Promise<this> {
|
|
528
|
-
if (this._mode) return this
|
|
529
|
-
|
|
530
|
-
socketPath = socketPath || this.options.socketPath || DEFAULT_SOCKET_PATH
|
|
531
|
-
const controlPath = controlPathFor(socketPath)
|
|
532
|
-
|
|
533
|
-
// Ensure the directory exists
|
|
534
|
-
const dir = dirname(socketPath)
|
|
535
|
-
if (!existsSync(dir)) {
|
|
536
|
-
try {
|
|
537
|
-
mkdirSync(dir, { recursive: true })
|
|
538
|
-
} catch (error: any) {
|
|
539
|
-
this.setState({ lastError: `Failed to create socket directory ${dir}: ${error?.message || String(error)}` })
|
|
540
|
-
return this
|
|
541
|
-
}
|
|
542
|
-
}
|
|
543
|
-
|
|
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)
|
|
548
|
-
}
|
|
549
|
-
|
|
550
|
-
// No broker — we become the broker
|
|
551
|
-
return this.becomeBroker(socketPath, controlPath)
|
|
552
|
-
}
|
|
553
|
-
|
|
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
|
-
}
|
|
574
|
-
}
|
|
575
|
-
|
|
576
|
-
/**
|
|
577
|
-
* Stop the window manager and clean up all connections.
|
|
578
|
-
* Rejects any pending window operation requests.
|
|
579
|
-
*
|
|
580
|
-
* @returns This feature instance for chaining
|
|
581
|
-
*/
|
|
582
|
-
async stop(): Promise<this> {
|
|
583
|
-
// Resolve all pending requests
|
|
584
|
-
for (const [, pending] of this._pending) {
|
|
585
|
-
clearTimeout(pending.timer)
|
|
586
|
-
pending.resolve({ ok: false, error: 'Server stopping' })
|
|
587
|
-
}
|
|
588
|
-
this._pending.clear()
|
|
589
|
-
this._handles.clear()
|
|
590
|
-
|
|
591
|
-
// --- Producer cleanup ---
|
|
592
|
-
if (this._controlClient) {
|
|
593
|
-
this._controlClient.socket.destroy()
|
|
594
|
-
this._controlClient = undefined
|
|
595
|
-
}
|
|
596
|
-
|
|
597
|
-
// --- Broker cleanup ---
|
|
598
|
-
if (this._client) {
|
|
599
|
-
this._client.socket.destroy()
|
|
600
|
-
this._client = undefined
|
|
601
|
-
}
|
|
602
|
-
|
|
603
|
-
for (const [, producer] of this._producers) {
|
|
604
|
-
producer.socket.destroy()
|
|
605
|
-
}
|
|
606
|
-
this._producers.clear()
|
|
607
|
-
this._requestOrigins.clear()
|
|
608
|
-
|
|
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
|
-
}
|
|
618
|
-
|
|
619
|
-
if (this._server) {
|
|
620
|
-
await new Promise<void>((resolve) => {
|
|
621
|
-
this._server!.close(() => resolve())
|
|
622
|
-
})
|
|
623
|
-
this._server = undefined
|
|
624
|
-
}
|
|
625
|
-
|
|
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
|
-
}
|
|
634
|
-
}
|
|
635
|
-
|
|
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
|
-
})
|
|
647
|
-
return this
|
|
648
|
-
}
|
|
649
|
-
|
|
650
|
-
// --- Window Operations ---
|
|
651
|
-
|
|
652
|
-
/**
|
|
653
|
-
* Spawn a new native browser window.
|
|
654
|
-
* Sends a window dispatch to the app and waits for the ack.
|
|
655
|
-
*
|
|
656
|
-
* @param opts - Window configuration (url, dimensions, chrome options)
|
|
657
|
-
* @returns A WindowHandle for the spawned window (with `.result` containing the ack data)
|
|
658
|
-
*/
|
|
659
|
-
async spawn(opts: SpawnOptions = {}): Promise<WindowHandle> {
|
|
660
|
-
const resolved = this.resolveDimensions(opts)
|
|
661
|
-
const { window: windowChrome, ...flat } = resolved
|
|
662
|
-
|
|
663
|
-
let ackResult: WindowAckResult
|
|
664
|
-
if (windowChrome) {
|
|
665
|
-
ackResult = await this.sendWindowCommand({
|
|
666
|
-
action: 'open',
|
|
667
|
-
request: {
|
|
668
|
-
...flat,
|
|
669
|
-
window: windowChrome,
|
|
670
|
-
},
|
|
671
|
-
})
|
|
672
|
-
} else {
|
|
673
|
-
ackResult = await this.sendWindowCommand({
|
|
674
|
-
action: 'open',
|
|
675
|
-
...flat,
|
|
676
|
-
alwaysOnTop: flat.alwaysOnTop ?? false,
|
|
677
|
-
})
|
|
678
|
-
}
|
|
679
|
-
|
|
680
|
-
return this.getOrCreateHandle(ackResult.windowId, ackResult, 'browser')
|
|
681
|
-
}
|
|
682
|
-
|
|
683
|
-
/**
|
|
684
|
-
* Spawn a native terminal window running a command.
|
|
685
|
-
* The terminal is read-only — stdout/stderr are rendered with ANSI support.
|
|
686
|
-
* Closing the window terminates the process.
|
|
687
|
-
*
|
|
688
|
-
* @param opts - Terminal configuration (command, args, cwd, dimensions, etc.)
|
|
689
|
-
* @returns A WindowHandle for the spawned terminal (with `.result` containing the ack data)
|
|
690
|
-
*/
|
|
691
|
-
async spawnTTY(opts: SpawnTTYOptions): Promise<WindowHandle> {
|
|
692
|
-
const resolved = this.resolveDimensions(opts)
|
|
693
|
-
const { window: windowChrome, ...flat } = resolved
|
|
694
|
-
|
|
695
|
-
let ackResult: WindowAckResult
|
|
696
|
-
if (windowChrome) {
|
|
697
|
-
ackResult = await this.sendWindowCommand({
|
|
698
|
-
action: 'terminal',
|
|
699
|
-
...flat,
|
|
700
|
-
window: windowChrome,
|
|
701
|
-
})
|
|
702
|
-
} else {
|
|
703
|
-
ackResult = await this.sendWindowCommand({
|
|
704
|
-
action: 'terminal',
|
|
705
|
-
...flat,
|
|
706
|
-
})
|
|
707
|
-
}
|
|
708
|
-
|
|
709
|
-
return this.getOrCreateHandle(ackResult.windowId, ackResult, 'terminal')
|
|
710
|
-
}
|
|
711
|
-
|
|
712
|
-
/**
|
|
713
|
-
* Bring a window to the front.
|
|
714
|
-
*
|
|
715
|
-
* @param windowId - The window ID. If omitted, the app uses the most recent window.
|
|
716
|
-
* @returns The window ack result
|
|
717
|
-
*/
|
|
718
|
-
async focus(windowId?: string): Promise<WindowAckResult> {
|
|
719
|
-
return this.sendWindowCommand({
|
|
720
|
-
action: 'focus',
|
|
721
|
-
...(windowId ? { windowId } : {}),
|
|
722
|
-
})
|
|
723
|
-
}
|
|
724
|
-
|
|
725
|
-
/**
|
|
726
|
-
* Close a window.
|
|
727
|
-
*
|
|
728
|
-
* @param windowId - The window ID. If omitted, the app closes the most recent window.
|
|
729
|
-
* @returns The window ack result
|
|
730
|
-
*/
|
|
731
|
-
async close(windowId?: string): Promise<WindowAckResult> {
|
|
732
|
-
return this.sendWindowCommand({
|
|
733
|
-
action: 'close',
|
|
734
|
-
...(windowId ? { windowId } : {}),
|
|
735
|
-
})
|
|
736
|
-
}
|
|
737
|
-
|
|
738
|
-
/**
|
|
739
|
-
* Navigate a window to a new URL.
|
|
740
|
-
*
|
|
741
|
-
* @param windowId - The window ID
|
|
742
|
-
* @param url - The URL to navigate to
|
|
743
|
-
* @returns The window ack result
|
|
744
|
-
*/
|
|
745
|
-
async navigate(windowId: string, url: string): Promise<WindowAckResult> {
|
|
746
|
-
return this.sendWindowCommand({
|
|
747
|
-
action: 'navigate',
|
|
748
|
-
windowId,
|
|
749
|
-
url,
|
|
750
|
-
})
|
|
751
|
-
}
|
|
752
|
-
|
|
753
|
-
/**
|
|
754
|
-
* Evaluate JavaScript in a window's web view.
|
|
755
|
-
*
|
|
756
|
-
* @param windowId - The window ID
|
|
757
|
-
* @param code - JavaScript code to evaluate
|
|
758
|
-
* @param opts - timeoutMs (default 5000), returnJson (default true)
|
|
759
|
-
* @returns The evaluation result from the window ack
|
|
760
|
-
*/
|
|
761
|
-
async eval(windowId: string, code: string, opts?: { timeoutMs?: number; returnJson?: boolean }): Promise<WindowAckResult> {
|
|
762
|
-
return this.sendWindowCommand({
|
|
763
|
-
action: 'eval',
|
|
764
|
-
windowId,
|
|
765
|
-
code,
|
|
766
|
-
...(opts?.timeoutMs != null ? { timeoutMs: opts.timeoutMs } : {}),
|
|
767
|
-
...(opts?.returnJson != null ? { returnJson: opts.returnJson } : {}),
|
|
768
|
-
})
|
|
769
|
-
}
|
|
770
|
-
|
|
771
|
-
/**
|
|
772
|
-
* Capture a PNG screenshot from a window.
|
|
773
|
-
*
|
|
774
|
-
* @param opts - Window target and output path
|
|
775
|
-
* @returns The window ack result
|
|
776
|
-
*/
|
|
777
|
-
async screengrab(opts: WindowScreenGrabOptions): Promise<WindowAckResult> {
|
|
778
|
-
return this.sendWindowCommand({
|
|
779
|
-
action: 'screengrab',
|
|
780
|
-
...(opts.windowId ? { windowId: opts.windowId } : {}),
|
|
781
|
-
path: opts.path,
|
|
782
|
-
})
|
|
783
|
-
}
|
|
784
|
-
|
|
785
|
-
/**
|
|
786
|
-
* Record a video from a window to disk.
|
|
787
|
-
*
|
|
788
|
-
* @param opts - Window target, output path, and optional duration
|
|
789
|
-
* @returns The window ack result
|
|
790
|
-
*/
|
|
791
|
-
async video(opts: WindowVideoOptions): Promise<WindowAckResult> {
|
|
792
|
-
return this.sendWindowCommand({
|
|
793
|
-
action: 'video',
|
|
794
|
-
...(opts.windowId ? { windowId: opts.windowId } : {}),
|
|
795
|
-
path: opts.path,
|
|
796
|
-
...(opts.durationMs != null ? { durationMs: opts.durationMs } : {}),
|
|
797
|
-
})
|
|
798
|
-
}
|
|
799
|
-
|
|
800
|
-
/**
|
|
801
|
-
* Get a WindowHandle for chainable operations on a specific window.
|
|
802
|
-
* Returns the tracked handle if one exists, otherwise creates a new one.
|
|
803
|
-
*
|
|
804
|
-
* @param windowId - The window ID
|
|
805
|
-
* @returns A WindowHandle instance
|
|
806
|
-
*/
|
|
807
|
-
window(windowId: string): WindowHandle {
|
|
808
|
-
return this.getOrCreateHandle(windowId)
|
|
809
|
-
}
|
|
810
|
-
|
|
811
|
-
/**
|
|
812
|
-
* Spawn multiple windows in parallel from a layout configuration.
|
|
813
|
-
* Returns handles in the same order as the config entries.
|
|
814
|
-
*
|
|
815
|
-
* @param config - Array of layout entries (window or tty)
|
|
816
|
-
* @returns Array of WindowHandle instances
|
|
817
|
-
*
|
|
818
|
-
* @example
|
|
819
|
-
* ```typescript
|
|
820
|
-
* const handles = await wm.spawnLayout([
|
|
821
|
-
* { type: 'window', url: 'https://google.com', width: 800, height: 600 },
|
|
822
|
-
* { type: 'tty', command: 'htop' },
|
|
823
|
-
* { url: 'https://github.com' }, // defaults to window
|
|
824
|
-
* ])
|
|
825
|
-
* ```
|
|
826
|
-
*/
|
|
827
|
-
async spawnLayout(config: LayoutEntry[]): Promise<WindowHandle[]> {
|
|
828
|
-
const handles: WindowHandle[] = []
|
|
829
|
-
for (const entry of config) {
|
|
830
|
-
if (entry.type === 'tty' || ('command' in entry && !entry.type)) {
|
|
831
|
-
const { type, ...opts } = entry as { type?: string } & SpawnTTYOptions
|
|
832
|
-
handles.push(await this.spawnTTY(opts))
|
|
833
|
-
} else {
|
|
834
|
-
const { type, ...opts } = entry as { type?: string } & SpawnOptions
|
|
835
|
-
handles.push(await this.spawn(opts))
|
|
836
|
-
}
|
|
837
|
-
}
|
|
838
|
-
return handles
|
|
839
|
-
}
|
|
840
|
-
|
|
841
|
-
/**
|
|
842
|
-
* Spawn multiple layouts sequentially. Each layout's windows spawn in parallel,
|
|
843
|
-
* but the next layout waits for the previous one to fully complete.
|
|
844
|
-
*
|
|
845
|
-
* @param configs - Array of layout configurations
|
|
846
|
-
* @returns Array of handle arrays, one per layout
|
|
847
|
-
*
|
|
848
|
-
* @example
|
|
849
|
-
* ```typescript
|
|
850
|
-
* const [firstBatch, secondBatch] = await wm.spawnLayouts([
|
|
851
|
-
* [{ url: 'https://google.com' }, { url: 'https://github.com' }],
|
|
852
|
-
* [{ type: 'tty', command: 'htop' }],
|
|
853
|
-
* ])
|
|
854
|
-
* ```
|
|
855
|
-
*/
|
|
856
|
-
async spawnLayouts(configs: LayoutEntry[][]): Promise<WindowHandle[][]> {
|
|
857
|
-
const results: WindowHandle[][] = []
|
|
858
|
-
for (const config of configs) {
|
|
859
|
-
results.push(await this.spawnLayout(config))
|
|
860
|
-
}
|
|
861
|
-
return results
|
|
862
|
-
}
|
|
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
|
-
|
|
886
|
-
/** Get or create a tracked WindowHandle for a given windowId. */
|
|
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)
|
|
895
|
-
if (!handle) {
|
|
896
|
-
handle = new WindowHandle(id, this, result)
|
|
897
|
-
this._handles.set(mapKey, handle)
|
|
898
|
-
} else if (result) {
|
|
899
|
-
handle.result = result
|
|
900
|
-
}
|
|
901
|
-
this.trackWindowOpened(id, { kind: kind !== 'unknown' ? kind : undefined })
|
|
902
|
-
return handle
|
|
903
|
-
}
|
|
904
|
-
|
|
905
|
-
/**
|
|
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.
|
|
908
|
-
*/
|
|
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)
|
|
917
|
-
|
|
918
|
-
probe.once('connect', () => {
|
|
919
|
-
clearTimeout(timer)
|
|
920
|
-
probe.destroy()
|
|
921
|
-
resolve(true)
|
|
922
|
-
})
|
|
923
|
-
|
|
924
|
-
probe.once('error', () => {
|
|
925
|
-
clearTimeout(timer)
|
|
926
|
-
probe.destroy()
|
|
927
|
-
resolve(false)
|
|
928
|
-
})
|
|
929
|
-
|
|
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 */ }
|
|
946
|
-
}
|
|
947
|
-
|
|
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
|
|
981
|
-
}
|
|
982
|
-
|
|
983
|
-
/**
|
|
984
|
-
* Handle a new app client connection from the native launcher.
|
|
985
|
-
* Sets up NDJSON buffering and event forwarding.
|
|
986
|
-
*/
|
|
987
|
-
private handleAppClientConnect(socket: Socket): void {
|
|
988
|
-
const client: ClientConnection = { socket, buffer: '' }
|
|
989
|
-
|
|
990
|
-
if (this._client) {
|
|
991
|
-
this._client.socket.destroy()
|
|
992
|
-
}
|
|
993
|
-
this._client = client
|
|
994
|
-
|
|
995
|
-
this.setState({ clientConnected: true })
|
|
996
|
-
this.emit('clientConnected', socket)
|
|
997
|
-
|
|
998
|
-
socket.on('data', (chunk) => {
|
|
999
|
-
client.buffer += chunk.toString()
|
|
1000
|
-
const lines = client.buffer.split('\n')
|
|
1001
|
-
client.buffer = lines.pop() || ''
|
|
1002
|
-
for (const line of lines) {
|
|
1003
|
-
if (line.trim()) this.processAppMessage(line)
|
|
1004
|
-
}
|
|
1005
|
-
})
|
|
1006
|
-
|
|
1007
|
-
socket.on('close', () => {
|
|
1008
|
-
if (this._client === client) {
|
|
1009
|
-
this._client = undefined
|
|
1010
|
-
this._handles.clear()
|
|
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) {
|
|
1021
|
-
clearTimeout(pending.timer)
|
|
1022
|
-
pending.resolve({ ok: false, error: 'Client disconnected' })
|
|
1023
|
-
}
|
|
1024
|
-
this._pending.clear()
|
|
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
|
-
|
|
1039
|
-
this.emit('clientDisconnected')
|
|
1040
|
-
}
|
|
1041
|
-
})
|
|
1042
|
-
|
|
1043
|
-
socket.on('error', (err) => {
|
|
1044
|
-
this.setState({ lastError: err.message })
|
|
1045
|
-
})
|
|
1046
|
-
}
|
|
1047
|
-
|
|
1048
|
-
/**
|
|
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.
|
|
1086
|
-
*/
|
|
1087
|
-
private processProducerMessage(line: string, producerSocket: Socket): void {
|
|
1088
|
-
let msg: any
|
|
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)
|
|
1094
|
-
}
|
|
1095
|
-
|
|
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
|
-
|
|
1125
|
-
if (msg.type === 'windowAck') {
|
|
1126
|
-
this.emit('windowAck', msg)
|
|
1127
|
-
const rosterChanged = this.updateTrackedWindowsFromAck(msg)
|
|
1128
|
-
|
|
1129
|
-
const ackId = this.normalizeRequestId(msg.id)
|
|
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
|
-
}
|
|
1151
|
-
}
|
|
1152
|
-
}
|
|
1153
|
-
if (rosterChanged) this.broadcastWindowStateSync()
|
|
1154
|
-
return
|
|
1155
|
-
}
|
|
1156
|
-
|
|
1157
|
-
let messageOut = msg
|
|
1158
|
-
|
|
1159
|
-
if (msg.type === 'windowClosed') {
|
|
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)
|
|
1167
|
-
}
|
|
1168
|
-
this.emit('windowClosed', messageOut)
|
|
1169
|
-
// Broadcast to all producers
|
|
1170
|
-
this.broadcastToProducers(JSON.stringify(messageOut))
|
|
1171
|
-
this.broadcastWindowStateSync()
|
|
1172
|
-
}
|
|
1173
|
-
|
|
1174
|
-
if (msg.type === 'terminalExited') {
|
|
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))
|
|
1182
|
-
}
|
|
1183
|
-
|
|
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)
|
|
1195
|
-
}
|
|
1196
|
-
|
|
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
|
-
}
|
|
1205
|
-
|
|
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
|
-
}
|
|
1215
|
-
|
|
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)
|
|
1308
|
-
return
|
|
1309
|
-
}
|
|
1310
|
-
|
|
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)
|
|
1357
|
-
}
|
|
1358
|
-
|
|
1359
|
-
this.emit('message', messageOut)
|
|
1360
|
-
}
|
|
1361
|
-
|
|
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
|
|
1379
|
-
}
|
|
1380
|
-
|
|
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
|
|
1403
|
-
}
|
|
1404
|
-
|
|
1405
|
-
/**
|
|
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.
|
|
1410
|
-
*/
|
|
1411
|
-
private sendWindowCommand(windowPayload: Record<string, any>): Promise<WindowAckResult> {
|
|
1412
|
-
return new Promise<WindowAckResult>(async (resolve, reject) => {
|
|
1413
|
-
const timeoutMs = this.options.requestTimeoutMs || 10000
|
|
1414
|
-
|
|
1415
|
-
// Auto-start if needed
|
|
1416
|
-
if (!this._mode) {
|
|
1417
|
-
await this.listen()
|
|
1418
|
-
}
|
|
1419
|
-
|
|
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
|
-
}
|
|
1427
|
-
|
|
1428
|
-
const rawId = randomUUID()
|
|
1429
|
-
const id = this.normalizeRequestId(rawId) || rawId
|
|
1430
|
-
|
|
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 })
|
|
1439
|
-
|
|
1440
|
-
const payload = {
|
|
1441
|
-
id,
|
|
1442
|
-
status: 'processing',
|
|
1443
|
-
window: windowPayload,
|
|
1444
|
-
timestamp: new Date().toISOString(),
|
|
1445
|
-
}
|
|
1446
|
-
|
|
1447
|
-
this._controlClient.socket.write(JSON.stringify(payload) + '\n')
|
|
1448
|
-
return
|
|
1449
|
-
}
|
|
1450
|
-
|
|
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
|
-
}
|
|
1461
|
-
}
|
|
1462
|
-
|
|
1463
|
-
const rawId = randomUUID()
|
|
1464
|
-
const id = this.normalizeRequestId(rawId) || rawId
|
|
1465
|
-
|
|
1466
|
-
const timer = setTimeout(() => {
|
|
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
|
-
}
|
|
1471
|
-
}, timeoutMs)
|
|
1472
|
-
|
|
1473
|
-
this.registerPendingOperation(id, windowPayload.action, { resolve, reject, timer })
|
|
1474
|
-
|
|
1475
|
-
const payload = {
|
|
1476
|
-
id,
|
|
1477
|
-
status: 'processing',
|
|
1478
|
-
window: windowPayload,
|
|
1479
|
-
timestamp: new Date().toISOString(),
|
|
1480
|
-
}
|
|
1481
|
-
const sent = this.send(payload)
|
|
1482
|
-
|
|
1483
|
-
if (!sent) {
|
|
1484
|
-
const p = this.takePendingRequest(id)
|
|
1485
|
-
const error = `Failed to send window ${windowPayload.action}: client disconnected`
|
|
1486
|
-
this.setState({ lastError: error })
|
|
1487
|
-
if (p) {
|
|
1488
|
-
p.resolve({ ok: false, error, code: 'Disconnected' })
|
|
1489
|
-
}
|
|
1490
|
-
}
|
|
1491
|
-
})
|
|
1492
|
-
}
|
|
1493
|
-
|
|
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
|
-
})
|
|
1600
|
-
}
|
|
1601
|
-
}
|
|
1602
|
-
|
|
1603
|
-
export default WindowManager
|