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.
Files changed (40) hide show
  1. package/dist/index.js +272 -1
  2. package/dist/index.js.map +1 -1
  3. package/docs/framework/guides/production.md +3 -3
  4. package/lib/{audit-GcLtvNLV.d.ts → audit-BD8_2pkh.d.ts} +25 -3
  5. package/lib/cordis/door.d.ts +2 -2
  6. package/lib/cordis/door.js +79 -6
  7. package/lib/door/host.d.ts +2 -2
  8. package/lib/door/host.js +1 -1
  9. package/lib/door/page.d.ts +24 -1
  10. package/lib/door/page.js +138 -9
  11. package/lib/door/protocol.d.ts +1 -1
  12. package/lib/door/protocol.js +1 -1
  13. package/lib/{protocol-Dt0AZBOz.js → protocol-CQ_if7Dg.js} +4 -0
  14. package/lib/{protocol-CR31M7K_.d.ts → protocol-a2aqrp2X.d.ts} +3 -0
  15. package/lib/{snapshot-text-CtQm3n8n.js → snapshot-text-m13Pk-AI.js} +95 -17
  16. package/package.json +1 -1
  17. package/plugins/acryl-door/plugin.json +3 -0
  18. package/plugins/acryl-door/src/cli/acryl.ts +33 -4
  19. package/plugins/acryl-door/src/host/channel.ts +107 -18
  20. package/plugins/acryl-door/src/host/door.ts +5 -2
  21. package/plugins/acryl-door/src/host/page-socket.ts +78 -0
  22. package/plugins/acryl-door/src/host/shim.ts +2 -1
  23. package/plugins/acryl-door/src/index.ts +10 -0
  24. package/plugins/acryl-door/src/page/channel.ts +67 -5
  25. package/plugins/acryl-door/src/page/driver/sensitivity.ts +39 -3
  26. package/plugins/acryl-door/src/page/index.ts +16 -1
  27. package/plugins/acryl-door/src/page/protocol.ts +3 -0
  28. package/plugins/acryl-door/src/page/socket-source.ts +75 -0
  29. package/plugins/agent-panel/client/main.ts +182 -20
  30. package/plugins/agent-panel/client/markdown.ts +219 -0
  31. package/plugins/agent-panel/headless.ts +20 -0
  32. package/plugins/agent-panel/index.ts +190 -50
  33. package/plugins/desktop/main.ts +50 -3
  34. package/plugins/desktop/menu.ts +60 -0
  35. package/plugins/desktop/window.ts +48 -2
  36. package/plugins/extensions/index.ts +81 -4
  37. package/templates/agent/AGENTS.md +4 -2
  38. package/templates/agent/src/app.ts +9 -1
  39. package/templates/agent/src/routes.ts +13 -3
  40. 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-Dt0AZBOz.js";
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) connection.lastActive = ++this.clock;
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.byRecency().map(({ windowId, focused, shellWindow }) => ({
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) return Promise.reject(new UiControlError("unloaded", "the door was unloaded"));
83
- const ordered = this.byRecency();
84
- const target = options.window === void 0 ? ordered[0] : ordered.find((connection) => connection.shellWindow === options.window);
85
- if (target === void 0 && options.window !== void 0) {
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
- return Promise.reject(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`));
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 (target === void 0) return Promise.reject(new UiControlError("no-window", "no page of the app is open to control"));
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.byRecency();
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
- byRecency() {
176
- return [...this.connections.values()].sort((a, b) => b.lastActive - a.lastActive);
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "acrylblends",
3
- "version": "1.0.6",
3
+ "version": "1.0.8",
4
4
  "description": "The Acryl web framework (import { Acryl } from \"acrylblends\") and the acrylblends command that scaffolds Cordis apps.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -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 make = (verb: string, args: Record<string, unknown> = {}): Invocation => ({ verb, args, json, dir })
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'), ...pick('--path', 'path'),
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
- const connection: Connection = { stream, windowId, shellWindow, focused, lastActive: ++this.clock }
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) connection.lastActive = ++this.clock
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.byRecency().map(({ windowId, focused, shellWindow }) => ({ windowId, focused, ...(shellWindow === undefined ? {} : { shellWindow }) }))
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) return Promise.reject(new UiControlError('unloaded', 'the door was unloaded'))
97
- const ordered = this.byRecency()
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 target = options.window === undefined ? ordered[0] : ordered.find(connection => connection.shellWindow === options.window)
100
- if (target === undefined && options.window !== undefined) {
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
- return Promise.reject(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`))
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 (target === undefined) return Promise.reject(new UiControlError('no-window', 'no page of the app is open to control'))
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
- this.pending.set(id, { connection: target, resolve, reject, timer, cleanup: () => { signal?.removeEventListener('abort', onAbort) } })
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.byRecency()
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
- private byRecency(): Connection[] {
165
- return [...this.connections.values()].sort((a, b) => b.lastActive - a.lastActive)
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.expectAttach(this.deps.pageBackMs ?? PAGE_BACK_MS)
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
- if (!await back) 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?`)
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
- const env = target.electron ? 'export ELECTRON_RUN_AS_NODE=1\n' : ''
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