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.
- package/dist/index.js +6 -1
- package/dist/index.js.map +1 -1
- package/lib/cordis/door.js +73 -2
- package/lib/door/page.d.ts +16 -0
- package/lib/door/page.js +81 -4
- package/package.json +1 -1
- package/plugins/acryl-door/plugin.json +3 -0
- package/plugins/acryl-door/src/host/page-socket.ts +78 -0
- package/plugins/acryl-door/src/index.ts +10 -0
- 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/agent-panel/client/main.ts +125 -18
- package/plugins/agent-panel/headless.ts +20 -0
- package/plugins/agent-panel/index.ts +190 -50
- package/plugins/desktop/main.ts +50 -3
- package/plugins/desktop/menu.ts +60 -0
- package/plugins/desktop/window.ts +48 -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
|
@@ -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', () =>
|
|
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 (
|
|
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
|
|
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
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
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(
|
|
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
|
|
294
|
-
if (latest
|
|
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 {
|
|
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,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
|
-
/**
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
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
|
|
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
|
}
|
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,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
|
-
|
|
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
|
+
}
|