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
|
@@ -4,7 +4,7 @@ import { t as methodNotAllowed } from "./method-not-allowed-TO2_hnfl.js";
|
|
|
4
4
|
import { r as streamSSE } from "./streaming-BlDaph8U.js";
|
|
5
5
|
import { i as loopbackOnly, r as loopbackAuthority } from "./loopback-only-BbasoF7R.js";
|
|
6
6
|
import "./helpers-BEjcdbgw.js";
|
|
7
|
-
import { d as parseChannelToHost, l as UiControlError, u as WINDOW_ID } from "./protocol-
|
|
7
|
+
import { d as parseChannelToHost, l as UiControlError, u as WINDOW_ID } from "./protocol-CQ_if7Dg.js";
|
|
8
8
|
import "./src-BTBm4OPD.js";
|
|
9
9
|
import "./middleware-BfG_fp8P.js";
|
|
10
10
|
import { dirname, join } from "node:path";
|
|
@@ -18,9 +18,27 @@ import { timingSafeEqual } from "node:crypto";
|
|
|
18
18
|
* The Host cannot reach into a page, so each page opens one event stream to the Host and answers calls by POST. With several pages open,
|
|
19
19
|
* the one the user focused last is the one the agent drives. A page that reconnects with the same window id replaces its old stream.
|
|
20
20
|
*/
|
|
21
|
+
/** A call that went to a page that never acknowledged it, and was taken back to be sent to the next page. */
|
|
22
|
+
var Silent = class extends UiControlError {
|
|
23
|
+
constructor() {
|
|
24
|
+
super("timeout", "the page did not acknowledge the call");
|
|
25
|
+
}
|
|
26
|
+
};
|
|
21
27
|
/** How long a call may take before it is given up (a wait may take up to 10 seconds itself). */
|
|
22
28
|
const DEFAULT_CALL_TIMEOUT_MS = 3e4;
|
|
29
|
+
/** How long a page has to say it heard a read-only call before the call goes to the next page. */
|
|
30
|
+
const DEFAULT_ACK_MS = 3e3;
|
|
31
|
+
/**
|
|
32
|
+
* The pages and who drives which. One page answers a call, chosen the same way every time: the page the Host just sent somewhere (it comes back as a new page, `prefer`), else the
|
|
33
|
+
* one in front (it reports `focused`), else the most recently active; a page that did not acknowledge a call in time goes to the end of the line. An explicit desktop `window` picks its page.
|
|
34
|
+
*/
|
|
23
35
|
var UiChannel = class {
|
|
36
|
+
ackMs;
|
|
37
|
+
/** The page the Host sent to a new place and that came back: it keeps being driven until the person focuses another page. */
|
|
38
|
+
preferred;
|
|
39
|
+
constructor(options = {}) {
|
|
40
|
+
this.ackMs = options.ackMs ?? DEFAULT_ACK_MS;
|
|
41
|
+
}
|
|
24
42
|
connections = /* @__PURE__ */ new Map();
|
|
25
43
|
pending = /* @__PURE__ */ new Map();
|
|
26
44
|
nextId = 1;
|
|
@@ -40,10 +58,11 @@ var UiChannel = class {
|
|
|
40
58
|
windowId,
|
|
41
59
|
shellWindow,
|
|
42
60
|
focused,
|
|
43
|
-
lastActive: ++this.clock
|
|
61
|
+
lastActive: ++this.clock,
|
|
62
|
+
suspect: previous?.suspect === true
|
|
44
63
|
};
|
|
45
64
|
this.connections.set(windowId, connection);
|
|
46
|
-
for (const waiter of [...this.attachWaiters]) waiter();
|
|
65
|
+
for (const waiter of [...this.attachWaiters]) waiter(windowId);
|
|
47
66
|
return () => {
|
|
48
67
|
if (this.connections.get(windowId) === connection) this.drop(connection, false);
|
|
49
68
|
};
|
|
@@ -52,13 +71,22 @@ var UiChannel = class {
|
|
|
52
71
|
receive(windowId, message) {
|
|
53
72
|
const connection = this.connections.get(windowId);
|
|
54
73
|
if (connection === void 0) return;
|
|
74
|
+
connection.suspect = false;
|
|
55
75
|
if (message.t === "focus") {
|
|
56
76
|
connection.focused = message.focused;
|
|
57
|
-
if (message.focused)
|
|
77
|
+
if (message.focused) {
|
|
78
|
+
connection.lastActive = ++this.clock;
|
|
79
|
+
if (this.preferred !== windowId) this.preferred = void 0;
|
|
80
|
+
}
|
|
58
81
|
return;
|
|
59
82
|
}
|
|
60
83
|
const entry = this.pending.get(message.id);
|
|
61
84
|
if (entry === void 0 || entry.connection !== connection) return;
|
|
85
|
+
if (message.t === "ack") {
|
|
86
|
+
entry.acked = true;
|
|
87
|
+
if (entry.ackTimer !== void 0) clearTimeout(entry.ackTimer);
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
62
90
|
if (message.t === "result") this.settle(message.id, message.value);
|
|
63
91
|
else this.settle(message.id, void 0, new UiControlError(message.code, message.message));
|
|
64
92
|
}
|
|
@@ -68,27 +96,44 @@ var UiChannel = class {
|
|
|
68
96
|
}
|
|
69
97
|
/** The windows currently connected, most recently active first (for diagnostics and tests). */
|
|
70
98
|
windows() {
|
|
71
|
-
return this.
|
|
99
|
+
return this.ordered().map(({ windowId, focused, shellWindow }) => ({
|
|
72
100
|
windowId,
|
|
73
101
|
focused,
|
|
74
102
|
...shellWindow === void 0 ? {} : { shellWindow }
|
|
75
103
|
}));
|
|
76
104
|
}
|
|
105
|
+
/** Drive this page from now on (it is the one the Host just sent to a new place), until the person focuses another. */
|
|
106
|
+
prefer(windowId) {
|
|
107
|
+
const connection = this.connections.get(windowId);
|
|
108
|
+
if (connection === void 0) return;
|
|
109
|
+
this.preferred = windowId;
|
|
110
|
+
connection.lastActive = ++this.clock;
|
|
111
|
+
}
|
|
77
112
|
/**
|
|
78
|
-
* Ask the current page to do something.
|
|
113
|
+
* Ask the current page to do something. A read-only call (`look`, `wait`) that the page does not acknowledge in time goes to the next page, and the silent one is last in line
|
|
114
|
+
* until it speaks; a call that changes the page is never sent to a second page, because the first might still run it.
|
|
79
115
|
* @throws UiControlError `no-window`, `timeout`, `aborted`, `unloaded`, `disconnected`, or whatever the page refused with.
|
|
80
116
|
*/
|
|
81
|
-
call(request, options = {}) {
|
|
82
|
-
if (this.closed)
|
|
83
|
-
const ordered = this.
|
|
84
|
-
const
|
|
85
|
-
if (
|
|
117
|
+
async call(request, options = {}) {
|
|
118
|
+
if (this.closed) throw new UiControlError("unloaded", "the door was unloaded");
|
|
119
|
+
const ordered = this.ordered();
|
|
120
|
+
const candidates = options.window === void 0 ? ordered : ordered.filter((connection) => connection.shellWindow === options.window);
|
|
121
|
+
if (candidates.length === 0 && options.window !== void 0) {
|
|
86
122
|
const known = ordered.flatMap((connection) => connection.shellWindow === void 0 ? [] : [String(connection.shellWindow)]);
|
|
87
|
-
|
|
123
|
+
throw new UiControlError("no-window", `no page of window ${String(options.window)} is connected to the door${known.length === 0 ? " (no desktop window has a page connected)" : `; windows with a page: ${known.join(", ")}`}; window list prints the windows`);
|
|
88
124
|
}
|
|
89
|
-
if (
|
|
125
|
+
if (candidates.length === 0) throw new UiControlError("no-window", "no page of the app is open to control");
|
|
126
|
+
if (options.signal?.aborted === true) throw new UiControlError("aborted", "the call was cancelled");
|
|
127
|
+
const readOnly = request.op === "snapshot" || request.op === "wait";
|
|
128
|
+
for (const target of candidates) try {
|
|
129
|
+
return await this.callOne(target, request, options, readOnly);
|
|
130
|
+
} catch (cause) {
|
|
131
|
+
if (!(cause instanceof Silent)) throw cause;
|
|
132
|
+
}
|
|
133
|
+
throw new UiControlError("timeout", `no page of the app answered within ${String(Math.round(this.ackMs / 1e3))} s: the app's pages are open but none responded (a tab in the background, or a page that is still loading). Bring the app's tab to the front, or navigate it, then look again`);
|
|
134
|
+
}
|
|
135
|
+
callOne(target, request, options, failOver) {
|
|
90
136
|
const signal = options.signal;
|
|
91
|
-
if (signal?.aborted === true) return Promise.reject(new UiControlError("aborted", "the call was cancelled"));
|
|
92
137
|
const id = this.nextId;
|
|
93
138
|
this.nextId += 1;
|
|
94
139
|
return new Promise((resolve, reject) => {
|
|
@@ -99,7 +144,15 @@ var UiChannel = class {
|
|
|
99
144
|
this.settle(id, void 0, new UiControlError("aborted", "the call was cancelled"));
|
|
100
145
|
};
|
|
101
146
|
signal?.addEventListener("abort", onAbort, { once: true });
|
|
147
|
+
const ackTimer = failOver ? setTimeout(() => {
|
|
148
|
+
const entry = this.pending.get(id);
|
|
149
|
+
if (entry === void 0 || entry.acked) return;
|
|
150
|
+
target.suspect = true;
|
|
151
|
+
this.settle(id, void 0, new Silent());
|
|
152
|
+
}, this.ackMs) : void 0;
|
|
102
153
|
this.pending.set(id, {
|
|
154
|
+
acked: false,
|
|
155
|
+
ackTimer,
|
|
103
156
|
connection: target,
|
|
104
157
|
resolve,
|
|
105
158
|
reject,
|
|
@@ -143,12 +196,33 @@ var UiChannel = class {
|
|
|
143
196
|
});
|
|
144
197
|
}
|
|
145
198
|
/**
|
|
199
|
+
* A promise for the window id of the next page that connects and was not connected when this was asked (the page that came back after a reload or a move).
|
|
200
|
+
* @returns that id, or `undefined` when no new page connected within `timeoutMs`.
|
|
201
|
+
*/
|
|
202
|
+
expectNewPage(timeoutMs) {
|
|
203
|
+
const known = new Set(this.connections.keys());
|
|
204
|
+
return new Promise((resolve) => {
|
|
205
|
+
const done = (value) => {
|
|
206
|
+
clearTimeout(timer);
|
|
207
|
+
this.attachWaiters.delete(waiter);
|
|
208
|
+
resolve(value);
|
|
209
|
+
};
|
|
210
|
+
const waiter = (windowId) => {
|
|
211
|
+
if (!known.has(windowId)) done(windowId);
|
|
212
|
+
};
|
|
213
|
+
const timer = setTimeout(() => {
|
|
214
|
+
done(void 0);
|
|
215
|
+
}, timeoutMs);
|
|
216
|
+
this.attachWaiters.add(waiter);
|
|
217
|
+
});
|
|
218
|
+
}
|
|
219
|
+
/**
|
|
146
220
|
* Ask every connected page to reload (the app just changed under them) and wait, up to `timeoutMs`, for them to come back. Best effort: a page that does not come back
|
|
147
221
|
* (it was closed, or it moved to a page without the door) is not an error here.
|
|
148
222
|
* @returns how many pages were asked.
|
|
149
223
|
*/
|
|
150
224
|
async reloadAll(timeoutMs = 8e3) {
|
|
151
|
-
const targets = this.
|
|
225
|
+
const targets = this.ordered();
|
|
152
226
|
if (this.closed || targets.length === 0) return 0;
|
|
153
227
|
const back = this.expectAttach(timeoutMs, targets.length);
|
|
154
228
|
for (const target of targets) {
|
|
@@ -172,12 +246,15 @@ var UiChannel = class {
|
|
|
172
246
|
for (const connection of [...this.connections.values()]) connection.stream.close();
|
|
173
247
|
this.connections.clear();
|
|
174
248
|
}
|
|
175
|
-
|
|
176
|
-
|
|
249
|
+
/** The order pages are driven in: the one the Host sent somewhere, suspect ones last, the one in front, then the most recently active. */
|
|
250
|
+
ordered() {
|
|
251
|
+
const rank = (connection) => (connection.suspect ? 0 : 4) + (connection.windowId === this.preferred ? 2 : 0) + (connection.focused ? 1 : 0);
|
|
252
|
+
return [...this.connections.values()].sort((a, b) => rank(b) - rank(a) || b.lastActive - a.lastActive);
|
|
177
253
|
}
|
|
178
254
|
/** Forget a connection; its calls in flight settle as `disconnected` at once rather than waiting for their timeout. */
|
|
179
255
|
drop(connection, closeStream) {
|
|
180
256
|
this.connections.delete(connection.windowId);
|
|
257
|
+
if (this.preferred === connection.windowId) this.preferred = void 0;
|
|
181
258
|
for (const [id, entry] of [...this.pending]) if (entry.connection === connection) this.settle(id, void 0, new UiControlError("disconnected", "the page went away before it answered"));
|
|
182
259
|
if (closeStream) connection.stream.close();
|
|
183
260
|
}
|
|
@@ -186,6 +263,7 @@ var UiChannel = class {
|
|
|
186
263
|
if (entry === void 0) return;
|
|
187
264
|
this.pending.delete(id);
|
|
188
265
|
clearTimeout(entry.timer);
|
|
266
|
+
if (entry.ackTimer !== void 0) clearTimeout(entry.ackTimer);
|
|
189
267
|
entry.cleanup();
|
|
190
268
|
if (failure !== void 0) entry.reject(failure);
|
|
191
269
|
else if (value !== void 0) entry.resolve(value);
|
package/package.json
CHANGED
|
@@ -8,6 +8,9 @@
|
|
|
8
8
|
"@deepseek-ai/cordis": "4.0.4",
|
|
9
9
|
"@deepseek-ai/schemastery": "3.18.4"
|
|
10
10
|
},
|
|
11
|
+
"devDependencies": {
|
|
12
|
+
"ws": "8.21.3"
|
|
13
|
+
},
|
|
11
14
|
"mount": "mount it after acryl-node-server and extensions in development builds: root.plugin(door); add <script type=\"module\" src=\"/api/acryl-door/page.js\"></script> to the pages; the command is .acryl/bin/acryl",
|
|
12
15
|
"gitignore": [".acryl/"],
|
|
13
16
|
"omit": ["**/*.test.*", "**/*.spec.*", "**/*.case.ts"],
|
|
@@ -26,6 +26,8 @@ export interface Invocation {
|
|
|
26
26
|
readonly args: Record<string, unknown>
|
|
27
27
|
readonly json: boolean
|
|
28
28
|
readonly dir: string | undefined
|
|
29
|
+
/** Things the client changed or noticed in the command line; printed to stderr before the call. */
|
|
30
|
+
readonly warnings: readonly string[]
|
|
29
31
|
}
|
|
30
32
|
|
|
31
33
|
const USAGE = `usage: acryl <verb> [options] [--json] [--dir <app folder>]
|
|
@@ -47,6 +49,31 @@ const USAGE = `usage: acryl <verb> [options] [--json] [--dir <app folder>]
|
|
|
47
49
|
tool list | tool call --tool <name> [--args '<json>']
|
|
48
50
|
verbs`
|
|
49
51
|
|
|
52
|
+
/**
|
|
53
|
+
* Git for Windows' bash (MSYS) turns an argument that looks like a Unix path into a Windows path before a native program sees it: `--path /nope` arrives as
|
|
54
|
+
* `C:/Program Files/Git/nope`. The shim stops that (MSYS_NO_PATHCONV), but a command that does not go through the shim, or an MSYS that ignores the variable, would still
|
|
55
|
+
* send the app a path it can never serve. A path that begins with the Git install folder is turned back into the path the person typed, with a warning. Only `--path` is read this way.
|
|
56
|
+
*/
|
|
57
|
+
export function undoMsysPath(value: string, env: NodeJS.ProcessEnv, platform: NodeJS.Platform, warnings: string[]): string {
|
|
58
|
+
if (platform !== 'win32' || !/^[A-Za-z]:[\\/]/u.test(value)) return value
|
|
59
|
+
const roots = new Set<string>()
|
|
60
|
+
if (env.EXEPATH) roots.add(env.EXEPATH)
|
|
61
|
+
for (const folder of (env.PATH ?? env.Path ?? '').split(';')) {
|
|
62
|
+
const found = /^(.*[\\/]Git)[\\/](?:usr[\\/]bin|cmd|bin|mingw64[\\/]bin)[\\/]?$/iu.exec(folder.trim())
|
|
63
|
+
if (found?.[1]) roots.add(found[1])
|
|
64
|
+
}
|
|
65
|
+
const flat = value.replaceAll('\\', '/')
|
|
66
|
+
for (const root of roots) {
|
|
67
|
+
const prefix = `${root.replaceAll('\\', '/').replace(/\/+$/u, '')}/`
|
|
68
|
+
if (flat.toLowerCase().startsWith(prefix.toLowerCase())) {
|
|
69
|
+
const path = `/${flat.slice(prefix.length)}`
|
|
70
|
+
warnings.push(`warning: Git Bash changed the path you typed into "${value}"; using "${path}". Run the command through .acryl/bin/acryl, or set MSYS_NO_PATHCONV=1, so it stays as typed.`)
|
|
71
|
+
return path
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
return value
|
|
75
|
+
}
|
|
76
|
+
|
|
50
77
|
function integer(values: Map<string, string>, flag: string): number | undefined {
|
|
51
78
|
const raw = values.get(flag)
|
|
52
79
|
if (raw === undefined) return undefined
|
|
@@ -56,7 +83,7 @@ function integer(values: Map<string, string>, flag: string): number | undefined
|
|
|
56
83
|
}
|
|
57
84
|
|
|
58
85
|
/** @throws UsageError. */
|
|
59
|
-
export function parseInvocation(argv: readonly string[]): Invocation {
|
|
86
|
+
export function parseInvocation(argv: readonly string[], env: NodeJS.ProcessEnv = process.env, platform: NodeJS.Platform = process.platform): Invocation {
|
|
60
87
|
const words: string[] = []
|
|
61
88
|
const values = new Map<string, string>()
|
|
62
89
|
const set = new Set<string>()
|
|
@@ -79,7 +106,8 @@ export function parseInvocation(argv: readonly string[]): Invocation {
|
|
|
79
106
|
const [first, second, third] = words
|
|
80
107
|
const json = set.has('--json')
|
|
81
108
|
const dir = values.get('--dir')
|
|
82
|
-
const
|
|
109
|
+
const warnings: string[] = []
|
|
110
|
+
const make = (verb: string, args: Record<string, unknown> = {}): Invocation => ({ verb, args, json, dir, warnings })
|
|
83
111
|
switch (first) {
|
|
84
112
|
case 'brief': return make('brief')
|
|
85
113
|
case 'verbs': return make('verbs')
|
|
@@ -98,7 +126,7 @@ export function parseInvocation(argv: readonly string[]): Invocation {
|
|
|
98
126
|
return make('act', {
|
|
99
127
|
op: second,
|
|
100
128
|
...pick('--ref', 'ref'), ...pick('--text', 'text'), ...pick('--option', 'option'), ...pick('--key', 'key'),
|
|
101
|
-
...pick('--direction', 'direction'), ...pick('--role', 'role'), ...pick('--name', 'name'), ...
|
|
129
|
+
...pick('--direction', 'direction'), ...pick('--role', 'role'), ...pick('--name', 'name'), ...(values.has('--path') ? { path: undoMsysPath(values.get('--path') as string, env, platform, warnings) } : {}),
|
|
102
130
|
...(amount === undefined ? {} : { amount }),
|
|
103
131
|
...(timeoutMs === undefined ? {} : { timeoutMs }),
|
|
104
132
|
...(window === undefined ? {} : { window }),
|
|
@@ -116,7 +144,7 @@ export function parseInvocation(argv: readonly string[]): Invocation {
|
|
|
116
144
|
case 'window': {
|
|
117
145
|
const id = integer(values, '--id')
|
|
118
146
|
if (second === 'list') return make('window-list')
|
|
119
|
-
if (second === 'open') return make('window-open', values.has('--path') ? { path: values.get('--path') } : {})
|
|
147
|
+
if (second === 'open') return make('window-open', values.has('--path') ? { path: undoMsysPath(values.get('--path') as string, env, platform, warnings) } : {})
|
|
120
148
|
if (second === 'focus' || second === 'close' || second === 'resize') {
|
|
121
149
|
if (id === undefined) throw new UsageError(`window ${second} needs --id <n> (window list prints the ids)`)
|
|
122
150
|
if (second === 'focus') return make('window-focus', { id })
|
|
@@ -182,6 +210,7 @@ export async function main(argv: readonly string[], io: Io): Promise<number> {
|
|
|
182
210
|
if (cause instanceof UsageError) { io.err(`${cause.message}\n`); return 2 }
|
|
183
211
|
throw cause
|
|
184
212
|
}
|
|
213
|
+
for (const warning of invocation.warnings) io.err(`${warning}\n`)
|
|
185
214
|
const app = findApp(io.cwd, invocation.dir)
|
|
186
215
|
const file = app === undefined ? undefined : readDoorFile(app)
|
|
187
216
|
if (app === undefined || file === undefined) {
|
|
@@ -20,7 +20,16 @@ export interface PageStream {
|
|
|
20
20
|
close(): void
|
|
21
21
|
}
|
|
22
22
|
|
|
23
|
+
/** A call that went to a page that never acknowledged it, and was taken back to be sent to the next page. */
|
|
24
|
+
class Silent extends UiControlError {
|
|
25
|
+
constructor() {
|
|
26
|
+
super('timeout', 'the page did not acknowledge the call')
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
23
30
|
interface Pending {
|
|
31
|
+
acked: boolean
|
|
32
|
+
ackTimer: NodeJS.Timeout | undefined
|
|
24
33
|
readonly connection: Connection
|
|
25
34
|
readonly resolve: (value: UiResult) => void
|
|
26
35
|
readonly reject: (cause: UiControlError) => void
|
|
@@ -36,19 +45,40 @@ interface Connection {
|
|
|
36
45
|
focused: boolean
|
|
37
46
|
/** Order of last focus (or connection), so the most recent wins. */
|
|
38
47
|
lastActive: number
|
|
48
|
+
/** A page that did not acknowledge a read-only call in time (a frozen or abandoned tab): last in line until it speaks again. */
|
|
49
|
+
suspect: boolean
|
|
39
50
|
}
|
|
40
51
|
|
|
41
52
|
/** How long a call may take before it is given up (a wait may take up to 10 seconds itself). */
|
|
42
53
|
const DEFAULT_CALL_TIMEOUT_MS = 30_000
|
|
54
|
+
/** How long a page has to say it heard a read-only call before the call goes to the next page. */
|
|
55
|
+
const DEFAULT_ACK_MS = 3000
|
|
56
|
+
|
|
57
|
+
export interface ChannelOptions {
|
|
58
|
+
/** How long a page has to acknowledge a read-only call (`look`, `wait`) before the next page is tried. */
|
|
59
|
+
readonly ackMs?: number
|
|
60
|
+
}
|
|
43
61
|
|
|
62
|
+
/**
|
|
63
|
+
* The pages and who drives which. One page answers a call, chosen the same way every time: the page the Host just sent somewhere (it comes back as a new page, `prefer`), else the
|
|
64
|
+
* one in front (it reports `focused`), else the most recently active; a page that did not acknowledge a call in time goes to the end of the line. An explicit desktop `window` picks its page.
|
|
65
|
+
*/
|
|
44
66
|
export class UiChannel {
|
|
67
|
+
private readonly ackMs: number
|
|
68
|
+
/** The page the Host sent to a new place and that came back: it keeps being driven until the person focuses another page. */
|
|
69
|
+
private preferred: string | undefined
|
|
70
|
+
|
|
71
|
+
constructor(options: ChannelOptions = {}) {
|
|
72
|
+
this.ackMs = options.ackMs ?? DEFAULT_ACK_MS
|
|
73
|
+
}
|
|
74
|
+
|
|
45
75
|
private readonly connections = new Map<string, Connection>()
|
|
46
76
|
private readonly pending = new Map<number, Pending>()
|
|
47
77
|
private nextId = 1
|
|
48
78
|
private clock = 0
|
|
49
79
|
private closed = false
|
|
50
80
|
/** Callers waiting for a page to (re)connect: each is told once, by the next stream that attaches. */
|
|
51
|
-
private readonly attachWaiters = new Set<() => void>()
|
|
81
|
+
private readonly attachWaiters = new Set<(windowId: string) => void>()
|
|
52
82
|
|
|
53
83
|
/**
|
|
54
84
|
* Register a page's stream. A stream already open for the same window is closed first, and its calls settle as `disconnected`.
|
|
@@ -57,9 +87,10 @@ export class UiChannel {
|
|
|
57
87
|
attach(windowId: string, focused: boolean, stream: PageStream, shellWindow?: number): () => void {
|
|
58
88
|
const previous = this.connections.get(windowId)
|
|
59
89
|
if (previous !== undefined) this.drop(previous, true)
|
|
60
|
-
|
|
90
|
+
// A page that did not answer stays last in line when its stream reconnects: a tab that cannot answer reconnects just as readily, and would otherwise be first again.
|
|
91
|
+
const connection: Connection = { stream, windowId, shellWindow, focused, lastActive: ++this.clock, suspect: previous?.suspect === true }
|
|
61
92
|
this.connections.set(windowId, connection)
|
|
62
|
-
for (const waiter of [...this.attachWaiters]) waiter()
|
|
93
|
+
for (const waiter of [...this.attachWaiters]) waiter(windowId)
|
|
63
94
|
return () => { if (this.connections.get(windowId) === connection) this.drop(connection, false) }
|
|
64
95
|
}
|
|
65
96
|
|
|
@@ -67,13 +98,24 @@ export class UiChannel {
|
|
|
67
98
|
receive(windowId: string, message: ChannelToHost): void {
|
|
68
99
|
const connection = this.connections.get(windowId)
|
|
69
100
|
if (connection === undefined) return
|
|
101
|
+
// Whatever a page says, it is alive.
|
|
102
|
+
connection.suspect = false
|
|
70
103
|
if (message.t === 'focus') {
|
|
71
104
|
connection.focused = message.focused
|
|
72
|
-
if (message.focused)
|
|
105
|
+
if (message.focused) {
|
|
106
|
+
connection.lastActive = ++this.clock
|
|
107
|
+
// The person moved to this page: the Host stops insisting on the one it sent somewhere.
|
|
108
|
+
if (this.preferred !== windowId) this.preferred = undefined
|
|
109
|
+
}
|
|
73
110
|
return
|
|
74
111
|
}
|
|
75
112
|
const entry = this.pending.get(message.id)
|
|
76
113
|
if (entry === undefined || entry.connection !== connection) return
|
|
114
|
+
if (message.t === 'ack') {
|
|
115
|
+
entry.acked = true
|
|
116
|
+
if (entry.ackTimer !== undefined) clearTimeout(entry.ackTimer)
|
|
117
|
+
return
|
|
118
|
+
}
|
|
77
119
|
if (message.t === 'result') this.settle(message.id, message.value)
|
|
78
120
|
else this.settle(message.id, undefined, new UiControlError(message.code, message.message))
|
|
79
121
|
}
|
|
@@ -85,32 +127,61 @@ export class UiChannel {
|
|
|
85
127
|
|
|
86
128
|
/** The windows currently connected, most recently active first (for diagnostics and tests). */
|
|
87
129
|
windows(): readonly { windowId: string; focused: boolean; shellWindow?: number }[] {
|
|
88
|
-
return this.
|
|
130
|
+
return this.ordered().map(({ windowId, focused, shellWindow }) => ({ windowId, focused, ...(shellWindow === undefined ? {} : { shellWindow }) }))
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** Drive this page from now on (it is the one the Host just sent to a new place), until the person focuses another. */
|
|
134
|
+
prefer(windowId: string): void {
|
|
135
|
+
const connection = this.connections.get(windowId)
|
|
136
|
+
if (connection === undefined) return
|
|
137
|
+
this.preferred = windowId
|
|
138
|
+
connection.lastActive = ++this.clock
|
|
89
139
|
}
|
|
90
140
|
|
|
91
141
|
/**
|
|
92
|
-
* Ask the current page to do something.
|
|
142
|
+
* Ask the current page to do something. A read-only call (`look`, `wait`) that the page does not acknowledge in time goes to the next page, and the silent one is last in line
|
|
143
|
+
* until it speaks; a call that changes the page is never sent to a second page, because the first might still run it.
|
|
93
144
|
* @throws UiControlError `no-window`, `timeout`, `aborted`, `unloaded`, `disconnected`, or whatever the page refused with.
|
|
94
145
|
*/
|
|
95
|
-
call(request: UiRequest, options: { signal?: AbortSignal; timeoutMs?: number; window?: number } = {}): Promise<UiResult> {
|
|
96
|
-
if (this.closed)
|
|
97
|
-
const ordered = this.
|
|
146
|
+
async call(request: UiRequest, options: { signal?: AbortSignal; timeoutMs?: number; window?: number } = {}): Promise<UiResult> {
|
|
147
|
+
if (this.closed) throw new UiControlError('unloaded', 'the door was unloaded')
|
|
148
|
+
const ordered = this.ordered()
|
|
98
149
|
// `window` is the number `window list` prints: that desktop window's page, not the one used last.
|
|
99
|
-
const
|
|
100
|
-
if (
|
|
150
|
+
const candidates = options.window === undefined ? ordered : ordered.filter(connection => connection.shellWindow === options.window)
|
|
151
|
+
if (candidates.length === 0 && options.window !== undefined) {
|
|
101
152
|
const known = ordered.flatMap(connection => (connection.shellWindow === undefined ? [] : [String(connection.shellWindow)]))
|
|
102
|
-
|
|
153
|
+
throw new UiControlError('no-window', `no page of window ${String(options.window)} is connected to the door${known.length === 0 ? ' (no desktop window has a page connected)' : `; windows with a page: ${known.join(', ')}`}; window list prints the windows`)
|
|
103
154
|
}
|
|
104
|
-
if (
|
|
155
|
+
if (candidates.length === 0) throw new UiControlError('no-window', 'no page of the app is open to control')
|
|
156
|
+
if (options.signal?.aborted === true) throw new UiControlError('aborted', 'the call was cancelled')
|
|
157
|
+
const readOnly = request.op === 'snapshot' || request.op === 'wait'
|
|
158
|
+
for (const target of candidates) {
|
|
159
|
+
try {
|
|
160
|
+
return await this.callOne(target, request, options, readOnly)
|
|
161
|
+
} catch (cause) {
|
|
162
|
+
if (!(cause instanceof Silent)) throw cause
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
throw new UiControlError('timeout', `no page of the app answered within ${String(Math.round(this.ackMs / 1000))} s: the app's pages are open but none responded (a tab in the background, or a page that is still loading). Bring the app's tab to the front, or navigate it, then look again`)
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
private callOne(target: Connection, request: UiRequest, options: { signal?: AbortSignal; timeoutMs?: number }, failOver: boolean): Promise<UiResult> {
|
|
105
169
|
const signal = options.signal
|
|
106
|
-
if (signal?.aborted === true) return Promise.reject(new UiControlError('aborted', 'the call was cancelled'))
|
|
107
170
|
const id = this.nextId
|
|
108
171
|
this.nextId += 1
|
|
109
172
|
return new Promise<UiResult>((resolve, reject) => {
|
|
110
173
|
const timer = setTimeout(() => { this.settle(id, undefined, new UiControlError('timeout', 'the page did not answer in time')) }, options.timeoutMs ?? DEFAULT_CALL_TIMEOUT_MS)
|
|
111
174
|
const onAbort = (): void => { this.settle(id, undefined, new UiControlError('aborted', 'the call was cancelled')) }
|
|
112
175
|
signal?.addEventListener('abort', onAbort, { once: true })
|
|
113
|
-
|
|
176
|
+
const ackTimer = failOver
|
|
177
|
+
? setTimeout(() => {
|
|
178
|
+
const entry = this.pending.get(id)
|
|
179
|
+
if (entry === undefined || entry.acked) return
|
|
180
|
+
target.suspect = true
|
|
181
|
+
this.settle(id, undefined, new Silent())
|
|
182
|
+
}, this.ackMs)
|
|
183
|
+
: undefined
|
|
184
|
+
this.pending.set(id, { acked: false, ackTimer, connection: target, resolve, reject, timer, cleanup: () => { signal?.removeEventListener('abort', onAbort) } } as Pending)
|
|
114
185
|
const frame: ChannelToPage = { t: 'call', id, request }
|
|
115
186
|
try {
|
|
116
187
|
target.stream.send(JSON.stringify(frame))
|
|
@@ -134,13 +205,27 @@ export class UiChannel {
|
|
|
134
205
|
})
|
|
135
206
|
}
|
|
136
207
|
|
|
208
|
+
/**
|
|
209
|
+
* A promise for the window id of the next page that connects and was not connected when this was asked (the page that came back after a reload or a move).
|
|
210
|
+
* @returns that id, or `undefined` when no new page connected within `timeoutMs`.
|
|
211
|
+
*/
|
|
212
|
+
expectNewPage(timeoutMs: number): Promise<string | undefined> {
|
|
213
|
+
const known = new Set(this.connections.keys())
|
|
214
|
+
return new Promise((resolve) => {
|
|
215
|
+
const done = (value: string | undefined): void => { clearTimeout(timer); this.attachWaiters.delete(waiter); resolve(value) }
|
|
216
|
+
const waiter = (windowId: string): void => { if (!known.has(windowId)) done(windowId) }
|
|
217
|
+
const timer = setTimeout(() => { done(undefined) }, timeoutMs)
|
|
218
|
+
this.attachWaiters.add(waiter)
|
|
219
|
+
})
|
|
220
|
+
}
|
|
221
|
+
|
|
137
222
|
/**
|
|
138
223
|
* Ask every connected page to reload (the app just changed under them) and wait, up to `timeoutMs`, for them to come back. Best effort: a page that does not come back
|
|
139
224
|
* (it was closed, or it moved to a page without the door) is not an error here.
|
|
140
225
|
* @returns how many pages were asked.
|
|
141
226
|
*/
|
|
142
227
|
async reloadAll(timeoutMs = 8000): Promise<number> {
|
|
143
|
-
const targets = this.
|
|
228
|
+
const targets = this.ordered()
|
|
144
229
|
if (this.closed || targets.length === 0) return 0
|
|
145
230
|
const back = this.expectAttach(timeoutMs, targets.length)
|
|
146
231
|
for (const target of targets) {
|
|
@@ -161,13 +246,16 @@ export class UiChannel {
|
|
|
161
246
|
this.connections.clear()
|
|
162
247
|
}
|
|
163
248
|
|
|
164
|
-
|
|
165
|
-
|
|
249
|
+
/** The order pages are driven in: the one the Host sent somewhere, suspect ones last, the one in front, then the most recently active. */
|
|
250
|
+
private ordered(): Connection[] {
|
|
251
|
+
const rank = (connection: Connection): number => (connection.suspect ? 0 : 4) + (connection.windowId === this.preferred ? 2 : 0) + (connection.focused ? 1 : 0)
|
|
252
|
+
return [...this.connections.values()].sort((a, b) => rank(b) - rank(a) || b.lastActive - a.lastActive)
|
|
166
253
|
}
|
|
167
254
|
|
|
168
255
|
/** Forget a connection; its calls in flight settle as `disconnected` at once rather than waiting for their timeout. */
|
|
169
256
|
private drop(connection: Connection, closeStream: boolean): void {
|
|
170
257
|
this.connections.delete(connection.windowId)
|
|
258
|
+
if (this.preferred === connection.windowId) this.preferred = undefined
|
|
171
259
|
for (const [id, entry] of [...this.pending]) {
|
|
172
260
|
if (entry.connection === connection) this.settle(id, undefined, new UiControlError('disconnected', 'the page went away before it answered'))
|
|
173
261
|
}
|
|
@@ -179,6 +267,7 @@ export class UiChannel {
|
|
|
179
267
|
if (entry === undefined) return
|
|
180
268
|
this.pending.delete(id)
|
|
181
269
|
clearTimeout(entry.timer)
|
|
270
|
+
if (entry.ackTimer !== undefined) clearTimeout(entry.ackTimer)
|
|
182
271
|
entry.cleanup()
|
|
183
272
|
if (failure !== undefined) entry.reject(failure)
|
|
184
273
|
else if (value !== undefined) entry.resolve(value)
|
|
@@ -158,7 +158,7 @@ export class Door {
|
|
|
158
158
|
const request = this.parse(rest)
|
|
159
159
|
if (request.op !== 'reload' && request.op !== 'navigate') return await this.callPage(`act ${String(input.op)}`, request, signal, window) as UiActionResult
|
|
160
160
|
// The page is replaced: wait for the new one to connect, so the next look sees it. The page may drop before it has answered; that is the reload working.
|
|
161
|
-
const back = this.deps.channel.
|
|
161
|
+
const back = this.deps.channel.expectNewPage(this.deps.pageBackMs ?? PAGE_BACK_MS)
|
|
162
162
|
let answer: UiActionResult
|
|
163
163
|
try {
|
|
164
164
|
answer = await this.callPage(`act ${request.op}`, request, signal, window) as UiActionResult
|
|
@@ -166,7 +166,10 @@ export class Door {
|
|
|
166
166
|
if (!(cause instanceof UiControlError && cause.code === 'disconnected')) { void back; throw cause }
|
|
167
167
|
answer = { ok: true, detail: request.op === 'reload' ? 'reloading' : `going to ${request.path}` }
|
|
168
168
|
}
|
|
169
|
-
|
|
169
|
+
const came = await back
|
|
170
|
+
// The page that came back is the one this agent was driving: it stays the target until the person focuses another page.
|
|
171
|
+
if (came !== undefined) this.deps.channel.prefer(came)
|
|
172
|
+
if (came === undefined) throw new UiControlError('timeout', request.op === 'reload' ? 'the page did not come back after the reload' : `the page did not come back after going to ${request.path}: is there a page at that path, and does it load the door's script?`)
|
|
170
173
|
const landed = request.op === 'navigate' ? await this.statusAt(request.path) : undefined
|
|
171
174
|
return { ...answer, detail: `${answer.detail ?? 'done'}${landed === undefined ? '' : `; ${landed}`}; the page is back: look again` }
|
|
172
175
|
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The page channel over a WebSocket. An event stream (`page-routes.ts`) holds one of the browser's six connections per host for as long as the page lives; two windows of an app that
|
|
3
|
+
* also stream their own state, with the panel following a run, use them all, and then no request of either window is sent (the door's answers, Stop). A WebSocket is not one of
|
|
4
|
+
* those six: it opens at once however many streams are open, and the page's answers travel on it too, so nothing is queued behind a stream.
|
|
5
|
+
*
|
|
6
|
+
* `GET <prefix>/channel/ws?window=<id>&focused=0|1[&shell=<n>]` upgrades. The messages are the same JSON as the stream's: down, `{ "e": "call", "d": "<the call>" }` and
|
|
7
|
+
* `{ "e": "ready" }` / `{ "e": "ping" }`; up, one {@link ChannelToHost} message per frame. A WebSocket has no CORS, so the `Origin` header is the boundary: it must be present and be
|
|
8
|
+
* exactly this app's own origin, or the upgrade is refused (403) before any handshake. A page on another port, a web site, and a program that sends no `Origin` are all refused.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { Acryl, type MiddlewareHandler } from 'acryl-router'
|
|
12
|
+
import type { UpgradeWebSocket } from 'acryl-router/helpers'
|
|
13
|
+
import { loopbackAuthority } from 'acryl-router/middleware'
|
|
14
|
+
import { parseChannelToHost, WINDOW_ID } from '../page/protocol.ts'
|
|
15
|
+
import type { UiChannel } from './channel.ts'
|
|
16
|
+
import { error } from './http.ts'
|
|
17
|
+
|
|
18
|
+
/** How often the Host writes a `ping`; the page gives up on a socket that is silent for three of these. */
|
|
19
|
+
export const SOCKET_HEARTBEAT_MS = 15_000
|
|
20
|
+
|
|
21
|
+
export interface PageSocketOptions {
|
|
22
|
+
readonly prefix: string
|
|
23
|
+
readonly upgrade: UpgradeWebSocket
|
|
24
|
+
readonly heartbeatMs?: number
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** The upgrade request comes from this app's own page: a loopback peer on this port, and an `Origin` that is exactly this app's origin. Nothing else is read as proof. */
|
|
28
|
+
export const ownOrigin: MiddlewareHandler = async (c, next) => {
|
|
29
|
+
const authority = loopbackAuthority(c)
|
|
30
|
+
const origin = c.req.header('origin')
|
|
31
|
+
const site = c.req.header('sec-fetch-site')
|
|
32
|
+
if (authority === undefined || origin === undefined || origin !== authority.origin || (site !== undefined && site !== 'same-origin')) {
|
|
33
|
+
return c.json(error('only this app\'s own page may open the channel'), 403)
|
|
34
|
+
}
|
|
35
|
+
const windowId = c.req.query('window')
|
|
36
|
+
if (windowId === undefined || !WINDOW_ID.test(windowId)) return c.json(error('a window id is required'), 400)
|
|
37
|
+
await next()
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function pageSocketRoutes(channel: UiChannel, options: PageSocketOptions): Acryl {
|
|
41
|
+
const heartbeatMs = options.heartbeatMs ?? SOCKET_HEARTBEAT_MS
|
|
42
|
+
return new Acryl().get(
|
|
43
|
+
`${options.prefix}/channel/ws`,
|
|
44
|
+
ownOrigin,
|
|
45
|
+
options.upgrade((c) => {
|
|
46
|
+
const windowId = c.req.query('window') as string
|
|
47
|
+
const focused = c.req.query('focused') === '1'
|
|
48
|
+
const shellQuery = Number(c.req.query('shell'))
|
|
49
|
+
const shellWindow = Number.isSafeInteger(shellQuery) && shellQuery > 0 ? shellQuery : undefined
|
|
50
|
+
let detach: (() => void) | undefined
|
|
51
|
+
let beat: NodeJS.Timeout | undefined
|
|
52
|
+
return {
|
|
53
|
+
onOpen(_event, ws) {
|
|
54
|
+
const write = (frame: { e: string; d?: string }): void => {
|
|
55
|
+
ws.send(JSON.stringify(frame))
|
|
56
|
+
}
|
|
57
|
+
// A call that cannot be written fails at once (`disconnected`), and a socket that is gone is dropped by its close: the door never holds a call for it.
|
|
58
|
+
detach = channel.attach(windowId, focused, { send: data => { write({ e: 'call', d: data }) }, close: () => { ws.close(1000, 'replaced') } }, shellWindow)
|
|
59
|
+
write({ e: 'ready' })
|
|
60
|
+
beat = setInterval(() => {
|
|
61
|
+
try { write({ e: 'ping' }) } catch { /* the close event follows */ }
|
|
62
|
+
}, heartbeatMs)
|
|
63
|
+
},
|
|
64
|
+
onMessage(event) {
|
|
65
|
+
if (typeof event.data !== 'string') return
|
|
66
|
+
let message: unknown
|
|
67
|
+
try { message = JSON.parse(event.data) } catch { return }
|
|
68
|
+
const parsed = parseChannelToHost(message)
|
|
69
|
+
if (parsed !== null) channel.receive(windowId, parsed)
|
|
70
|
+
},
|
|
71
|
+
onClose() {
|
|
72
|
+
if (beat !== undefined) clearInterval(beat)
|
|
73
|
+
detach?.()
|
|
74
|
+
},
|
|
75
|
+
}
|
|
76
|
+
}, { textOnly: true }),
|
|
77
|
+
)
|
|
78
|
+
}
|
|
@@ -60,7 +60,8 @@ function cmdWord(value: string): string {
|
|
|
60
60
|
|
|
61
61
|
export function posixShim(root: string, target: ShimTarget): string {
|
|
62
62
|
const words = [target.node, ...target.nodeOptions, target.client, '--dir', root].map(shWord).join(' ')
|
|
63
|
-
|
|
63
|
+
// Git Bash on Windows rewrites an argument such as /nope into C:/Program Files/Git/nope when it starts a native program; the door's paths must reach it as typed. Harmless elsewhere.
|
|
64
|
+
const env = `${target.electron ? 'export ELECTRON_RUN_AS_NODE=1\n' : ''}export MSYS_NO_PATHCONV=1\nexport MSYS2_ARG_CONV_EXCL='*'\n`
|
|
64
65
|
return `#!/bin/sh\n# This app's door (acryl-door): reaches only the app running from this folder. Rewritten each time the app starts.\n${env}exec ${words} "$@"\n`
|
|
65
66
|
}
|
|
66
67
|
|