acrylblends 1.0.7 → 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.
@@ -37,6 +37,77 @@ async function* events(response: Response): AsyncGenerator<PanelEvent> {
37
37
  }
38
38
  }
39
39
 
40
+ /** What a run is read from: its events, and a way to end the run. A socket and a stream give the same events. */
41
+ interface RunSource {
42
+ readonly events: AsyncIterable<PanelEvent>
43
+ }
44
+
45
+ /** A WebSocket is not one of the browser's six connections per host, so the panel's Stop and a run still work when two windows stream their own state. */
46
+ let socketBroken = false
47
+ let activeSocket: WebSocket | undefined
48
+
49
+ /** Open the run socket and send the token and the first action. Rejects when the page cannot upgrade (a proxy, a server without `ws`): the caller falls back to the stream. */
50
+ function openSocket(token: string, action: Record<string, unknown>): Promise<WebSocket> {
51
+ return new Promise((resolve, reject) => {
52
+ const socket = new WebSocket(
53
+ `${location.protocol === 'https:' ? 'wss' : 'ws'}://${location.host}${PREFIX}/run/ws`,
54
+ )
55
+ const timer = setTimeout(() => {
56
+ socket.close()
57
+ reject(new Error('the socket did not open in time'))
58
+ }, 4000)
59
+ socket.onopen = () => {
60
+ clearTimeout(timer)
61
+ socket.send(JSON.stringify({ t: 'hello', token }))
62
+ socket.send(JSON.stringify(action))
63
+ resolve(socket)
64
+ }
65
+ socket.onerror = () => {
66
+ clearTimeout(timer)
67
+ reject(new Error('the server refused the upgrade'))
68
+ }
69
+ })
70
+ }
71
+
72
+ /** The frames of an open run socket as panel events, until it closes. */
73
+ async function* socketEvents(socket: WebSocket): AsyncGenerator<PanelEvent> {
74
+ const queue: PanelEvent[] = []
75
+ let wake: (() => void) | undefined
76
+ let closed: CloseEvent | undefined
77
+ socket.onmessage = (message) => {
78
+ try {
79
+ const frame = JSON.parse(String(message.data)) as { e?: unknown; d?: unknown }
80
+ if (typeof frame.e === 'string')
81
+ queue.push({ event: frame.e, data: (frame.d ?? {}) as Record<string, unknown> })
82
+ } catch {
83
+ // Not a frame of ours.
84
+ }
85
+ wake?.()
86
+ }
87
+ socket.onclose = (event) => {
88
+ closed = event
89
+ wake?.()
90
+ }
91
+ for (;;) {
92
+ const next = queue.shift()
93
+ if (next !== undefined) {
94
+ yield next
95
+ continue
96
+ }
97
+ if (closed !== undefined) {
98
+ if (closed.code === 4401)
99
+ yield {
100
+ event: 'error',
101
+ data: { message: 'the panel was not accepted (reload the page and try again)' },
102
+ }
103
+ return
104
+ }
105
+ await new Promise<void>((resolve) => {
106
+ wake = resolve
107
+ })
108
+ }
109
+ }
110
+
40
111
  const css = `
41
112
  :host { all: initial }
42
113
  * { box-sizing: border-box; font: 14px system-ui, sans-serif }
@@ -68,6 +139,7 @@ textarea { width: 100%; min-height: 64px; resize: vertical; padding: 8px; border
68
139
  .row { display: flex; gap: 8px; align-items: center }
69
140
  .row button, header button { padding: 6px 12px; border: 1px solid #d1d5db; border-radius: 6px; background: #fff; cursor: pointer }
70
141
  .row button.send { background: #1f2937; color: #fff; border-color: #1f2937 }
142
+ .row button:disabled { opacity: 0.45; cursor: not-allowed }
71
143
  `
72
144
 
73
145
  async function start(): Promise<void> {
@@ -194,12 +266,55 @@ async function start(): Promise<void> {
194
266
  // loads next follows it again. Only Stop cancels it.
195
267
  const headers = { 'content-type': 'application/json', 'x-agent-panel-token': session.token }
196
268
  let following = false
197
- stop.addEventListener('click', () => void fetch(`${PREFIX}/stop`, { method: 'POST', headers }))
269
+ stop.addEventListener('click', () => {
270
+ // On the run's socket when there is one: a request would wait behind the browser's six connections per host.
271
+ if (activeSocket?.readyState === WebSocket.OPEN)
272
+ activeSocket.send(JSON.stringify({ t: 'stop' }))
273
+ else void fetch(`${PREFIX}/stop`, { method: 'POST', headers })
274
+ })
275
+ /** A run over the socket when the server upgrades, else over the stream, with one console warning saying why. */
276
+ const openRun = async (
277
+ action: { t: 'ask'; message: string; driver: string } | { t: 'follow' },
278
+ ): Promise<RunSource | { error: string } | undefined> => {
279
+ if (!socketBroken && typeof WebSocket !== 'undefined') {
280
+ try {
281
+ const socket = await openSocket(session.token, action)
282
+ activeSocket = socket
283
+ socket.addEventListener('close', () => {
284
+ if (activeSocket === socket) activeSocket = undefined
285
+ })
286
+ return { events: socketEvents(socket) }
287
+ } catch (cause) {
288
+ socketBroken = true
289
+ console.warn(
290
+ `agent-panel: the run could not open a WebSocket (${cause instanceof Error ? cause.message : String(cause)}); using server-sent events, which count against the browser's six connections per host`,
291
+ )
292
+ }
293
+ }
294
+ if (action.t === 'ask') {
295
+ const response = await fetch(`${PREFIX}/ask`, {
296
+ method: 'POST',
297
+ headers,
298
+ body: JSON.stringify({ message: action.message, driver: action.driver }),
299
+ })
300
+ if (!response.ok)
301
+ return {
302
+ error:
303
+ ((await response.json().catch(() => ({}))) as { error?: string }).error ??
304
+ `The panel answered ${response.status}.`,
305
+ }
306
+ return { events: events(response) }
307
+ }
308
+ const latest = await fetch(`${PREFIX}/run`, { headers })
309
+ return latest.ok && (latest.headers.get('content-type') ?? '').includes('text/event-stream')
310
+ ? { events: events(latest) }
311
+ : undefined
312
+ }
198
313
  /**
199
314
  * Show one run, as its events come, until it ends. `own` is true for a run this page just started (its question is on screen); a run found on load is shown from its question, and
200
315
  * a run that had already ended before the page loaded is only shown, never reloads the page.
201
316
  */
202
- const watch = async (response: Response, own: boolean): Promise<void> => {
317
+ const watch = async (source: RunSource, own: boolean): Promise<void> => {
203
318
  let live = true
204
319
  following = true
205
320
  send.disabled = true
@@ -218,7 +333,7 @@ async function start(): Promise<void> {
218
333
  tick()
219
334
  const timer = setInterval(tick, 1000)
220
335
  try {
221
- for await (const { event, data } of events(response)) {
336
+ for await (const { event, data } of source.events) {
222
337
  if (event === 'run') {
223
338
  live = data.ended !== true
224
339
  if (!own) {
@@ -270,29 +385,21 @@ async function start(): Promise<void> {
270
385
  input.value = ''
271
386
  line(message, 'me')
272
387
  try {
273
- const response = await fetch(`${PREFIX}/ask`, {
274
- method: 'POST',
275
- headers,
276
- body: JSON.stringify({ message, driver: driver.value }),
277
- })
278
- if (!response.ok) {
279
- line(
280
- ((await response.json().catch(() => ({}))) as { error?: string }).error ??
281
- `The panel answered ${response.status}.`,
282
- 'err',
283
- )
388
+ const run = await openRun({ t: 'ask', message, driver: driver.value })
389
+ if (run === undefined) return
390
+ if ('error' in run) {
391
+ line(run.error, 'err')
284
392
  return
285
393
  }
286
- await watch(response, true)
394
+ await watch(run, true)
287
395
  } catch (error) {
288
396
  line(`The request failed: ${error instanceof Error ? error.message : String(error)}`, 'err')
289
397
  }
290
398
  })
291
399
  // The latest run, if there is one: shown again after a reload (its question, its events), and followed if it is still going.
292
400
  try {
293
- const latest = await fetch(`${PREFIX}/run`, { headers })
294
- if (latest.ok && (latest.headers.get('content-type') ?? '').includes('text/event-stream'))
295
- void watch(latest, false)
401
+ const latest = await openRun({ t: 'follow' })
402
+ if (latest !== undefined && !('error' in latest)) void watch(latest, false)
296
403
  } catch {
297
404
  // No run to show.
298
405
  }
@@ -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
+ }