acrylblends 1.0.5 → 1.0.6
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/docs/framework/agents/index.md +1 -1
- package/lib/{audit-D5z9uwOP.d.ts → audit-GcLtvNLV.d.ts} +4 -2
- package/lib/cordis/door.d.ts +90 -4
- package/lib/cordis/door.js +524 -13
- package/lib/door/host.d.ts +2 -2
- package/lib/door/host.js +1 -1
- package/lib/door/page.d.ts +3 -1
- package/lib/door/page.js +1 -1
- package/lib/door/protocol.d.ts +1 -1
- package/lib/{protocol-C37gPePD.d.ts → protocol-CR31M7K_.d.ts} +2 -0
- package/lib/{snapshot-text-Bbx43mN0.js → snapshot-text-CtQm3n8n.js} +14 -5
- package/package.json +1 -1
- package/plugins/acryl-door/src/cli/acryl.ts +36 -5
- package/plugins/acryl-door/src/host/channel.ts +14 -6
- package/plugins/acryl-door/src/host/door.ts +78 -10
- package/plugins/acryl-door/src/host/page-routes.ts +3 -1
- package/plugins/acryl-door/src/host/shell.ts +147 -0
- package/plugins/acryl-door/src/host/shim.ts +39 -3
- package/plugins/acryl-door/src/host/verbs.ts +39 -2
- package/plugins/acryl-door/src/index.ts +7 -1
- package/plugins/acryl-door/src/page/channel.ts +3 -1
- package/plugins/acryl-door/src/page/index.ts +2 -1
- package/plugins/acryl-door/src/page/protocol.ts +2 -0
- package/plugins/acryl-door/src/shell/protocol.ts +130 -0
- package/plugins/agent-panel/headless.ts +29 -8
- package/plugins/agent-panel/launch.ts +67 -0
- package/plugins/desktop/door-shell.ts +379 -0
- package/plugins/desktop/main.ts +66 -12
- package/templates/agent/AGENTS.md +2 -0
- package/plugins/acryl-router/src/connection.d.ts +0 -25
- package/plugins/acryl-router/src/core/app-base.d.ts +0 -215
- package/plugins/acryl-router/src/core/app.d.ts +0 -19
- package/plugins/acryl-router/src/core/client/client.d.ts +0 -4
- package/plugins/acryl-router/src/core/client/fetch-result-please.d.ts +0 -35
- package/plugins/acryl-router/src/core/client/index.d.ts +0 -7
- package/plugins/acryl-router/src/core/client/types.d.ts +0 -229
- package/plugins/acryl-router/src/core/client/utils.d.ts +0 -18
- package/plugins/acryl-router/src/core/compose.d.ts +0 -14
- package/plugins/acryl-router/src/core/context.d.ts +0 -443
- package/plugins/acryl-router/src/core/helper/accepts/accepts.d.ts +0 -30
- package/plugins/acryl-router/src/core/helper/accepts/index.d.ts +0 -5
- package/plugins/acryl-router/src/core/helper/conninfo/index.d.ts +0 -5
- package/plugins/acryl-router/src/core/helper/conninfo/types.d.ts +0 -36
- package/plugins/acryl-router/src/core/helper/cookie/index.d.ts +0 -25
- package/plugins/acryl-router/src/core/helper/css/common.d.ts +0 -59
- package/plugins/acryl-router/src/core/helper/css/index.d.ts +0 -80
- package/plugins/acryl-router/src/core/helper/dev/index.d.ts +0 -20
- package/plugins/acryl-router/src/core/helper/factory/index.d.ts +0 -82
- package/plugins/acryl-router/src/core/helper/html/index.d.ts +0 -8
- package/plugins/acryl-router/src/core/helper/proxy/index.d.ts +0 -73
- package/plugins/acryl-router/src/core/helper/route/index.d.ts +0 -75
- package/plugins/acryl-router/src/core/helper/ssg/index.d.ts +0 -7
- package/plugins/acryl-router/src/core/helper/ssg/middleware.d.ts +0 -44
- package/plugins/acryl-router/src/core/helper/ssg/plugins.d.ts +0 -27
- package/plugins/acryl-router/src/core/helper/ssg/ssg.d.ts +0 -91
- package/plugins/acryl-router/src/core/helper/ssg/utils.d.ts +0 -16
- package/plugins/acryl-router/src/core/helper/streaming/index.d.ts +0 -8
- package/plugins/acryl-router/src/core/helper/streaming/sse.d.ts +0 -13
- package/plugins/acryl-router/src/core/helper/streaming/stream.d.ts +0 -3
- package/plugins/acryl-router/src/core/helper/streaming/text.d.ts +0 -3
- package/plugins/acryl-router/src/core/helper/streaming/utils.d.ts +0 -1
- package/plugins/acryl-router/src/core/helper/testing/index.d.ts +0 -13
- package/plugins/acryl-router/src/core/helper/websocket/index.d.ts +0 -70
- package/plugins/acryl-router/src/core/http-exception.d.ts +0 -58
- package/plugins/acryl-router/src/core/index.d.ts +0 -37
- package/plugins/acryl-router/src/core/jsx/base.d.ts +0 -61
- package/plugins/acryl-router/src/core/jsx/children.d.ts +0 -9
- package/plugins/acryl-router/src/core/jsx/components.d.ts +0 -15
- package/plugins/acryl-router/src/core/jsx/constants.d.ts +0 -6
- package/plugins/acryl-router/src/core/jsx/context.d.ts +0 -51
- package/plugins/acryl-router/src/core/jsx/dom/components.d.ts +0 -10
- package/plugins/acryl-router/src/core/jsx/dom/context.d.ts +0 -3
- package/plugins/acryl-router/src/core/jsx/dom/css.d.ts +0 -83
- package/plugins/acryl-router/src/core/jsx/dom/hooks/index.d.ts +0 -39
- package/plugins/acryl-router/src/core/jsx/dom/intrinsic-element/components.d.ts +0 -16
- package/plugins/acryl-router/src/core/jsx/dom/jsx-dev-runtime.d.ts +0 -8
- package/plugins/acryl-router/src/core/jsx/dom/jsx-runtime.d.ts +0 -7
- package/plugins/acryl-router/src/core/jsx/dom/render.d.ts +0 -73
- package/plugins/acryl-router/src/core/jsx/dom/utils.d.ts +0 -1
- package/plugins/acryl-router/src/core/jsx/hooks/index.d.ts +0 -47
- package/plugins/acryl-router/src/core/jsx/index.d.ts +0 -76
- package/plugins/acryl-router/src/core/jsx/intrinsic-element/common.d.ts +0 -6
- package/plugins/acryl-router/src/core/jsx/intrinsic-element/components.d.ts +0 -13
- package/plugins/acryl-router/src/core/jsx/intrinsic-elements.d.ts +0 -735
- package/plugins/acryl-router/src/core/jsx/streaming.d.ts +0 -36
- package/plugins/acryl-router/src/core/jsx/types.d.ts +0 -36
- package/plugins/acryl-router/src/core/jsx/utils.d.ts +0 -4
- package/plugins/acryl-router/src/core/request/constants.d.ts +0 -1
- package/plugins/acryl-router/src/core/request.d.ts +0 -306
- package/plugins/acryl-router/src/core/router/reg-exp-router/index.d.ts +0 -6
- package/plugins/acryl-router/src/core/router/reg-exp-router/matcher.d.ts +0 -7
- package/plugins/acryl-router/src/core/router/reg-exp-router/node.d.ts +0 -13
- package/plugins/acryl-router/src/core/router/reg-exp-router/prepared-router.d.ts +0 -17
- package/plugins/acryl-router/src/core/router/reg-exp-router/router.d.ts +0 -11
- package/plugins/acryl-router/src/core/router/reg-exp-router/trie.d.ts +0 -8
- package/plugins/acryl-router/src/core/router/smart-router/index.d.ts +0 -5
- package/plugins/acryl-router/src/core/router/smart-router/router.d.ts +0 -11
- package/plugins/acryl-router/src/core/router/trie-router/index.d.ts +0 -5
- package/plugins/acryl-router/src/core/router/trie-router/node.d.ts +0 -6
- package/plugins/acryl-router/src/core/router/trie-router/router.d.ts +0 -7
- package/plugins/acryl-router/src/core/router/utils.d.ts +0 -1
- package/plugins/acryl-router/src/core/router.d.ts +0 -97
- package/plugins/acryl-router/src/core/types.d.ts +0 -573
- package/plugins/acryl-router/src/core/utils/accept.d.ts +0 -6
- package/plugins/acryl-router/src/core/utils/body.d.ts +0 -79
- package/plugins/acryl-router/src/core/utils/buffer.d.ts +0 -17
- package/plugins/acryl-router/src/core/utils/color.d.ts +0 -21
- package/plugins/acryl-router/src/core/utils/concurrent.d.ts +0 -11
- package/plugins/acryl-router/src/core/utils/constants.d.ts +0 -4
- package/plugins/acryl-router/src/core/utils/cookie.d.ts +0 -41
- package/plugins/acryl-router/src/core/utils/crypto.d.ts +0 -15
- package/plugins/acryl-router/src/core/utils/handler.d.ts +0 -6
- package/plugins/acryl-router/src/core/utils/headers.d.ts +0 -8
- package/plugins/acryl-router/src/core/utils/html.d.ts +0 -44
- package/plugins/acryl-router/src/core/utils/http-status.d.ts +0 -32
- package/plugins/acryl-router/src/core/utils/mime.d.ts +0 -70
- package/plugins/acryl-router/src/core/utils/stream.d.ts +0 -31
- package/plugins/acryl-router/src/core/utils/types.d.ts +0 -128
- package/plugins/acryl-router/src/core/utils/url.d.ts +0 -44
- package/plugins/acryl-router/src/core/validator/index.d.ts +0 -7
- package/plugins/acryl-router/src/core/validator/utils.d.ts +0 -29
- package/plugins/acryl-router/src/core/validator/validator.d.ts +0 -16
- package/plugins/acryl-router/src/helpers/index.d.ts +0 -17
- package/plugins/acryl-router/src/helpers/schema-validator.d.ts +0 -54
|
@@ -0,0 +1,379 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The desktop shell's side of the door: the app's windows and menu, for an agent that drives the app (`acryl window list|open|focus|close|resize`, `acryl menu list|click`).
|
|
3
|
+
*
|
|
4
|
+
* The door lives in the app's server, which is this process when `npm run desktop` started it and another one when the window attached to `npm run dev`; so the shell
|
|
5
|
+
* reaches the door the way a page does, over HTTP: it reads `.acryl/door.json` (the address and this run's secret), opens an event stream, answers each call by POST, and
|
|
6
|
+
* opens the stream again when the server restarts. Nothing here imports electron: `main.ts` passes in what Electron offers ({@link DesktopPort}), so the handler is tested
|
|
7
|
+
* without it. The door is development only; with no `.acryl/door.json` this keeps looking and does nothing else.
|
|
8
|
+
*/
|
|
9
|
+
import { readFileSync } from 'node:fs'
|
|
10
|
+
import { join } from 'node:path'
|
|
11
|
+
|
|
12
|
+
/** A window as Electron has it, the part this file uses. */
|
|
13
|
+
export interface WindowPort {
|
|
14
|
+
readonly id: number
|
|
15
|
+
getTitle(): string
|
|
16
|
+
getURL(): string
|
|
17
|
+
isFocused(): boolean
|
|
18
|
+
isMinimized(): boolean
|
|
19
|
+
getSize(): number[]
|
|
20
|
+
restore(): void
|
|
21
|
+
focus(): void
|
|
22
|
+
close(): void
|
|
23
|
+
setSize(width: number, height: number): void
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** A menu item as Electron has it. */
|
|
27
|
+
export interface MenuItemPort {
|
|
28
|
+
readonly label: string
|
|
29
|
+
readonly enabled: boolean
|
|
30
|
+
readonly visible: boolean
|
|
31
|
+
readonly type: string
|
|
32
|
+
/** Electron's role of the item (`quit`, `close`, ...). */
|
|
33
|
+
readonly role?: string | undefined
|
|
34
|
+
readonly submenu?: { readonly items: readonly MenuItemPort[] } | null | undefined
|
|
35
|
+
click(): void
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface DesktopPort {
|
|
39
|
+
/** Every window, in Electron's order. */
|
|
40
|
+
windows(): readonly WindowPort[]
|
|
41
|
+
/** A new window on a path of the app (the page is not awaited). @returns the window. */
|
|
42
|
+
open(path: string | undefined): WindowPort
|
|
43
|
+
/** The application menu's items, or none when the app has no menu. */
|
|
44
|
+
menu(): readonly MenuItemPort[]
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** The door's side of a call, as `plugins/acryl-door/src/shell/protocol.ts` defines it. */
|
|
48
|
+
export type ShellRequest =
|
|
49
|
+
| { readonly op: 'window-list' }
|
|
50
|
+
| { readonly op: 'window-open'; readonly path?: string }
|
|
51
|
+
| { readonly op: 'window-focus'; readonly id: number }
|
|
52
|
+
| { readonly op: 'window-close'; readonly id: number; readonly force?: boolean }
|
|
53
|
+
| {
|
|
54
|
+
readonly op: 'window-resize'
|
|
55
|
+
readonly id: number
|
|
56
|
+
readonly width: number
|
|
57
|
+
readonly height: number
|
|
58
|
+
}
|
|
59
|
+
| { readonly op: 'menu-list' }
|
|
60
|
+
| { readonly op: 'menu-click'; readonly path: string; readonly force?: boolean }
|
|
61
|
+
|
|
62
|
+
export type ShellResult =
|
|
63
|
+
| {
|
|
64
|
+
readonly windows: readonly {
|
|
65
|
+
id: number
|
|
66
|
+
title: string
|
|
67
|
+
path: string
|
|
68
|
+
focused: boolean
|
|
69
|
+
width: number
|
|
70
|
+
height: number
|
|
71
|
+
}[]
|
|
72
|
+
}
|
|
73
|
+
| { readonly items: readonly { path: string; label: string; enabled: boolean; ends?: boolean }[] }
|
|
74
|
+
| { readonly ok: true; readonly detail: string; readonly id?: number }
|
|
75
|
+
|
|
76
|
+
/** A refusal with a code the door passes on to the agent. */
|
|
77
|
+
export class ShellRefusal extends Error {
|
|
78
|
+
readonly code: string
|
|
79
|
+
|
|
80
|
+
constructor(code: string, message: string) {
|
|
81
|
+
super(message)
|
|
82
|
+
this.code = code
|
|
83
|
+
this.name = 'ShellRefusal'
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** The path and query a window shows, never its origin. */
|
|
88
|
+
function pathOf(url: string): string {
|
|
89
|
+
try {
|
|
90
|
+
const parsed = new URL(url)
|
|
91
|
+
return `${parsed.pathname}${parsed.search}`
|
|
92
|
+
} catch {
|
|
93
|
+
return ''
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** The menu as paths (`File>New Note`), submenus in order, separators and hidden items left out. */
|
|
98
|
+
export function menuPaths(
|
|
99
|
+
items: readonly MenuItemPort[],
|
|
100
|
+
prefix = '',
|
|
101
|
+
): { path: string; label: string; enabled: boolean; item: MenuItemPort }[] {
|
|
102
|
+
const found: { path: string; label: string; enabled: boolean; item: MenuItemPort }[] = []
|
|
103
|
+
for (const item of items) {
|
|
104
|
+
if (item.type === 'separator' || !item.visible || item.label === '') continue
|
|
105
|
+
const path = prefix === '' ? item.label : `${prefix}>${item.label}`
|
|
106
|
+
const children = item.submenu?.items ?? []
|
|
107
|
+
if (children.length > 0) found.push(...menuPaths(children, path))
|
|
108
|
+
else found.push({ path, label: item.label, enabled: item.enabled, item })
|
|
109
|
+
}
|
|
110
|
+
return found
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** Whether pressing this item ends the desktop app: Quit always, Close Window when it is the last window. */
|
|
114
|
+
function endsApp(item: MenuItemPort, windows: number): boolean {
|
|
115
|
+
return item.role === 'quit' || (item.role === 'close' && windows <= 1)
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** Answer one call of the door. @throws ShellRefusal for what the app cannot do (no such window, the last window, a disabled item). */
|
|
119
|
+
export function handleShellCall(request: ShellRequest, desktop: DesktopPort): ShellResult {
|
|
120
|
+
const windowById = (id: number): WindowPort => {
|
|
121
|
+
const found = desktop.windows().find((window) => window.id === id)
|
|
122
|
+
if (found === undefined) {
|
|
123
|
+
const ids = desktop.windows().map((window) => String(window.id))
|
|
124
|
+
throw new ShellRefusal(
|
|
125
|
+
'no-such-window',
|
|
126
|
+
`there is no window ${String(id)}${ids.length === 0 ? ' (no window is open)' : `; the windows are ${ids.join(', ')}`}`,
|
|
127
|
+
)
|
|
128
|
+
}
|
|
129
|
+
return found
|
|
130
|
+
}
|
|
131
|
+
switch (request.op) {
|
|
132
|
+
case 'window-list':
|
|
133
|
+
return {
|
|
134
|
+
windows: desktop.windows().map((window) => {
|
|
135
|
+
const [width = 0, height = 0] = window.getSize()
|
|
136
|
+
return {
|
|
137
|
+
id: window.id,
|
|
138
|
+
title: window.getTitle(),
|
|
139
|
+
path: pathOf(window.getURL()),
|
|
140
|
+
focused: window.isFocused(),
|
|
141
|
+
width,
|
|
142
|
+
height,
|
|
143
|
+
}
|
|
144
|
+
}),
|
|
145
|
+
}
|
|
146
|
+
case 'window-open': {
|
|
147
|
+
const window = desktop.open(request.path)
|
|
148
|
+
return {
|
|
149
|
+
ok: true,
|
|
150
|
+
detail: `opened window ${String(window.id)} on ${request.path ?? '/'}`,
|
|
151
|
+
id: window.id,
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
case 'window-focus': {
|
|
155
|
+
const window = windowById(request.id)
|
|
156
|
+
if (window.isMinimized()) window.restore()
|
|
157
|
+
window.focus()
|
|
158
|
+
return { ok: true, detail: `focused window ${String(window.id)}`, id: window.id }
|
|
159
|
+
}
|
|
160
|
+
case 'window-close': {
|
|
161
|
+
const window = windowById(request.id)
|
|
162
|
+
// The door refuses this first; the shell does not rely on it, because the last window ends the desktop app.
|
|
163
|
+
if (desktop.windows().length === 1 && request.force !== true) {
|
|
164
|
+
throw new ShellRefusal(
|
|
165
|
+
'last-window',
|
|
166
|
+
`window ${String(window.id)} is the app's last window: closing it ends the desktop app. Open another window first, or pass force to end it on purpose`,
|
|
167
|
+
)
|
|
168
|
+
}
|
|
169
|
+
window.close()
|
|
170
|
+
return { ok: true, detail: `closed window ${String(window.id)}`, id: window.id }
|
|
171
|
+
}
|
|
172
|
+
case 'window-resize': {
|
|
173
|
+
const window = windowById(request.id)
|
|
174
|
+
window.setSize(request.width, request.height)
|
|
175
|
+
return {
|
|
176
|
+
ok: true,
|
|
177
|
+
detail: `window ${String(window.id)} is now ${String(request.width)}x${String(request.height)}`,
|
|
178
|
+
id: window.id,
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
case 'menu-list':
|
|
182
|
+
return {
|
|
183
|
+
items: menuPaths(desktop.menu()).map(({ path, label, enabled, item }) => ({
|
|
184
|
+
path,
|
|
185
|
+
label,
|
|
186
|
+
enabled,
|
|
187
|
+
...(endsApp(item, desktop.windows().length) ? { ends: true } : {}),
|
|
188
|
+
})),
|
|
189
|
+
}
|
|
190
|
+
case 'menu-click': {
|
|
191
|
+
const wanted = request.path
|
|
192
|
+
.split('>')
|
|
193
|
+
.map((label) => label.trim())
|
|
194
|
+
.join('>')
|
|
195
|
+
const found = menuPaths(desktop.menu()).find((entry) => entry.path === wanted)
|
|
196
|
+
if (found === undefined)
|
|
197
|
+
throw new ShellRefusal(
|
|
198
|
+
'no-such-menu-item',
|
|
199
|
+
`the menu has no item ${JSON.stringify(wanted)}; menu list prints the paths`,
|
|
200
|
+
)
|
|
201
|
+
if (!found.enabled) throw new ShellRefusal('disabled', `${found.path} is disabled`)
|
|
202
|
+
// Quit, or Close Window on the last window, ends the desktop app: an agent must not end the session it works in by accident.
|
|
203
|
+
if (endsApp(found.item, desktop.windows().length) && request.force !== true) {
|
|
204
|
+
throw new ShellRefusal(
|
|
205
|
+
'quits-app',
|
|
206
|
+
`${found.path} ends the desktop app${found.item.role === 'close' ? ' (it is the last window)' : ''}. Pass force to do it on purpose`,
|
|
207
|
+
)
|
|
208
|
+
}
|
|
209
|
+
found.item.click()
|
|
210
|
+
return { ok: true, detail: `pressed ${found.path}` }
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
export interface DoorShellDeps {
|
|
216
|
+
readonly desktop: DesktopPort
|
|
217
|
+
readonly fetch?: typeof fetch
|
|
218
|
+
readonly read?: (file: string) => string
|
|
219
|
+
/** Milliseconds to wait before looking for the door again; grows to `maxDelayMs`. */
|
|
220
|
+
readonly retryMs?: number
|
|
221
|
+
readonly maxDelayMs?: number
|
|
222
|
+
readonly log?: (message: string) => void
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
interface DoorLocation {
|
|
226
|
+
readonly url: string
|
|
227
|
+
readonly secret: string
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
const LOOPBACK = new Set(['127.0.0.1', 'localhost', '[::1]'])
|
|
231
|
+
|
|
232
|
+
/** Where the door is now: its address and this run's secret, from `.acryl/door.json` (read again on every connection: a restarted server has a new secret). */
|
|
233
|
+
export function findDoor(
|
|
234
|
+
root: string,
|
|
235
|
+
read: (file: string) => string = (file) => readFileSync(file, 'utf8'),
|
|
236
|
+
): DoorLocation | undefined {
|
|
237
|
+
try {
|
|
238
|
+
const file = JSON.parse(read(join(root, '.acryl', 'door.json'))) as {
|
|
239
|
+
url?: unknown
|
|
240
|
+
secret?: unknown
|
|
241
|
+
}
|
|
242
|
+
if (typeof file.url !== 'string' || typeof file.secret !== 'string' || file.secret === '')
|
|
243
|
+
return undefined
|
|
244
|
+
const parsed = new URL(file.url)
|
|
245
|
+
if (parsed.protocol !== 'http:' || !LOOPBACK.has(parsed.hostname)) return undefined
|
|
246
|
+
return { url: parsed.origin, secret: file.secret }
|
|
247
|
+
} catch {
|
|
248
|
+
return undefined
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/** Server-sent events from a response body: `{ event, data }` per block. */
|
|
253
|
+
async function* events(
|
|
254
|
+
body: ReadableStream<Uint8Array>,
|
|
255
|
+
): AsyncGenerator<{ event: string; data: string }> {
|
|
256
|
+
const reader = body.getReader()
|
|
257
|
+
const decoder = new TextDecoder()
|
|
258
|
+
let buffer = ''
|
|
259
|
+
try {
|
|
260
|
+
for (;;) {
|
|
261
|
+
const { done, value } = await reader.read()
|
|
262
|
+
if (done) return
|
|
263
|
+
buffer += decoder.decode(value, { stream: true })
|
|
264
|
+
for (let end = buffer.indexOf('\n\n'); end !== -1; end = buffer.indexOf('\n\n')) {
|
|
265
|
+
const block = buffer.slice(0, end)
|
|
266
|
+
buffer = buffer.slice(end + 2)
|
|
267
|
+
let event = 'message'
|
|
268
|
+
const data: string[] = []
|
|
269
|
+
for (const line of block.split('\n')) {
|
|
270
|
+
if (line.startsWith('event:')) event = line.slice(6).trim()
|
|
271
|
+
else if (line.startsWith('data:'))
|
|
272
|
+
data.push(line.slice(line.startsWith('data: ') ? 6 : 5))
|
|
273
|
+
}
|
|
274
|
+
yield { event, data: data.join('\n') }
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
} finally {
|
|
278
|
+
await reader.cancel().catch(() => undefined)
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* Keep the shell connected to the door of the app in `root` until the returned function is called. The door is found again after each loss, so a dev server that restarts (a
|
|
284
|
+
* change of `src/`) is followed. Each call is answered with its result, or with the refusal's code and message.
|
|
285
|
+
*/
|
|
286
|
+
export function startDoorShell(root: string, deps: DoorShellDeps): () => void {
|
|
287
|
+
const request = deps.fetch ?? fetch
|
|
288
|
+
const retry = deps.retryMs ?? 500
|
|
289
|
+
const maxDelay = deps.maxDelayMs ?? 5000
|
|
290
|
+
const controller = new AbortController()
|
|
291
|
+
let wait: NodeJS.Timeout | undefined
|
|
292
|
+
const sleep = (ms: number): Promise<void> =>
|
|
293
|
+
new Promise((resolve) => {
|
|
294
|
+
wait = setTimeout(resolve, ms)
|
|
295
|
+
controller.signal.addEventListener(
|
|
296
|
+
'abort',
|
|
297
|
+
() => {
|
|
298
|
+
clearTimeout(wait)
|
|
299
|
+
resolve()
|
|
300
|
+
},
|
|
301
|
+
{ once: true },
|
|
302
|
+
)
|
|
303
|
+
})
|
|
304
|
+
|
|
305
|
+
const answer = async (door: DoorLocation, body: unknown): Promise<void> => {
|
|
306
|
+
await request(`${door.url}/api/acryl-door/shell/answer`, {
|
|
307
|
+
method: 'POST',
|
|
308
|
+
headers: { authorization: `Bearer ${door.secret}`, 'content-type': 'application/json' },
|
|
309
|
+
body: JSON.stringify(body),
|
|
310
|
+
signal: controller.signal,
|
|
311
|
+
}).catch(() => undefined)
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
const serve = async (door: DoorLocation): Promise<void> => {
|
|
315
|
+
const response = await request(`${door.url}/api/acryl-door/shell/events`, {
|
|
316
|
+
headers: { authorization: `Bearer ${door.secret}`, accept: 'text/event-stream' },
|
|
317
|
+
signal: controller.signal,
|
|
318
|
+
})
|
|
319
|
+
if (!response.ok || response.body === null)
|
|
320
|
+
throw new Error(`the door answered ${String(response.status)}`)
|
|
321
|
+
for await (const { event, data } of events(response.body)) {
|
|
322
|
+
if (event !== 'call') continue
|
|
323
|
+
let call: { id?: unknown; request?: ShellRequest }
|
|
324
|
+
try {
|
|
325
|
+
call = JSON.parse(data) as { id?: unknown; request?: ShellRequest }
|
|
326
|
+
} catch {
|
|
327
|
+
continue
|
|
328
|
+
}
|
|
329
|
+
if (typeof call.id !== 'number' || call.request === undefined) continue
|
|
330
|
+
try {
|
|
331
|
+
await answer(door, {
|
|
332
|
+
t: 'result',
|
|
333
|
+
id: call.id,
|
|
334
|
+
value: handleShellCall(call.request, deps.desktop),
|
|
335
|
+
})
|
|
336
|
+
} catch (cause) {
|
|
337
|
+
const refusal =
|
|
338
|
+
cause instanceof ShellRefusal
|
|
339
|
+
? cause
|
|
340
|
+
: new ShellRefusal(
|
|
341
|
+
'failed',
|
|
342
|
+
cause instanceof Error ? cause.message : 'the shell could not do that',
|
|
343
|
+
)
|
|
344
|
+
await answer(door, {
|
|
345
|
+
t: 'error',
|
|
346
|
+
id: call.id,
|
|
347
|
+
code: refusal.code,
|
|
348
|
+
message: refusal.message,
|
|
349
|
+
})
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
void (async () => {
|
|
355
|
+
let delay = retry
|
|
356
|
+
let announced = false
|
|
357
|
+
while (!controller.signal.aborted) {
|
|
358
|
+
const door = findDoor(root, deps.read)
|
|
359
|
+
if (door !== undefined) {
|
|
360
|
+
try {
|
|
361
|
+
if (!announced)
|
|
362
|
+
deps.log?.(
|
|
363
|
+
'desktop: windows and menu are available to the door (acryl window list, acryl menu list)',
|
|
364
|
+
)
|
|
365
|
+
announced = true
|
|
366
|
+
await serve(door)
|
|
367
|
+
delay = retry
|
|
368
|
+
} catch {
|
|
369
|
+
// The server restarted or is not up yet: look again.
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
await sleep(delay)
|
|
373
|
+
delay = Math.min(delay * 2, maxDelay)
|
|
374
|
+
}
|
|
375
|
+
})()
|
|
376
|
+
return () => {
|
|
377
|
+
controller.abort()
|
|
378
|
+
}
|
|
379
|
+
}
|
package/plugins/desktop/main.ts
CHANGED
|
@@ -9,11 +9,17 @@
|
|
|
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, shell } from 'electron'
|
|
12
|
+
import { app, BrowserWindow, Menu, type MenuItem, 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'
|
|
16
16
|
import { runningApp } from './attach.ts'
|
|
17
|
+
import {
|
|
18
|
+
type DesktopPort,
|
|
19
|
+
type MenuItemPort,
|
|
20
|
+
startDoorShell,
|
|
21
|
+
type WindowPort,
|
|
22
|
+
} from './door-shell.ts'
|
|
17
23
|
import { createDesktopShutdown, installShutdownRequests } from './shutdown.ts'
|
|
18
24
|
import { resolveDesktopUserDataOverride } from './user-data.ts'
|
|
19
25
|
import { windowOptions } from './window.ts'
|
|
@@ -41,9 +47,11 @@ if (!app.requestSingleInstanceLock()) {
|
|
|
41
47
|
} else {
|
|
42
48
|
let running: App | undefined
|
|
43
49
|
let window: BrowserWindow | undefined
|
|
50
|
+
let stopDoorShell: (() => void) | undefined
|
|
44
51
|
|
|
45
52
|
const shutdown = createDesktopShutdown(
|
|
46
53
|
async () => {
|
|
54
|
+
stopDoorShell?.()
|
|
47
55
|
await running?.stop()
|
|
48
56
|
},
|
|
49
57
|
(code) => {
|
|
@@ -81,18 +89,64 @@ if (!app.requestSingleInstanceLock()) {
|
|
|
81
89
|
url = running.url
|
|
82
90
|
}
|
|
83
91
|
const origin = new URL(url).origin
|
|
84
|
-
window
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
92
|
+
/** 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
|
+
const createWindow = (path: string): BrowserWindow => {
|
|
94
|
+
const created = new BrowserWindow(windowOptions({ title: chosen.name }))
|
|
95
|
+
created.webContents.setUserAgent(
|
|
96
|
+
`${created.webContents.getUserAgent()} AcrylWindow/${String(created.id)}`,
|
|
97
|
+
)
|
|
98
|
+
created.once('ready-to-show', () => created.show())
|
|
99
|
+
created.webContents.setWindowOpenHandler(({ url }) => {
|
|
100
|
+
void shell.openExternal(url)
|
|
101
|
+
return { action: 'deny' }
|
|
102
|
+
})
|
|
103
|
+
created.webContents.on('will-navigate', (event, url) => {
|
|
104
|
+
if (new URL(url).origin === origin) return
|
|
105
|
+
event.preventDefault()
|
|
106
|
+
void shell.openExternal(url)
|
|
107
|
+
})
|
|
108
|
+
created.loadURL(new URL(path, url).href).catch((cause: unknown) => {
|
|
109
|
+
console.warn(
|
|
110
|
+
`desktop: the window could not load ${path}: ${cause instanceof Error ? cause.message : String(cause)}`,
|
|
111
|
+
)
|
|
112
|
+
})
|
|
113
|
+
return created
|
|
114
|
+
}
|
|
115
|
+
window = createWindow(startPath())
|
|
116
|
+
// The door (development) reaches the windows and the menu through this port: `acryl window list`, `acryl menu click`.
|
|
117
|
+
const live = (): BrowserWindow[] =>
|
|
118
|
+
BrowserWindow.getAllWindows().filter((candidate) => !candidate.isDestroyed())
|
|
119
|
+
const port = (candidate: BrowserWindow): WindowPort => ({
|
|
120
|
+
id: candidate.id,
|
|
121
|
+
getTitle: () => candidate.getTitle(),
|
|
122
|
+
getURL: () => candidate.webContents.getURL(),
|
|
123
|
+
isFocused: () => candidate.isFocused(),
|
|
124
|
+
isMinimized: () => candidate.isMinimized(),
|
|
125
|
+
getSize: () => candidate.getSize(),
|
|
126
|
+
restore: () => candidate.restore(),
|
|
127
|
+
focus: () => candidate.focus(),
|
|
128
|
+
close: () => candidate.close(),
|
|
129
|
+
setSize: (width, height) => candidate.setSize(width, height),
|
|
90
130
|
})
|
|
91
|
-
window
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
131
|
+
// Pressing an item as Electron's own menu does: with the window that has focus (the roles reload, zoom and close act on it).
|
|
132
|
+
const menuItem = (item: MenuItem): MenuItemPort => ({
|
|
133
|
+
label: item.label,
|
|
134
|
+
enabled: item.enabled,
|
|
135
|
+
visible: item.visible,
|
|
136
|
+
type: item.type,
|
|
137
|
+
role: item.role,
|
|
138
|
+
// Electron gives a plain item `submenu: null` on Linux and `undefined` elsewhere.
|
|
139
|
+
submenu: item.submenu ? { items: item.submenu.items.map(menuItem) } : null,
|
|
140
|
+
click: () => {
|
|
141
|
+
const target = BrowserWindow.getFocusedWindow() ?? live()[0]
|
|
142
|
+
item.click(undefined, target, target?.webContents)
|
|
143
|
+
},
|
|
95
144
|
})
|
|
96
|
-
|
|
145
|
+
const desktop: DesktopPort = {
|
|
146
|
+
windows: () => live().map(port),
|
|
147
|
+
open: (path) => port(createWindow(path ?? startPath())),
|
|
148
|
+
menu: () => (Menu.getApplicationMenu()?.items ?? []).map(menuItem),
|
|
149
|
+
}
|
|
150
|
+
stopDoorShell = startDoorShell(projectRoot, { desktop, log: console.log })
|
|
97
151
|
})
|
|
98
152
|
}
|
|
@@ -7,4 +7,6 @@ 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. When a person asks for settings, build them as a page at `/settings`, so `acryl window open --path /settings` opens them in every app. Without a desktop app these answer "no desktop window is attached; run npm run desktop".
|
|
11
|
+
|
|
10
12
|
**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 those three rows out, so `/ask`, `/extensions*` and `/tasks*` are not mounted; the page and `GET /health` stay. `ACRYL_PRODUCTION_KEEP=agent,builder,extensions` keeps the named rows, and each one kept prints a `WARNING` line at start.
|
|
@@ -1,25 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Transport facts: who is on the other end of the socket and which local port it reached. The caller of `router.fetch` supplies them
|
|
3
|
-
* (an HTTP adapter passes the socket's peer address and real port); they travel in `c.env.connection`, beside the request and never in a header.
|
|
4
|
-
*/
|
|
5
|
-
import type { Context } from './core/context.ts';
|
|
6
|
-
import type { GetConnInfo } from './core/helper/conninfo/types.ts';
|
|
7
|
-
/** An in-process caller (the terminal UI, a test) passes nothing, so a handler that needs a trusted peer must refuse an empty one. */
|
|
8
|
-
export interface ConnectionInfo {
|
|
9
|
-
/** The peer address of the socket, when the request came over one (for example `127.0.0.1`). */
|
|
10
|
-
readonly remoteAddress?: string | undefined;
|
|
11
|
-
/** The local port the socket is connected to, the real one a `Host` header should be checked against. */
|
|
12
|
-
readonly localPort?: number | undefined;
|
|
13
|
-
}
|
|
14
|
-
/** What the router puts in `c.env`. */
|
|
15
|
-
export interface RouterBindings {
|
|
16
|
-
readonly connection: ConnectionInfo;
|
|
17
|
-
}
|
|
18
|
-
/** The `Env` of an `Acryl` that wants `c.env.connection` typed: `new Acryl<RouterEnv>()`. */
|
|
19
|
-
export interface RouterEnv {
|
|
20
|
-
Bindings: RouterBindings;
|
|
21
|
-
}
|
|
22
|
-
/** The transport facts of this request; empty for an in-process call or an `Acryl` run outside the router. */
|
|
23
|
-
export declare function connectionOf(c: Context): ConnectionInfo;
|
|
24
|
-
/** The peer in the core's conninfo shape, for middleware written against it (for example `ipRestriction`). */
|
|
25
|
-
export declare const getConnInfo: GetConnInfo;
|