acrylblends 1.0.8 → 1.0.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/docs/framework/guides/bring-your-own-tool.md +2 -0
- package/lib/{audit-BD8_2pkh.d.ts → audit-Dw-siYTp.d.ts} +7 -1
- package/lib/cordis/door.d.ts +4 -1
- package/lib/cordis/door.js +3 -3
- package/lib/db/conformance.d.ts +6 -2
- package/lib/db/conformance.js +14 -2
- package/lib/door/host.d.ts +1 -1
- package/lib/door/host.js +1 -1
- package/lib/kv/conformance.d.ts +6 -2
- package/lib/kv/conformance.js +14 -2
- package/lib/{snapshot-text-m13Pk-AI.js → snapshot-text-BfEbEOjA.js} +19 -7
- package/lib/vitest-binding-CctNu3jT.d.ts +15 -0
- package/lib/vitest-binding-CrH7rKg2.js +17 -0
- package/package.json +1 -5
- package/plugins/acryl-door/src/host/channel.ts +20 -3
- package/plugins/acryl-door/src/host/door.ts +1 -1
- package/plugins/acryl-door/src/index.ts +2 -1
- package/plugins/acryl-door/src/shell/protocol.ts +3 -0
- package/plugins/agent-panel/client/main.ts +29 -14
- package/plugins/agent-panel/index.ts +3 -0
- package/plugins/desktop/door-shell.ts +6 -0
- package/plugins/desktop/main.ts +17 -5
- package/plugins/desktop/window.ts +21 -7
|
@@ -119,6 +119,8 @@ export async function apply(ctx: Context, config: { path?: string } = {}): Promi
|
|
|
119
119
|
|
|
120
120
|
The suite is the contract: it runs the same cases against every provider. A test gives it a fresh store for each case and a way to reopen the same storage.
|
|
121
121
|
|
|
122
|
+
The suite imports `vitest` from your project, where `--suite` put it as a dev dependency. The same suite is in the package as `acrylblends/kv/conformance` (and `acrylblends/db/conformance`), and there it runs on the vitest your test hands it: `import * as vitest from 'vitest'` and `describeKvConformance('my-provider', open, vitest)`. The package does not declare `vitest` as a peer, so a production install of an app that uses `acrylblends` carries no vitest, vite or native build tools (1.0.10 and later; before that, `pnpm deploy --prod` pulled them in).
|
|
123
|
+
|
|
122
124
|
```ts no-check
|
|
123
125
|
// test/kv-lowdb.spec.ts
|
|
124
126
|
import { mkdir, mkdtemp } from 'node:fs/promises'
|
|
@@ -9,6 +9,11 @@ interface PageStream {
|
|
|
9
9
|
interface ChannelOptions {
|
|
10
10
|
/** How long a page has to acknowledge a read-only call (`look`, `wait`) before the next page is tried. */
|
|
11
11
|
readonly ackMs?: number;
|
|
12
|
+
/**
|
|
13
|
+
* How long a read-only call waits for a page to register when there is none, or when every page was silent and a new one may be on its way (a page that is moving to another
|
|
14
|
+
* address registers again a moment later). 0 (the default) fails at once.
|
|
15
|
+
*/
|
|
16
|
+
readonly attachWaitMs?: number;
|
|
12
17
|
}
|
|
13
18
|
/**
|
|
14
19
|
* 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
|
|
@@ -16,6 +21,7 @@ interface ChannelOptions {
|
|
|
16
21
|
*/
|
|
17
22
|
declare class UiChannel {
|
|
18
23
|
private readonly ackMs;
|
|
24
|
+
private readonly attachWaitMs;
|
|
19
25
|
/** The page the Host sent to a new place and that came back: it keeps being driven until the person focuses another page. */
|
|
20
26
|
private preferred;
|
|
21
27
|
constructor(options?: ChannelOptions);
|
|
@@ -52,7 +58,7 @@ declare class UiChannel {
|
|
|
52
58
|
signal?: AbortSignal;
|
|
53
59
|
timeoutMs?: number;
|
|
54
60
|
window?: number;
|
|
55
|
-
}): Promise<UiResult>;
|
|
61
|
+
}, retried?: boolean): Promise<UiResult>;
|
|
56
62
|
private callOne;
|
|
57
63
|
/**
|
|
58
64
|
* A promise for the next time a page connects (a page that reloaded or moved opens a new stream). Ask for it BEFORE the call that makes the page leave, or a fast page is missed.
|
package/lib/cordis/door.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { f as UiActionResult, y as UiSnapshot } from "../protocol-a2aqrp2X.js";
|
|
2
|
-
import { i as UiChannel, r as PageStream, t as AuditLog } from "../audit-
|
|
2
|
+
import { i as UiChannel, r as PageStream, t as AuditLog } from "../audit-Dw-siYTp.js";
|
|
3
3
|
import { Context } from "@deepseek-ai/cordis";
|
|
4
4
|
import z from "@deepseek-ai/schemastery";
|
|
5
5
|
|
|
@@ -37,6 +37,9 @@ interface ShellWindow {
|
|
|
37
37
|
readonly focused: boolean;
|
|
38
38
|
readonly width: number;
|
|
39
39
|
readonly height: number;
|
|
40
|
+
/** Where the window's top left corner is on the screen (so two windows on top of each other can be told). */
|
|
41
|
+
readonly x?: number;
|
|
42
|
+
readonly y?: number;
|
|
40
43
|
}
|
|
41
44
|
interface ShellMenuItem {
|
|
42
45
|
/** Labels from the top of the menu, joined with `>`: `File>New Note`. */
|
package/lib/cordis/door.js
CHANGED
|
@@ -4,7 +4,7 @@ import { r as loopbackAuthority } from "../loopback-only-BbasoF7R.js";
|
|
|
4
4
|
import "../helpers-BEjcdbgw.js";
|
|
5
5
|
import { n as clientModules } from "../client-modules-drj4mjhj.js";
|
|
6
6
|
import { a as MAX_WAIT_MS, d as parseChannelToHost, l as UiControlError, p as parseUiRequest, r as MAX_SNAPSHOT_NODES, u as WINDOW_ID } from "../protocol-CQ_if7Dg.js";
|
|
7
|
-
import { a as INVALID_JSON, c as operatorOnly, d as AuditLog, f as auditPath, i as DOOR_PREFIX, l as privateApi, o as error, p as UiChannel, r as pageChannelRoutes, s as jsonBody, t as renderSnapshot, u as readJson } from "../snapshot-text-
|
|
7
|
+
import { a as INVALID_JSON, c as operatorOnly, d as AuditLog, f as auditPath, i as DOOR_PREFIX, l as privateApi, o as error, p as UiChannel, r as pageChannelRoutes, s as jsonBody, t as renderSnapshot, u as readJson } from "../snapshot-text-BfEbEOjA.js";
|
|
8
8
|
import "../src-BTBm4OPD.js";
|
|
9
9
|
import "../middleware-BfG_fp8P.js";
|
|
10
10
|
import { isAbsolute, join, relative, resolve } from "node:path";
|
|
@@ -600,7 +600,7 @@ var DoorError = class extends Error {
|
|
|
600
600
|
};
|
|
601
601
|
/** A shell answer as text an agent reads: windows and menu items one to a line. */
|
|
602
602
|
function renderShell(result) {
|
|
603
|
-
if ("windows" in result) return result.windows.length === 0 ? "no windows are open" : result.windows.map((window) => `${String(window.id)} ${JSON.stringify(window.title)} ${window.path} ${String(window.width)}x${String(window.height)}${window.focused ? " focused" : ""}`).join("\n");
|
|
603
|
+
if ("windows" in result) return result.windows.length === 0 ? "no windows are open" : result.windows.map((window) => `${String(window.id)} ${JSON.stringify(window.title)} ${window.path} ${String(window.width)}x${String(window.height)}${window.x === void 0 || window.y === void 0 ? "" : `@${String(window.x)},${String(window.y)}`}${window.focused ? " focused" : ""}`).join("\n");
|
|
604
604
|
if ("items" in result) return result.items.length === 0 ? "the app has no menu" : result.items.map((item) => `${item.path}${item.enabled ? "" : " (disabled)"}${item.ends === true ? " (ends the app; needs force)" : ""}`).join("\n");
|
|
605
605
|
return result.detail;
|
|
606
606
|
}
|
|
@@ -1380,7 +1380,7 @@ function apply(ctx, rawConfig = {}) {
|
|
|
1380
1380
|
ctx.logger?.error?.(`acryl-door: failed to ${operation}: ${cause instanceof Error ? cause.message : String(cause)}`);
|
|
1381
1381
|
};
|
|
1382
1382
|
const secret = newSecret();
|
|
1383
|
-
const channel = new UiChannel();
|
|
1383
|
+
const channel = new UiChannel({ attachWaitMs: 2e3 });
|
|
1384
1384
|
const shell = new ShellChannel();
|
|
1385
1385
|
const door = new Door({
|
|
1386
1386
|
channel,
|
package/lib/db/conformance.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { t as Db } from "../database-DBHdwefi.js";
|
|
2
|
+
import { t as VitestApi } from "../vitest-binding-CctNu3jT.js";
|
|
2
3
|
|
|
3
4
|
//#region ../../plugins/acryl-db/src/conformance.d.ts
|
|
4
5
|
/** A fresh, empty database from the provider under test, and how to close it. */
|
|
@@ -6,6 +7,9 @@ interface DbUnderTest {
|
|
|
6
7
|
readonly db: Db;
|
|
7
8
|
close(): Promise<void>;
|
|
8
9
|
}
|
|
9
|
-
declare function describeDbConformance(provider: string, open: () => Promise<DbUnderTest>): void;
|
|
10
|
+
declare function describeDbConformance$1(provider: string, open: () => Promise<DbUnderTest>): void;
|
|
10
11
|
//#endregion
|
|
11
|
-
|
|
12
|
+
//#region src/db-conformance.d.ts
|
|
13
|
+
declare function describeDbConformance(provider: string, open: Parameters<typeof describeDbConformance$1>[1], vitest: VitestApi): void;
|
|
14
|
+
//#endregion
|
|
15
|
+
export { type DbUnderTest, type VitestApi, describeDbConformance };
|
package/lib/db/conformance.js
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { i as applyMigrations, n as MIGRATIONS_TABLE, r as readMigrationFiles } from "../src-MWcsMS8i.js";
|
|
2
|
+
import { a as it, i as expect, n as bind, r as describe, t as afterEach } from "../vitest-binding-CrH7rKg2.js";
|
|
2
3
|
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
|
3
4
|
import { join } from "node:path";
|
|
4
5
|
import { tmpdir } from "node:os";
|
|
5
|
-
import { afterEach, describe, expect, it } from "vitest";
|
|
6
6
|
//#region ../../plugins/acryl-db/src/conformance.ts
|
|
7
7
|
/**
|
|
8
8
|
* The `db` conformance suite: what every provider of the port must do, as Vitest tests. A provider passes it to be accepted
|
|
@@ -24,7 +24,7 @@ function gate() {
|
|
|
24
24
|
open
|
|
25
25
|
};
|
|
26
26
|
}
|
|
27
|
-
function describeDbConformance(provider, open) {
|
|
27
|
+
function describeDbConformance$1(provider, open) {
|
|
28
28
|
describe(`db conformance: ${provider}`, () => {
|
|
29
29
|
const opened = [];
|
|
30
30
|
const fresh = async () => {
|
|
@@ -264,4 +264,16 @@ function describeDbConformance(provider, open) {
|
|
|
264
264
|
});
|
|
265
265
|
}
|
|
266
266
|
//#endregion
|
|
267
|
+
//#region src/db-conformance.ts
|
|
268
|
+
/**
|
|
269
|
+
* `acrylblends/db/conformance`: the `db` conformance suite a provider of the port passes. It runs on the vitest your test hands it, so the package declares no vitest:
|
|
270
|
+
*
|
|
271
|
+
* import * as vitest from 'vitest'
|
|
272
|
+
* describeDbConformance('my-provider', open, vitest)
|
|
273
|
+
*/
|
|
274
|
+
function describeDbConformance(provider, open, vitest) {
|
|
275
|
+
bind(vitest);
|
|
276
|
+
describeDbConformance$1(provider, open);
|
|
277
|
+
}
|
|
278
|
+
//#endregion
|
|
267
279
|
export { describeDbConformance };
|
package/lib/door/host.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { t as Acryl } from "../app-D8riatvv.js";
|
|
2
2
|
import { n as AuditEntry, y as UiSnapshot } from "../protocol-a2aqrp2X.js";
|
|
3
|
-
import { i as UiChannel, n as auditPath, r as PageStream, t as AuditLog } from "../audit-
|
|
3
|
+
import { i as UiChannel, n as auditPath, r as PageStream, t as AuditLog } from "../audit-Dw-siYTp.js";
|
|
4
4
|
|
|
5
5
|
//#region ../../plugins/acryl-door/src/host/page-routes.d.ts
|
|
6
6
|
/** How often the Host writes a `ping`; the page gives up on a stream that is silent for three of these. */
|
package/lib/door/host.js
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
import { d as AuditLog, f as auditPath, n as HEARTBEAT_MS, p as UiChannel, r as pageChannelRoutes, t as renderSnapshot } from "../snapshot-text-
|
|
1
|
+
import { d as AuditLog, f as auditPath, n as HEARTBEAT_MS, p as UiChannel, r as pageChannelRoutes, t as renderSnapshot } from "../snapshot-text-BfEbEOjA.js";
|
|
2
2
|
export { AuditLog, HEARTBEAT_MS, UiChannel, auditPath, pageChannelRoutes, renderSnapshot };
|
package/lib/kv/conformance.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { t as VitestApi } from "../vitest-binding-CctNu3jT.js";
|
|
1
2
|
import { n as Kv } from "../kv-9f-AlRKN.js";
|
|
2
3
|
|
|
3
4
|
//#region ../../plugins/acryl-kv/src/conformance.d.ts
|
|
@@ -8,6 +9,9 @@ interface KvUnderTest {
|
|
|
8
9
|
/** Close this store (if it is still open) and open the same storage again, to prove what was written is durable. */
|
|
9
10
|
reopen(): Promise<KvUnderTest>;
|
|
10
11
|
}
|
|
11
|
-
declare function describeKvConformance(provider: string, open: () => Promise<KvUnderTest>): void;
|
|
12
|
+
declare function describeKvConformance$1(provider: string, open: () => Promise<KvUnderTest>): void;
|
|
12
13
|
//#endregion
|
|
13
|
-
|
|
14
|
+
//#region src/kv-conformance.d.ts
|
|
15
|
+
declare function describeKvConformance(provider: string, open: Parameters<typeof describeKvConformance$1>[1], vitest: VitestApi): void;
|
|
16
|
+
//#endregion
|
|
17
|
+
export { type KvUnderTest, type VitestApi, describeKvConformance };
|
package/lib/kv/conformance.js
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { a as it, i as expect, n as bind, r as describe, t as afterEach } from "../vitest-binding-CrH7rKg2.js";
|
|
2
2
|
//#region ../../plugins/acryl-kv/src/conformance.ts
|
|
3
3
|
/**
|
|
4
4
|
* The `kv` conformance suite: what every provider of the port must do, as Vitest tests. A provider passes it to be accepted
|
|
5
5
|
* (`describeKvConformance('acryl-kv-json', open)` in its own tests), so the same cases run on a file and on SQL.
|
|
6
6
|
*/
|
|
7
|
-
function describeKvConformance(provider, open) {
|
|
7
|
+
function describeKvConformance$1(provider, open) {
|
|
8
8
|
describe(`kv conformance: ${provider}`, () => {
|
|
9
9
|
const opened = [];
|
|
10
10
|
const track = (subject) => {
|
|
@@ -298,4 +298,16 @@ function describeKvConformance(provider, open) {
|
|
|
298
298
|
});
|
|
299
299
|
}
|
|
300
300
|
//#endregion
|
|
301
|
+
//#region src/kv-conformance.ts
|
|
302
|
+
/**
|
|
303
|
+
* `acrylblends/kv/conformance`: the `kv` conformance suite a provider of the port passes. It runs on the vitest your test hands it, so the package declares no vitest:
|
|
304
|
+
*
|
|
305
|
+
* import * as vitest from 'vitest'
|
|
306
|
+
* describeKvConformance('my-provider', open, vitest)
|
|
307
|
+
*/
|
|
308
|
+
function describeKvConformance(provider, open, vitest) {
|
|
309
|
+
bind(vitest);
|
|
310
|
+
describeKvConformance$1(provider, open);
|
|
311
|
+
}
|
|
312
|
+
//#endregion
|
|
301
313
|
export { describeKvConformance };
|
|
@@ -34,10 +34,12 @@ const DEFAULT_ACK_MS = 3e3;
|
|
|
34
34
|
*/
|
|
35
35
|
var UiChannel = class {
|
|
36
36
|
ackMs;
|
|
37
|
+
attachWaitMs;
|
|
37
38
|
/** The page the Host sent to a new place and that came back: it keeps being driven until the person focuses another page. */
|
|
38
39
|
preferred;
|
|
39
40
|
constructor(options = {}) {
|
|
40
41
|
this.ackMs = options.ackMs ?? DEFAULT_ACK_MS;
|
|
42
|
+
this.attachWaitMs = options.attachWaitMs ?? 0;
|
|
41
43
|
}
|
|
42
44
|
connections = /* @__PURE__ */ new Map();
|
|
43
45
|
pending = /* @__PURE__ */ new Map();
|
|
@@ -114,8 +116,10 @@ var UiChannel = class {
|
|
|
114
116
|
* until it speaks; a call that changes the page is never sent to a second page, because the first might still run it.
|
|
115
117
|
* @throws UiControlError `no-window`, `timeout`, `aborted`, `unloaded`, `disconnected`, or whatever the page refused with.
|
|
116
118
|
*/
|
|
117
|
-
async call(request, options = {}) {
|
|
119
|
+
async call(request, options = {}, retried = false) {
|
|
118
120
|
if (this.closed) throw new UiControlError("unloaded", "the door was unloaded");
|
|
121
|
+
const readOnlyCall = request.op === "snapshot" || request.op === "wait";
|
|
122
|
+
if (readOnlyCall && this.attachWaitMs > 0 && this.connections.size === 0) await this.expectNewPage(this.attachWaitMs);
|
|
119
123
|
const ordered = this.ordered();
|
|
120
124
|
const candidates = options.window === void 0 ? ordered : ordered.filter((connection) => connection.shellWindow === options.window);
|
|
121
125
|
if (candidates.length === 0 && options.window !== void 0) {
|
|
@@ -124,13 +128,21 @@ var UiChannel = class {
|
|
|
124
128
|
}
|
|
125
129
|
if (candidates.length === 0) throw new UiControlError("no-window", "no page of the app is open to control");
|
|
126
130
|
if (options.signal?.aborted === true) throw new UiControlError("aborted", "the call was cancelled");
|
|
127
|
-
const readOnly =
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
131
|
+
const readOnly = readOnlyCall;
|
|
132
|
+
const tried = /* @__PURE__ */ new Set();
|
|
133
|
+
for (const target of candidates) {
|
|
134
|
+
tried.add(target.windowId);
|
|
135
|
+
try {
|
|
136
|
+
return await this.callOne(target, request, options, readOnly);
|
|
137
|
+
} catch (cause) {
|
|
138
|
+
if (!(cause instanceof Silent)) throw cause;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
if (readOnly && !retried && this.attachWaitMs > 0 && options.window === void 0) {
|
|
142
|
+
const fresh = await this.expectNewPage(this.attachWaitMs);
|
|
143
|
+
if (fresh !== void 0 && !tried.has(fresh)) return await this.call(request, options, true);
|
|
132
144
|
}
|
|
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
|
|
145
|
+
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. Likely causes: a tab in the background, a page that is still loading after a navigation (look again), a page busy running a long script, or a page that holds the browser's six connections with event streams of its own (a page should keep to two). Bring the app's tab to the front, or navigate it, then look again`);
|
|
134
146
|
}
|
|
135
147
|
callOne(target, request, options, failOver) {
|
|
136
148
|
const signal = options.signal;
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
//#region src/vitest-binding.d.ts
|
|
2
|
+
/**
|
|
3
|
+
* The conformance suites (`acrylblends/db/conformance`, `acrylblends/kv/conformance`) run on the test runner's own vitest, which the test hands them. In the package build their
|
|
4
|
+
* `import ... from 'vitest'` is pointed here (`tsdown.config.ts`), so the package imports no vitest and does not declare one: it cannot put vitest, vite, rolldown or lightningcss into
|
|
5
|
+
* a production install, and the suite can never see a second copy of vitest that is not the one running the test.
|
|
6
|
+
*/
|
|
7
|
+
/** The four functions of vitest the suites use; a test passes `import * as vitest from 'vitest'`. */
|
|
8
|
+
interface VitestApi {
|
|
9
|
+
readonly afterEach: (...args: never[]) => unknown;
|
|
10
|
+
readonly describe: (...args: never[]) => unknown;
|
|
11
|
+
readonly expect: (...args: never[]) => unknown;
|
|
12
|
+
readonly it: (...args: never[]) => unknown;
|
|
13
|
+
}
|
|
14
|
+
//#endregion
|
|
15
|
+
export { VitestApi as t };
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
//#region src/vitest-binding.ts
|
|
2
|
+
let bound;
|
|
3
|
+
/** Use `api` for the suites that run next. They register their cases synchronously, so one call per suite is enough. */
|
|
4
|
+
function bind(api) {
|
|
5
|
+
if (api === void 0 || typeof api.describe !== "function" || typeof api.it !== "function" || typeof api.expect !== "function" || typeof api.afterEach !== "function") throw new Error("the conformance suite needs the vitest your tests run on: pass it as the third argument, `import * as vitest from 'vitest'` then `describeKvConformance(name, open, vitest)`");
|
|
6
|
+
bound = api;
|
|
7
|
+
}
|
|
8
|
+
const use = () => {
|
|
9
|
+
if (bound === void 0) throw new Error("the conformance suite ran before it was given vitest");
|
|
10
|
+
return bound;
|
|
11
|
+
};
|
|
12
|
+
const afterEach = (...args) => Reflect.apply(use().afterEach, void 0, args);
|
|
13
|
+
const describe = (...args) => Reflect.apply(use().describe, void 0, args);
|
|
14
|
+
const expect = (...args) => Reflect.apply(use().expect, void 0, args);
|
|
15
|
+
const it = (...args) => Reflect.apply(use().it, void 0, args);
|
|
16
|
+
//#endregion
|
|
17
|
+
export { it as a, expect as i, bind as n, describe as r, afterEach as t };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "acrylblends",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.10",
|
|
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",
|
|
@@ -436,7 +436,6 @@
|
|
|
436
436
|
"@deepseek-ai/cordis": "^4.0.2",
|
|
437
437
|
"@deepseek-ai/schemastery": "~3.18.4",
|
|
438
438
|
"react": ">=18",
|
|
439
|
-
"vitest": ">=3",
|
|
440
439
|
"ws": ">=8"
|
|
441
440
|
},
|
|
442
441
|
"peerDependenciesMeta": {
|
|
@@ -449,9 +448,6 @@
|
|
|
449
448
|
"react": {
|
|
450
449
|
"optional": true
|
|
451
450
|
},
|
|
452
|
-
"vitest": {
|
|
453
|
-
"optional": true
|
|
454
|
-
},
|
|
455
451
|
"ws": {
|
|
456
452
|
"optional": true
|
|
457
453
|
}
|
|
@@ -57,6 +57,11 @@ const DEFAULT_ACK_MS = 3000
|
|
|
57
57
|
export interface ChannelOptions {
|
|
58
58
|
/** How long a page has to acknowledge a read-only call (`look`, `wait`) before the next page is tried. */
|
|
59
59
|
readonly ackMs?: number
|
|
60
|
+
/**
|
|
61
|
+
* How long a read-only call waits for a page to register when there is none, or when every page was silent and a new one may be on its way (a page that is moving to another
|
|
62
|
+
* address registers again a moment later). 0 (the default) fails at once.
|
|
63
|
+
*/
|
|
64
|
+
readonly attachWaitMs?: number
|
|
60
65
|
}
|
|
61
66
|
|
|
62
67
|
/**
|
|
@@ -65,11 +70,13 @@ export interface ChannelOptions {
|
|
|
65
70
|
*/
|
|
66
71
|
export class UiChannel {
|
|
67
72
|
private readonly ackMs: number
|
|
73
|
+
private readonly attachWaitMs: number
|
|
68
74
|
/** The page the Host sent to a new place and that came back: it keeps being driven until the person focuses another page. */
|
|
69
75
|
private preferred: string | undefined
|
|
70
76
|
|
|
71
77
|
constructor(options: ChannelOptions = {}) {
|
|
72
78
|
this.ackMs = options.ackMs ?? DEFAULT_ACK_MS
|
|
79
|
+
this.attachWaitMs = options.attachWaitMs ?? 0
|
|
73
80
|
}
|
|
74
81
|
|
|
75
82
|
private readonly connections = new Map<string, Connection>()
|
|
@@ -143,8 +150,11 @@ export class UiChannel {
|
|
|
143
150
|
* until it speaks; a call that changes the page is never sent to a second page, because the first might still run it.
|
|
144
151
|
* @throws UiControlError `no-window`, `timeout`, `aborted`, `unloaded`, `disconnected`, or whatever the page refused with.
|
|
145
152
|
*/
|
|
146
|
-
async call(request: UiRequest, options: { signal?: AbortSignal; timeoutMs?: number; window?: number } = {}): Promise<UiResult> {
|
|
153
|
+
async call(request: UiRequest, options: { signal?: AbortSignal; timeoutMs?: number; window?: number } = {}, retried = false): Promise<UiResult> {
|
|
147
154
|
if (this.closed) throw new UiControlError('unloaded', 'the door was unloaded')
|
|
155
|
+
const readOnlyCall = request.op === 'snapshot' || request.op === 'wait'
|
|
156
|
+
// A page that is moving to another address registers again a moment later: a read-only call right after a navigate or reload waits for it, rather than failing.
|
|
157
|
+
if (readOnlyCall && this.attachWaitMs > 0 && this.connections.size === 0) await this.expectNewPage(this.attachWaitMs)
|
|
148
158
|
const ordered = this.ordered()
|
|
149
159
|
// `window` is the number `window list` prints: that desktop window's page, not the one used last.
|
|
150
160
|
const candidates = options.window === undefined ? ordered : ordered.filter(connection => connection.shellWindow === options.window)
|
|
@@ -154,15 +164,22 @@ export class UiChannel {
|
|
|
154
164
|
}
|
|
155
165
|
if (candidates.length === 0) throw new UiControlError('no-window', 'no page of the app is open to control')
|
|
156
166
|
if (options.signal?.aborted === true) throw new UiControlError('aborted', 'the call was cancelled')
|
|
157
|
-
const readOnly =
|
|
167
|
+
const readOnly = readOnlyCall
|
|
168
|
+
const tried = new Set<string>()
|
|
158
169
|
for (const target of candidates) {
|
|
170
|
+
tried.add(target.windowId)
|
|
159
171
|
try {
|
|
160
172
|
return await this.callOne(target, request, options, readOnly)
|
|
161
173
|
} catch (cause) {
|
|
162
174
|
if (!(cause instanceof Silent)) throw cause
|
|
163
175
|
}
|
|
164
176
|
}
|
|
165
|
-
|
|
177
|
+
// Every page was silent. A page that registered meanwhile (the one that was moving) gets the call once.
|
|
178
|
+
if (readOnly && !retried && this.attachWaitMs > 0 && options.window === undefined) {
|
|
179
|
+
const fresh = await this.expectNewPage(this.attachWaitMs)
|
|
180
|
+
if (fresh !== undefined && !tried.has(fresh)) return await this.call(request, options, true)
|
|
181
|
+
}
|
|
182
|
+
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. Likely causes: a tab in the background, a page that is still loading after a navigation (look again), a page busy running a long script, or a page that holds the browser's six connections with event streams of its own (a page should keep to two). Bring the app's tab to the front, or navigate it, then look again`)
|
|
166
183
|
}
|
|
167
184
|
|
|
168
185
|
private callOne(target: Connection, request: UiRequest, options: { signal?: AbortSignal; timeoutMs?: number }, failOver: boolean): Promise<UiResult> {
|
|
@@ -71,7 +71,7 @@ function renderShell(result: ShellResult): string {
|
|
|
71
71
|
if ('windows' in result) {
|
|
72
72
|
return result.windows.length === 0
|
|
73
73
|
? 'no windows are open'
|
|
74
|
-
: result.windows.map(window => `${String(window.id)} ${JSON.stringify(window.title)} ${window.path} ${String(window.width)}x${String(window.height)}${window.focused ? ' focused' : ''}`).join('\n')
|
|
74
|
+
: result.windows.map(window => `${String(window.id)} ${JSON.stringify(window.title)} ${window.path} ${String(window.width)}x${String(window.height)}${window.x === undefined || window.y === undefined ? '' : `@${String(window.x)},${String(window.y)}`}${window.focused ? ' focused' : ''}`).join('\n')
|
|
75
75
|
}
|
|
76
76
|
if ('items' in result) return result.items.length === 0 ? 'the app has no menu' : result.items.map(item => `${item.path}${item.enabled ? '' : ' (disabled)'}${item.ends === true ? ' (ends the app; needs force)' : ''}`).join('\n')
|
|
77
77
|
return result.detail
|
|
@@ -115,7 +115,8 @@ export function apply(ctx: Context, rawConfig: Config = {}): void {
|
|
|
115
115
|
ctx.logger?.error?.(`acryl-door: failed to ${operation}: ${cause instanceof Error ? cause.message : String(cause)}`)
|
|
116
116
|
}
|
|
117
117
|
const secret = newSecret()
|
|
118
|
-
|
|
118
|
+
// A look right after a navigate or reload waits up to two seconds for the page that is moving to register again, and a read-only call that no page acknowledged gives a page that just registered one more try.
|
|
119
|
+
const channel = new UiChannel({ attachWaitMs: 2000 })
|
|
119
120
|
const shell = new ShellChannel()
|
|
120
121
|
const audit = new AuditLog(auditPath(stateDir(app.root)))
|
|
121
122
|
const door = new Door({
|
|
@@ -27,6 +27,9 @@ export interface ShellWindow {
|
|
|
27
27
|
readonly focused: boolean
|
|
28
28
|
readonly width: number
|
|
29
29
|
readonly height: number
|
|
30
|
+
/** Where the window's top left corner is on the screen (so two windows on top of each other can be told). */
|
|
31
|
+
readonly x?: number
|
|
32
|
+
readonly y?: number
|
|
30
33
|
}
|
|
31
34
|
|
|
32
35
|
export interface ShellMenuItem {
|
|
@@ -316,25 +316,38 @@ async function start(): Promise<void> {
|
|
|
316
316
|
*/
|
|
317
317
|
const watch = async (source: RunSource, own: boolean): Promise<void> => {
|
|
318
318
|
let live = true
|
|
319
|
-
following = true
|
|
320
|
-
send.disabled = true
|
|
321
|
-
stop.disabled = false
|
|
322
319
|
let changed = false
|
|
323
320
|
let answer: HTMLElement | undefined
|
|
324
321
|
let answered = ''
|
|
325
|
-
// A coding agent started headless says little until it is done, so the panel says it is working, and for how long.
|
|
326
|
-
|
|
327
|
-
|
|
322
|
+
// A coding agent started headless says little until it is done, so the panel says it is working, and for how long (from when the run began, not from when this page loaded).
|
|
323
|
+
let started = Date.now()
|
|
324
|
+
let working: HTMLElement | undefined
|
|
325
|
+
let timer: ReturnType<typeof setInterval> | undefined
|
|
328
326
|
const tick = (): void => {
|
|
329
|
-
|
|
327
|
+
if (working === undefined) return
|
|
328
|
+
const seconds = Math.max(0, Math.floor((Date.now() - started) / 1000))
|
|
330
329
|
working.textContent = `Working... ${String(Math.floor(seconds / 60))}:${String(seconds % 60).padStart(2, '0')}`
|
|
331
330
|
log.append(working)
|
|
332
331
|
}
|
|
333
|
-
|
|
334
|
-
|
|
332
|
+
// The panel is busy once there is a run to show. A page that just loaded asks the server for the latest run first, and "there is none" must not make a message typed in that moment
|
|
333
|
+
// wait or vanish, so a run that is only being looked for does not count (it begins at its first event).
|
|
334
|
+
const begin = (): void => {
|
|
335
|
+
if (working !== undefined) return
|
|
336
|
+
following = true
|
|
337
|
+
send.disabled = true
|
|
338
|
+
stop.disabled = false
|
|
339
|
+
working = line('', 'working')
|
|
340
|
+
tick()
|
|
341
|
+
timer = setInterval(tick, 1000)
|
|
342
|
+
}
|
|
343
|
+
if (own) begin()
|
|
335
344
|
try {
|
|
336
345
|
for await (const { event, data } of source.events) {
|
|
346
|
+
if (event === 'idle') continue
|
|
347
|
+
begin()
|
|
337
348
|
if (event === 'run') {
|
|
349
|
+
if (typeof data.startedAt === 'number') started = data.startedAt
|
|
350
|
+
tick()
|
|
338
351
|
live = data.ended !== true
|
|
339
352
|
if (!own) {
|
|
340
353
|
if (typeof data.message === 'string') line(data.message, 'me')
|
|
@@ -365,11 +378,13 @@ async function start(): Promise<void> {
|
|
|
365
378
|
} catch (error) {
|
|
366
379
|
line(`The request failed: ${error instanceof Error ? error.message : String(error)}`, 'err')
|
|
367
380
|
} finally {
|
|
368
|
-
clearInterval(timer)
|
|
369
|
-
working
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
381
|
+
if (timer !== undefined) clearInterval(timer)
|
|
382
|
+
if (working !== undefined) {
|
|
383
|
+
working.remove()
|
|
384
|
+
following = false
|
|
385
|
+
send.disabled = false
|
|
386
|
+
stop.disabled = true
|
|
387
|
+
}
|
|
373
388
|
}
|
|
374
389
|
// A plugin was installed or removed while this page watched: the new feature is served by the running app, so the page only needs to be loaded again (unless something is being typed).
|
|
375
390
|
// A run that ended before this page loaded was already shown after its reload, and does not reload it again.
|
|
@@ -175,6 +175,7 @@ export function apply(ctx: Context, config: PanelConfig = {}): void {
|
|
|
175
175
|
readonly id: number
|
|
176
176
|
readonly driver: string
|
|
177
177
|
readonly message: string
|
|
178
|
+
readonly startedAt: number
|
|
178
179
|
readonly events: Array<{ event: string; data: unknown }>
|
|
179
180
|
readonly followers: Set<(item: { event: string; data: unknown }) => void>
|
|
180
181
|
readonly cancel: AbortController
|
|
@@ -189,6 +190,7 @@ export function apply(ctx: Context, config: PanelConfig = {}): void {
|
|
|
189
190
|
id: runs,
|
|
190
191
|
driver: driverName,
|
|
191
192
|
message,
|
|
193
|
+
startedAt: Date.now(),
|
|
192
194
|
events: [],
|
|
193
195
|
followers: new Set(),
|
|
194
196
|
cancel: new AbortController(),
|
|
@@ -241,6 +243,7 @@ export function apply(ctx: Context, config: PanelConfig = {}): void {
|
|
|
241
243
|
id: run.id,
|
|
242
244
|
driver: run.driver,
|
|
243
245
|
message: run.message,
|
|
246
|
+
startedAt: run.startedAt,
|
|
244
247
|
ended: run.ended,
|
|
245
248
|
endedAt: run.endedAt ?? null,
|
|
246
249
|
},
|
|
@@ -17,6 +17,7 @@ export interface WindowPort {
|
|
|
17
17
|
isFocused(): boolean
|
|
18
18
|
isMinimized(): boolean
|
|
19
19
|
getSize(): number[]
|
|
20
|
+
getPosition(): number[]
|
|
20
21
|
restore(): void
|
|
21
22
|
focus(): void
|
|
22
23
|
close(): void
|
|
@@ -68,6 +69,8 @@ export type ShellResult =
|
|
|
68
69
|
focused: boolean
|
|
69
70
|
width: number
|
|
70
71
|
height: number
|
|
72
|
+
x?: number
|
|
73
|
+
y?: number
|
|
71
74
|
}[]
|
|
72
75
|
}
|
|
73
76
|
| { readonly items: readonly { path: string; label: string; enabled: boolean; ends?: boolean }[] }
|
|
@@ -133,6 +136,7 @@ export function handleShellCall(request: ShellRequest, desktop: DesktopPort): Sh
|
|
|
133
136
|
return {
|
|
134
137
|
windows: desktop.windows().map((window) => {
|
|
135
138
|
const [width = 0, height = 0] = window.getSize()
|
|
139
|
+
const [x = 0, y = 0] = window.getPosition()
|
|
136
140
|
return {
|
|
137
141
|
id: window.id,
|
|
138
142
|
title: window.getTitle(),
|
|
@@ -140,6 +144,8 @@ export function handleShellCall(request: ShellRequest, desktop: DesktopPort): Sh
|
|
|
140
144
|
focused: window.isFocused(),
|
|
141
145
|
width,
|
|
142
146
|
height,
|
|
147
|
+
x,
|
|
148
|
+
y,
|
|
143
149
|
}
|
|
144
150
|
}),
|
|
145
151
|
}
|
package/plugins/desktop/main.ts
CHANGED
|
@@ -91,6 +91,8 @@ if (!app.requestSingleInstanceLock()) {
|
|
|
91
91
|
}
|
|
92
92
|
const origin = new URL(url).origin
|
|
93
93
|
/** One window on a path of the app. Navigation stays on the app's own server; any other address opens in the system browser. The user agent names the window to the door (`AcrylWindow/<id>`), so an agent can drive it with `--window <id>`. */
|
|
94
|
+
// Where each window was opened: `window list` shows it until the page reports its own address (Electron gives none while the first load has not started).
|
|
95
|
+
const openedOn = new Map<number, string>()
|
|
94
96
|
const createWindow = (path: string): BrowserWindow => {
|
|
95
97
|
// A window opened while another is open sits a step down and to the right of it, not exactly on top.
|
|
96
98
|
const beside =
|
|
@@ -99,12 +101,20 @@ if (!app.requestSingleInstanceLock()) {
|
|
|
99
101
|
const where = (() => {
|
|
100
102
|
if (beside === undefined) return {}
|
|
101
103
|
const bounds = beside.getBounds()
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
104
|
+
// The places of the windows that are open: a new window never goes exactly where one already is.
|
|
105
|
+
const taken = BrowserWindow.getAllWindows()
|
|
106
|
+
.filter((other) => !other.isDestroyed())
|
|
107
|
+
.map((other) => other.getPosition())
|
|
108
|
+
.map(([x = 0, y = 0]) => ({ x, y }))
|
|
109
|
+
return cascade(
|
|
110
|
+
bounds,
|
|
111
|
+
screen.getDisplayMatching(bounds).workArea,
|
|
112
|
+
{ width: 1100, height: 760 },
|
|
113
|
+
taken,
|
|
114
|
+
)
|
|
106
115
|
})()
|
|
107
116
|
const created = new BrowserWindow(windowOptions({ title: chosen.name, ...where }))
|
|
117
|
+
openedOn.set(created.id, path)
|
|
108
118
|
created.webContents.setUserAgent(
|
|
109
119
|
`${created.webContents.getUserAgent()} AcrylWindow/${String(created.id)}`,
|
|
110
120
|
)
|
|
@@ -132,10 +142,12 @@ if (!app.requestSingleInstanceLock()) {
|
|
|
132
142
|
const port = (candidate: BrowserWindow): WindowPort => ({
|
|
133
143
|
id: candidate.id,
|
|
134
144
|
getTitle: () => candidate.getTitle(),
|
|
135
|
-
getURL: () =>
|
|
145
|
+
getURL: () =>
|
|
146
|
+
candidate.webContents.getURL() || new URL(openedOn.get(candidate.id) ?? '/', url).href,
|
|
136
147
|
isFocused: () => candidate.isFocused(),
|
|
137
148
|
isMinimized: () => candidate.isMinimized(),
|
|
138
149
|
getSize: () => candidate.getSize(),
|
|
150
|
+
getPosition: () => candidate.getPosition(),
|
|
139
151
|
restore: () => candidate.restore(),
|
|
140
152
|
focus: () => candidate.focus(),
|
|
141
153
|
close: () => candidate.close(),
|
|
@@ -20,19 +20,33 @@ export interface Rect {
|
|
|
20
20
|
export const CASCADE_STEP = 32
|
|
21
21
|
|
|
22
22
|
/**
|
|
23
|
-
* Where a new window goes so that it does not cover another one exactly: down and to the right of `from
|
|
24
|
-
*
|
|
23
|
+
* Where a new window goes so that it does not cover another one exactly. The first free place of: a step down and to the right of `from`, and again, while the window still fits on the
|
|
24
|
+
* screen; then the same steps from the top left of the work area (a row of windows cascades, then starts again); and, on a screen no bigger than the window, the steps from `from` even
|
|
25
|
+
* though the window runs past the edge (two windows at one place cannot be told apart). A place is free when no window in `taken` is there.
|
|
25
26
|
*/
|
|
26
27
|
export function cascade(
|
|
27
28
|
from: Rect,
|
|
28
29
|
work: Rect,
|
|
29
30
|
size: { width: number; height: number },
|
|
31
|
+
taken: readonly { x: number; y: number }[] = [],
|
|
30
32
|
): { x: number; y: number } {
|
|
31
|
-
const
|
|
32
|
-
|
|
33
|
-
const
|
|
34
|
-
|
|
35
|
-
|
|
33
|
+
const free = (place: { x: number; y: number }): boolean =>
|
|
34
|
+
!taken.some((other) => other.x === place.x && other.y === place.y)
|
|
35
|
+
const fits = (place: { x: number; y: number }): boolean =>
|
|
36
|
+
place.x + size.width <= work.x + work.width && place.y + size.height <= work.y + work.height
|
|
37
|
+
const steps = (origin: { x: number; y: number }): { x: number; y: number }[] =>
|
|
38
|
+
Array.from({ length: 40 }, (_, at) => ({
|
|
39
|
+
x: origin.x + CASCADE_STEP * (at + 1),
|
|
40
|
+
y: origin.y + CASCADE_STEP * (at + 1),
|
|
41
|
+
}))
|
|
42
|
+
const fromHere = steps(from)
|
|
43
|
+
const fromCorner = [{ x: work.x, y: work.y }, ...steps(work)]
|
|
44
|
+
return (
|
|
45
|
+
fromHere.find((place) => fits(place) && free(place)) ??
|
|
46
|
+
fromCorner.find((place) => fits(place) && free(place)) ??
|
|
47
|
+
fromHere.find(free) ??
|
|
48
|
+
fromHere[0] ?? { x: from.x + CASCADE_STEP, y: from.y + CASCADE_STEP }
|
|
49
|
+
)
|
|
36
50
|
}
|
|
37
51
|
|
|
38
52
|
/**
|