@fungi.computer/agar 0.0.0-stage → 0.0.1
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/LICENSE +21 -0
- package/README.md +766 -2
- package/dist/agent-message-meta.d.ts +74 -0
- package/dist/agent-message-meta.js +63 -0
- package/dist/agents.d.ts +170 -0
- package/dist/agents.js +218 -0
- package/dist/app-build-contract.d.ts +43 -0
- package/dist/app-build-contract.js +24 -0
- package/dist/apps.d.ts +66 -0
- package/dist/apps.js +47 -0
- package/dist/backend-declaration.d.ts +6 -0
- package/dist/backend-declaration.js +65 -0
- package/dist/backend-definition.d.ts +88 -0
- package/dist/backend-definition.js +158 -0
- package/dist/backend-refusal.d.ts +10 -0
- package/dist/backend-refusal.js +10 -0
- package/dist/backend.d.ts +30 -0
- package/dist/backend.js +28 -0
- package/dist/capabilities.d.ts +30 -0
- package/dist/capabilities.js +50 -0
- package/dist/channels.d.ts +142 -0
- package/dist/channels.js +97 -0
- package/dist/commands.d.ts +33 -0
- package/dist/commands.js +46 -0
- package/dist/computer-contracts.d.ts +73 -0
- package/dist/computer-contracts.js +57 -0
- package/dist/computer.d.ts +294 -0
- package/dist/computer.js +319 -0
- package/dist/connect.d.ts +11 -0
- package/dist/connect.js +211 -0
- package/dist/errors.d.ts +34 -0
- package/dist/errors.js +106 -0
- package/dist/failure.d.ts +2 -0
- package/dist/failure.js +9 -0
- package/dist/files.d.ts +25 -0
- package/dist/files.js +84 -0
- package/dist/game-servers.d.ts +530 -0
- package/dist/game-servers.js +244 -0
- package/dist/gills.d.ts +43 -0
- package/dist/gills.js +174 -0
- package/dist/git-commit.d.ts +143 -0
- package/dist/git-commit.js +251 -0
- package/dist/git-rpc.d.ts +310 -0
- package/dist/git-rpc.js +166 -0
- package/dist/git-steps.d.ts +579 -0
- package/dist/git-steps.js +804 -0
- package/dist/git-wire.d.ts +33 -0
- package/dist/git-wire.js +240 -0
- package/dist/git.d.ts +322 -0
- package/dist/git.js +98 -0
- package/dist/host.d.ts +97 -0
- package/dist/host.js +732 -0
- package/dist/inbox.d.ts +99 -0
- package/dist/inbox.js +184 -0
- package/dist/index.d.ts +31 -0
- package/dist/index.js +15 -0
- package/dist/inference.d.ts +17 -0
- package/dist/inference.js +115 -0
- package/dist/internal/app-error.d.ts +8 -0
- package/dist/internal/app-error.js +36 -0
- package/dist/internal/app-session.d.ts +8 -0
- package/dist/internal/app-session.js +551 -0
- package/dist/internal/back-inputs.d.ts +22 -0
- package/dist/internal/back-inputs.js +93 -0
- package/dist/internal/browser-bridge.d.ts +30 -0
- package/dist/internal/browser-bridge.js +203 -0
- package/dist/internal/capabilities.d.ts +13 -0
- package/dist/internal/capabilities.js +138 -0
- package/dist/internal/commands.d.ts +9 -0
- package/dist/internal/commands.js +257 -0
- package/dist/internal/disconnect-host.d.ts +7 -0
- package/dist/internal/disconnect-host.js +19 -0
- package/dist/internal/git-edit-tree.d.ts +6 -0
- package/dist/internal/git-edit-tree.js +46 -0
- package/dist/internal/memory-fs.d.ts +39 -0
- package/dist/internal/memory-fs.js +154 -0
- package/dist/internal/protocol.d.ts +116 -0
- package/dist/internal/protocol.js +6 -0
- package/dist/internal/query-feed.d.ts +23 -0
- package/dist/internal/query-feed.js +111 -0
- package/dist/internal/result.d.ts +22 -0
- package/dist/internal/result.js +61 -0
- package/dist/internal/rpc-contract.d.ts +24 -0
- package/dist/internal/rpc-contract.js +78 -0
- package/dist/internal/schemas.d.ts +307 -0
- package/dist/internal/schemas.js +524 -0
- package/dist/internal/store.d.ts +8 -0
- package/dist/internal/store.js +30 -0
- package/dist/internal/terminal-gate.d.ts +17 -0
- package/dist/internal/terminal-gate.js +94 -0
- package/dist/lifecycle.d.ts +44 -0
- package/dist/lifecycle.js +1 -0
- package/dist/marketplace.d.ts +159 -0
- package/dist/marketplace.js +102 -0
- package/dist/media-json.d.ts +181 -0
- package/dist/media-json.js +204 -0
- package/dist/media-library.d.ts +11 -0
- package/dist/media-library.js +15 -0
- package/dist/media-request-id.d.ts +7 -0
- package/dist/media-request-id.js +16 -0
- package/dist/media.d.ts +354 -0
- package/dist/media.js +654 -0
- package/dist/method-refusal.d.ts +64 -0
- package/dist/method-refusal.js +48 -0
- package/dist/pending.d.ts +10 -0
- package/dist/pending.js +10 -0
- package/dist/query.d.ts +76 -0
- package/dist/query.js +195 -0
- package/dist/react-mount.d.ts +23 -0
- package/dist/react-mount.js +108 -0
- package/dist/react.d.ts +41 -0
- package/dist/react.js +111 -0
- package/dist/read-copy.d.ts +103 -0
- package/dist/read-copy.js +195 -0
- package/dist/roles/files.d.ts +295 -0
- package/dist/roles/files.js +184 -0
- package/dist/root.d.ts +21 -0
- package/dist/root.js +1 -0
- package/dist/rpc.d.ts +1 -0
- package/dist/rpc.js +1 -0
- package/dist/status.d.ts +17 -0
- package/dist/status.js +50 -0
- package/dist/storage-wire.d.ts +167 -0
- package/dist/storage-wire.js +115 -0
- package/dist/storage.d.ts +72 -0
- package/dist/storage.js +143 -0
- package/dist/store.d.ts +11 -0
- package/dist/store.js +1 -0
- package/dist/test-kit/git.d.ts +31 -0
- package/dist/test-kit/git.js +174 -0
- package/dist/ui.d.ts +169 -0
- package/dist/ui.js +10 -0
- package/dist/window.d.ts +41 -0
- package/dist/window.js +15 -0
- package/dist/world.d.ts +16 -0
- package/dist/world.js +17 -0
- package/docs/api/README.md +28 -0
- package/docs/api/app-build-contract.md +113 -0
- package/docs/api/backend-declaration.md +62 -0
- package/docs/api/backend-definition.md +215 -0
- package/docs/api/backend.md +269 -0
- package/docs/api/errors.md +169 -0
- package/docs/api/files.md +91 -0
- package/docs/api/gills.md +164 -0
- package/docs/api/git-commit.md +279 -0
- package/docs/api/git-rpc.md +243 -0
- package/docs/api/git-steps.md +1010 -0
- package/docs/api/git-wire.md +197 -0
- package/docs/api/git.md +579 -0
- package/docs/api/index.md +2880 -0
- package/docs/api/media-json.md +507 -0
- package/docs/api/media-request-id.md +32 -0
- package/docs/api/modules.md +32 -0
- package/docs/api/query.md +253 -0
- package/docs/api/react.md +216 -0
- package/docs/api/read-copy.md +204 -0
- package/docs/api/rpc.md +128 -0
- package/docs/api/storage.md +620 -0
- package/package.json +197 -3
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
import { reportCatchFailure } from "../failure.js";
|
|
2
|
+
/**
|
|
3
|
+
* WHY: focused iframe input must report focus and forward only host-declared
|
|
4
|
+
* reserved chords while retaining local event precedence (2026-08-27).
|
|
5
|
+
*/
|
|
6
|
+
import { Keymap } from "@opentui/keymap";
|
|
7
|
+
import * as addons from "@opentui/keymap/addons";
|
|
8
|
+
import { createHtmlKeymapHost, htmlEventMatchResolver, normalizeHtmlKeyName, } from "@opentui/keymap/html";
|
|
9
|
+
import { callRpc } from "./result.js";
|
|
10
|
+
import { parseFocus, parseReservedChords } from "./schemas.js";
|
|
11
|
+
import { parseVoidResult } from "./schemas.js";
|
|
12
|
+
export function createLocalBindingState() {
|
|
13
|
+
let bindings = [];
|
|
14
|
+
const listeners = new Set();
|
|
15
|
+
return {
|
|
16
|
+
get: () => bindings,
|
|
17
|
+
subscribe: (listener) => {
|
|
18
|
+
listeners.add(listener);
|
|
19
|
+
return () => listeners.delete(listener);
|
|
20
|
+
},
|
|
21
|
+
update: (next) => {
|
|
22
|
+
bindings = next;
|
|
23
|
+
for (const listener of Array.from(listeners)) {
|
|
24
|
+
try {
|
|
25
|
+
listener();
|
|
26
|
+
}
|
|
27
|
+
catch (cause) {
|
|
28
|
+
console.warn("App binding subscriber failed", cause);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
},
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
export function createReservedChordState(initial) {
|
|
35
|
+
let chords = parseReservedChords(initial);
|
|
36
|
+
const listeners = new Set();
|
|
37
|
+
return {
|
|
38
|
+
get: () => chords,
|
|
39
|
+
subscribe: (listener) => {
|
|
40
|
+
listeners.add(listener);
|
|
41
|
+
return () => listeners.delete(listener);
|
|
42
|
+
},
|
|
43
|
+
update: (next) => {
|
|
44
|
+
chords = parseReservedChords(next);
|
|
45
|
+
for (const listener of Array.from(listeners)) {
|
|
46
|
+
try {
|
|
47
|
+
listener();
|
|
48
|
+
}
|
|
49
|
+
catch (cause) {
|
|
50
|
+
console.warn("Reserved chord subscriber failed", cause);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
},
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
export function installBrowserBridge(options) {
|
|
57
|
+
let active = true;
|
|
58
|
+
let previous;
|
|
59
|
+
const documentElement = options.window.document.querySelector("html");
|
|
60
|
+
if (!documentElement)
|
|
61
|
+
return () => {
|
|
62
|
+
active = false;
|
|
63
|
+
};
|
|
64
|
+
const htmlHost = createHtmlKeymapHost(documentElement);
|
|
65
|
+
const keymap = new Keymap(htmlHost);
|
|
66
|
+
addons.registerDefaultKeys(keymap);
|
|
67
|
+
addons.registerModBindings(keymap);
|
|
68
|
+
keymap.prependEventMatchResolver(htmlEventMatchResolver);
|
|
69
|
+
const supervise = createSupervisor(options.onBroken);
|
|
70
|
+
let offReserved = registerReservedLayer(keymap, options.chords, options.remote, supervise);
|
|
71
|
+
const report = createFocusReporter(options, supervise, () => active, (focus) => {
|
|
72
|
+
previous = focus;
|
|
73
|
+
}, () => previous);
|
|
74
|
+
const offFocus = htmlHost.onFocusChange(() => {
|
|
75
|
+
queueMicrotask(report);
|
|
76
|
+
});
|
|
77
|
+
// A click on non-focusable App content focuses this document without a
|
|
78
|
+
// focusin, and the host cannot see a pointer inside the App's frame. The
|
|
79
|
+
// document's own focus changes are what tell the host the App is in use.
|
|
80
|
+
const reportDocumentFocus = () => {
|
|
81
|
+
queueMicrotask(report);
|
|
82
|
+
};
|
|
83
|
+
options.window.addEventListener("focus", reportDocumentFocus);
|
|
84
|
+
options.window.addEventListener("blur", reportDocumentFocus);
|
|
85
|
+
const bindings = options.bindings;
|
|
86
|
+
const editing = () => currentFocus(options.window.document, options.identity.surfaceId).editing;
|
|
87
|
+
let offLocal = bindings === undefined
|
|
88
|
+
? () => undefined
|
|
89
|
+
: registerLocalLayer(keymap, bindings, editing);
|
|
90
|
+
const offBindingSubscription = bindings === undefined
|
|
91
|
+
? () => undefined
|
|
92
|
+
: bindings.subscribe(() => {
|
|
93
|
+
offLocal();
|
|
94
|
+
offLocal = registerLocalLayer(keymap, bindings, editing);
|
|
95
|
+
});
|
|
96
|
+
const offChordSubscription = options.chords.subscribe(() => {
|
|
97
|
+
offReserved();
|
|
98
|
+
offReserved = registerReservedLayer(keymap, options.chords, options.remote, supervise);
|
|
99
|
+
});
|
|
100
|
+
queueMicrotask(report);
|
|
101
|
+
return () => {
|
|
102
|
+
if (!active)
|
|
103
|
+
return;
|
|
104
|
+
active = false;
|
|
105
|
+
offFocus();
|
|
106
|
+
options.window.removeEventListener("focus", reportDocumentFocus);
|
|
107
|
+
options.window.removeEventListener("blur", reportDocumentFocus);
|
|
108
|
+
offChordSubscription();
|
|
109
|
+
offReserved();
|
|
110
|
+
offBindingSubscription();
|
|
111
|
+
offLocal();
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
function createFocusReporter(options, supervise, isActive, setPrevious, getPrevious) {
|
|
115
|
+
return () => {
|
|
116
|
+
if (!isActive())
|
|
117
|
+
return;
|
|
118
|
+
const focus = currentFocus(options.window.document, options.identity.surfaceId);
|
|
119
|
+
const previous = getPrevious();
|
|
120
|
+
if (focus.editing === previous?.editing &&
|
|
121
|
+
focus.surface === previous.surface)
|
|
122
|
+
return;
|
|
123
|
+
setPrevious(focus);
|
|
124
|
+
supervise(() => options.remote.reportFocus(focus));
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
function createSupervisor(onBroken) {
|
|
128
|
+
return (operation) => {
|
|
129
|
+
try {
|
|
130
|
+
void callRpc(operation(), parseVoidResult).catch((cause) => {
|
|
131
|
+
reportCatchFailure("browser-bridge.host-call", cause);
|
|
132
|
+
onBroken(cause);
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
catch (cause) {
|
|
136
|
+
reportCatchFailure("browser-bridge.host-call", cause);
|
|
137
|
+
onBroken(cause);
|
|
138
|
+
}
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
function currentFocus(document, surfaceId) {
|
|
142
|
+
const active = document.activeElement;
|
|
143
|
+
return parseFocus({
|
|
144
|
+
editing: Boolean(active?.matches("input, textarea, select, [contenteditable='true']") ===
|
|
145
|
+
true || active?.closest("[contenteditable='true']")),
|
|
146
|
+
surface: document.hasFocus() ? surfaceId : null,
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
/**
|
|
150
|
+
* The App layer: its own registered bindings, below the reserved layer.
|
|
151
|
+
* Text entry is a mode; bare keys yield to typing, modifier chords still run.
|
|
152
|
+
*/
|
|
153
|
+
function registerLocalLayer(keymap, bindings, editing) {
|
|
154
|
+
const values = bindings.get();
|
|
155
|
+
return keymap.registerLayer({
|
|
156
|
+
bindings: values.map((binding, index) => ({
|
|
157
|
+
cmd: `app/${String(index)}`,
|
|
158
|
+
key: binding.key,
|
|
159
|
+
})),
|
|
160
|
+
commands: values.map((binding, index) => ({
|
|
161
|
+
name: `app/${String(index)}`,
|
|
162
|
+
run: (context) => {
|
|
163
|
+
const event = context.event.originalEvent;
|
|
164
|
+
if (event?.defaultPrevented || event?.isComposing)
|
|
165
|
+
return false;
|
|
166
|
+
if (isBareKey(binding.key) && editing())
|
|
167
|
+
return false;
|
|
168
|
+
binding.run();
|
|
169
|
+
return undefined;
|
|
170
|
+
},
|
|
171
|
+
})),
|
|
172
|
+
priority: 500,
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
function isBareKey(binding) {
|
|
176
|
+
return !/(?:^|\+)(?:ctrl|control|super|meta|cmd|alt|option|mod)\+/iu.test(binding);
|
|
177
|
+
}
|
|
178
|
+
function registerReservedLayer(keymap, chords, remote, supervise) {
|
|
179
|
+
const values = chords.get();
|
|
180
|
+
return keymap.registerLayer({
|
|
181
|
+
bindings: values.map((chord) => ({
|
|
182
|
+
cmd: `reserved/${chord.id}`,
|
|
183
|
+
key: {
|
|
184
|
+
ctrl: chord.ctrlKey,
|
|
185
|
+
meta: chord.altKey,
|
|
186
|
+
name: normalizeHtmlKeyName(chord.key),
|
|
187
|
+
shift: chord.shiftKey,
|
|
188
|
+
super: chord.metaKey,
|
|
189
|
+
},
|
|
190
|
+
})),
|
|
191
|
+
commands: values.map((chord) => ({
|
|
192
|
+
name: `reserved/${chord.id}`,
|
|
193
|
+
run: (context) => {
|
|
194
|
+
const event = context.event.originalEvent;
|
|
195
|
+
if (event?.defaultPrevented || event?.isComposing)
|
|
196
|
+
return false;
|
|
197
|
+
supervise(() => remote.invokeReservedChord(chord.id, event?.repeat ?? false));
|
|
198
|
+
return undefined;
|
|
199
|
+
},
|
|
200
|
+
})),
|
|
201
|
+
priority: 1000,
|
|
202
|
+
});
|
|
203
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { CapabilityDirectory } from "../capabilities.js";
|
|
2
|
+
import type { CapabilityGrantWire } from "./protocol.js";
|
|
3
|
+
export type CapabilityDirectoryController = {
|
|
4
|
+
readonly directory: CapabilityDirectory;
|
|
5
|
+
replace(values: readonly CapabilityGrantWire[]): void;
|
|
6
|
+
disconnect(): void;
|
|
7
|
+
};
|
|
8
|
+
export declare function createCapabilityDirectory(values: readonly CapabilityGrantWire[]): CapabilityDirectoryController;
|
|
9
|
+
export declare function disposeRpcValue(value: unknown): void;
|
|
10
|
+
/** Own a Cap'n Web result until its decoded data has been copied. */
|
|
11
|
+
export declare function ownedRpcValue<T extends Partial<Disposable>>(value: T): Readonly<{
|
|
12
|
+
value: T;
|
|
13
|
+
}> & Disposable;
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import { AppError } from "../errors.js";
|
|
2
|
+
import * as z from "zod";
|
|
3
|
+
import * as Schema from "effect/Schema";
|
|
4
|
+
import { parseCapabilityGrant } from "./schemas.js";
|
|
5
|
+
import { createReadonlyStore } from "./store.js";
|
|
6
|
+
export function createCapabilityDirectory(values) {
|
|
7
|
+
const grants = new Map();
|
|
8
|
+
const version = createReadonlyStore(0);
|
|
9
|
+
let disconnected = false;
|
|
10
|
+
const decode = (nextValues) => {
|
|
11
|
+
const next = new Map();
|
|
12
|
+
try {
|
|
13
|
+
for (const value of nextValues) {
|
|
14
|
+
const grant = parseCapabilityGrant(value);
|
|
15
|
+
const key = capabilityKey(grant.id, grant.version);
|
|
16
|
+
if (next.has(key)) {
|
|
17
|
+
disposeRpcValue(grant.handle);
|
|
18
|
+
throw new AppError("Duplicate capability grant", {
|
|
19
|
+
code: "PROTOCOL",
|
|
20
|
+
retryable: false,
|
|
21
|
+
});
|
|
22
|
+
}
|
|
23
|
+
// Do not clone or freeze an object-capability handle.
|
|
24
|
+
next.set(key, grant.handle);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
catch (cause) {
|
|
28
|
+
for (const handle of next.values())
|
|
29
|
+
disposeRpcValue(handle);
|
|
30
|
+
throw cause;
|
|
31
|
+
}
|
|
32
|
+
return next;
|
|
33
|
+
};
|
|
34
|
+
for (const [key, handle] of decode(values))
|
|
35
|
+
grants.set(key, handle);
|
|
36
|
+
const dispose = (handles) => {
|
|
37
|
+
for (const handle of handles)
|
|
38
|
+
disposeRpcValue(handle);
|
|
39
|
+
};
|
|
40
|
+
const find = (contract) => {
|
|
41
|
+
if (disconnected) {
|
|
42
|
+
throw new AppError("The app connection is disposed", {
|
|
43
|
+
code: "DISCONNECTED",
|
|
44
|
+
retryable: true,
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
const key = capabilityKey(contract.id, contract.version);
|
|
48
|
+
return grants.get(key);
|
|
49
|
+
};
|
|
50
|
+
const directory = Object.freeze({
|
|
51
|
+
get version() {
|
|
52
|
+
return version.get();
|
|
53
|
+
},
|
|
54
|
+
subscribe: (listener) => version.subscribe(listener),
|
|
55
|
+
has(contract) {
|
|
56
|
+
return Boolean(find(contract));
|
|
57
|
+
},
|
|
58
|
+
require(contract) {
|
|
59
|
+
const handle = find(contract);
|
|
60
|
+
if (!handle) {
|
|
61
|
+
throw new AppError("The capability is not granted", {
|
|
62
|
+
code: "CAPABILITY_DENIED",
|
|
63
|
+
retryable: false,
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
try {
|
|
67
|
+
return contract.schema.parse(handle);
|
|
68
|
+
}
|
|
69
|
+
catch (cause) {
|
|
70
|
+
throw new AppError("The capability interface is malformed", {
|
|
71
|
+
cause,
|
|
72
|
+
code: "PROTOCOL",
|
|
73
|
+
retryable: false,
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
},
|
|
77
|
+
});
|
|
78
|
+
return {
|
|
79
|
+
directory,
|
|
80
|
+
replace(values) {
|
|
81
|
+
if (disconnected)
|
|
82
|
+
throw new AppError("The app connection is disposed", {
|
|
83
|
+
code: "DISCONNECTED",
|
|
84
|
+
retryable: true,
|
|
85
|
+
});
|
|
86
|
+
const next = decode(values);
|
|
87
|
+
const previous = [...grants.values()];
|
|
88
|
+
grants.clear();
|
|
89
|
+
for (const [key, handle] of next)
|
|
90
|
+
grants.set(key, handle);
|
|
91
|
+
dispose(previous.filter((handle) => ![...next.values()].includes(handle)));
|
|
92
|
+
version.update(version.get() + 1);
|
|
93
|
+
},
|
|
94
|
+
disconnect() {
|
|
95
|
+
if (disconnected)
|
|
96
|
+
return;
|
|
97
|
+
disconnected = true;
|
|
98
|
+
dispose(grants.values());
|
|
99
|
+
grants.clear();
|
|
100
|
+
version.update(version.get() + 1);
|
|
101
|
+
},
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
const rpcDisposalSchema = z.unknown().transform((value) => {
|
|
105
|
+
const result = z.instanceof(Object).safeParse(value);
|
|
106
|
+
if (!result.success)
|
|
107
|
+
return undefined;
|
|
108
|
+
const view = Object.create(result.data);
|
|
109
|
+
const fields = Schema.decodeUnknownSync(Schema.Struct({
|
|
110
|
+
[Symbol.dispose]: Schema.optional(Schema.instanceOf(Function)),
|
|
111
|
+
}))(view);
|
|
112
|
+
return { owner: result.data, dispose: fields[Symbol.dispose] };
|
|
113
|
+
});
|
|
114
|
+
export function disposeRpcValue(value) {
|
|
115
|
+
try {
|
|
116
|
+
const disposal = rpcDisposalSchema.parse(value);
|
|
117
|
+
if (disposal === undefined || disposedValues.has(disposal.owner))
|
|
118
|
+
return;
|
|
119
|
+
disposedValues.add(disposal.owner);
|
|
120
|
+
disposal.dispose?.call(disposal.owner);
|
|
121
|
+
}
|
|
122
|
+
catch (cause) {
|
|
123
|
+
console.warn("RPC value disposal failed", cause);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
/** Own a Cap'n Web result until its decoded data has been copied. */
|
|
127
|
+
export function ownedRpcValue(value) {
|
|
128
|
+
return {
|
|
129
|
+
value,
|
|
130
|
+
[Symbol.dispose]() {
|
|
131
|
+
disposeRpcValue(value);
|
|
132
|
+
},
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
const disposedValues = new WeakSet();
|
|
136
|
+
function capabilityKey(id, version) {
|
|
137
|
+
return `${id}@${String(version)}`;
|
|
138
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { type RpcStub } from "@fungi.computer/capnweb";
|
|
2
|
+
import type { AppCommands } from "../commands.js";
|
|
3
|
+
import type { LocalBindingState } from "./browser-bridge.js";
|
|
4
|
+
import type { AppHostRpc } from "./protocol.js";
|
|
5
|
+
export type AppCommandsController = {
|
|
6
|
+
readonly commands: AppCommands;
|
|
7
|
+
dispose(): void;
|
|
8
|
+
};
|
|
9
|
+
export declare function createAppCommands(remote: RpcStub<AppHostRpc>, assertConnected: () => void, onFatal: () => void, bindings?: LocalBindingState): AppCommandsController;
|
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
import * as Predicate from "effect/Predicate";
|
|
2
|
+
import { RpcTarget } from "@fungi.computer/capnweb";
|
|
3
|
+
import { WhistleActionError } from "@fungi.computer/whistle";
|
|
4
|
+
import { parseFailure } from "@fungi.computer/whistle/wire";
|
|
5
|
+
import { AppError } from "../errors.js";
|
|
6
|
+
import { callRpc, rpcFailure, rpcSuccess } from "./result.js";
|
|
7
|
+
import { appInvocation, parseCommand, parseCommandId, parseCommandInvocation, parseCommandResult, parseRegisteredCommandResult, parseVoidResult, } from "./schemas.js";
|
|
8
|
+
class CommandHandlerTarget extends RpcTarget {
|
|
9
|
+
#active = true;
|
|
10
|
+
#handler;
|
|
11
|
+
#requests = new Map();
|
|
12
|
+
constructor(handler) {
|
|
13
|
+
super();
|
|
14
|
+
this.#handler = handler;
|
|
15
|
+
}
|
|
16
|
+
cancel(requestId) {
|
|
17
|
+
try {
|
|
18
|
+
this.#requests.get(parseCommandId(requestId))?.abort();
|
|
19
|
+
return Promise.resolve(rpcSuccess(undefined));
|
|
20
|
+
}
|
|
21
|
+
catch (cause) {
|
|
22
|
+
return Promise.resolve(rpcFailure(cause));
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
dispose() {
|
|
26
|
+
if (!this.#active)
|
|
27
|
+
return;
|
|
28
|
+
this.#active = false;
|
|
29
|
+
for (const controller of this.#requests.values())
|
|
30
|
+
controller.abort();
|
|
31
|
+
this.#requests.clear();
|
|
32
|
+
}
|
|
33
|
+
async invoke(value) {
|
|
34
|
+
try {
|
|
35
|
+
if (!this.#active)
|
|
36
|
+
throw new AppError("The command is disposed", {
|
|
37
|
+
code: "DISCONNECTED",
|
|
38
|
+
retryable: true,
|
|
39
|
+
});
|
|
40
|
+
const invocation = parseCommandInvocation(value);
|
|
41
|
+
if (this.#requests.has(invocation.requestId)) {
|
|
42
|
+
throw new AppError("Duplicate command request", {
|
|
43
|
+
code: "PROTOCOL",
|
|
44
|
+
retryable: false,
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
if (this.#requests.size >= 64) {
|
|
48
|
+
throw new AppError("Too many command requests", {
|
|
49
|
+
code: "HOST_FAILURE",
|
|
50
|
+
retryable: true,
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
const controller = new AbortController();
|
|
54
|
+
this.#requests.set(invocation.requestId, controller);
|
|
55
|
+
try {
|
|
56
|
+
const result = await this.#handler(appInvocation(invocation, controller.signal));
|
|
57
|
+
const parsed = parseCommandResult(result);
|
|
58
|
+
return rpcSuccess(parsed === undefined
|
|
59
|
+
? { status: "handled" }
|
|
60
|
+
: { status: "handled", result: parsed });
|
|
61
|
+
}
|
|
62
|
+
finally {
|
|
63
|
+
this.#requests.delete(invocation.requestId);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
catch (cause) {
|
|
67
|
+
if (cause instanceof WhistleActionError) {
|
|
68
|
+
try {
|
|
69
|
+
const failureInput = {
|
|
70
|
+
code: cause.code,
|
|
71
|
+
message: cause.message,
|
|
72
|
+
details: cause.details,
|
|
73
|
+
};
|
|
74
|
+
const failure = parseFailure(failureInput);
|
|
75
|
+
return rpcSuccess({
|
|
76
|
+
status: "failed",
|
|
77
|
+
error: failure,
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
catch (failureCause) {
|
|
81
|
+
return rpcFailure(failureCause);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
return rpcFailure(cause);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
[Symbol.dispose]() {
|
|
88
|
+
this.dispose();
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* Project the committed commands' single-chord bindings for the iframe's App
|
|
93
|
+
* layer. `<leader>` sequences belong to the shell, which reaches them through
|
|
94
|
+
* the reserved chord, so they are not matched here.
|
|
95
|
+
*/
|
|
96
|
+
function localBindings(registrations) {
|
|
97
|
+
const bindings = [];
|
|
98
|
+
for (const registration of registrations.values()) {
|
|
99
|
+
const { definition, handler } = registration;
|
|
100
|
+
if (registration.commandId === undefined)
|
|
101
|
+
continue;
|
|
102
|
+
// An unavailable command still owns its chord, so the browser default
|
|
103
|
+
// (e.g. Save Page) does not run in its place.
|
|
104
|
+
const available = definition.availability?.status !== "unavailable";
|
|
105
|
+
for (const key of definition.projections?.bindings?.values ?? []) {
|
|
106
|
+
if (key.trim().includes(" ") || key.startsWith("<"))
|
|
107
|
+
continue;
|
|
108
|
+
bindings.push({
|
|
109
|
+
key,
|
|
110
|
+
run: () => {
|
|
111
|
+
if (!available)
|
|
112
|
+
return;
|
|
113
|
+
void Promise.resolve()
|
|
114
|
+
.then(() => handler({
|
|
115
|
+
origin: "keyboard",
|
|
116
|
+
signal: new AbortController().signal,
|
|
117
|
+
}))
|
|
118
|
+
.catch((cause) => {
|
|
119
|
+
console.warn("App key command failed", cause);
|
|
120
|
+
});
|
|
121
|
+
},
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
return bindings;
|
|
126
|
+
}
|
|
127
|
+
export function createAppCommands(remote, assertConnected, onFatal, bindings) {
|
|
128
|
+
const registrations = new Map();
|
|
129
|
+
let disposed = false;
|
|
130
|
+
const publishBindings = () => {
|
|
131
|
+
bindings?.update(disposed ? [] : localBindings(registrations));
|
|
132
|
+
};
|
|
133
|
+
const reportFatal = (cause) => {
|
|
134
|
+
try {
|
|
135
|
+
onFatal();
|
|
136
|
+
}
|
|
137
|
+
catch (callbackError) {
|
|
138
|
+
console.warn("App command failure callback failed", callbackError, cause);
|
|
139
|
+
}
|
|
140
|
+
};
|
|
141
|
+
const supervise = (operation) => {
|
|
142
|
+
try {
|
|
143
|
+
void callRpc(operation(), parseVoidResult).catch((cause) => {
|
|
144
|
+
reportFatal(cause);
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
catch (cause) {
|
|
148
|
+
console.warn("App command RPC supervision failed", cause);
|
|
149
|
+
reportFatal(cause);
|
|
150
|
+
}
|
|
151
|
+
};
|
|
152
|
+
const unregister = (id) => {
|
|
153
|
+
const registration = registrations.get(id);
|
|
154
|
+
if (!registration)
|
|
155
|
+
return;
|
|
156
|
+
registrations.delete(id);
|
|
157
|
+
registration.target.dispose();
|
|
158
|
+
publishBindings();
|
|
159
|
+
const commandId = registration.commandId;
|
|
160
|
+
if (!disposed && commandId !== undefined) {
|
|
161
|
+
supervise(() => remote.unregisterCommand(commandId));
|
|
162
|
+
}
|
|
163
|
+
};
|
|
164
|
+
const commands = createCommandApi(remote, assertConnected, registrations, () => disposed, unregister, publishBindings);
|
|
165
|
+
return {
|
|
166
|
+
commands,
|
|
167
|
+
dispose() {
|
|
168
|
+
if (disposed)
|
|
169
|
+
return;
|
|
170
|
+
disposed = true;
|
|
171
|
+
for (const registration of registrations.values())
|
|
172
|
+
registration.target[Symbol.dispose]();
|
|
173
|
+
registrations.clear();
|
|
174
|
+
publishBindings();
|
|
175
|
+
},
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
function claimRegistration(registrations, definition, handler, target) {
|
|
179
|
+
const id = definition.id;
|
|
180
|
+
if (registrations.size >= 128 || registrations.has(id)) {
|
|
181
|
+
target.dispose();
|
|
182
|
+
throw new AppError("Command registration is unavailable", {
|
|
183
|
+
code: "INVALID_ARGUMENT",
|
|
184
|
+
retryable: false,
|
|
185
|
+
});
|
|
186
|
+
}
|
|
187
|
+
const registration = {
|
|
188
|
+
commandId: undefined,
|
|
189
|
+
definition,
|
|
190
|
+
handler,
|
|
191
|
+
target,
|
|
192
|
+
};
|
|
193
|
+
registrations.set(id, registration);
|
|
194
|
+
let released = false;
|
|
195
|
+
return {
|
|
196
|
+
commit(commandId) {
|
|
197
|
+
if (released)
|
|
198
|
+
return;
|
|
199
|
+
registration.commandId = commandId;
|
|
200
|
+
},
|
|
201
|
+
release() {
|
|
202
|
+
if (released)
|
|
203
|
+
return;
|
|
204
|
+
released = true;
|
|
205
|
+
if (registrations.get(id) === registration)
|
|
206
|
+
registrations.delete(id);
|
|
207
|
+
target.dispose();
|
|
208
|
+
},
|
|
209
|
+
target,
|
|
210
|
+
};
|
|
211
|
+
}
|
|
212
|
+
function createCommandApi(remote, assertConnected, registrations, isDisposed, unregister, committed) {
|
|
213
|
+
return Object.freeze({
|
|
214
|
+
async register(command, handler) {
|
|
215
|
+
assertConnected();
|
|
216
|
+
if (!Predicate.isFunction(handler)) {
|
|
217
|
+
throw new AppError("Command handler must be a function", {
|
|
218
|
+
code: "INVALID_ARGUMENT",
|
|
219
|
+
retryable: false,
|
|
220
|
+
});
|
|
221
|
+
}
|
|
222
|
+
const definition = parseCommand(command);
|
|
223
|
+
const target = new CommandHandlerTarget(handler);
|
|
224
|
+
const lease = claimRegistration(registrations, definition, handler, target);
|
|
225
|
+
try {
|
|
226
|
+
const registration = await registerRemoteCommand(remote, definition, lease.target);
|
|
227
|
+
if (isDisposed()) {
|
|
228
|
+
await rejectDisposedRegistration(remote, registration.commandId, target);
|
|
229
|
+
}
|
|
230
|
+
lease.commit(registration.commandId);
|
|
231
|
+
committed();
|
|
232
|
+
}
|
|
233
|
+
catch (cause) {
|
|
234
|
+
lease.release();
|
|
235
|
+
throw cause;
|
|
236
|
+
}
|
|
237
|
+
let active = true;
|
|
238
|
+
return () => {
|
|
239
|
+
if (!active)
|
|
240
|
+
return;
|
|
241
|
+
active = false;
|
|
242
|
+
unregister(definition.id);
|
|
243
|
+
};
|
|
244
|
+
},
|
|
245
|
+
});
|
|
246
|
+
}
|
|
247
|
+
async function registerRemoteCommand(remote, definition, target) {
|
|
248
|
+
return callRpc(remote.registerCommand(definition, target), parseRegisteredCommandResult);
|
|
249
|
+
}
|
|
250
|
+
async function rejectDisposedRegistration(remote, commandId, target) {
|
|
251
|
+
target.dispose();
|
|
252
|
+
await callRpc(remote.unregisterCommand(commandId), parseVoidResult);
|
|
253
|
+
throw new AppError("The command controller is disposed", {
|
|
254
|
+
code: "DISCONNECTED",
|
|
255
|
+
retryable: true,
|
|
256
|
+
});
|
|
257
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { RpcStub } from "@fungi.computer/capnweb";
|
|
2
|
+
import type { AppError } from "../errors.js";
|
|
3
|
+
import type { AppClientRpc } from "./protocol.js";
|
|
4
|
+
export declare function disconnectHost(error: AppError | undefined, assertLive: () => void, options: {
|
|
5
|
+
readonly client: RpcStub<AppClientRpc>;
|
|
6
|
+
readonly onGracefulDisconnect: () => Promise<void>;
|
|
7
|
+
}, dispose: () => void): Promise<void>;
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { callRpc } from "./result.js";
|
|
2
|
+
import { parseErrorData, parseVoidResult } from "./schemas.js";
|
|
3
|
+
export async function disconnectHost(error, assertLive, options, dispose) {
|
|
4
|
+
assertLive();
|
|
5
|
+
try {
|
|
6
|
+
const data = error === undefined
|
|
7
|
+
? undefined
|
|
8
|
+
: parseErrorData({
|
|
9
|
+
code: error.code,
|
|
10
|
+
message: error.message,
|
|
11
|
+
retryable: error.retryable,
|
|
12
|
+
});
|
|
13
|
+
await callRpc(options.client.disconnect(data), parseVoidResult);
|
|
14
|
+
await options.onGracefulDisconnect();
|
|
15
|
+
}
|
|
16
|
+
finally {
|
|
17
|
+
dispose();
|
|
18
|
+
}
|
|
19
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import type { GitFileChange } from "../git-commit.js";
|
|
2
|
+
import type { MemoryFs } from "./memory-fs.js";
|
|
3
|
+
/** Apply changes under one tree; null means the tree became empty. */
|
|
4
|
+
export declare function editTree(fs: MemoryFs, oid: string | null, changes: readonly (GitFileChange & Readonly<{
|
|
5
|
+
parts: readonly string[];
|
|
6
|
+
}>)[]): Promise<string | null>;
|