acrylblends 1.0.6 → 1.0.8

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 (40) hide show
  1. package/dist/index.js +272 -1
  2. package/dist/index.js.map +1 -1
  3. package/docs/framework/guides/production.md +3 -3
  4. package/lib/{audit-GcLtvNLV.d.ts → audit-BD8_2pkh.d.ts} +25 -3
  5. package/lib/cordis/door.d.ts +2 -2
  6. package/lib/cordis/door.js +79 -6
  7. package/lib/door/host.d.ts +2 -2
  8. package/lib/door/host.js +1 -1
  9. package/lib/door/page.d.ts +24 -1
  10. package/lib/door/page.js +138 -9
  11. package/lib/door/protocol.d.ts +1 -1
  12. package/lib/door/protocol.js +1 -1
  13. package/lib/{protocol-Dt0AZBOz.js → protocol-CQ_if7Dg.js} +4 -0
  14. package/lib/{protocol-CR31M7K_.d.ts → protocol-a2aqrp2X.d.ts} +3 -0
  15. package/lib/{snapshot-text-CtQm3n8n.js → snapshot-text-m13Pk-AI.js} +95 -17
  16. package/package.json +1 -1
  17. package/plugins/acryl-door/plugin.json +3 -0
  18. package/plugins/acryl-door/src/cli/acryl.ts +33 -4
  19. package/plugins/acryl-door/src/host/channel.ts +107 -18
  20. package/plugins/acryl-door/src/host/door.ts +5 -2
  21. package/plugins/acryl-door/src/host/page-socket.ts +78 -0
  22. package/plugins/acryl-door/src/host/shim.ts +2 -1
  23. package/plugins/acryl-door/src/index.ts +10 -0
  24. package/plugins/acryl-door/src/page/channel.ts +67 -5
  25. package/plugins/acryl-door/src/page/driver/sensitivity.ts +39 -3
  26. package/plugins/acryl-door/src/page/index.ts +16 -1
  27. package/plugins/acryl-door/src/page/protocol.ts +3 -0
  28. package/plugins/acryl-door/src/page/socket-source.ts +75 -0
  29. package/plugins/agent-panel/client/main.ts +182 -20
  30. package/plugins/agent-panel/client/markdown.ts +219 -0
  31. package/plugins/agent-panel/headless.ts +20 -0
  32. package/plugins/agent-panel/index.ts +190 -50
  33. package/plugins/desktop/main.ts +50 -3
  34. package/plugins/desktop/menu.ts +60 -0
  35. package/plugins/desktop/window.ts +48 -2
  36. package/plugins/extensions/index.ts +81 -4
  37. package/templates/agent/AGENTS.md +4 -2
  38. package/templates/agent/src/app.ts +9 -1
  39. package/templates/agent/src/routes.ts +13 -3
  40. package/templates/agent/test/app.test.ts +27 -2
@@ -0,0 +1,219 @@
1
+ /**
2
+ * A small, safe Markdown for the agent's answers. The parser turns text into a tree of blocks and inlines; nothing in it is HTML: the panel builds elements from the tree with
3
+ * `createElement` and `textContent`, so an answer can never put markup, a script or an event handler into the page. A link keeps its address only for `http:`, `https:` and `mailto:`.
4
+ * What it knows: headings, paragraphs, fenced code, bullet and numbered lists, block quotes, and `code`, **bold**, *italic*, [links](https://...) inside a line.
5
+ */
6
+
7
+ export type Inline =
8
+ | { readonly type: 'text'; readonly text: string }
9
+ | { readonly type: 'code'; readonly text: string }
10
+ | { readonly type: 'strong'; readonly children: readonly Inline[] }
11
+ | { readonly type: 'em'; readonly children: readonly Inline[] }
12
+ | {
13
+ readonly type: 'link'
14
+ readonly href: string | undefined
15
+ readonly children: readonly Inline[]
16
+ }
17
+
18
+ export type Block =
19
+ | { readonly type: 'heading'; readonly level: number; readonly children: readonly Inline[] }
20
+ | { readonly type: 'paragraph'; readonly children: readonly Inline[] }
21
+ | { readonly type: 'code'; readonly language: string; readonly text: string }
22
+ | {
23
+ readonly type: 'list'
24
+ readonly ordered: boolean
25
+ readonly items: readonly (readonly Inline[])[]
26
+ }
27
+ | { readonly type: 'quote'; readonly children: readonly Inline[] }
28
+
29
+ const SAFE_LINK = /^(?:https?:\/\/|mailto:)/iu
30
+
31
+ /** The address of a link if it is one the panel will open, else nothing (a `javascript:` or `data:` address is shown as text). */
32
+ export function safeHref(href: string): string | undefined {
33
+ const trimmed = href.trim()
34
+ return SAFE_LINK.test(trimmed) && !/[\s<>"]/u.test(trimmed) ? trimmed : undefined
35
+ }
36
+
37
+ /** Inline syntax inside one line or paragraph. An opener with no closer is plain text. */
38
+ export function parseInline(text: string): Inline[] {
39
+ const out: Inline[] = []
40
+ let plain = ''
41
+ const flush = (): void => {
42
+ if (plain !== '') out.push({ type: 'text', text: plain })
43
+ plain = ''
44
+ }
45
+ let at = 0
46
+ while (at < text.length) {
47
+ const char = text[at] as string
48
+ if (char === '`') {
49
+ const end = text.indexOf('`', at + 1)
50
+ if (end > at + 1) {
51
+ flush()
52
+ out.push({ type: 'code', text: text.slice(at + 1, end) })
53
+ at = end + 1
54
+ continue
55
+ }
56
+ } else if (char === '*' && text[at + 1] === '*') {
57
+ const end = text.indexOf('**', at + 2)
58
+ if (end > at + 2) {
59
+ flush()
60
+ out.push({ type: 'strong', children: parseInline(text.slice(at + 2, end)) })
61
+ at = end + 2
62
+ continue
63
+ }
64
+ } else if (
65
+ (char === '*' || char === '_') &&
66
+ text[at + 1] !== ' ' &&
67
+ (at === 0 || !/\w/u.test(text[at - 1] as string))
68
+ ) {
69
+ const end = text.indexOf(char, at + 1)
70
+ if (end > at + 1 && text[end - 1] !== ' ' && !/\w/u.test(text[end + 1] ?? ' ')) {
71
+ flush()
72
+ out.push({ type: 'em', children: parseInline(text.slice(at + 1, end)) })
73
+ at = end + 1
74
+ continue
75
+ }
76
+ } else if (char === '[') {
77
+ const close = text.indexOf('](', at + 1)
78
+ const end = close === -1 ? -1 : text.indexOf(')', close + 2)
79
+ if (close > at + 1 && end > close) {
80
+ flush()
81
+ out.push({
82
+ type: 'link',
83
+ href: safeHref(text.slice(close + 2, end)),
84
+ children: parseInline(text.slice(at + 1, close)),
85
+ })
86
+ at = end + 1
87
+ continue
88
+ }
89
+ }
90
+ plain += char
91
+ at += 1
92
+ }
93
+ flush()
94
+ return out
95
+ }
96
+
97
+ /** Blocks of a whole answer. A fence that is still open (the answer is streaming) shows what it has so far. */
98
+ export function parseMarkdown(source: string): Block[] {
99
+ const lines = source.replaceAll('\r\n', '\n').split('\n')
100
+ const blocks: Block[] = []
101
+ let paragraph: string[] = []
102
+ const endParagraph = (): void => {
103
+ if (paragraph.length > 0)
104
+ blocks.push({ type: 'paragraph', children: parseInline(paragraph.join('\n')) })
105
+ paragraph = []
106
+ }
107
+ for (let at = 0; at < lines.length; at += 1) {
108
+ const line = lines[at] as string
109
+ const fence = /^\s*```\s*([\w+-]*)\s*$/u.exec(line)
110
+ if (fence) {
111
+ endParagraph()
112
+ const body: string[] = []
113
+ for (at += 1; at < lines.length && !/^\s*```\s*$/u.test(lines[at] as string); at += 1)
114
+ body.push(lines[at] as string)
115
+ blocks.push({ type: 'code', language: fence[1] ?? '', text: body.join('\n') })
116
+ continue
117
+ }
118
+ const heading = /^(#{1,6})\s+(.*?)\s*#*\s*$/u.exec(line)
119
+ if (heading) {
120
+ endParagraph()
121
+ blocks.push({
122
+ type: 'heading',
123
+ level: (heading[1] as string).length,
124
+ children: parseInline(heading[2] as string),
125
+ })
126
+ continue
127
+ }
128
+ const bullet = /^\s*[-*+]\s+(.*)$/u.exec(line)
129
+ const numbered = /^\s*\d{1,3}[.)]\s+(.*)$/u.exec(line)
130
+ if (bullet || numbered) {
131
+ endParagraph()
132
+ const ordered = numbered !== null
133
+ const items: Inline[][] = []
134
+ for (; at < lines.length; at += 1) {
135
+ const item = (ordered ? /^\s*\d{1,3}[.)]\s+(.*)$/u : /^\s*[-*+]\s+(.*)$/u).exec(
136
+ lines[at] as string,
137
+ )
138
+ if (!item) break
139
+ items.push(parseInline(item[1] as string))
140
+ }
141
+ at -= 1
142
+ blocks.push({ type: 'list', ordered, items })
143
+ continue
144
+ }
145
+ const quote = /^\s*>\s?(.*)$/u.exec(line)
146
+ if (quote) {
147
+ endParagraph()
148
+ const quoted: string[] = []
149
+ for (; at < lines.length; at += 1) {
150
+ const next = /^\s*>\s?(.*)$/u.exec(lines[at] as string)
151
+ if (!next) break
152
+ quoted.push(next[1] as string)
153
+ }
154
+ at -= 1
155
+ blocks.push({ type: 'quote', children: parseInline(quoted.join('\n')) })
156
+ continue
157
+ }
158
+ if (line.trim() === '') endParagraph()
159
+ else paragraph.push(line)
160
+ }
161
+ endParagraph()
162
+ return blocks
163
+ }
164
+
165
+ /** Build the elements of a parsed answer. Only `createElement`, `textContent` and attributes set here: no HTML string reaches the page. */
166
+ export function renderMarkdown(source: string, document: Document): DocumentFragment {
167
+ const fragment = document.createDocumentFragment()
168
+ const inline = (parent: Node, nodes: readonly Inline[]): void => {
169
+ for (const node of nodes) {
170
+ if (node.type === 'text') parent.appendChild(document.createTextNode(node.text))
171
+ else if (node.type === 'code') {
172
+ const code = document.createElement('code')
173
+ code.textContent = node.text
174
+ parent.appendChild(code)
175
+ } else if (node.type === 'strong' || node.type === 'em') {
176
+ const element = document.createElement(node.type)
177
+ inline(element, node.children)
178
+ parent.appendChild(element)
179
+ } else if (node.href === undefined) {
180
+ inline(parent, node.children)
181
+ } else {
182
+ const anchor = document.createElement('a')
183
+ anchor.href = node.href
184
+ anchor.target = '_blank'
185
+ anchor.rel = 'noopener noreferrer'
186
+ inline(anchor, node.children)
187
+ parent.appendChild(anchor)
188
+ }
189
+ }
190
+ }
191
+ for (const block of parseMarkdown(source)) {
192
+ if (block.type === 'code') {
193
+ const pre = document.createElement('pre')
194
+ const code = document.createElement('code')
195
+ code.textContent = block.text
196
+ pre.appendChild(code)
197
+ fragment.appendChild(pre)
198
+ } else if (block.type === 'list') {
199
+ const list = document.createElement(block.ordered ? 'ol' : 'ul')
200
+ for (const item of block.items) {
201
+ const li = document.createElement('li')
202
+ inline(li, item)
203
+ list.appendChild(li)
204
+ }
205
+ fragment.appendChild(list)
206
+ } else {
207
+ const element = document.createElement(
208
+ block.type === 'heading'
209
+ ? `h${String(Math.min(block.level + 2, 6))}`
210
+ : block.type === 'quote'
211
+ ? 'blockquote'
212
+ : 'p',
213
+ )
214
+ inline(element, block.children)
215
+ fragment.appendChild(element)
216
+ }
217
+ }
218
+ return fragment
219
+ }
@@ -195,3 +195,23 @@ export function headlessDriver(name: HeadlessName, root: string): ChatDriver {
195
195
  },
196
196
  }
197
197
  }
198
+
199
+ /**
200
+ * Why a driver name cannot be used to ask a question, in a sentence: it is a terminal (picked in the panel, not asked over `/ask`), it is a coding agent that is not installed or not
201
+ * able to start, or pi without a key to reach its model. Nothing when the name is not one of the headless agents.
202
+ */
203
+ export function whyNotChat(
204
+ name: string,
205
+ kind: 'chat' | 'terminal' | undefined,
206
+ ): string | undefined {
207
+ if (kind === 'terminal')
208
+ return `"${name}" is a terminal driver: pick it in the panel to open its terminal; /ask runs the chat drivers`
209
+ if (name !== 'claude' && name !== 'codex' && name !== 'pi') return undefined
210
+ if (!onPath(name))
211
+ return `${name} was not found on the PATH of the app, so the panel cannot start it`
212
+ const launch = resolveLaunch(name, [])
213
+ if ('error' in launch) return launch.error
214
+ if (name === 'pi' && !piHasDeepseekKey())
215
+ return 'pi needs a DeepSeek key: set DEEPSEEK_API_KEY in the environment of the app, or log in with pi once (it keeps the key in ~/.pi/agent/auth.json)'
216
+ return undefined
217
+ }
@@ -9,17 +9,24 @@
9
9
  * - Only the app's own page may ask (a same-origin request carrying the token), one run at a time, and every ask is written to `.acryl/audit/agent-panel.jsonl`.
10
10
  * - Development only: the plugin does nothing with `NODE_ENV=production` or `ACRYL_DOOR=off`.
11
11
  */
12
- import { randomBytes } from 'node:crypto'
12
+ import { randomBytes, timingSafeEqual } from 'node:crypto'
13
13
  import { appendFileSync, mkdirSync } from 'node:fs'
14
14
  import { dirname, join } from 'node:path'
15
15
  import type { Context } from '@deepseek-ai/cordis'
16
16
  import { clientModules } from '../acryl-node-server/src/client-modules.ts'
17
- import { streamSSE } from '../acryl-router/src/helpers/index.ts'
18
- import { Acryl } from '../acryl-router/src/index.ts'
17
+ import { streamSSE, type UpgradeWebSocket } from '../acryl-router/src/helpers/index.ts'
18
+ import { Acryl, type MiddlewareHandler } from '../acryl-router/src/index.ts'
19
+ import { loopbackAuthority } from '../acryl-router/src/middleware/index.ts'
19
20
  import type {} from '../agent/index.ts'
20
21
  import type {} from '../extensions/index.ts'
21
22
  import type { ChatDriver } from './drivers.ts'
22
- import { agentDriver, availableHeadless, type HeadlessName, headlessDriver } from './headless.ts'
23
+ import {
24
+ agentDriver,
25
+ availableHeadless,
26
+ type HeadlessName,
27
+ headlessDriver,
28
+ whyNotChat,
29
+ } from './headless.ts'
23
30
  import { provideDrivers } from './registry.ts'
24
31
 
25
32
  export type {
@@ -117,25 +124,9 @@ export function apply(ctx: Context, config: PanelConfig = {}): void {
117
124
  if (!fromOwnPage(c.req.raw.headers) || c.req.header('x-agent-panel-token') !== token)
118
125
  return c.json({ error: "only the app's own page may ask" }, 403)
119
126
  const body = (await c.req.json().catch(() => ({}))) as { message?: unknown; driver?: unknown }
120
- const message = typeof body.message === 'string' ? body.message.trim() : ''
121
- const driverName = typeof body.driver === 'string' ? body.driver : 'agent'
122
- if (message === '' || message.length > 8000)
123
- return c.json({ error: 'write a message of at most 8000 characters' }, 400)
124
- const driver = registry.get(driverName)
125
- if (driver?.kind !== 'chat')
126
- return c.json(
127
- {
128
- error: `the driver "${driverName}" is not available here (${registry
129
- .list()
130
- .filter((d) => d.kind === 'chat')
131
- .map((d) => d.name)
132
- .join(', ')})`,
133
- },
134
- 400,
135
- )
136
- if (current !== undefined && !current.ended)
137
- return c.json({ error: 'a run is already going; wait for it to end' }, 409)
138
- const run = startRun(driverName, message, driver)
127
+ const checked = checkAsk(body.message, body.driver)
128
+ if ('error' in checked) return c.json({ error: checked.error }, checked.status)
129
+ const run = startRun(checked.driverName, checked.message, checked.driver)
139
130
  return follow(c, run, false)
140
131
  })
141
132
  // Stop: the only thing that cancels a run. A page that goes away (a reload, a move to another page, a closed tab) does not: the agent goes on, and the page picks it up again.
@@ -151,6 +142,34 @@ export function apply(ctx: Context, config: PanelConfig = {}): void {
151
142
  return c.json({ error: "only the app's own page may read the run" }, 403)
152
143
  return current === undefined ? c.json({ run: null }) : follow(c, current, true)
153
144
  })
145
+ /** What `/ask` and the socket's `ask` both check: a message of a sensible size, a chat driver that is here, and no run going. */
146
+ const checkAsk = (
147
+ rawMessage: unknown,
148
+ rawDriver: unknown,
149
+ ):
150
+ | { message: string; driverName: string; driver: ChatDriver }
151
+ | { error: string; status: 400 | 409 } => {
152
+ const message = typeof rawMessage === 'string' ? rawMessage.trim() : ''
153
+ const driverName = typeof rawDriver === 'string' ? rawDriver : 'agent'
154
+ if (message === '' || message.length > 8000)
155
+ return { error: 'write a message of at most 8000 characters', status: 400 }
156
+ const driver = registry.get(driverName)
157
+ if (driver?.kind !== 'chat') {
158
+ const chat = registry
159
+ .list()
160
+ .filter((d) => d.kind === 'chat')
161
+ .map((d) => d.name)
162
+ .join(', ')
163
+ const why = whyNotChat(driverName, driver?.kind)
164
+ return {
165
+ error: `the driver "${driverName}" is not available here${why === undefined ? '' : `: ${why}`} (chat drivers: ${chat})`,
166
+ status: 400,
167
+ }
168
+ }
169
+ if (current !== undefined && !current.ended)
170
+ return { error: 'a run is already going; wait for it to end', status: 409 }
171
+ return { message, driverName, driver }
172
+ }
154
173
  /** One run of an agent: its events as they came, and who is following. It lives in the app, not in a page. */
155
174
  interface Run {
156
175
  readonly id: number
@@ -205,33 +224,35 @@ export function apply(ctx: Context, config: PanelConfig = {}): void {
205
224
  })()
206
225
  return run
207
226
  }
208
- /** Follow a run as server-sent events: a `run` event (what it is, whether it already ended), the events so far, then the new ones until it ends. Following does not keep it alive. */
209
- const follow = (c: Parameters<typeof streamSSE>[0], run: Run, announce: boolean): Response =>
210
- streamSSE(c, async (stream) => {
211
- if (announce)
212
- await stream.writeSSE({
213
- event: 'run',
214
- data: JSON.stringify({
215
- id: run.id,
216
- driver: run.driver,
217
- message: run.message,
218
- ended: run.ended,
219
- endedAt: run.endedAt ?? null,
220
- }),
221
- })
222
- const queue: Array<{ event: string; data: unknown }> = [...run.events]
223
- let wake: (() => void) | undefined
224
- const follower = (item: { event: string; data: unknown }): void => {
225
- queue.push(item)
226
- wake?.()
227
- }
228
- if (!run.ended) run.followers.add(follower)
229
- let open = true
230
- stream.onAbort(() => {
231
- open = false
232
- run.followers.delete(follower)
233
- wake?.()
227
+ /**
228
+ * Follow a run into `write`: a `run` event (what it is, whether it already ended) when `announce`, the events so far, then the new ones until it ends. Following does not keep the
229
+ * run alive. `detach` stops following (the page went away); `done` settles when the run ended or the following was detached.
230
+ */
231
+ const pump = (
232
+ run: Run,
233
+ announce: boolean,
234
+ write: (event: string, data: unknown) => void | Promise<void>,
235
+ ): { done: Promise<void>; detach(): void } => {
236
+ const queue: Array<{ event: string; data: unknown }> = [...run.events]
237
+ if (announce)
238
+ queue.unshift({
239
+ event: 'run',
240
+ data: {
241
+ id: run.id,
242
+ driver: run.driver,
243
+ message: run.message,
244
+ ended: run.ended,
245
+ endedAt: run.endedAt ?? null,
246
+ },
234
247
  })
248
+ let wake: (() => void) | undefined
249
+ const follower = (item: { event: string; data: unknown }): void => {
250
+ queue.push(item)
251
+ wake?.()
252
+ }
253
+ if (!run.ended) run.followers.add(follower)
254
+ let open = true
255
+ const done = (async () => {
235
256
  try {
236
257
  while (open) {
237
258
  const item = queue.shift()
@@ -244,11 +265,130 @@ export function apply(ctx: Context, config: PanelConfig = {}): void {
244
265
  continue
245
266
  }
246
267
  if (item.event === 'end') break
247
- await stream.writeSSE({ event: item.event, data: JSON.stringify(item.data) })
268
+ await write(item.event, item.data)
248
269
  }
249
270
  } finally {
250
271
  run.followers.delete(follower)
251
272
  }
273
+ })()
274
+ return {
275
+ done,
276
+ detach: () => {
277
+ open = false
278
+ run.followers.delete(follower)
279
+ wake?.()
280
+ },
281
+ }
282
+ }
283
+ /** Follow a run as server-sent events. */
284
+ const follow = (c: Parameters<typeof streamSSE>[0], run: Run, announce: boolean): Response =>
285
+ streamSSE(c, async (stream) => {
286
+ const following = pump(run, announce, (event, data) =>
287
+ stream.writeSSE({ event, data: JSON.stringify(data) }),
288
+ )
289
+ stream.onAbort(following.detach)
290
+ await following.done
252
291
  })
292
+ // The same runs over a WebSocket, which is not one of the browser's six connections per host: a page that streams its own state in two windows, with a run going, would otherwise
293
+ // queue the panel's Stop and the door's answers behind them. A WebSocket has no CORS and cannot set a header, so the Origin must be this app's own and the first frame carries the
294
+ // token. Frames: up `{ t: 'hello', token }`, then `{ t: 'ask', message, driver }`, `{ t: 'follow' }` or `{ t: 'stop' }`; down `{ e: <event>, d: <data> }`, as the stream's events.
295
+ // The server row comes after this plugin in a blend, so the WebSocket form is mounted when the server appears (and not at all without one that upgrades or without the `ws` package).
296
+ ctx.inject(['nodeServer'], (inner) => {
297
+ let upgrade: UpgradeWebSocket
298
+ try {
299
+ upgrade = (
300
+ inner as unknown as { nodeServer: { upgrades(): UpgradeWebSocket } }
301
+ ).nodeServer.upgrades()
302
+ } catch {
303
+ return
304
+ }
305
+ const ws = new Acryl()
306
+ const own: MiddlewareHandler = async (c, next) => {
307
+ const authority = loopbackAuthority(c)
308
+ const origin = c.req.header('origin')
309
+ const site = c.req.header('sec-fetch-site')
310
+ if (
311
+ authority === undefined ||
312
+ origin === undefined ||
313
+ origin !== authority.origin ||
314
+ (site !== undefined && site !== 'same-origin')
315
+ )
316
+ return c.json({ error: "only the app's own page may use the panel" }, 403)
317
+ await next()
318
+ }
319
+ const sameToken = (presented: unknown): boolean => {
320
+ if (typeof presented !== 'string') return false
321
+ const a = Buffer.from(presented)
322
+ const b = Buffer.from(token)
323
+ return a.length === b.length && timingSafeEqual(a, b)
324
+ }
325
+ ws.get(
326
+ `${PREFIX}/run/ws`,
327
+ own,
328
+ upgrade(
329
+ () => {
330
+ let authed = false
331
+ let following: { detach(): void } | undefined
332
+ let hello: NodeJS.Timeout | undefined
333
+ return {
334
+ onOpen(_event, ws) {
335
+ hello = setTimeout(() => ws.close(4401, 'no token'), 5000)
336
+ },
337
+ onMessage(event, ws) {
338
+ if (typeof event.data !== 'string') return
339
+ let message: { t?: unknown; token?: unknown; message?: unknown; driver?: unknown }
340
+ try {
341
+ message = JSON.parse(event.data) as typeof message
342
+ } catch {
343
+ return
344
+ }
345
+ if (!authed) {
346
+ if (message.t !== 'hello' || !sameToken(message.token)) {
347
+ ws.close(4401, 'token')
348
+ return
349
+ }
350
+ authed = true
351
+ if (hello !== undefined) clearTimeout(hello)
352
+ return
353
+ }
354
+ const send = (e: string, d: unknown): void => {
355
+ ws.send(JSON.stringify({ e, d }))
356
+ }
357
+ if (message.t === 'stop') {
358
+ current?.cancel.abort()
359
+ return
360
+ }
361
+ if (following !== undefined) return
362
+ const watch = (run: Run, announce: boolean): void => {
363
+ const pumped = pump(run, announce, send)
364
+ following = pumped
365
+ void pumped.done.then(() => ws.close(1000, 'run ended'))
366
+ }
367
+ if (message.t === 'ask') {
368
+ const checked = checkAsk(message.message, message.driver)
369
+ if ('error' in checked) {
370
+ send('error', { message: checked.error })
371
+ ws.close(1000, 'refused')
372
+ return
373
+ }
374
+ watch(startRun(checked.driverName, checked.message, checked.driver), false)
375
+ } else if (message.t === 'follow') {
376
+ if (current === undefined) {
377
+ send('idle', {})
378
+ ws.close(1000, 'no run')
379
+ } else watch(current, true)
380
+ }
381
+ },
382
+ onClose() {
383
+ if (hello !== undefined) clearTimeout(hello)
384
+ following?.detach()
385
+ },
386
+ }
387
+ },
388
+ { textOnly: true },
389
+ ),
390
+ )
391
+ inner.router.route('/', ws)
392
+ })
253
393
  ctx.router.route('/', app)
254
394
  }
@@ -9,7 +9,7 @@
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, Menu, type MenuItem, shell } from 'electron'
12
+ import { app, BrowserWindow, Menu, MenuItem, screen, 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'
@@ -20,9 +20,10 @@ import {
20
20
  startDoorShell,
21
21
  type WindowPort,
22
22
  } from './door-shell.ts'
23
+ import { hasSettings, settingsPlacement } from './menu.ts'
23
24
  import { createDesktopShutdown, installShutdownRequests } from './shutdown.ts'
24
25
  import { resolveDesktopUserDataOverride } from './user-data.ts'
25
- import { windowOptions } from './window.ts'
26
+ import { cascade, windowOptions } from './window.ts'
26
27
 
27
28
  const projectRoot = fileURLToPath(new URL('../..', import.meta.url))
28
29
  const chosen = selectInstance({ projectRoot, env: process.env })
@@ -91,7 +92,19 @@ if (!app.requestSingleInstanceLock()) {
91
92
  const origin = new URL(url).origin
92
93
  /** 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
94
  const createWindow = (path: string): BrowserWindow => {
94
- const created = new BrowserWindow(windowOptions({ title: chosen.name }))
95
+ // A window opened while another is open sits a step down and to the right of it, not exactly on top.
96
+ const beside =
97
+ BrowserWindow.getFocusedWindow() ??
98
+ BrowserWindow.getAllWindows().find((other) => !other.isDestroyed())
99
+ const where = (() => {
100
+ if (beside === undefined) return {}
101
+ const bounds = beside.getBounds()
102
+ return cascade(bounds, screen.getDisplayMatching(bounds).workArea, {
103
+ width: 1100,
104
+ height: 760,
105
+ })
106
+ })()
107
+ const created = new BrowserWindow(windowOptions({ title: chosen.name, ...where }))
95
108
  created.webContents.setUserAgent(
96
109
  `${created.webContents.getUserAgent()} AcrylWindow/${String(created.id)}`,
97
110
  )
@@ -147,6 +160,40 @@ if (!app.requestSingleInstanceLock()) {
147
160
  open: (path) => port(createWindow(path ?? startPath())),
148
161
  menu: () => (Menu.getApplicationMenu()?.items ?? []).map(menuItem),
149
162
  }
163
+ // An app that serves a /settings page gets a Settings item in the menu (Electron's own menu otherwise): it opens that page, in the window that already shows it when there is one.
164
+ const settingsServed = await fetch(new URL('/settings', url), {
165
+ headers: { accept: 'text/html' },
166
+ signal: AbortSignal.timeout(2000),
167
+ }).then(
168
+ (answer) => answer.ok,
169
+ () => false,
170
+ )
171
+ const menu = Menu.getApplicationMenu()
172
+ const placement =
173
+ menu === null || !settingsServed || hasSettings(menu.items)
174
+ ? undefined
175
+ : settingsPlacement(menu.items, process.platform)
176
+ if (menu !== null && placement !== undefined) {
177
+ const target = menu.items[placement.menu]?.submenu
178
+ const openSettings = (): void => {
179
+ const shown = live().find(
180
+ (candidate) =>
181
+ new URL(candidate.webContents.getURL() || 'about:blank', url).pathname === '/settings',
182
+ )
183
+ if (shown === undefined) createWindow('/settings')
184
+ else {
185
+ if (shown.isMinimized()) shown.restore()
186
+ shown.focus()
187
+ }
188
+ }
189
+ target?.insert(
190
+ placement.at,
191
+ new MenuItem({ label: 'Settings...', accelerator: 'CmdOrCtrl+,', click: openSettings }),
192
+ )
193
+ if (placement.separatorAfter)
194
+ target?.insert(placement.at + 1, new MenuItem({ type: 'separator' }))
195
+ Menu.setApplicationMenu(menu)
196
+ }
150
197
  stopDoorShell = startDoorShell(projectRoot, { desktop, log: console.log })
151
198
  })
152
199
  }
@@ -0,0 +1,60 @@
1
+ /**
2
+ * The desktop app's Settings menu item. An app that serves a `/settings` page gets a "Settings..." item that opens it in a window (Cmd+, on macOS, Ctrl+, elsewhere); an app without
3
+ * the page gets nothing. Where the item goes is decided here, from the labels of the application menu Electron built, so it is tested without Electron.
4
+ */
5
+
6
+ /** The part of an Electron menu item this file reads. */
7
+ export interface MenuNode {
8
+ readonly label: string
9
+ readonly role?: string | undefined
10
+ readonly type?: string | undefined
11
+ readonly submenu?: { readonly items: readonly MenuNode[] } | null | undefined
12
+ }
13
+
14
+ export interface Placement {
15
+ /** Index of the top-level menu that gets the item. */
16
+ readonly menu: number
17
+ /** Index inside that menu to insert at. */
18
+ readonly at: number
19
+ /** Whether a separator goes after the item (so it sits in a group of its own). */
20
+ readonly separatorAfter: boolean
21
+ }
22
+
23
+ /**
24
+ * macOS puts Settings in the application menu, under About; elsewhere it is the first item of the File menu. Returns nothing when the menu has neither place (an app that
25
+ * replaced Electron's menu with its own keeps its own).
26
+ */
27
+ export function settingsPlacement(
28
+ items: readonly MenuNode[],
29
+ platform: NodeJS.Platform,
30
+ ): Placement | undefined {
31
+ if (platform === 'darwin') {
32
+ const submenu = items[0]?.submenu?.items
33
+ if (submenu === undefined || submenu === null) return undefined
34
+ const about = submenu.findIndex(
35
+ (item) => item.role === 'about' || /^about\b/iu.test(item.label),
36
+ )
37
+ // Under About and the separator after it; first when there is no About.
38
+ const separator =
39
+ about === -1
40
+ ? -1
41
+ : submenu.findIndex((item, index) => index > about && item.type === 'separator')
42
+ return {
43
+ menu: 0,
44
+ at: separator === -1 ? (about === -1 ? 0 : about + 1) : separator + 1,
45
+ separatorAfter: true,
46
+ }
47
+ }
48
+ const file = items.findIndex((item) => /^&?file$/iu.test(item.label) || item.role === 'fileMenu')
49
+ if (file === -1 || items[file]?.submenu == null) return undefined
50
+ return { menu: file, at: 0, separatorAfter: true }
51
+ }
52
+
53
+ /** Whether a menu already has a Settings item (so a restart that builds the menu twice adds it once). */
54
+ export function hasSettings(items: readonly MenuNode[]): boolean {
55
+ return items.some(
56
+ (item) =>
57
+ /^settings\b|^preferences\b/iu.test(item.label) ||
58
+ (item.submenu?.items !== undefined && hasSettings(item.submenu.items)),
59
+ )
60
+ }