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
|
@@ -1,23 +1,69 @@
|
|
|
1
1
|
/** The one window of a desktop app: the operating system's frame around a page the app's own server serves, with the renderer locked down. */
|
|
2
|
-
import type { BrowserWindowConstructorOptions } from 'electron'
|
|
3
2
|
|
|
4
3
|
export interface WindowSpec {
|
|
5
4
|
readonly title: string
|
|
6
5
|
readonly width?: number
|
|
7
6
|
readonly height?: number
|
|
7
|
+
/** Where the window's top left corner goes; Electron centers the window when these are left out. */
|
|
8
|
+
readonly x?: number
|
|
9
|
+
readonly y?: number
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface Rect {
|
|
13
|
+
readonly x: number
|
|
14
|
+
readonly y: number
|
|
15
|
+
readonly width: number
|
|
16
|
+
readonly height: number
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** How far each new window sits from the one it was opened from. */
|
|
20
|
+
export const CASCADE_STEP = 32
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Where a new window goes so that it does not cover another one exactly: down and to the right of `from` by one step, and back at the top left of the work area when that would put
|
|
24
|
+
* it past the edge of the screen (so a row of windows cascades, then starts again).
|
|
25
|
+
*/
|
|
26
|
+
export function cascade(
|
|
27
|
+
from: Rect,
|
|
28
|
+
work: Rect,
|
|
29
|
+
size: { width: number; height: number },
|
|
30
|
+
): { x: number; y: number } {
|
|
31
|
+
const x = from.x + CASCADE_STEP
|
|
32
|
+
const y = from.y + CASCADE_STEP
|
|
33
|
+
const fitsRight = x + size.width <= work.x + work.width
|
|
34
|
+
const fitsBottom = y + size.height <= work.y + work.height
|
|
35
|
+
return { x: fitsRight ? x : work.x, y: fitsBottom ? y : work.y }
|
|
8
36
|
}
|
|
9
37
|
|
|
10
38
|
/**
|
|
11
39
|
* Secure BrowserWindow options. The settings are ACRYL Desktop's: context isolation on, no Node in the page, the sandbox on, web security on; the window stays
|
|
12
40
|
* hidden until the page has painted (`show: false`, shown on `ready-to-show`).
|
|
13
41
|
*/
|
|
14
|
-
export
|
|
42
|
+
export interface WindowOptions {
|
|
43
|
+
readonly title: string
|
|
44
|
+
readonly width: number
|
|
45
|
+
readonly height: number
|
|
46
|
+
readonly minWidth: number
|
|
47
|
+
readonly minHeight: number
|
|
48
|
+
readonly x?: number
|
|
49
|
+
readonly y?: number
|
|
50
|
+
readonly show: boolean
|
|
51
|
+
readonly webPreferences: {
|
|
52
|
+
readonly contextIsolation: boolean
|
|
53
|
+
readonly nodeIntegration: boolean
|
|
54
|
+
readonly sandbox: boolean
|
|
55
|
+
readonly webSecurity: boolean
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function windowOptions(spec: WindowSpec): WindowOptions {
|
|
15
60
|
return {
|
|
16
61
|
title: spec.title,
|
|
17
62
|
width: spec.width ?? 1100,
|
|
18
63
|
height: spec.height ?? 760,
|
|
19
64
|
minWidth: 480,
|
|
20
65
|
minHeight: 360,
|
|
66
|
+
...(spec.x === undefined || spec.y === undefined ? {} : { x: spec.x, y: spec.y }),
|
|
21
67
|
show: false,
|
|
22
68
|
webPreferences: {
|
|
23
69
|
contextIsolation: true,
|
|
@@ -7,6 +7,8 @@ and an optional `check(ctx)` that receives the plugin's own context (so `ctx.rou
|
|
|
7
7
|
|
|
8
8
|
**Write an extension, not a row.** A feature you build while the app runs is a folder `plugins/<name>/` installed with `.acryl/bin/acryl plugin add plugins/<name>` (the `extensions` plugin loads it again at every start). Add a row to `blend.yaml` and an entry to the `registry` of `src/app.ts` only when the person asks for a plugin that is part of the app itself. Both kinds may import the framework by name (`acrylblends`, `acrylblends/body-limit`, `acryl-router`); a row's relative imports of its sibling plugins also work.
|
|
9
9
|
|
|
10
|
-
**Drive the app you build.** `.acryl/bin/acryl look` reads the open page as text with refs, and `acryl act click|type|press|scroll|wait --ref <ref>` presses its buttons, opens its sidebars and fills its forms (look again before each act: the refs change). `act navigate --path /settings` goes to another page of the app; a path the app does not serve answers `404 at /nope` and the window shows a 404 page, so navigate on from there. In the desktop app (`npm run desktop`) the app's windows and menu are verbs too: `acryl window list`, `acryl window open --path /settings`, `acryl look --window <id>` (and `act ... --window <id>`) to drive that window's page, `acryl window resize|focus|close --id <id>`, `acryl menu list` and `acryl menu click --path "File>New Note"`. Closing the last window and Quit end the desktop app, so they need `--force`: do not use it unless the person asks.
|
|
10
|
+
**Drive the app you build.** `.acryl/bin/acryl look` reads the open page as text with refs, and `acryl act click|type|press|scroll|wait --ref <ref>` presses its buttons, opens its sidebars and fills its forms (look again before each act: the refs change). `act navigate --path /settings` goes to another page of the app; a path the app does not serve answers `404 at /nope` and the window shows a 404 page, so navigate on from there. In the desktop app (`npm run desktop`) the app's windows and menu are verbs too: `acryl window list`, `acryl window open --path /settings`, `acryl look --window <id>` (and `act ... --window <id>`) to drive that window's page, `acryl window resize|focus|close --id <id>`, `acryl menu list` and `acryl menu click --path "File>New Note"`. Closing the last window and Quit end the desktop app, so they need `--force`: do not use it unless the person asks. The app has a `/settings` page already (the appearance: system, light or dark, kept in the browser) and the desktop app's menu has a Settings item that opens it; add a person's other settings to that page, not to a new one, so `acryl menu click --path "File>Settings..."` and `acryl window open --path /settings` keep opening them (on macOS the item is in the application menu). Without a desktop app these answer "no desktop window is attached; run npm run desktop".
|
|
11
|
+
|
|
12
|
+
**Keep to two event streams a page.** A browser lets a page hold six connections to one host, across all the windows of the app, and an `EventSource` holds one for as long as the page is open. The door and the panel use none (they are WebSockets), but a page you build that streams several things in two windows can fill all six, and then every request waits. Give a page at most two streams: share one stream for several cells, or fetch what changes now and then instead.
|
|
11
13
|
|
|
12
14
|
**Production.** The agent, the builder and the extensions are development tools: `POST /ask` spends the model key and runs tools, a plugin installed live runs in this process, and none of it is behind a login. With `NODE_ENV=production` `src/app.ts` leaves the agent and the builder out, so `/ask`, `/extensions*` and `/tasks*` are not mounted; the page and `GET /health` stay. The extensions you installed (the folders of `plugins/`, committed with the app) still run in production, read-only: nothing can be installed or changed there. `ACRYL_PRODUCTION_KEEP=agent,builder,extensions` keeps the named rows, and each one kept prints a `WARNING` line at start.
|
|
@@ -19,6 +19,15 @@ const DOOR_TAG =
|
|
|
19
19
|
* What changes is also streamed: `GET /api/state/tasks/events` and `GET /api/state/extensions/events`. In production `src/app.ts` leaves the agent and builder rows out
|
|
20
20
|
* and starts the extensions read-only (unless `ACRYL_PRODUCTION_KEEP` names them), and then those routes are not mounted at all. The mounts are removed when the plugin is disposed.
|
|
21
21
|
*/
|
|
22
|
+
/** The page's look: the person's choice from /settings (kept in this browser), applied before the page paints. */
|
|
23
|
+
const APPEARANCE_STYLE =
|
|
24
|
+
'<style>:root{color-scheme:light dark}html[data-theme=dark]{color-scheme:dark;background:#111827;color:#f9fafb}html[data-theme=light]{color-scheme:light;background:#fff;color:#111827}body{font:16px system-ui,sans-serif;margin:2rem auto;max-width:42rem;padding:0 1rem}a{color:inherit}</style>'
|
|
25
|
+
const APPEARANCE_SCRIPT =
|
|
26
|
+
"<script>(function(){try{var t=localStorage.getItem('theme');if(t==='dark'||t==='light')document.documentElement.dataset.theme=t}catch(e){}})()</script>"
|
|
27
|
+
|
|
28
|
+
/** `/settings`: the one setting the template has, the appearance. The desktop app's Settings menu item opens this page. */
|
|
29
|
+
const settingsPage = `<!doctype html><html lang="en"><head><meta charset="utf-8"><title>Settings - ${APP_NAME}</title>${APPEARANCE_STYLE}${APPEARANCE_SCRIPT}</head><body><h1>Settings</h1><p><label for="theme">Appearance</label> <select id="theme" name="theme"><option value="system">Match the system</option><option value="light">Light</option><option value="dark">Dark</option></select></p><p id="saved" role="status"></p><p><a href="/">Back to ${APP_NAME}</a></p><script>(function(){var select=document.getElementById('theme'),saved=document.getElementById('saved');try{select.value=localStorage.getItem('theme')||'system'}catch(e){}select.addEventListener('change',function(){var value=select.value;try{if(value==='system'){localStorage.removeItem('theme');delete document.documentElement.dataset.theme}else{localStorage.setItem('theme',value);document.documentElement.dataset.theme=value}saved.textContent='Saved: '+select.options[select.selectedIndex].text}catch(e){saved.textContent='This browser would not keep the setting.'}})})()</script></body></html>`
|
|
30
|
+
|
|
22
31
|
export const routes = {
|
|
23
32
|
name: 'routes',
|
|
24
33
|
inject: ['router'],
|
|
@@ -37,11 +46,12 @@ export const routes = {
|
|
|
37
46
|
.get('/', (c) =>
|
|
38
47
|
(c.req.header('accept') ?? '').includes('text/html')
|
|
39
48
|
? c.html(
|
|
40
|
-
`<!doctype html><html lang="en"><head><meta charset="utf-8"><title>${APP_NAME}</title
|
|
49
|
+
`<!doctype html><html lang="en"><head><meta charset="utf-8"><title>${APP_NAME}</title>${APPEARANCE_STYLE}${APPEARANCE_SCRIPT}</head><body><h1>${APP_NAME}</h1><p>This app runs an agent unless it is switched off (production switches it off, see ACRYL_PRODUCTION_KEEP in AGENTS.md). Ask it for a feature in the agent panel on this page (or with <code>POST /ask</code>).</p>${DOOR_TAG}</body></html>`,
|
|
41
50
|
)
|
|
42
51
|
: c.json({ app: APP_NAME, ok: true }),
|
|
43
52
|
)
|
|
44
53
|
.get('/health', (c) => c.json({ ok: true }))
|
|
54
|
+
.get('/settings', (c) => c.html(settingsPage))
|
|
45
55
|
ctx.router.route('/', base)
|
|
46
56
|
// The routes that give a visitor the agent: mounted only while the agent, the extensions and the tasks run (see the header).
|
|
47
57
|
ctx.inject(['agent', 'extensions', 'tasks'], (inner) => {
|
|
@@ -144,3 +144,23 @@ test('outside production nothing changes: the agent answers and the extensions r
|
|
|
144
144
|
assert.equal((await app.root.router.request('/extensions')).status, 200)
|
|
145
145
|
assert.deepEqual(warnings, [])
|
|
146
146
|
}))
|
|
147
|
+
|
|
148
|
+
test('the app has a /settings page with the appearance setting, and the home page applies it', () =>
|
|
149
|
+
inEnvironment({ NODE_ENV: 'test', ACRYL_PRODUCTION_KEEP: undefined }, async (app) => {
|
|
150
|
+
const settings = await app.root.router.request('/settings', {
|
|
151
|
+
headers: { accept: 'text/html' },
|
|
152
|
+
})
|
|
153
|
+
assert.equal(settings.status, 200)
|
|
154
|
+
const page = await settings.text()
|
|
155
|
+
assert.match(page, /<h1>Settings<\/h1>/)
|
|
156
|
+
assert.match(page, /<select id="theme"/)
|
|
157
|
+
for (const option of ['System', 'Light', 'Dark'])
|
|
158
|
+
assert.match(page, new RegExp(`>(?:Match the )?${option.toLowerCase()}|>${option}<`, 'i'))
|
|
159
|
+
// The setting is kept in the browser and applied to the home page before it paints.
|
|
160
|
+
const home = await (
|
|
161
|
+
await app.root.router.request('/', { headers: { accept: 'text/html' } })
|
|
162
|
+
).text()
|
|
163
|
+
assert.match(home, /localStorage\.getItem\('theme'\)/)
|
|
164
|
+
// ... without leaving a global behind for the page's own code to collide with.
|
|
165
|
+
assert.doesNotMatch(home, /<script>\s*(?:var|let|const) /)
|
|
166
|
+
}))
|