acrylblends 1.0.6 → 1.0.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.js +272 -1
- package/dist/index.js.map +1 -1
- package/docs/framework/guides/production.md +3 -3
- package/lib/{audit-GcLtvNLV.d.ts → audit-BD8_2pkh.d.ts} +25 -3
- package/lib/cordis/door.d.ts +2 -2
- package/lib/cordis/door.js +79 -6
- package/lib/door/host.d.ts +2 -2
- package/lib/door/host.js +1 -1
- package/lib/door/page.d.ts +24 -1
- package/lib/door/page.js +138 -9
- package/lib/door/protocol.d.ts +1 -1
- package/lib/door/protocol.js +1 -1
- package/lib/{protocol-Dt0AZBOz.js → protocol-CQ_if7Dg.js} +4 -0
- package/lib/{protocol-CR31M7K_.d.ts → protocol-a2aqrp2X.d.ts} +3 -0
- package/lib/{snapshot-text-CtQm3n8n.js → snapshot-text-m13Pk-AI.js} +95 -17
- package/package.json +1 -1
- package/plugins/acryl-door/plugin.json +3 -0
- package/plugins/acryl-door/src/cli/acryl.ts +33 -4
- package/plugins/acryl-door/src/host/channel.ts +107 -18
- package/plugins/acryl-door/src/host/door.ts +5 -2
- package/plugins/acryl-door/src/host/page-socket.ts +78 -0
- package/plugins/acryl-door/src/host/shim.ts +2 -1
- package/plugins/acryl-door/src/index.ts +10 -0
- package/plugins/acryl-door/src/page/channel.ts +67 -5
- package/plugins/acryl-door/src/page/driver/sensitivity.ts +39 -3
- package/plugins/acryl-door/src/page/index.ts +16 -1
- package/plugins/acryl-door/src/page/protocol.ts +3 -0
- package/plugins/acryl-door/src/page/socket-source.ts +75 -0
- package/plugins/agent-panel/client/main.ts +182 -20
- package/plugins/agent-panel/client/markdown.ts +219 -0
- 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/plugins/extensions/index.ts +81 -4
- package/templates/agent/AGENTS.md +4 -2
- package/templates/agent/src/app.ts +9 -1
- package/templates/agent/src/routes.ts +13 -3
- package/templates/agent/test/app.test.ts +27 -2
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A small, safe Markdown for the agent's answers. The parser turns text into a tree of blocks and inlines; nothing in it is HTML: the panel builds elements from the tree with
|
|
3
|
+
* `createElement` and `textContent`, so an answer can never put markup, a script or an event handler into the page. A link keeps its address only for `http:`, `https:` and `mailto:`.
|
|
4
|
+
* What it knows: headings, paragraphs, fenced code, bullet and numbered lists, block quotes, and `code`, **bold**, *italic*, [links](https://...) inside a line.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
export type Inline =
|
|
8
|
+
| { readonly type: 'text'; readonly text: string }
|
|
9
|
+
| { readonly type: 'code'; readonly text: string }
|
|
10
|
+
| { readonly type: 'strong'; readonly children: readonly Inline[] }
|
|
11
|
+
| { readonly type: 'em'; readonly children: readonly Inline[] }
|
|
12
|
+
| {
|
|
13
|
+
readonly type: 'link'
|
|
14
|
+
readonly href: string | undefined
|
|
15
|
+
readonly children: readonly Inline[]
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export type Block =
|
|
19
|
+
| { readonly type: 'heading'; readonly level: number; readonly children: readonly Inline[] }
|
|
20
|
+
| { readonly type: 'paragraph'; readonly children: readonly Inline[] }
|
|
21
|
+
| { readonly type: 'code'; readonly language: string; readonly text: string }
|
|
22
|
+
| {
|
|
23
|
+
readonly type: 'list'
|
|
24
|
+
readonly ordered: boolean
|
|
25
|
+
readonly items: readonly (readonly Inline[])[]
|
|
26
|
+
}
|
|
27
|
+
| { readonly type: 'quote'; readonly children: readonly Inline[] }
|
|
28
|
+
|
|
29
|
+
const SAFE_LINK = /^(?:https?:\/\/|mailto:)/iu
|
|
30
|
+
|
|
31
|
+
/** The address of a link if it is one the panel will open, else nothing (a `javascript:` or `data:` address is shown as text). */
|
|
32
|
+
export function safeHref(href: string): string | undefined {
|
|
33
|
+
const trimmed = href.trim()
|
|
34
|
+
return SAFE_LINK.test(trimmed) && !/[\s<>"]/u.test(trimmed) ? trimmed : undefined
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Inline syntax inside one line or paragraph. An opener with no closer is plain text. */
|
|
38
|
+
export function parseInline(text: string): Inline[] {
|
|
39
|
+
const out: Inline[] = []
|
|
40
|
+
let plain = ''
|
|
41
|
+
const flush = (): void => {
|
|
42
|
+
if (plain !== '') out.push({ type: 'text', text: plain })
|
|
43
|
+
plain = ''
|
|
44
|
+
}
|
|
45
|
+
let at = 0
|
|
46
|
+
while (at < text.length) {
|
|
47
|
+
const char = text[at] as string
|
|
48
|
+
if (char === '`') {
|
|
49
|
+
const end = text.indexOf('`', at + 1)
|
|
50
|
+
if (end > at + 1) {
|
|
51
|
+
flush()
|
|
52
|
+
out.push({ type: 'code', text: text.slice(at + 1, end) })
|
|
53
|
+
at = end + 1
|
|
54
|
+
continue
|
|
55
|
+
}
|
|
56
|
+
} else if (char === '*' && text[at + 1] === '*') {
|
|
57
|
+
const end = text.indexOf('**', at + 2)
|
|
58
|
+
if (end > at + 2) {
|
|
59
|
+
flush()
|
|
60
|
+
out.push({ type: 'strong', children: parseInline(text.slice(at + 2, end)) })
|
|
61
|
+
at = end + 2
|
|
62
|
+
continue
|
|
63
|
+
}
|
|
64
|
+
} else if (
|
|
65
|
+
(char === '*' || char === '_') &&
|
|
66
|
+
text[at + 1] !== ' ' &&
|
|
67
|
+
(at === 0 || !/\w/u.test(text[at - 1] as string))
|
|
68
|
+
) {
|
|
69
|
+
const end = text.indexOf(char, at + 1)
|
|
70
|
+
if (end > at + 1 && text[end - 1] !== ' ' && !/\w/u.test(text[end + 1] ?? ' ')) {
|
|
71
|
+
flush()
|
|
72
|
+
out.push({ type: 'em', children: parseInline(text.slice(at + 1, end)) })
|
|
73
|
+
at = end + 1
|
|
74
|
+
continue
|
|
75
|
+
}
|
|
76
|
+
} else if (char === '[') {
|
|
77
|
+
const close = text.indexOf('](', at + 1)
|
|
78
|
+
const end = close === -1 ? -1 : text.indexOf(')', close + 2)
|
|
79
|
+
if (close > at + 1 && end > close) {
|
|
80
|
+
flush()
|
|
81
|
+
out.push({
|
|
82
|
+
type: 'link',
|
|
83
|
+
href: safeHref(text.slice(close + 2, end)),
|
|
84
|
+
children: parseInline(text.slice(at + 1, close)),
|
|
85
|
+
})
|
|
86
|
+
at = end + 1
|
|
87
|
+
continue
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
plain += char
|
|
91
|
+
at += 1
|
|
92
|
+
}
|
|
93
|
+
flush()
|
|
94
|
+
return out
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Blocks of a whole answer. A fence that is still open (the answer is streaming) shows what it has so far. */
|
|
98
|
+
export function parseMarkdown(source: string): Block[] {
|
|
99
|
+
const lines = source.replaceAll('\r\n', '\n').split('\n')
|
|
100
|
+
const blocks: Block[] = []
|
|
101
|
+
let paragraph: string[] = []
|
|
102
|
+
const endParagraph = (): void => {
|
|
103
|
+
if (paragraph.length > 0)
|
|
104
|
+
blocks.push({ type: 'paragraph', children: parseInline(paragraph.join('\n')) })
|
|
105
|
+
paragraph = []
|
|
106
|
+
}
|
|
107
|
+
for (let at = 0; at < lines.length; at += 1) {
|
|
108
|
+
const line = lines[at] as string
|
|
109
|
+
const fence = /^\s*```\s*([\w+-]*)\s*$/u.exec(line)
|
|
110
|
+
if (fence) {
|
|
111
|
+
endParagraph()
|
|
112
|
+
const body: string[] = []
|
|
113
|
+
for (at += 1; at < lines.length && !/^\s*```\s*$/u.test(lines[at] as string); at += 1)
|
|
114
|
+
body.push(lines[at] as string)
|
|
115
|
+
blocks.push({ type: 'code', language: fence[1] ?? '', text: body.join('\n') })
|
|
116
|
+
continue
|
|
117
|
+
}
|
|
118
|
+
const heading = /^(#{1,6})\s+(.*?)\s*#*\s*$/u.exec(line)
|
|
119
|
+
if (heading) {
|
|
120
|
+
endParagraph()
|
|
121
|
+
blocks.push({
|
|
122
|
+
type: 'heading',
|
|
123
|
+
level: (heading[1] as string).length,
|
|
124
|
+
children: parseInline(heading[2] as string),
|
|
125
|
+
})
|
|
126
|
+
continue
|
|
127
|
+
}
|
|
128
|
+
const bullet = /^\s*[-*+]\s+(.*)$/u.exec(line)
|
|
129
|
+
const numbered = /^\s*\d{1,3}[.)]\s+(.*)$/u.exec(line)
|
|
130
|
+
if (bullet || numbered) {
|
|
131
|
+
endParagraph()
|
|
132
|
+
const ordered = numbered !== null
|
|
133
|
+
const items: Inline[][] = []
|
|
134
|
+
for (; at < lines.length; at += 1) {
|
|
135
|
+
const item = (ordered ? /^\s*\d{1,3}[.)]\s+(.*)$/u : /^\s*[-*+]\s+(.*)$/u).exec(
|
|
136
|
+
lines[at] as string,
|
|
137
|
+
)
|
|
138
|
+
if (!item) break
|
|
139
|
+
items.push(parseInline(item[1] as string))
|
|
140
|
+
}
|
|
141
|
+
at -= 1
|
|
142
|
+
blocks.push({ type: 'list', ordered, items })
|
|
143
|
+
continue
|
|
144
|
+
}
|
|
145
|
+
const quote = /^\s*>\s?(.*)$/u.exec(line)
|
|
146
|
+
if (quote) {
|
|
147
|
+
endParagraph()
|
|
148
|
+
const quoted: string[] = []
|
|
149
|
+
for (; at < lines.length; at += 1) {
|
|
150
|
+
const next = /^\s*>\s?(.*)$/u.exec(lines[at] as string)
|
|
151
|
+
if (!next) break
|
|
152
|
+
quoted.push(next[1] as string)
|
|
153
|
+
}
|
|
154
|
+
at -= 1
|
|
155
|
+
blocks.push({ type: 'quote', children: parseInline(quoted.join('\n')) })
|
|
156
|
+
continue
|
|
157
|
+
}
|
|
158
|
+
if (line.trim() === '') endParagraph()
|
|
159
|
+
else paragraph.push(line)
|
|
160
|
+
}
|
|
161
|
+
endParagraph()
|
|
162
|
+
return blocks
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** Build the elements of a parsed answer. Only `createElement`, `textContent` and attributes set here: no HTML string reaches the page. */
|
|
166
|
+
export function renderMarkdown(source: string, document: Document): DocumentFragment {
|
|
167
|
+
const fragment = document.createDocumentFragment()
|
|
168
|
+
const inline = (parent: Node, nodes: readonly Inline[]): void => {
|
|
169
|
+
for (const node of nodes) {
|
|
170
|
+
if (node.type === 'text') parent.appendChild(document.createTextNode(node.text))
|
|
171
|
+
else if (node.type === 'code') {
|
|
172
|
+
const code = document.createElement('code')
|
|
173
|
+
code.textContent = node.text
|
|
174
|
+
parent.appendChild(code)
|
|
175
|
+
} else if (node.type === 'strong' || node.type === 'em') {
|
|
176
|
+
const element = document.createElement(node.type)
|
|
177
|
+
inline(element, node.children)
|
|
178
|
+
parent.appendChild(element)
|
|
179
|
+
} else if (node.href === undefined) {
|
|
180
|
+
inline(parent, node.children)
|
|
181
|
+
} else {
|
|
182
|
+
const anchor = document.createElement('a')
|
|
183
|
+
anchor.href = node.href
|
|
184
|
+
anchor.target = '_blank'
|
|
185
|
+
anchor.rel = 'noopener noreferrer'
|
|
186
|
+
inline(anchor, node.children)
|
|
187
|
+
parent.appendChild(anchor)
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
for (const block of parseMarkdown(source)) {
|
|
192
|
+
if (block.type === 'code') {
|
|
193
|
+
const pre = document.createElement('pre')
|
|
194
|
+
const code = document.createElement('code')
|
|
195
|
+
code.textContent = block.text
|
|
196
|
+
pre.appendChild(code)
|
|
197
|
+
fragment.appendChild(pre)
|
|
198
|
+
} else if (block.type === 'list') {
|
|
199
|
+
const list = document.createElement(block.ordered ? 'ol' : 'ul')
|
|
200
|
+
for (const item of block.items) {
|
|
201
|
+
const li = document.createElement('li')
|
|
202
|
+
inline(li, item)
|
|
203
|
+
list.appendChild(li)
|
|
204
|
+
}
|
|
205
|
+
fragment.appendChild(list)
|
|
206
|
+
} else {
|
|
207
|
+
const element = document.createElement(
|
|
208
|
+
block.type === 'heading'
|
|
209
|
+
? `h${String(Math.min(block.level + 2, 6))}`
|
|
210
|
+
: block.type === 'quote'
|
|
211
|
+
? 'blockquote'
|
|
212
|
+
: 'p',
|
|
213
|
+
)
|
|
214
|
+
inline(element, block.children)
|
|
215
|
+
fragment.appendChild(element)
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
return fragment
|
|
219
|
+
}
|
|
@@ -195,3 +195,23 @@ export function headlessDriver(name: HeadlessName, root: string): ChatDriver {
|
|
|
195
195
|
},
|
|
196
196
|
}
|
|
197
197
|
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Why a driver name cannot be used to ask a question, in a sentence: it is a terminal (picked in the panel, not asked over `/ask`), it is a coding agent that is not installed or not
|
|
201
|
+
* able to start, or pi without a key to reach its model. Nothing when the name is not one of the headless agents.
|
|
202
|
+
*/
|
|
203
|
+
export function whyNotChat(
|
|
204
|
+
name: string,
|
|
205
|
+
kind: 'chat' | 'terminal' | undefined,
|
|
206
|
+
): string | undefined {
|
|
207
|
+
if (kind === 'terminal')
|
|
208
|
+
return `"${name}" is a terminal driver: pick it in the panel to open its terminal; /ask runs the chat drivers`
|
|
209
|
+
if (name !== 'claude' && name !== 'codex' && name !== 'pi') return undefined
|
|
210
|
+
if (!onPath(name))
|
|
211
|
+
return `${name} was not found on the PATH of the app, so the panel cannot start it`
|
|
212
|
+
const launch = resolveLaunch(name, [])
|
|
213
|
+
if ('error' in launch) return launch.error
|
|
214
|
+
if (name === 'pi' && !piHasDeepseekKey())
|
|
215
|
+
return 'pi needs a DeepSeek key: set DEEPSEEK_API_KEY in the environment of the app, or log in with pi once (it keeps the key in ~/.pi/agent/auth.json)'
|
|
216
|
+
return undefined
|
|
217
|
+
}
|
|
@@ -9,17 +9,24 @@
|
|
|
9
9
|
* - Only the app's own page may ask (a same-origin request carrying the token), one run at a time, and every ask is written to `.acryl/audit/agent-panel.jsonl`.
|
|
10
10
|
* - Development only: the plugin does nothing with `NODE_ENV=production` or `ACRYL_DOOR=off`.
|
|
11
11
|
*/
|
|
12
|
-
import { randomBytes } from 'node:crypto'
|
|
12
|
+
import { randomBytes, timingSafeEqual } from 'node:crypto'
|
|
13
13
|
import { appendFileSync, mkdirSync } from 'node:fs'
|
|
14
14
|
import { dirname, join } from 'node:path'
|
|
15
15
|
import type { Context } from '@deepseek-ai/cordis'
|
|
16
16
|
import { clientModules } from '../acryl-node-server/src/client-modules.ts'
|
|
17
|
-
import { streamSSE } from '../acryl-router/src/helpers/index.ts'
|
|
18
|
-
import { Acryl } from '../acryl-router/src/index.ts'
|
|
17
|
+
import { streamSSE, type UpgradeWebSocket } from '../acryl-router/src/helpers/index.ts'
|
|
18
|
+
import { Acryl, type MiddlewareHandler } from '../acryl-router/src/index.ts'
|
|
19
|
+
import { loopbackAuthority } from '../acryl-router/src/middleware/index.ts'
|
|
19
20
|
import type {} from '../agent/index.ts'
|
|
20
21
|
import type {} from '../extensions/index.ts'
|
|
21
22
|
import type { ChatDriver } from './drivers.ts'
|
|
22
|
-
import {
|
|
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
|
+
}
|