acrylblends 1.0.7 → 1.0.9
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/dist/index.js +6 -1
- package/dist/index.js.map +1 -1
- package/lib/{audit-BD8_2pkh.d.ts → audit-Dw-siYTp.d.ts} +7 -1
- package/lib/cordis/door.d.ts +4 -1
- package/lib/cordis/door.js +76 -5
- package/lib/door/host.d.ts +1 -1
- package/lib/door/host.js +1 -1
- package/lib/door/page.d.ts +16 -0
- package/lib/door/page.js +81 -4
- package/lib/{snapshot-text-m13Pk-AI.js → snapshot-text-CBi3EMnj.js} +19 -7
- package/package.json +1 -1
- package/plugins/acryl-door/plugin.json +3 -0
- package/plugins/acryl-door/src/host/channel.ts +20 -3
- package/plugins/acryl-door/src/host/door.ts +1 -1
- package/plugins/acryl-door/src/host/page-socket.ts +78 -0
- package/plugins/acryl-door/src/index.ts +12 -1
- package/plugins/acryl-door/src/page/channel.ts +47 -5
- package/plugins/acryl-door/src/page/index.ts +9 -1
- package/plugins/acryl-door/src/page/socket-source.ts +75 -0
- package/plugins/acryl-door/src/shell/protocol.ts +3 -0
- package/plugins/agent-panel/client/main.ts +154 -32
- package/plugins/agent-panel/headless.ts +20 -0
- package/plugins/agent-panel/index.ts +193 -50
- package/plugins/desktop/door-shell.ts +6 -0
- package/plugins/desktop/main.ts +58 -3
- package/plugins/desktop/menu.ts +60 -0
- package/plugins/desktop/window.ts +62 -2
- package/templates/agent/AGENTS.md +3 -1
- package/templates/agent/src/routes.ts +11 -1
- package/templates/agent/test/app.test.ts +20 -0
|
@@ -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 {
|
|
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
|
|
121
|
-
|
|
122
|
-
|
|
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,11 +142,40 @@ 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
|
|
157
176
|
readonly driver: string
|
|
158
177
|
readonly message: string
|
|
178
|
+
readonly startedAt: number
|
|
159
179
|
readonly events: Array<{ event: string; data: unknown }>
|
|
160
180
|
readonly followers: Set<(item: { event: string; data: unknown }) => void>
|
|
161
181
|
readonly cancel: AbortController
|
|
@@ -170,6 +190,7 @@ export function apply(ctx: Context, config: PanelConfig = {}): void {
|
|
|
170
190
|
id: runs,
|
|
171
191
|
driver: driverName,
|
|
172
192
|
message,
|
|
193
|
+
startedAt: Date.now(),
|
|
173
194
|
events: [],
|
|
174
195
|
followers: new Set(),
|
|
175
196
|
cancel: new AbortController(),
|
|
@@ -205,33 +226,36 @@ export function apply(ctx: Context, config: PanelConfig = {}): void {
|
|
|
205
226
|
})()
|
|
206
227
|
return run
|
|
207
228
|
}
|
|
208
|
-
/**
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
let open = true
|
|
230
|
-
stream.onAbort(() => {
|
|
231
|
-
open = false
|
|
232
|
-
run.followers.delete(follower)
|
|
233
|
-
wake?.()
|
|
229
|
+
/**
|
|
230
|
+
* 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
|
|
231
|
+
* run alive. `detach` stops following (the page went away); `done` settles when the run ended or the following was detached.
|
|
232
|
+
*/
|
|
233
|
+
const pump = (
|
|
234
|
+
run: Run,
|
|
235
|
+
announce: boolean,
|
|
236
|
+
write: (event: string, data: unknown) => void | Promise<void>,
|
|
237
|
+
): { done: Promise<void>; detach(): void } => {
|
|
238
|
+
const queue: Array<{ event: string; data: unknown }> = [...run.events]
|
|
239
|
+
if (announce)
|
|
240
|
+
queue.unshift({
|
|
241
|
+
event: 'run',
|
|
242
|
+
data: {
|
|
243
|
+
id: run.id,
|
|
244
|
+
driver: run.driver,
|
|
245
|
+
message: run.message,
|
|
246
|
+
startedAt: run.startedAt,
|
|
247
|
+
ended: run.ended,
|
|
248
|
+
endedAt: run.endedAt ?? null,
|
|
249
|
+
},
|
|
234
250
|
})
|
|
251
|
+
let wake: (() => void) | undefined
|
|
252
|
+
const follower = (item: { event: string; data: unknown }): void => {
|
|
253
|
+
queue.push(item)
|
|
254
|
+
wake?.()
|
|
255
|
+
}
|
|
256
|
+
if (!run.ended) run.followers.add(follower)
|
|
257
|
+
let open = true
|
|
258
|
+
const done = (async () => {
|
|
235
259
|
try {
|
|
236
260
|
while (open) {
|
|
237
261
|
const item = queue.shift()
|
|
@@ -244,11 +268,130 @@ export function apply(ctx: Context, config: PanelConfig = {}): void {
|
|
|
244
268
|
continue
|
|
245
269
|
}
|
|
246
270
|
if (item.event === 'end') break
|
|
247
|
-
await
|
|
271
|
+
await write(item.event, item.data)
|
|
248
272
|
}
|
|
249
273
|
} finally {
|
|
250
274
|
run.followers.delete(follower)
|
|
251
275
|
}
|
|
276
|
+
})()
|
|
277
|
+
return {
|
|
278
|
+
done,
|
|
279
|
+
detach: () => {
|
|
280
|
+
open = false
|
|
281
|
+
run.followers.delete(follower)
|
|
282
|
+
wake?.()
|
|
283
|
+
},
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
/** Follow a run as server-sent events. */
|
|
287
|
+
const follow = (c: Parameters<typeof streamSSE>[0], run: Run, announce: boolean): Response =>
|
|
288
|
+
streamSSE(c, async (stream) => {
|
|
289
|
+
const following = pump(run, announce, (event, data) =>
|
|
290
|
+
stream.writeSSE({ event, data: JSON.stringify(data) }),
|
|
291
|
+
)
|
|
292
|
+
stream.onAbort(following.detach)
|
|
293
|
+
await following.done
|
|
252
294
|
})
|
|
295
|
+
// 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
|
|
296
|
+
// 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
|
|
297
|
+
// 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.
|
|
298
|
+
// 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).
|
|
299
|
+
ctx.inject(['nodeServer'], (inner) => {
|
|
300
|
+
let upgrade: UpgradeWebSocket
|
|
301
|
+
try {
|
|
302
|
+
upgrade = (
|
|
303
|
+
inner as unknown as { nodeServer: { upgrades(): UpgradeWebSocket } }
|
|
304
|
+
).nodeServer.upgrades()
|
|
305
|
+
} catch {
|
|
306
|
+
return
|
|
307
|
+
}
|
|
308
|
+
const ws = new Acryl()
|
|
309
|
+
const own: MiddlewareHandler = async (c, next) => {
|
|
310
|
+
const authority = loopbackAuthority(c)
|
|
311
|
+
const origin = c.req.header('origin')
|
|
312
|
+
const site = c.req.header('sec-fetch-site')
|
|
313
|
+
if (
|
|
314
|
+
authority === undefined ||
|
|
315
|
+
origin === undefined ||
|
|
316
|
+
origin !== authority.origin ||
|
|
317
|
+
(site !== undefined && site !== 'same-origin')
|
|
318
|
+
)
|
|
319
|
+
return c.json({ error: "only the app's own page may use the panel" }, 403)
|
|
320
|
+
await next()
|
|
321
|
+
}
|
|
322
|
+
const sameToken = (presented: unknown): boolean => {
|
|
323
|
+
if (typeof presented !== 'string') return false
|
|
324
|
+
const a = Buffer.from(presented)
|
|
325
|
+
const b = Buffer.from(token)
|
|
326
|
+
return a.length === b.length && timingSafeEqual(a, b)
|
|
327
|
+
}
|
|
328
|
+
ws.get(
|
|
329
|
+
`${PREFIX}/run/ws`,
|
|
330
|
+
own,
|
|
331
|
+
upgrade(
|
|
332
|
+
() => {
|
|
333
|
+
let authed = false
|
|
334
|
+
let following: { detach(): void } | undefined
|
|
335
|
+
let hello: NodeJS.Timeout | undefined
|
|
336
|
+
return {
|
|
337
|
+
onOpen(_event, ws) {
|
|
338
|
+
hello = setTimeout(() => ws.close(4401, 'no token'), 5000)
|
|
339
|
+
},
|
|
340
|
+
onMessage(event, ws) {
|
|
341
|
+
if (typeof event.data !== 'string') return
|
|
342
|
+
let message: { t?: unknown; token?: unknown; message?: unknown; driver?: unknown }
|
|
343
|
+
try {
|
|
344
|
+
message = JSON.parse(event.data) as typeof message
|
|
345
|
+
} catch {
|
|
346
|
+
return
|
|
347
|
+
}
|
|
348
|
+
if (!authed) {
|
|
349
|
+
if (message.t !== 'hello' || !sameToken(message.token)) {
|
|
350
|
+
ws.close(4401, 'token')
|
|
351
|
+
return
|
|
352
|
+
}
|
|
353
|
+
authed = true
|
|
354
|
+
if (hello !== undefined) clearTimeout(hello)
|
|
355
|
+
return
|
|
356
|
+
}
|
|
357
|
+
const send = (e: string, d: unknown): void => {
|
|
358
|
+
ws.send(JSON.stringify({ e, d }))
|
|
359
|
+
}
|
|
360
|
+
if (message.t === 'stop') {
|
|
361
|
+
current?.cancel.abort()
|
|
362
|
+
return
|
|
363
|
+
}
|
|
364
|
+
if (following !== undefined) return
|
|
365
|
+
const watch = (run: Run, announce: boolean): void => {
|
|
366
|
+
const pumped = pump(run, announce, send)
|
|
367
|
+
following = pumped
|
|
368
|
+
void pumped.done.then(() => ws.close(1000, 'run ended'))
|
|
369
|
+
}
|
|
370
|
+
if (message.t === 'ask') {
|
|
371
|
+
const checked = checkAsk(message.message, message.driver)
|
|
372
|
+
if ('error' in checked) {
|
|
373
|
+
send('error', { message: checked.error })
|
|
374
|
+
ws.close(1000, 'refused')
|
|
375
|
+
return
|
|
376
|
+
}
|
|
377
|
+
watch(startRun(checked.driverName, checked.message, checked.driver), false)
|
|
378
|
+
} else if (message.t === 'follow') {
|
|
379
|
+
if (current === undefined) {
|
|
380
|
+
send('idle', {})
|
|
381
|
+
ws.close(1000, 'no run')
|
|
382
|
+
} else watch(current, true)
|
|
383
|
+
}
|
|
384
|
+
},
|
|
385
|
+
onClose() {
|
|
386
|
+
if (hello !== undefined) clearTimeout(hello)
|
|
387
|
+
following?.detach()
|
|
388
|
+
},
|
|
389
|
+
}
|
|
390
|
+
},
|
|
391
|
+
{ textOnly: true },
|
|
392
|
+
),
|
|
393
|
+
)
|
|
394
|
+
inner.router.route('/', ws)
|
|
395
|
+
})
|
|
253
396
|
ctx.router.route('/', app)
|
|
254
397
|
}
|
|
@@ -17,6 +17,7 @@ export interface WindowPort {
|
|
|
17
17
|
isFocused(): boolean
|
|
18
18
|
isMinimized(): boolean
|
|
19
19
|
getSize(): number[]
|
|
20
|
+
getPosition(): number[]
|
|
20
21
|
restore(): void
|
|
21
22
|
focus(): void
|
|
22
23
|
close(): void
|
|
@@ -68,6 +69,8 @@ export type ShellResult =
|
|
|
68
69
|
focused: boolean
|
|
69
70
|
width: number
|
|
70
71
|
height: number
|
|
72
|
+
x?: number
|
|
73
|
+
y?: number
|
|
71
74
|
}[]
|
|
72
75
|
}
|
|
73
76
|
| { readonly items: readonly { path: string; label: string; enabled: boolean; ends?: boolean }[] }
|
|
@@ -133,6 +136,7 @@ export function handleShellCall(request: ShellRequest, desktop: DesktopPort): Sh
|
|
|
133
136
|
return {
|
|
134
137
|
windows: desktop.windows().map((window) => {
|
|
135
138
|
const [width = 0, height = 0] = window.getSize()
|
|
139
|
+
const [x = 0, y = 0] = window.getPosition()
|
|
136
140
|
return {
|
|
137
141
|
id: window.id,
|
|
138
142
|
title: window.getTitle(),
|
|
@@ -140,6 +144,8 @@ export function handleShellCall(request: ShellRequest, desktop: DesktopPort): Sh
|
|
|
140
144
|
focused: window.isFocused(),
|
|
141
145
|
width,
|
|
142
146
|
height,
|
|
147
|
+
x,
|
|
148
|
+
y,
|
|
143
149
|
}
|
|
144
150
|
}),
|
|
145
151
|
}
|
package/plugins/desktop/main.ts
CHANGED
|
@@ -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,
|
|
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,26 @@ 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
|
-
|
|
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
|
+
// The places of the windows that are open: a new window never goes exactly where one already is.
|
|
103
|
+
const taken = BrowserWindow.getAllWindows()
|
|
104
|
+
.filter((other) => !other.isDestroyed())
|
|
105
|
+
.map((other) => other.getPosition())
|
|
106
|
+
.map(([x = 0, y = 0]) => ({ x, y }))
|
|
107
|
+
return cascade(
|
|
108
|
+
bounds,
|
|
109
|
+
screen.getDisplayMatching(bounds).workArea,
|
|
110
|
+
{ width: 1100, height: 760 },
|
|
111
|
+
taken,
|
|
112
|
+
)
|
|
113
|
+
})()
|
|
114
|
+
const created = new BrowserWindow(windowOptions({ title: chosen.name, ...where }))
|
|
95
115
|
created.webContents.setUserAgent(
|
|
96
116
|
`${created.webContents.getUserAgent()} AcrylWindow/${String(created.id)}`,
|
|
97
117
|
)
|
|
@@ -123,6 +143,7 @@ if (!app.requestSingleInstanceLock()) {
|
|
|
123
143
|
isFocused: () => candidate.isFocused(),
|
|
124
144
|
isMinimized: () => candidate.isMinimized(),
|
|
125
145
|
getSize: () => candidate.getSize(),
|
|
146
|
+
getPosition: () => candidate.getPosition(),
|
|
126
147
|
restore: () => candidate.restore(),
|
|
127
148
|
focus: () => candidate.focus(),
|
|
128
149
|
close: () => candidate.close(),
|
|
@@ -147,6 +168,40 @@ if (!app.requestSingleInstanceLock()) {
|
|
|
147
168
|
open: (path) => port(createWindow(path ?? startPath())),
|
|
148
169
|
menu: () => (Menu.getApplicationMenu()?.items ?? []).map(menuItem),
|
|
149
170
|
}
|
|
171
|
+
// 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.
|
|
172
|
+
const settingsServed = await fetch(new URL('/settings', url), {
|
|
173
|
+
headers: { accept: 'text/html' },
|
|
174
|
+
signal: AbortSignal.timeout(2000),
|
|
175
|
+
}).then(
|
|
176
|
+
(answer) => answer.ok,
|
|
177
|
+
() => false,
|
|
178
|
+
)
|
|
179
|
+
const menu = Menu.getApplicationMenu()
|
|
180
|
+
const placement =
|
|
181
|
+
menu === null || !settingsServed || hasSettings(menu.items)
|
|
182
|
+
? undefined
|
|
183
|
+
: settingsPlacement(menu.items, process.platform)
|
|
184
|
+
if (menu !== null && placement !== undefined) {
|
|
185
|
+
const target = menu.items[placement.menu]?.submenu
|
|
186
|
+
const openSettings = (): void => {
|
|
187
|
+
const shown = live().find(
|
|
188
|
+
(candidate) =>
|
|
189
|
+
new URL(candidate.webContents.getURL() || 'about:blank', url).pathname === '/settings',
|
|
190
|
+
)
|
|
191
|
+
if (shown === undefined) createWindow('/settings')
|
|
192
|
+
else {
|
|
193
|
+
if (shown.isMinimized()) shown.restore()
|
|
194
|
+
shown.focus()
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
target?.insert(
|
|
198
|
+
placement.at,
|
|
199
|
+
new MenuItem({ label: 'Settings...', accelerator: 'CmdOrCtrl+,', click: openSettings }),
|
|
200
|
+
)
|
|
201
|
+
if (placement.separatorAfter)
|
|
202
|
+
target?.insert(placement.at + 1, new MenuItem({ type: 'separator' }))
|
|
203
|
+
Menu.setApplicationMenu(menu)
|
|
204
|
+
}
|
|
150
205
|
stopDoorShell = startDoorShell(projectRoot, { desktop, log: console.log })
|
|
151
206
|
})
|
|
152
207
|
}
|
|
@@ -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
|
+
}
|
|
@@ -1,23 +1,83 @@
|
|
|
1
1
|
/** The one window of a desktop app: the operating system's frame around a page the app's own server serves, with the renderer locked down. */
|
|
2
|
-
import type { BrowserWindowConstructorOptions } from 'electron'
|
|
3
2
|
|
|
4
3
|
export interface WindowSpec {
|
|
5
4
|
readonly title: string
|
|
6
5
|
readonly width?: number
|
|
7
6
|
readonly height?: number
|
|
7
|
+
/** Where the window's top left corner goes; Electron centers the window when these are left out. */
|
|
8
|
+
readonly x?: number
|
|
9
|
+
readonly y?: number
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface Rect {
|
|
13
|
+
readonly x: number
|
|
14
|
+
readonly y: number
|
|
15
|
+
readonly width: number
|
|
16
|
+
readonly height: number
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** How far each new window sits from the one it was opened from. */
|
|
20
|
+
export const CASCADE_STEP = 32
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Where a new window goes so that it does not cover another one exactly. The first free place of: a step down and to the right of `from`, and again, while the window still fits on the
|
|
24
|
+
* screen; then the same steps from the top left of the work area (a row of windows cascades, then starts again); and, on a screen no bigger than the window, the steps from `from` even
|
|
25
|
+
* though the window runs past the edge (two windows at one place cannot be told apart). A place is free when no window in `taken` is there.
|
|
26
|
+
*/
|
|
27
|
+
export function cascade(
|
|
28
|
+
from: Rect,
|
|
29
|
+
work: Rect,
|
|
30
|
+
size: { width: number; height: number },
|
|
31
|
+
taken: readonly { x: number; y: number }[] = [],
|
|
32
|
+
): { x: number; y: number } {
|
|
33
|
+
const free = (place: { x: number; y: number }): boolean =>
|
|
34
|
+
!taken.some((other) => other.x === place.x && other.y === place.y)
|
|
35
|
+
const fits = (place: { x: number; y: number }): boolean =>
|
|
36
|
+
place.x + size.width <= work.x + work.width && place.y + size.height <= work.y + work.height
|
|
37
|
+
const steps = (origin: { x: number; y: number }): { x: number; y: number }[] =>
|
|
38
|
+
Array.from({ length: 40 }, (_, at) => ({
|
|
39
|
+
x: origin.x + CASCADE_STEP * (at + 1),
|
|
40
|
+
y: origin.y + CASCADE_STEP * (at + 1),
|
|
41
|
+
}))
|
|
42
|
+
const fromHere = steps(from)
|
|
43
|
+
const fromCorner = [{ x: work.x, y: work.y }, ...steps(work)]
|
|
44
|
+
return (
|
|
45
|
+
fromHere.find((place) => fits(place) && free(place)) ??
|
|
46
|
+
fromCorner.find((place) => fits(place) && free(place)) ??
|
|
47
|
+
fromHere.find(free) ??
|
|
48
|
+
fromHere[0] ?? { x: from.x + CASCADE_STEP, y: from.y + CASCADE_STEP }
|
|
49
|
+
)
|
|
8
50
|
}
|
|
9
51
|
|
|
10
52
|
/**
|
|
11
53
|
* Secure BrowserWindow options. The settings are ACRYL Desktop's: context isolation on, no Node in the page, the sandbox on, web security on; the window stays
|
|
12
54
|
* hidden until the page has painted (`show: false`, shown on `ready-to-show`).
|
|
13
55
|
*/
|
|
14
|
-
export
|
|
56
|
+
export interface WindowOptions {
|
|
57
|
+
readonly title: string
|
|
58
|
+
readonly width: number
|
|
59
|
+
readonly height: number
|
|
60
|
+
readonly minWidth: number
|
|
61
|
+
readonly minHeight: number
|
|
62
|
+
readonly x?: number
|
|
63
|
+
readonly y?: number
|
|
64
|
+
readonly show: boolean
|
|
65
|
+
readonly webPreferences: {
|
|
66
|
+
readonly contextIsolation: boolean
|
|
67
|
+
readonly nodeIntegration: boolean
|
|
68
|
+
readonly sandbox: boolean
|
|
69
|
+
readonly webSecurity: boolean
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function windowOptions(spec: WindowSpec): WindowOptions {
|
|
15
74
|
return {
|
|
16
75
|
title: spec.title,
|
|
17
76
|
width: spec.width ?? 1100,
|
|
18
77
|
height: spec.height ?? 760,
|
|
19
78
|
minWidth: 480,
|
|
20
79
|
minHeight: 360,
|
|
80
|
+
...(spec.x === undefined || spec.y === undefined ? {} : { x: spec.x, y: spec.y }),
|
|
21
81
|
show: false,
|
|
22
82
|
webPreferences: {
|
|
23
83
|
contextIsolation: true,
|
|
@@ -7,6 +7,8 @@ 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.
|
|
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. The app has a `/settings` page already (the appearance: system, light or dark, kept in the browser) and the desktop app's menu has a Settings item that opens it; add a person's other settings to that page, not to a new one, so `acryl menu click --path "File>Settings..."` and `acryl window open --path /settings` keep opening them (on macOS the item is in the application menu). Without a desktop app these answer "no desktop window is attached; run npm run desktop".
|
|
11
|
+
|
|
12
|
+
**Keep to two event streams a page.** A browser lets a page hold six connections to one host, across all the windows of the app, and an `EventSource` holds one for as long as the page is open. The door and the panel use none (they are WebSockets), but a page you build that streams several things in two windows can fill all six, and then every request waits. Give a page at most two streams: share one stream for several cells, or fetch what changes now and then instead.
|
|
11
13
|
|
|
12
14
|
**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 the agent and the builder out, so `/ask`, `/extensions*` and `/tasks*` are not mounted; the page and `GET /health` stay. The extensions you installed (the folders of `plugins/`, committed with the app) still run in production, read-only: nothing can be installed or changed there. `ACRYL_PRODUCTION_KEEP=agent,builder,extensions` keeps the named rows, and each one kept prints a `WARNING` line at start.
|
|
@@ -19,6 +19,15 @@ const DOOR_TAG =
|
|
|
19
19
|
* What changes is also streamed: `GET /api/state/tasks/events` and `GET /api/state/extensions/events`. In production `src/app.ts` leaves the agent and builder rows out
|
|
20
20
|
* and starts the extensions read-only (unless `ACRYL_PRODUCTION_KEEP` names them), and then those routes are not mounted at all. The mounts are removed when the plugin is disposed.
|
|
21
21
|
*/
|
|
22
|
+
/** The page's look: the person's choice from /settings (kept in this browser), applied before the page paints. */
|
|
23
|
+
const APPEARANCE_STYLE =
|
|
24
|
+
'<style>:root{color-scheme:light dark}html[data-theme=dark]{color-scheme:dark;background:#111827;color:#f9fafb}html[data-theme=light]{color-scheme:light;background:#fff;color:#111827}body{font:16px system-ui,sans-serif;margin:2rem auto;max-width:42rem;padding:0 1rem}a{color:inherit}</style>'
|
|
25
|
+
const APPEARANCE_SCRIPT =
|
|
26
|
+
"<script>(function(){try{var t=localStorage.getItem('theme');if(t==='dark'||t==='light')document.documentElement.dataset.theme=t}catch(e){}})()</script>"
|
|
27
|
+
|
|
28
|
+
/** `/settings`: the one setting the template has, the appearance. The desktop app's Settings menu item opens this page. */
|
|
29
|
+
const settingsPage = `<!doctype html><html lang="en"><head><meta charset="utf-8"><title>Settings - ${APP_NAME}</title>${APPEARANCE_STYLE}${APPEARANCE_SCRIPT}</head><body><h1>Settings</h1><p><label for="theme">Appearance</label> <select id="theme" name="theme"><option value="system">Match the system</option><option value="light">Light</option><option value="dark">Dark</option></select></p><p id="saved" role="status"></p><p><a href="/">Back to ${APP_NAME}</a></p><script>(function(){var select=document.getElementById('theme'),saved=document.getElementById('saved');try{select.value=localStorage.getItem('theme')||'system'}catch(e){}select.addEventListener('change',function(){var value=select.value;try{if(value==='system'){localStorage.removeItem('theme');delete document.documentElement.dataset.theme}else{localStorage.setItem('theme',value);document.documentElement.dataset.theme=value}saved.textContent='Saved: '+select.options[select.selectedIndex].text}catch(e){saved.textContent='This browser would not keep the setting.'}})})()</script></body></html>`
|
|
30
|
+
|
|
22
31
|
export const routes = {
|
|
23
32
|
name: 'routes',
|
|
24
33
|
inject: ['router'],
|
|
@@ -37,11 +46,12 @@ export const routes = {
|
|
|
37
46
|
.get('/', (c) =>
|
|
38
47
|
(c.req.header('accept') ?? '').includes('text/html')
|
|
39
48
|
? c.html(
|
|
40
|
-
`<!doctype html><html lang="en"><head><meta charset="utf-8"><title>${APP_NAME}</title
|
|
49
|
+
`<!doctype html><html lang="en"><head><meta charset="utf-8"><title>${APP_NAME}</title>${APPEARANCE_STYLE}${APPEARANCE_SCRIPT}</head><body><h1>${APP_NAME}</h1><p>This app runs an agent unless it is switched off (production switches it off, see ACRYL_PRODUCTION_KEEP in AGENTS.md). Ask it for a feature in the agent panel on this page (or with <code>POST /ask</code>).</p>${DOOR_TAG}</body></html>`,
|
|
41
50
|
)
|
|
42
51
|
: c.json({ app: APP_NAME, ok: true }),
|
|
43
52
|
)
|
|
44
53
|
.get('/health', (c) => c.json({ ok: true }))
|
|
54
|
+
.get('/settings', (c) => c.html(settingsPage))
|
|
45
55
|
ctx.router.route('/', base)
|
|
46
56
|
// The routes that give a visitor the agent: mounted only while the agent, the extensions and the tasks run (see the header).
|
|
47
57
|
ctx.inject(['agent', 'extensions', 'tasks'], (inner) => {
|
|
@@ -144,3 +144,23 @@ test('outside production nothing changes: the agent answers and the extensions r
|
|
|
144
144
|
assert.equal((await app.root.router.request('/extensions')).status, 200)
|
|
145
145
|
assert.deepEqual(warnings, [])
|
|
146
146
|
}))
|
|
147
|
+
|
|
148
|
+
test('the app has a /settings page with the appearance setting, and the home page applies it', () =>
|
|
149
|
+
inEnvironment({ NODE_ENV: 'test', ACRYL_PRODUCTION_KEEP: undefined }, async (app) => {
|
|
150
|
+
const settings = await app.root.router.request('/settings', {
|
|
151
|
+
headers: { accept: 'text/html' },
|
|
152
|
+
})
|
|
153
|
+
assert.equal(settings.status, 200)
|
|
154
|
+
const page = await settings.text()
|
|
155
|
+
assert.match(page, /<h1>Settings<\/h1>/)
|
|
156
|
+
assert.match(page, /<select id="theme"/)
|
|
157
|
+
for (const option of ['System', 'Light', 'Dark'])
|
|
158
|
+
assert.match(page, new RegExp(`>(?:Match the )?${option.toLowerCase()}|>${option}<`, 'i'))
|
|
159
|
+
// The setting is kept in the browser and applied to the home page before it paints.
|
|
160
|
+
const home = await (
|
|
161
|
+
await app.root.router.request('/', { headers: { accept: 'text/html' } })
|
|
162
|
+
).text()
|
|
163
|
+
assert.match(home, /localStorage\.getItem\('theme'\)/)
|
|
164
|
+
// ... without leaving a global behind for the page's own code to collide with.
|
|
165
|
+
assert.doesNotMatch(home, /<script>\s*(?:var|let|const) /)
|
|
166
|
+
}))
|