@frockbot/desktop-core 0.0.0 → 0.1.0

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/package.json CHANGED
@@ -1,14 +1,28 @@
1
1
  {
2
2
  "name": "@frockbot/desktop-core",
3
- "version": "0.0.0",
4
- "description": "Placeholder reserving this name for trusted publishing. Superseded by the first release.",
5
- "license": "UNLICENSED",
3
+ "version": "0.1.0",
4
+ "private": false,
5
+ "type": "module",
6
+ "exports": {
7
+ ".": "./src/index.ts"
8
+ },
9
+ "scripts": {
10
+ "test": "bun test src",
11
+ "typecheck": "tsc --noEmit -p tsconfig.json"
12
+ },
13
+ "dependencies": {
14
+ "cordis": "4.0.0-rc.8"
15
+ },
16
+ "devDependencies": {
17
+ "@types/bun": "1.4.0",
18
+ "typescript": "^7.0.2"
19
+ },
20
+ "publishConfig": {
21
+ "access": "public"
22
+ },
6
23
  "repository": {
7
24
  "type": "git",
8
25
  "url": "git+https://github.com/timoconnellaus/frockbot.git",
9
26
  "directory": "packages/desktop-core"
10
- },
11
- "publishConfig": {
12
- "access": "public"
13
27
  }
14
28
  }
@@ -0,0 +1,105 @@
1
+ import { afterEach, describe, expect, test } from "bun:test";
2
+ import { Context } from "cordis";
3
+ import { DesktopCommandRegistry } from "./index.js";
4
+
5
+ const roots: Context[] = [];
6
+
7
+ async function createRegistry(): Promise<Context> {
8
+ const root = new Context();
9
+ roots.push(root);
10
+ await root.plugin(DesktopCommandRegistry);
11
+ return root;
12
+ }
13
+
14
+ afterEach(async () => {
15
+ await Promise.all(roots.splice(0).map((root) => root.fiber.dispose()));
16
+ });
17
+
18
+ async function expectFailure(
19
+ promise: Promise<unknown>,
20
+ message: string,
21
+ ): Promise<void> {
22
+ let failure: unknown;
23
+ try {
24
+ await promise;
25
+ } catch (error) {
26
+ failure = error;
27
+ }
28
+ expect(failure).toBeInstanceOf(Error);
29
+ expect(failure instanceof Error ? failure.message : "").toContain(message);
30
+ }
31
+
32
+ describe("DesktopCommandRegistry", () => {
33
+ test("decodes and invokes a registered command", async () => {
34
+ const root = await createRegistry();
35
+ root.desktopCommands.register({
36
+ id: "fixture.echo",
37
+ decode(input: unknown): string {
38
+ if (typeof input !== "string") throw new Error("text is required");
39
+ return input;
40
+ },
41
+ execute: (input: string) => Promise.resolve(`echo:${input}`),
42
+ });
43
+
44
+ expect(
45
+ await root.desktopCommands.invoke<string>("fixture.echo", "hello"),
46
+ ).toBe("echo:hello");
47
+ await expectFailure(
48
+ root.desktopCommands.invoke("fixture.echo", { text: "no" }),
49
+ "text is required",
50
+ );
51
+ });
52
+
53
+ test("removes only the registration that owns the command", async () => {
54
+ const root = await createRegistry();
55
+ const dispose = root.desktopCommands.register({
56
+ id: "fixture.command",
57
+ decode: () => null,
58
+ execute: () => Promise.resolve(),
59
+ });
60
+
61
+ expect(root.desktopCommands.list()).toEqual([{ id: "fixture.command" }]);
62
+ dispose();
63
+ dispose();
64
+ expect(root.desktopCommands.list()).toEqual([]);
65
+ await expectFailure(
66
+ root.desktopCommands.invoke("fixture.command", null),
67
+ 'desktop command "fixture.command" is unavailable',
68
+ );
69
+ });
70
+
71
+ test("rejects duplicate command ids", async () => {
72
+ const root = await createRegistry();
73
+ const first = {
74
+ id: "fixture.command",
75
+ decode: () => null,
76
+ execute: () => Promise.resolve(),
77
+ };
78
+ root.desktopCommands.register(first);
79
+
80
+ expect(() => root.desktopCommands.register(first)).toThrow(
81
+ 'desktop command "fixture.command" is already registered',
82
+ );
83
+ });
84
+
85
+ test("honors cancellation before execution", async () => {
86
+ const root = await createRegistry();
87
+ let executions = 0;
88
+ root.desktopCommands.register({
89
+ id: "fixture.cancelled",
90
+ decode: () => null,
91
+ execute: () => {
92
+ executions += 1;
93
+ return Promise.resolve();
94
+ },
95
+ });
96
+ const controller = new AbortController();
97
+ controller.abort(new Error("cancelled by test"));
98
+
99
+ await expectFailure(
100
+ root.desktopCommands.invoke("fixture.cancelled", null, controller.signal),
101
+ "cancelled by test",
102
+ );
103
+ expect(executions).toBe(0);
104
+ });
105
+ });
package/src/index.ts ADDED
@@ -0,0 +1,299 @@
1
+ import { type Context, Service } from "cordis";
2
+
3
+ export interface DesktopCommandContext {
4
+ signal: AbortSignal;
5
+ }
6
+
7
+ export type DesktopCommandPayload = object | string | number | boolean | null;
8
+ export type DesktopCommandResult = DesktopCommandPayload | void;
9
+
10
+ export interface DesktopCommand<
11
+ Input extends DesktopCommandPayload,
12
+ Output extends DesktopCommandResult,
13
+ > {
14
+ id: string;
15
+ decode(input: unknown): Input;
16
+ execute(input: Input, context: DesktopCommandContext): Promise<Output>;
17
+ }
18
+
19
+ interface RegisteredDesktopCommand {
20
+ source: object;
21
+ decode(input: unknown): DesktopCommandPayload;
22
+ execute(
23
+ input: DesktopCommandPayload,
24
+ context: DesktopCommandContext,
25
+ ): Promise<DesktopCommandResult>;
26
+ }
27
+
28
+ export interface DesktopCommandSummary {
29
+ id: string;
30
+ }
31
+
32
+ export class DesktopCommandRegistry extends Service {
33
+ private commands = new Map<string, RegisteredDesktopCommand>();
34
+
35
+ constructor(ctx: Context) {
36
+ super(ctx, "desktopCommands");
37
+ }
38
+
39
+ register<
40
+ Input extends DesktopCommandPayload,
41
+ Output extends DesktopCommandResult,
42
+ >(command: DesktopCommand<Input, Output>): () => void {
43
+ const id = command.id.trim();
44
+ if (!id) throw new Error("desktop command id must not be empty");
45
+ if (this.commands.has(id)) {
46
+ throw new Error(`desktop command "${id}" is already registered`);
47
+ }
48
+ const registered: RegisteredDesktopCommand = {
49
+ source: command,
50
+ decode: command.decode,
51
+ execute: (input, context) => command.execute(input as Input, context),
52
+ };
53
+ this.commands.set(id, registered);
54
+ return () => {
55
+ if (this.commands.get(id)?.source === command) this.commands.delete(id);
56
+ };
57
+ }
58
+
59
+ list(): DesktopCommandSummary[] {
60
+ return [...this.commands.keys()].sort().map((id) => ({ id }));
61
+ }
62
+
63
+ async invoke<Output extends DesktopCommandResult = DesktopCommandResult>(
64
+ commandId: string,
65
+ input: unknown,
66
+ signal: AbortSignal = new AbortController().signal,
67
+ ): Promise<Output> {
68
+ const command = this.commands.get(commandId);
69
+ if (!command) {
70
+ throw new Error(`desktop command "${commandId}" is unavailable`);
71
+ }
72
+ signal.throwIfAborted();
73
+ const decoded = command.decode(input);
74
+ signal.throwIfAborted();
75
+ return (await command.execute(decoded, { signal })) as Output;
76
+ }
77
+ }
78
+
79
+ export type NotificationUrgency = "normal" | "critical";
80
+
81
+ export interface DesktopNotificationRequest {
82
+ title: string;
83
+ body?: string;
84
+ urgency: NotificationUrgency;
85
+ }
86
+
87
+ export abstract class DesktopNotificationCapability extends Service {
88
+ constructor(ctx: Context) {
89
+ super(ctx, "desktopNotifications");
90
+ }
91
+
92
+ abstract show(
93
+ request: DesktopNotificationRequest,
94
+ signal: AbortSignal,
95
+ ): Promise<void>;
96
+ }
97
+
98
+ export type DirectoryPickerMode = "file" | "directory";
99
+
100
+ export interface DesktopDirectoryPickerRequest {
101
+ mode: DirectoryPickerMode;
102
+ title?: string;
103
+ multiple: boolean;
104
+ }
105
+
106
+ export interface DesktopDirectoryPickerResult {
107
+ paths: string[];
108
+ cancelled: boolean;
109
+ }
110
+
111
+ export abstract class DesktopDirectoryPickerCapability extends Service {
112
+ constructor(ctx: Context) {
113
+ super(ctx, "desktopDirectoryPicker");
114
+ }
115
+
116
+ abstract pick(
117
+ request: DesktopDirectoryPickerRequest,
118
+ signal: AbortSignal,
119
+ ): Promise<DesktopDirectoryPickerResult>;
120
+ }
121
+
122
+ /**
123
+ * What one shell command on the machine asks for, and what came back.
124
+ *
125
+ * These live here rather than in `@frockbot/machine-protocol` because they are
126
+ * the *host's* vocabulary, not the wire's: the protocol says what a Bot asked
127
+ * the machine to do, and this says what the Electron main process was asked to
128
+ * run. Keeping them apart is what lets the agent loop be tested with no host
129
+ * at all, and the host be implemented with no protocol knowledge.
130
+ */
131
+ export interface DesktopMachineExecRequest {
132
+ command: string;
133
+ cwd?: string;
134
+ timeoutMs: number;
135
+ /** Each stream is cut at this many bytes; `truncated` says whether it was. */
136
+ maxOutputBytes: number;
137
+ }
138
+
139
+ export interface DesktopMachineExecResult {
140
+ /** Absent when the process was killed rather than exiting. */
141
+ exitCode?: number;
142
+ stdout: string;
143
+ stderr: string;
144
+ truncated: boolean;
145
+ /** The command outlived `timeoutMs` and was killed. */
146
+ timedOut: boolean;
147
+ }
148
+
149
+ export interface DesktopMachineFileRequest {
150
+ path: string;
151
+ maxBytes: number;
152
+ }
153
+
154
+ export interface DesktopMachineFileResult {
155
+ bytesBase64: string;
156
+ truncated: boolean;
157
+ }
158
+
159
+ export interface DesktopMachineIdentity {
160
+ /** The machine's own name for itself. A hostname. */
161
+ label: string;
162
+ platform: "macos" | "windows" | "linux";
163
+ }
164
+
165
+ /**
166
+ * The only authority a registered machine's agent has over the laptop.
167
+ *
168
+ * Deliberately two verbs and one fact. Everything the agent *decides* — which
169
+ * command to claim, what a timeout means, when to back off, when to forget its
170
+ * token — lives in `@frockbot/plugin-user-machine`, where it runs in CI. This
171
+ * seam holds the parts that can only run inside Electron's main process.
172
+ */
173
+ export abstract class DesktopMachineHostCapability extends Service {
174
+ constructor(ctx: Context) {
175
+ super(ctx, "desktopMachineHost");
176
+ }
177
+
178
+ abstract identity(): DesktopMachineIdentity;
179
+
180
+ abstract exec(
181
+ request: DesktopMachineExecRequest,
182
+ signal: AbortSignal,
183
+ ): Promise<DesktopMachineExecResult>;
184
+
185
+ abstract readFile(
186
+ request: DesktopMachineFileRequest,
187
+ signal: AbortSignal,
188
+ ): Promise<DesktopMachineFileResult>;
189
+ }
190
+
191
+ /**
192
+ * The OS secure store — the login keychain on macOS, DPAPI on Windows, the
193
+ * platform's secret service on Linux — behind three verbs.
194
+ *
195
+ * A machine token is the one long-lived secret the desktop app holds, and the
196
+ * constitution's "no secrets client-side" has exactly one exemption: a secret
197
+ * the OS itself protects at rest. `read` answering `undefined` is a normal
198
+ * state (nothing stored yet, or a store this platform cannot encrypt to), not
199
+ * an error — the caller pairs again rather than crashing.
200
+ */
201
+ export abstract class DesktopSecretStoreCapability extends Service {
202
+ constructor(ctx: Context) {
203
+ super(ctx, "desktopSecretStore");
204
+ }
205
+
206
+ abstract read(key: string): Promise<string | undefined>;
207
+
208
+ abstract write(key: string, value: string): Promise<void>;
209
+
210
+ abstract clear(key: string): Promise<void>;
211
+ }
212
+
213
+ /**
214
+ * What macOS has granted the Messages handlers, right now (register row 57g).
215
+ *
216
+ * Both flags are TCC's and the User's: Full Disk Access to read
217
+ * `~/Library/Messages/chat.db`, and Automation over Messages.app to send. The
218
+ * capability can only ever *report* them — nothing in FrockBot can grant
219
+ * either, and a build that pretended otherwise would be lying to a person
220
+ * about their own machine.
221
+ */
222
+ export interface DesktopMessagesPermissions {
223
+ fullDiskAccess: boolean;
224
+ automation: boolean;
225
+ /** Whatever macOS said, when it said anything. */
226
+ detail?: string;
227
+ }
228
+
229
+ export interface DesktopMessagesQueryRequest {
230
+ /** A `SELECT`. The statement is composed in a Package, under test. */
231
+ sql: string;
232
+ parameters: Array<string | number>;
233
+ maxRows: number;
234
+ }
235
+
236
+ export interface DesktopMessagesSendRequest {
237
+ recipient: string;
238
+ text: string;
239
+ }
240
+
241
+ /**
242
+ * The only authority the Messages tools have over the Mac.
243
+ *
244
+ * Deliberately four verbs and one fact, and not one of them takes a decision.
245
+ * Which statement to run, what a row means, what a denied permission answers
246
+ * and what may be sent all live in `@frockbot/plugin-machine-messages`, where
247
+ * they run in CI. This seam holds only the parts that cannot run anywhere but
248
+ * a Mac with a real login session: SQLite, `osascript`, and the disk.
249
+ */
250
+ export abstract class DesktopMessagesCapability extends Service {
251
+ constructor(ctx: Context) {
252
+ super(ctx, "desktopMessages");
253
+ }
254
+
255
+ abstract checkPermissions(
256
+ signal: AbortSignal,
257
+ ): Promise<DesktopMessagesPermissions>;
258
+
259
+ abstract query(
260
+ request: DesktopMessagesQueryRequest,
261
+ signal: AbortSignal,
262
+ ): Promise<Array<Record<string, string | number | null>>>;
263
+
264
+ abstract send(
265
+ request: DesktopMessagesSendRequest,
266
+ signal: AbortSignal,
267
+ ): Promise<void>;
268
+
269
+ abstract readFile(
270
+ request: DesktopMachineFileRequest,
271
+ signal: AbortSignal,
272
+ ): Promise<DesktopMachineFileResult>;
273
+
274
+ /** The account's home directory, so a `~`-relative attachment resolves. */
275
+ abstract home(): string;
276
+ }
277
+
278
+ export abstract class DesktopClipboardCapability extends Service {
279
+ constructor(ctx: Context) {
280
+ super(ctx, "desktopClipboard");
281
+ }
282
+
283
+ abstract readText(signal: AbortSignal): Promise<string>;
284
+
285
+ abstract writeText(text: string, signal: AbortSignal): Promise<void>;
286
+ }
287
+
288
+ // Cordis context services exposed to desktop contribution plugins.
289
+ declare module "cordis" {
290
+ interface Context {
291
+ desktopCommands: DesktopCommandRegistry;
292
+ desktopNotifications: DesktopNotificationCapability;
293
+ desktopDirectoryPicker: DesktopDirectoryPickerCapability;
294
+ desktopClipboard: DesktopClipboardCapability;
295
+ desktopMachineHost: DesktopMachineHostCapability;
296
+ desktopMessages: DesktopMessagesCapability;
297
+ desktopSecretStore: DesktopSecretStoreCapability;
298
+ }
299
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,13 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2023",
4
+ "module": "ESNext",
5
+ "moduleResolution": "Bundler",
6
+ "strict": true,
7
+ "noEmit": true,
8
+ "skipLibCheck": true,
9
+ "lib": ["ES2023", "DOM"],
10
+ "types": ["bun"]
11
+ },
12
+ "include": ["src/**/*.ts"]
13
+ }
package/README.md DELETED
@@ -1,3 +0,0 @@
1
- # @frockbot/desktop-core
2
-
3
- Placeholder reserving this name. See https://github.com/timoconnellaus/frockbot.