acrylblends 1.0.5 → 1.0.6

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.
Files changed (124) hide show
  1. package/docs/framework/agents/index.md +1 -1
  2. package/lib/{audit-D5z9uwOP.d.ts → audit-GcLtvNLV.d.ts} +4 -2
  3. package/lib/cordis/door.d.ts +90 -4
  4. package/lib/cordis/door.js +524 -13
  5. package/lib/door/host.d.ts +2 -2
  6. package/lib/door/host.js +1 -1
  7. package/lib/door/page.d.ts +3 -1
  8. package/lib/door/page.js +1 -1
  9. package/lib/door/protocol.d.ts +1 -1
  10. package/lib/{protocol-C37gPePD.d.ts → protocol-CR31M7K_.d.ts} +2 -0
  11. package/lib/{snapshot-text-Bbx43mN0.js → snapshot-text-CtQm3n8n.js} +14 -5
  12. package/package.json +1 -1
  13. package/plugins/acryl-door/src/cli/acryl.ts +36 -5
  14. package/plugins/acryl-door/src/host/channel.ts +14 -6
  15. package/plugins/acryl-door/src/host/door.ts +78 -10
  16. package/plugins/acryl-door/src/host/page-routes.ts +3 -1
  17. package/plugins/acryl-door/src/host/shell.ts +147 -0
  18. package/plugins/acryl-door/src/host/shim.ts +39 -3
  19. package/plugins/acryl-door/src/host/verbs.ts +39 -2
  20. package/plugins/acryl-door/src/index.ts +7 -1
  21. package/plugins/acryl-door/src/page/channel.ts +3 -1
  22. package/plugins/acryl-door/src/page/index.ts +2 -1
  23. package/plugins/acryl-door/src/page/protocol.ts +2 -0
  24. package/plugins/acryl-door/src/shell/protocol.ts +130 -0
  25. package/plugins/agent-panel/headless.ts +29 -8
  26. package/plugins/agent-panel/launch.ts +67 -0
  27. package/plugins/desktop/door-shell.ts +379 -0
  28. package/plugins/desktop/main.ts +66 -12
  29. package/templates/agent/AGENTS.md +2 -0
  30. package/plugins/acryl-router/src/connection.d.ts +0 -25
  31. package/plugins/acryl-router/src/core/app-base.d.ts +0 -215
  32. package/plugins/acryl-router/src/core/app.d.ts +0 -19
  33. package/plugins/acryl-router/src/core/client/client.d.ts +0 -4
  34. package/plugins/acryl-router/src/core/client/fetch-result-please.d.ts +0 -35
  35. package/plugins/acryl-router/src/core/client/index.d.ts +0 -7
  36. package/plugins/acryl-router/src/core/client/types.d.ts +0 -229
  37. package/plugins/acryl-router/src/core/client/utils.d.ts +0 -18
  38. package/plugins/acryl-router/src/core/compose.d.ts +0 -14
  39. package/plugins/acryl-router/src/core/context.d.ts +0 -443
  40. package/plugins/acryl-router/src/core/helper/accepts/accepts.d.ts +0 -30
  41. package/plugins/acryl-router/src/core/helper/accepts/index.d.ts +0 -5
  42. package/plugins/acryl-router/src/core/helper/conninfo/index.d.ts +0 -5
  43. package/plugins/acryl-router/src/core/helper/conninfo/types.d.ts +0 -36
  44. package/plugins/acryl-router/src/core/helper/cookie/index.d.ts +0 -25
  45. package/plugins/acryl-router/src/core/helper/css/common.d.ts +0 -59
  46. package/plugins/acryl-router/src/core/helper/css/index.d.ts +0 -80
  47. package/plugins/acryl-router/src/core/helper/dev/index.d.ts +0 -20
  48. package/plugins/acryl-router/src/core/helper/factory/index.d.ts +0 -82
  49. package/plugins/acryl-router/src/core/helper/html/index.d.ts +0 -8
  50. package/plugins/acryl-router/src/core/helper/proxy/index.d.ts +0 -73
  51. package/plugins/acryl-router/src/core/helper/route/index.d.ts +0 -75
  52. package/plugins/acryl-router/src/core/helper/ssg/index.d.ts +0 -7
  53. package/plugins/acryl-router/src/core/helper/ssg/middleware.d.ts +0 -44
  54. package/plugins/acryl-router/src/core/helper/ssg/plugins.d.ts +0 -27
  55. package/plugins/acryl-router/src/core/helper/ssg/ssg.d.ts +0 -91
  56. package/plugins/acryl-router/src/core/helper/ssg/utils.d.ts +0 -16
  57. package/plugins/acryl-router/src/core/helper/streaming/index.d.ts +0 -8
  58. package/plugins/acryl-router/src/core/helper/streaming/sse.d.ts +0 -13
  59. package/plugins/acryl-router/src/core/helper/streaming/stream.d.ts +0 -3
  60. package/plugins/acryl-router/src/core/helper/streaming/text.d.ts +0 -3
  61. package/plugins/acryl-router/src/core/helper/streaming/utils.d.ts +0 -1
  62. package/plugins/acryl-router/src/core/helper/testing/index.d.ts +0 -13
  63. package/plugins/acryl-router/src/core/helper/websocket/index.d.ts +0 -70
  64. package/plugins/acryl-router/src/core/http-exception.d.ts +0 -58
  65. package/plugins/acryl-router/src/core/index.d.ts +0 -37
  66. package/plugins/acryl-router/src/core/jsx/base.d.ts +0 -61
  67. package/plugins/acryl-router/src/core/jsx/children.d.ts +0 -9
  68. package/plugins/acryl-router/src/core/jsx/components.d.ts +0 -15
  69. package/plugins/acryl-router/src/core/jsx/constants.d.ts +0 -6
  70. package/plugins/acryl-router/src/core/jsx/context.d.ts +0 -51
  71. package/plugins/acryl-router/src/core/jsx/dom/components.d.ts +0 -10
  72. package/plugins/acryl-router/src/core/jsx/dom/context.d.ts +0 -3
  73. package/plugins/acryl-router/src/core/jsx/dom/css.d.ts +0 -83
  74. package/plugins/acryl-router/src/core/jsx/dom/hooks/index.d.ts +0 -39
  75. package/plugins/acryl-router/src/core/jsx/dom/intrinsic-element/components.d.ts +0 -16
  76. package/plugins/acryl-router/src/core/jsx/dom/jsx-dev-runtime.d.ts +0 -8
  77. package/plugins/acryl-router/src/core/jsx/dom/jsx-runtime.d.ts +0 -7
  78. package/plugins/acryl-router/src/core/jsx/dom/render.d.ts +0 -73
  79. package/plugins/acryl-router/src/core/jsx/dom/utils.d.ts +0 -1
  80. package/plugins/acryl-router/src/core/jsx/hooks/index.d.ts +0 -47
  81. package/plugins/acryl-router/src/core/jsx/index.d.ts +0 -76
  82. package/plugins/acryl-router/src/core/jsx/intrinsic-element/common.d.ts +0 -6
  83. package/plugins/acryl-router/src/core/jsx/intrinsic-element/components.d.ts +0 -13
  84. package/plugins/acryl-router/src/core/jsx/intrinsic-elements.d.ts +0 -735
  85. package/plugins/acryl-router/src/core/jsx/streaming.d.ts +0 -36
  86. package/plugins/acryl-router/src/core/jsx/types.d.ts +0 -36
  87. package/plugins/acryl-router/src/core/jsx/utils.d.ts +0 -4
  88. package/plugins/acryl-router/src/core/request/constants.d.ts +0 -1
  89. package/plugins/acryl-router/src/core/request.d.ts +0 -306
  90. package/plugins/acryl-router/src/core/router/reg-exp-router/index.d.ts +0 -6
  91. package/plugins/acryl-router/src/core/router/reg-exp-router/matcher.d.ts +0 -7
  92. package/plugins/acryl-router/src/core/router/reg-exp-router/node.d.ts +0 -13
  93. package/plugins/acryl-router/src/core/router/reg-exp-router/prepared-router.d.ts +0 -17
  94. package/plugins/acryl-router/src/core/router/reg-exp-router/router.d.ts +0 -11
  95. package/plugins/acryl-router/src/core/router/reg-exp-router/trie.d.ts +0 -8
  96. package/plugins/acryl-router/src/core/router/smart-router/index.d.ts +0 -5
  97. package/plugins/acryl-router/src/core/router/smart-router/router.d.ts +0 -11
  98. package/plugins/acryl-router/src/core/router/trie-router/index.d.ts +0 -5
  99. package/plugins/acryl-router/src/core/router/trie-router/node.d.ts +0 -6
  100. package/plugins/acryl-router/src/core/router/trie-router/router.d.ts +0 -7
  101. package/plugins/acryl-router/src/core/router/utils.d.ts +0 -1
  102. package/plugins/acryl-router/src/core/router.d.ts +0 -97
  103. package/plugins/acryl-router/src/core/types.d.ts +0 -573
  104. package/plugins/acryl-router/src/core/utils/accept.d.ts +0 -6
  105. package/plugins/acryl-router/src/core/utils/body.d.ts +0 -79
  106. package/plugins/acryl-router/src/core/utils/buffer.d.ts +0 -17
  107. package/plugins/acryl-router/src/core/utils/color.d.ts +0 -21
  108. package/plugins/acryl-router/src/core/utils/concurrent.d.ts +0 -11
  109. package/plugins/acryl-router/src/core/utils/constants.d.ts +0 -4
  110. package/plugins/acryl-router/src/core/utils/cookie.d.ts +0 -41
  111. package/plugins/acryl-router/src/core/utils/crypto.d.ts +0 -15
  112. package/plugins/acryl-router/src/core/utils/handler.d.ts +0 -6
  113. package/plugins/acryl-router/src/core/utils/headers.d.ts +0 -8
  114. package/plugins/acryl-router/src/core/utils/html.d.ts +0 -44
  115. package/plugins/acryl-router/src/core/utils/http-status.d.ts +0 -32
  116. package/plugins/acryl-router/src/core/utils/mime.d.ts +0 -70
  117. package/plugins/acryl-router/src/core/utils/stream.d.ts +0 -31
  118. package/plugins/acryl-router/src/core/utils/types.d.ts +0 -128
  119. package/plugins/acryl-router/src/core/utils/url.d.ts +0 -44
  120. package/plugins/acryl-router/src/core/validator/index.d.ts +0 -7
  121. package/plugins/acryl-router/src/core/validator/utils.d.ts +0 -29
  122. package/plugins/acryl-router/src/core/validator/validator.d.ts +0 -16
  123. package/plugins/acryl-router/src/helpers/index.d.ts +0 -17
  124. package/plugins/acryl-router/src/helpers/schema-validator.d.ts +0 -54
@@ -0,0 +1,379 @@
1
+ /**
2
+ * The desktop shell's side of the door: the app's windows and menu, for an agent that drives the app (`acryl window list|open|focus|close|resize`, `acryl menu list|click`).
3
+ *
4
+ * The door lives in the app's server, which is this process when `npm run desktop` started it and another one when the window attached to `npm run dev`; so the shell
5
+ * reaches the door the way a page does, over HTTP: it reads `.acryl/door.json` (the address and this run's secret), opens an event stream, answers each call by POST, and
6
+ * opens the stream again when the server restarts. Nothing here imports electron: `main.ts` passes in what Electron offers ({@link DesktopPort}), so the handler is tested
7
+ * without it. The door is development only; with no `.acryl/door.json` this keeps looking and does nothing else.
8
+ */
9
+ import { readFileSync } from 'node:fs'
10
+ import { join } from 'node:path'
11
+
12
+ /** A window as Electron has it, the part this file uses. */
13
+ export interface WindowPort {
14
+ readonly id: number
15
+ getTitle(): string
16
+ getURL(): string
17
+ isFocused(): boolean
18
+ isMinimized(): boolean
19
+ getSize(): number[]
20
+ restore(): void
21
+ focus(): void
22
+ close(): void
23
+ setSize(width: number, height: number): void
24
+ }
25
+
26
+ /** A menu item as Electron has it. */
27
+ export interface MenuItemPort {
28
+ readonly label: string
29
+ readonly enabled: boolean
30
+ readonly visible: boolean
31
+ readonly type: string
32
+ /** Electron's role of the item (`quit`, `close`, ...). */
33
+ readonly role?: string | undefined
34
+ readonly submenu?: { readonly items: readonly MenuItemPort[] } | null | undefined
35
+ click(): void
36
+ }
37
+
38
+ export interface DesktopPort {
39
+ /** Every window, in Electron's order. */
40
+ windows(): readonly WindowPort[]
41
+ /** A new window on a path of the app (the page is not awaited). @returns the window. */
42
+ open(path: string | undefined): WindowPort
43
+ /** The application menu's items, or none when the app has no menu. */
44
+ menu(): readonly MenuItemPort[]
45
+ }
46
+
47
+ /** The door's side of a call, as `plugins/acryl-door/src/shell/protocol.ts` defines it. */
48
+ export type ShellRequest =
49
+ | { readonly op: 'window-list' }
50
+ | { readonly op: 'window-open'; readonly path?: string }
51
+ | { readonly op: 'window-focus'; readonly id: number }
52
+ | { readonly op: 'window-close'; readonly id: number; readonly force?: boolean }
53
+ | {
54
+ readonly op: 'window-resize'
55
+ readonly id: number
56
+ readonly width: number
57
+ readonly height: number
58
+ }
59
+ | { readonly op: 'menu-list' }
60
+ | { readonly op: 'menu-click'; readonly path: string; readonly force?: boolean }
61
+
62
+ export type ShellResult =
63
+ | {
64
+ readonly windows: readonly {
65
+ id: number
66
+ title: string
67
+ path: string
68
+ focused: boolean
69
+ width: number
70
+ height: number
71
+ }[]
72
+ }
73
+ | { readonly items: readonly { path: string; label: string; enabled: boolean; ends?: boolean }[] }
74
+ | { readonly ok: true; readonly detail: string; readonly id?: number }
75
+
76
+ /** A refusal with a code the door passes on to the agent. */
77
+ export class ShellRefusal extends Error {
78
+ readonly code: string
79
+
80
+ constructor(code: string, message: string) {
81
+ super(message)
82
+ this.code = code
83
+ this.name = 'ShellRefusal'
84
+ }
85
+ }
86
+
87
+ /** The path and query a window shows, never its origin. */
88
+ function pathOf(url: string): string {
89
+ try {
90
+ const parsed = new URL(url)
91
+ return `${parsed.pathname}${parsed.search}`
92
+ } catch {
93
+ return ''
94
+ }
95
+ }
96
+
97
+ /** The menu as paths (`File>New Note`), submenus in order, separators and hidden items left out. */
98
+ export function menuPaths(
99
+ items: readonly MenuItemPort[],
100
+ prefix = '',
101
+ ): { path: string; label: string; enabled: boolean; item: MenuItemPort }[] {
102
+ const found: { path: string; label: string; enabled: boolean; item: MenuItemPort }[] = []
103
+ for (const item of items) {
104
+ if (item.type === 'separator' || !item.visible || item.label === '') continue
105
+ const path = prefix === '' ? item.label : `${prefix}>${item.label}`
106
+ const children = item.submenu?.items ?? []
107
+ if (children.length > 0) found.push(...menuPaths(children, path))
108
+ else found.push({ path, label: item.label, enabled: item.enabled, item })
109
+ }
110
+ return found
111
+ }
112
+
113
+ /** Whether pressing this item ends the desktop app: Quit always, Close Window when it is the last window. */
114
+ function endsApp(item: MenuItemPort, windows: number): boolean {
115
+ return item.role === 'quit' || (item.role === 'close' && windows <= 1)
116
+ }
117
+
118
+ /** Answer one call of the door. @throws ShellRefusal for what the app cannot do (no such window, the last window, a disabled item). */
119
+ export function handleShellCall(request: ShellRequest, desktop: DesktopPort): ShellResult {
120
+ const windowById = (id: number): WindowPort => {
121
+ const found = desktop.windows().find((window) => window.id === id)
122
+ if (found === undefined) {
123
+ const ids = desktop.windows().map((window) => String(window.id))
124
+ throw new ShellRefusal(
125
+ 'no-such-window',
126
+ `there is no window ${String(id)}${ids.length === 0 ? ' (no window is open)' : `; the windows are ${ids.join(', ')}`}`,
127
+ )
128
+ }
129
+ return found
130
+ }
131
+ switch (request.op) {
132
+ case 'window-list':
133
+ return {
134
+ windows: desktop.windows().map((window) => {
135
+ const [width = 0, height = 0] = window.getSize()
136
+ return {
137
+ id: window.id,
138
+ title: window.getTitle(),
139
+ path: pathOf(window.getURL()),
140
+ focused: window.isFocused(),
141
+ width,
142
+ height,
143
+ }
144
+ }),
145
+ }
146
+ case 'window-open': {
147
+ const window = desktop.open(request.path)
148
+ return {
149
+ ok: true,
150
+ detail: `opened window ${String(window.id)} on ${request.path ?? '/'}`,
151
+ id: window.id,
152
+ }
153
+ }
154
+ case 'window-focus': {
155
+ const window = windowById(request.id)
156
+ if (window.isMinimized()) window.restore()
157
+ window.focus()
158
+ return { ok: true, detail: `focused window ${String(window.id)}`, id: window.id }
159
+ }
160
+ case 'window-close': {
161
+ const window = windowById(request.id)
162
+ // The door refuses this first; the shell does not rely on it, because the last window ends the desktop app.
163
+ if (desktop.windows().length === 1 && request.force !== true) {
164
+ throw new ShellRefusal(
165
+ 'last-window',
166
+ `window ${String(window.id)} is the app's last window: closing it ends the desktop app. Open another window first, or pass force to end it on purpose`,
167
+ )
168
+ }
169
+ window.close()
170
+ return { ok: true, detail: `closed window ${String(window.id)}`, id: window.id }
171
+ }
172
+ case 'window-resize': {
173
+ const window = windowById(request.id)
174
+ window.setSize(request.width, request.height)
175
+ return {
176
+ ok: true,
177
+ detail: `window ${String(window.id)} is now ${String(request.width)}x${String(request.height)}`,
178
+ id: window.id,
179
+ }
180
+ }
181
+ case 'menu-list':
182
+ return {
183
+ items: menuPaths(desktop.menu()).map(({ path, label, enabled, item }) => ({
184
+ path,
185
+ label,
186
+ enabled,
187
+ ...(endsApp(item, desktop.windows().length) ? { ends: true } : {}),
188
+ })),
189
+ }
190
+ case 'menu-click': {
191
+ const wanted = request.path
192
+ .split('>')
193
+ .map((label) => label.trim())
194
+ .join('>')
195
+ const found = menuPaths(desktop.menu()).find((entry) => entry.path === wanted)
196
+ if (found === undefined)
197
+ throw new ShellRefusal(
198
+ 'no-such-menu-item',
199
+ `the menu has no item ${JSON.stringify(wanted)}; menu list prints the paths`,
200
+ )
201
+ if (!found.enabled) throw new ShellRefusal('disabled', `${found.path} is disabled`)
202
+ // Quit, or Close Window on the last window, ends the desktop app: an agent must not end the session it works in by accident.
203
+ if (endsApp(found.item, desktop.windows().length) && request.force !== true) {
204
+ throw new ShellRefusal(
205
+ 'quits-app',
206
+ `${found.path} ends the desktop app${found.item.role === 'close' ? ' (it is the last window)' : ''}. Pass force to do it on purpose`,
207
+ )
208
+ }
209
+ found.item.click()
210
+ return { ok: true, detail: `pressed ${found.path}` }
211
+ }
212
+ }
213
+ }
214
+
215
+ export interface DoorShellDeps {
216
+ readonly desktop: DesktopPort
217
+ readonly fetch?: typeof fetch
218
+ readonly read?: (file: string) => string
219
+ /** Milliseconds to wait before looking for the door again; grows to `maxDelayMs`. */
220
+ readonly retryMs?: number
221
+ readonly maxDelayMs?: number
222
+ readonly log?: (message: string) => void
223
+ }
224
+
225
+ interface DoorLocation {
226
+ readonly url: string
227
+ readonly secret: string
228
+ }
229
+
230
+ const LOOPBACK = new Set(['127.0.0.1', 'localhost', '[::1]'])
231
+
232
+ /** Where the door is now: its address and this run's secret, from `.acryl/door.json` (read again on every connection: a restarted server has a new secret). */
233
+ export function findDoor(
234
+ root: string,
235
+ read: (file: string) => string = (file) => readFileSync(file, 'utf8'),
236
+ ): DoorLocation | undefined {
237
+ try {
238
+ const file = JSON.parse(read(join(root, '.acryl', 'door.json'))) as {
239
+ url?: unknown
240
+ secret?: unknown
241
+ }
242
+ if (typeof file.url !== 'string' || typeof file.secret !== 'string' || file.secret === '')
243
+ return undefined
244
+ const parsed = new URL(file.url)
245
+ if (parsed.protocol !== 'http:' || !LOOPBACK.has(parsed.hostname)) return undefined
246
+ return { url: parsed.origin, secret: file.secret }
247
+ } catch {
248
+ return undefined
249
+ }
250
+ }
251
+
252
+ /** Server-sent events from a response body: `{ event, data }` per block. */
253
+ async function* events(
254
+ body: ReadableStream<Uint8Array>,
255
+ ): AsyncGenerator<{ event: string; data: string }> {
256
+ const reader = body.getReader()
257
+ const decoder = new TextDecoder()
258
+ let buffer = ''
259
+ try {
260
+ for (;;) {
261
+ const { done, value } = await reader.read()
262
+ if (done) return
263
+ buffer += decoder.decode(value, { stream: true })
264
+ for (let end = buffer.indexOf('\n\n'); end !== -1; end = buffer.indexOf('\n\n')) {
265
+ const block = buffer.slice(0, end)
266
+ buffer = buffer.slice(end + 2)
267
+ let event = 'message'
268
+ const data: string[] = []
269
+ for (const line of block.split('\n')) {
270
+ if (line.startsWith('event:')) event = line.slice(6).trim()
271
+ else if (line.startsWith('data:'))
272
+ data.push(line.slice(line.startsWith('data: ') ? 6 : 5))
273
+ }
274
+ yield { event, data: data.join('\n') }
275
+ }
276
+ }
277
+ } finally {
278
+ await reader.cancel().catch(() => undefined)
279
+ }
280
+ }
281
+
282
+ /**
283
+ * Keep the shell connected to the door of the app in `root` until the returned function is called. The door is found again after each loss, so a dev server that restarts (a
284
+ * change of `src/`) is followed. Each call is answered with its result, or with the refusal's code and message.
285
+ */
286
+ export function startDoorShell(root: string, deps: DoorShellDeps): () => void {
287
+ const request = deps.fetch ?? fetch
288
+ const retry = deps.retryMs ?? 500
289
+ const maxDelay = deps.maxDelayMs ?? 5000
290
+ const controller = new AbortController()
291
+ let wait: NodeJS.Timeout | undefined
292
+ const sleep = (ms: number): Promise<void> =>
293
+ new Promise((resolve) => {
294
+ wait = setTimeout(resolve, ms)
295
+ controller.signal.addEventListener(
296
+ 'abort',
297
+ () => {
298
+ clearTimeout(wait)
299
+ resolve()
300
+ },
301
+ { once: true },
302
+ )
303
+ })
304
+
305
+ const answer = async (door: DoorLocation, body: unknown): Promise<void> => {
306
+ await request(`${door.url}/api/acryl-door/shell/answer`, {
307
+ method: 'POST',
308
+ headers: { authorization: `Bearer ${door.secret}`, 'content-type': 'application/json' },
309
+ body: JSON.stringify(body),
310
+ signal: controller.signal,
311
+ }).catch(() => undefined)
312
+ }
313
+
314
+ const serve = async (door: DoorLocation): Promise<void> => {
315
+ const response = await request(`${door.url}/api/acryl-door/shell/events`, {
316
+ headers: { authorization: `Bearer ${door.secret}`, accept: 'text/event-stream' },
317
+ signal: controller.signal,
318
+ })
319
+ if (!response.ok || response.body === null)
320
+ throw new Error(`the door answered ${String(response.status)}`)
321
+ for await (const { event, data } of events(response.body)) {
322
+ if (event !== 'call') continue
323
+ let call: { id?: unknown; request?: ShellRequest }
324
+ try {
325
+ call = JSON.parse(data) as { id?: unknown; request?: ShellRequest }
326
+ } catch {
327
+ continue
328
+ }
329
+ if (typeof call.id !== 'number' || call.request === undefined) continue
330
+ try {
331
+ await answer(door, {
332
+ t: 'result',
333
+ id: call.id,
334
+ value: handleShellCall(call.request, deps.desktop),
335
+ })
336
+ } catch (cause) {
337
+ const refusal =
338
+ cause instanceof ShellRefusal
339
+ ? cause
340
+ : new ShellRefusal(
341
+ 'failed',
342
+ cause instanceof Error ? cause.message : 'the shell could not do that',
343
+ )
344
+ await answer(door, {
345
+ t: 'error',
346
+ id: call.id,
347
+ code: refusal.code,
348
+ message: refusal.message,
349
+ })
350
+ }
351
+ }
352
+ }
353
+
354
+ void (async () => {
355
+ let delay = retry
356
+ let announced = false
357
+ while (!controller.signal.aborted) {
358
+ const door = findDoor(root, deps.read)
359
+ if (door !== undefined) {
360
+ try {
361
+ if (!announced)
362
+ deps.log?.(
363
+ 'desktop: windows and menu are available to the door (acryl window list, acryl menu list)',
364
+ )
365
+ announced = true
366
+ await serve(door)
367
+ delay = retry
368
+ } catch {
369
+ // The server restarted or is not up yet: look again.
370
+ }
371
+ }
372
+ await sleep(delay)
373
+ delay = Math.min(delay * 2, maxDelay)
374
+ }
375
+ })()
376
+ return () => {
377
+ controller.abort()
378
+ }
379
+ }
@@ -9,11 +9,17 @@
9
9
  import { mkdirSync, readFileSync } from 'node:fs'
10
10
  import { join } from 'node:path'
11
11
  import { fileURLToPath } from 'node:url'
12
- import { app, BrowserWindow, shell } from 'electron'
12
+ import { app, BrowserWindow, Menu, type MenuItem, shell } from 'electron'
13
13
  import type { App } from '../../src/app.ts'
14
14
  import '../acryl-node-server/src/register.ts'
15
15
  import { selectInstance } from '../instance/index.ts'
16
16
  import { runningApp } from './attach.ts'
17
+ import {
18
+ type DesktopPort,
19
+ type MenuItemPort,
20
+ startDoorShell,
21
+ type WindowPort,
22
+ } from './door-shell.ts'
17
23
  import { createDesktopShutdown, installShutdownRequests } from './shutdown.ts'
18
24
  import { resolveDesktopUserDataOverride } from './user-data.ts'
19
25
  import { windowOptions } from './window.ts'
@@ -41,9 +47,11 @@ if (!app.requestSingleInstanceLock()) {
41
47
  } else {
42
48
  let running: App | undefined
43
49
  let window: BrowserWindow | undefined
50
+ let stopDoorShell: (() => void) | undefined
44
51
 
45
52
  const shutdown = createDesktopShutdown(
46
53
  async () => {
54
+ stopDoorShell?.()
47
55
  await running?.stop()
48
56
  },
49
57
  (code) => {
@@ -81,18 +89,64 @@ if (!app.requestSingleInstanceLock()) {
81
89
  url = running.url
82
90
  }
83
91
  const origin = new URL(url).origin
84
- window = new BrowserWindow(windowOptions({ title: chosen.name }))
85
- window.once('ready-to-show', () => window?.show())
86
- // Navigation stays on the app's own server; any other address opens in the system browser.
87
- window.webContents.setWindowOpenHandler(({ url }) => {
88
- void shell.openExternal(url)
89
- return { action: 'deny' }
92
+ /** One window on a path of the app. Navigation stays on the app's own server; any other address opens in the system browser. The user agent names the window to the door (`AcrylWindow/<id>`), so an agent can drive it with `--window <id>`. */
93
+ const createWindow = (path: string): BrowserWindow => {
94
+ const created = new BrowserWindow(windowOptions({ title: chosen.name }))
95
+ created.webContents.setUserAgent(
96
+ `${created.webContents.getUserAgent()} AcrylWindow/${String(created.id)}`,
97
+ )
98
+ created.once('ready-to-show', () => created.show())
99
+ created.webContents.setWindowOpenHandler(({ url }) => {
100
+ void shell.openExternal(url)
101
+ return { action: 'deny' }
102
+ })
103
+ created.webContents.on('will-navigate', (event, url) => {
104
+ if (new URL(url).origin === origin) return
105
+ event.preventDefault()
106
+ void shell.openExternal(url)
107
+ })
108
+ created.loadURL(new URL(path, url).href).catch((cause: unknown) => {
109
+ console.warn(
110
+ `desktop: the window could not load ${path}: ${cause instanceof Error ? cause.message : String(cause)}`,
111
+ )
112
+ })
113
+ return created
114
+ }
115
+ window = createWindow(startPath())
116
+ // The door (development) reaches the windows and the menu through this port: `acryl window list`, `acryl menu click`.
117
+ const live = (): BrowserWindow[] =>
118
+ BrowserWindow.getAllWindows().filter((candidate) => !candidate.isDestroyed())
119
+ const port = (candidate: BrowserWindow): WindowPort => ({
120
+ id: candidate.id,
121
+ getTitle: () => candidate.getTitle(),
122
+ getURL: () => candidate.webContents.getURL(),
123
+ isFocused: () => candidate.isFocused(),
124
+ isMinimized: () => candidate.isMinimized(),
125
+ getSize: () => candidate.getSize(),
126
+ restore: () => candidate.restore(),
127
+ focus: () => candidate.focus(),
128
+ close: () => candidate.close(),
129
+ setSize: (width, height) => candidate.setSize(width, height),
90
130
  })
91
- window.webContents.on('will-navigate', (event, url) => {
92
- if (new URL(url).origin === origin) return
93
- event.preventDefault()
94
- void shell.openExternal(url)
131
+ // Pressing an item as Electron's own menu does: with the window that has focus (the roles reload, zoom and close act on it).
132
+ const menuItem = (item: MenuItem): MenuItemPort => ({
133
+ label: item.label,
134
+ enabled: item.enabled,
135
+ visible: item.visible,
136
+ type: item.type,
137
+ role: item.role,
138
+ // Electron gives a plain item `submenu: null` on Linux and `undefined` elsewhere.
139
+ submenu: item.submenu ? { items: item.submenu.items.map(menuItem) } : null,
140
+ click: () => {
141
+ const target = BrowserWindow.getFocusedWindow() ?? live()[0]
142
+ item.click(undefined, target, target?.webContents)
143
+ },
95
144
  })
96
- await window.loadURL(new URL(startPath(), url).href)
145
+ const desktop: DesktopPort = {
146
+ windows: () => live().map(port),
147
+ open: (path) => port(createWindow(path ?? startPath())),
148
+ menu: () => (Menu.getApplicationMenu()?.items ?? []).map(menuItem),
149
+ }
150
+ stopDoorShell = startDoorShell(projectRoot, { desktop, log: console.log })
97
151
  })
98
152
  }
@@ -7,4 +7,6 @@ and an optional `check(ctx)` that receives the plugin's own context (so `ctx.rou
7
7
 
8
8
  **Write an extension, not a row.** A feature you build while the app runs is a folder `plugins/<name>/` installed with `.acryl/bin/acryl plugin add plugins/<name>` (the `extensions` plugin loads it again at every start). Add a row to `blend.yaml` and an entry to the `registry` of `src/app.ts` only when the person asks for a plugin that is part of the app itself. Both kinds may import the framework by name (`acrylblends`, `acrylblends/body-limit`, `acryl-router`); a row's relative imports of its sibling plugins also work.
9
9
 
10
+ **Drive the app you build.** `.acryl/bin/acryl look` reads the open page as text with refs, and `acryl act click|type|press|scroll|wait --ref <ref>` presses its buttons, opens its sidebars and fills its forms (look again before each act: the refs change). `act navigate --path /settings` goes to another page of the app; a path the app does not serve answers `404 at /nope` and the window shows a 404 page, so navigate on from there. In the desktop app (`npm run desktop`) the app's windows and menu are verbs too: `acryl window list`, `acryl window open --path /settings`, `acryl look --window <id>` (and `act ... --window <id>`) to drive that window's page, `acryl window resize|focus|close --id <id>`, `acryl menu list` and `acryl menu click --path "File>New Note"`. Closing the last window and Quit end the desktop app, so they need `--force`: do not use it unless the person asks. When a person asks for settings, build them as a page at `/settings`, so `acryl window open --path /settings` opens them in every app. Without a desktop app these answer "no desktop window is attached; run npm run desktop".
11
+
10
12
  **Production.** The agent, the builder and the extensions are development tools: `POST /ask` spends the model key and runs tools, a plugin installed live runs in this process, and none of it is behind a login. With `NODE_ENV=production` `src/app.ts` leaves those three rows out, so `/ask`, `/extensions*` and `/tasks*` are not mounted; the page and `GET /health` stay. `ACRYL_PRODUCTION_KEEP=agent,builder,extensions` keeps the named rows, and each one kept prints a `WARNING` line at start.
@@ -1,25 +0,0 @@
1
- /**
2
- * Transport facts: who is on the other end of the socket and which local port it reached. The caller of `router.fetch` supplies them
3
- * (an HTTP adapter passes the socket's peer address and real port); they travel in `c.env.connection`, beside the request and never in a header.
4
- */
5
- import type { Context } from './core/context.ts';
6
- import type { GetConnInfo } from './core/helper/conninfo/types.ts';
7
- /** An in-process caller (the terminal UI, a test) passes nothing, so a handler that needs a trusted peer must refuse an empty one. */
8
- export interface ConnectionInfo {
9
- /** The peer address of the socket, when the request came over one (for example `127.0.0.1`). */
10
- readonly remoteAddress?: string | undefined;
11
- /** The local port the socket is connected to, the real one a `Host` header should be checked against. */
12
- readonly localPort?: number | undefined;
13
- }
14
- /** What the router puts in `c.env`. */
15
- export interface RouterBindings {
16
- readonly connection: ConnectionInfo;
17
- }
18
- /** The `Env` of an `Acryl` that wants `c.env.connection` typed: `new Acryl<RouterEnv>()`. */
19
- export interface RouterEnv {
20
- Bindings: RouterBindings;
21
- }
22
- /** The transport facts of this request; empty for an in-process call or an `Acryl` run outside the router. */
23
- export declare function connectionOf(c: Context): ConnectionInfo;
24
- /** The peer in the core's conninfo shape, for middleware written against it (for example `ipRestriction`). */
25
- export declare const getConnInfo: GetConnInfo;