@tapcue/extension-sdk 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/LICENSE +21 -0
- package/README.md +28 -0
- package/package.json +45 -0
- package/src/capabilities.ts +1037 -0
- package/src/components.ts +277 -0
- package/src/define-extension.ts +205 -0
- package/src/errors.ts +90 -0
- package/src/i18n.ts +64 -0
- package/src/index.ts +13 -0
- package/src/json.ts +24 -0
- package/src/jsx-runtime.ts +118 -0
- package/src/manifest.ts +1572 -0
- package/src/overlay-jsx-runtime.ts +72 -0
- package/src/overlay.ts +130 -0
- package/src/permission-units.ts +109 -0
- package/src/reactive.ts +374 -0
- package/src/scene.ts +297 -0
- package/src/testing/http-fake.ts +240 -0
- package/src/testing/index.ts +2 -0
- package/src/testing/test-host.ts +2330 -0
- package/src/types.ts +614 -0
- package/src/view-runtime.ts +270 -0
- package/src/view.ts +116 -0
|
@@ -0,0 +1,1037 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Capability objects reachable from the invocation context.
|
|
3
|
+
*
|
|
4
|
+
* **The capability surface is the grant.** A permission declared `required` in the
|
|
5
|
+
* manifest is guaranteed at runtime — denying it means the extension is never
|
|
6
|
+
* enabled — so extension code does not check for it. A permission declared
|
|
7
|
+
* `optional` may be absent, and then the capability, or the individual method
|
|
8
|
+
* where the permission table names methods separately (`clipboard.read` vs
|
|
9
|
+
* `clipboard.write`), is simply `undefined`. There is no permission-query API:
|
|
10
|
+
* asking whether you may do a thing is the same as looking at whether you can.
|
|
11
|
+
*
|
|
12
|
+
* Every underlying host op still re-checks the effective grant on every call, so a
|
|
13
|
+
* revocation lands immediately — even mid-invocation, after your check passed.
|
|
14
|
+
*
|
|
15
|
+
* **Capability methods are bound.** Pulling one off its object keeps it working —
|
|
16
|
+
* `requireCapability(ctx.screen?.overlay, "screen.overlay")` is the only way to
|
|
17
|
+
* narrow a *method*-level unit in one expression, and it would be a trap if the
|
|
18
|
+
* result then lost its receiver. Detaching one does not detach it from the grant
|
|
19
|
+
* check either: the op re-checks on call, not on capture.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import type { JsonValue } from "./json.js";
|
|
23
|
+
import type { Signal } from "./reactive.js";
|
|
24
|
+
|
|
25
|
+
/* ------------------------------------------------------------------ http --- */
|
|
26
|
+
|
|
27
|
+
export interface HttpCapability {
|
|
28
|
+
/** Shorthand for `client().fetch(...)`. */
|
|
29
|
+
fetch(url: string, init?: HttpRequestInit): Promise<HttpResponse>;
|
|
30
|
+
/**
|
|
31
|
+
* A client handle with pinned options. The handle lives in the host's resource
|
|
32
|
+
* table; a secret attached here is applied by the broker after the destination
|
|
33
|
+
* is approved, so its plaintext never enters the isolate.
|
|
34
|
+
*/
|
|
35
|
+
client(options?: HttpClientOptions): HttpClient;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface HttpClient {
|
|
39
|
+
fetch(url: string, init?: HttpRequestInit): Promise<HttpResponse>;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface HttpClientOptions {
|
|
43
|
+
headers?: Record<string, string>;
|
|
44
|
+
/** Attach a secret to every request this client makes. */
|
|
45
|
+
authorization?: { secret: SecretReference; header?: string; scheme?: string };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export type HttpMethod = "GET" | "POST" | "PUT" | "PATCH" | "DELETE" | "HEAD";
|
|
49
|
+
|
|
50
|
+
export interface HttpRequestInit {
|
|
51
|
+
method?: HttpMethod;
|
|
52
|
+
headers?: Record<string, string>;
|
|
53
|
+
body?: string | Uint8Array;
|
|
54
|
+
/** Always pass `ctx.signal` so cancellation reaches the in-flight request. */
|
|
55
|
+
signal?: AbortSignal;
|
|
56
|
+
/** Lower than the host limit only; the host cap always wins. */
|
|
57
|
+
maxResponseBytes?: number;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export interface HttpResponse {
|
|
61
|
+
status: number;
|
|
62
|
+
ok: boolean;
|
|
63
|
+
/** Lowercased header names. Hop-by-hop and cookie headers are stripped. */
|
|
64
|
+
headers: Readonly<Record<string, string>>;
|
|
65
|
+
/** Final URL after redirects (every hop is re-checked against the grant). */
|
|
66
|
+
url: string;
|
|
67
|
+
text(): Promise<string>;
|
|
68
|
+
json<T = unknown>(): Promise<T>;
|
|
69
|
+
bytes(): Promise<Uint8Array>;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/* --------------------------------------------------------------- storage --- */
|
|
73
|
+
|
|
74
|
+
export interface StorageCapability {
|
|
75
|
+
/** Namespaces are private to the extension; another extension's is unreachable. */
|
|
76
|
+
open(namespace: string): StorageHandle;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export interface StorageHandle {
|
|
80
|
+
get<T extends JsonValue = JsonValue>(key: string): Promise<T | undefined>;
|
|
81
|
+
set(key: string, value: JsonValue): Promise<void>;
|
|
82
|
+
delete(key: string): Promise<void>;
|
|
83
|
+
keys(): Promise<string[]>;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/* ----------------------------------------------------------------- cache --- */
|
|
87
|
+
|
|
88
|
+
/** Evictable at any time. Never store anything you cannot recompute. */
|
|
89
|
+
export interface CacheCapability {
|
|
90
|
+
get<T extends JsonValue = JsonValue>(key: string): Promise<T | undefined>;
|
|
91
|
+
set(key: string, value: JsonValue, options?: { ttlMs?: number }): Promise<void>;
|
|
92
|
+
delete(key: string): Promise<void>;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/* --------------------------------------------------------------- secrets --- */
|
|
96
|
+
|
|
97
|
+
export interface SecretsCapability {
|
|
98
|
+
/** An opaque handle. Reading plaintext requires a separate, stronger grant. */
|
|
99
|
+
reference(name: string): SecretReference;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export interface SecretReference {
|
|
103
|
+
readonly name: string;
|
|
104
|
+
readonly __brand: "tapcue.secret";
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/* ---------------------------------------------------- invocation context --- */
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* **The situation that summoned Tapcue**, and nothing else.
|
|
111
|
+
*
|
|
112
|
+
* Every method here answers the same question: *what was true at the moment the user
|
|
113
|
+
* pressed the hotkey?* Which app was in front, what they had selected, which files were
|
|
114
|
+
* highlighted in the file manager. These facts are invocation-scoped — they are
|
|
115
|
+
* meaningless outside the call that observed them, and an extension that cached one is
|
|
116
|
+
* holding a stale answer to a question nobody asked again.
|
|
117
|
+
*
|
|
118
|
+
* This is deliberately **not** where machine-wide queries live. "Which fonts are
|
|
119
|
+
* installed" and "where am I" are facts about the *machine*, true whether or not
|
|
120
|
+
* anyone opened the launcher, and they sit on their own capabilities (`ctx.fonts`,
|
|
121
|
+
* `ctx.location`). The tell is the consent
|
|
122
|
+
* string: "read the text you had selected when you opened Tapcue" and "see the fonts
|
|
123
|
+
* installed on this Mac" are not the same kind of permission, so they are not the same
|
|
124
|
+
* kind of capability.
|
|
125
|
+
*
|
|
126
|
+
* Each method is present only when its own permission unit is granted.
|
|
127
|
+
*/
|
|
128
|
+
export interface InvocationContextCapability {
|
|
129
|
+
frontmostApp?(): Promise<FrontmostAppSnapshot | null>;
|
|
130
|
+
selectedText?(): Promise<string | null>;
|
|
131
|
+
selectedFiles?(): Promise<FileHandle[]>;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export interface FrontmostAppSnapshot {
|
|
135
|
+
id: string;
|
|
136
|
+
name: string;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/* ------------------------------------------------- machine-wide queries --- */
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* **The fonts installed on this machine.**
|
|
143
|
+
*
|
|
144
|
+
* Enumeration is a platform API — `CTFontManagerCopyAvailableFontFamilyNames` on macOS,
|
|
145
|
+
* DirectWrite on Windows, fontconfig on Linux — and emphatically *not* a directory read:
|
|
146
|
+
* families span multiple files, one `.ttc` holds several families, and a font can be
|
|
147
|
+
* deactivated without moving. So the host resolves it and hands back a normalized list;
|
|
148
|
+
* the guest never touches a platform API and never needs a filesystem grant to see a font.
|
|
149
|
+
*
|
|
150
|
+
* One constraint is borrowed from the web's Local Font Access API and is load-bearing: the result
|
|
151
|
+
* is **sorted**, so install order leaks no entropy about the machine. The other borrowed rule —
|
|
152
|
+
* withhold file paths — was deliberately reversed, and `FontFamily.path` says why.
|
|
153
|
+
*/
|
|
154
|
+
export interface FontsCapability {
|
|
155
|
+
list(): Promise<FontFamily[]>;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* A font family — the unit a person actually searches for ("Helvetica Neue"), not a file.
|
|
160
|
+
* The mapping between families and files is many-to-many, which is why this models the family
|
|
161
|
+
* and treats a file as one of its attributes rather than its identity.
|
|
162
|
+
*/
|
|
163
|
+
export type FontFamily = {
|
|
164
|
+
/** Localized display name. This is the row title, and the value a `font:` icon names. */
|
|
165
|
+
family: string;
|
|
166
|
+
/** What kind of typeface it is, from the font's own OS/2 family class. */
|
|
167
|
+
classification: FontClassification;
|
|
168
|
+
faces: FontFace[];
|
|
169
|
+
/**
|
|
170
|
+
* The rest comes from the font file's `name` table, so it is present only when the designer
|
|
171
|
+
* filled it in — most system fonts carry a copyright and a version, far fewer carry a
|
|
172
|
+
* description. Absent means "the file does not say", never "the host would not tell you".
|
|
173
|
+
*/
|
|
174
|
+
description?: string;
|
|
175
|
+
designer?: string;
|
|
176
|
+
manufacturer?: string;
|
|
177
|
+
version?: string;
|
|
178
|
+
copyright?: string;
|
|
179
|
+
/** e.g. `"OpenType (TrueType)"`, `"OpenType (PostScript)"`, `"TrueType Collection"`. */
|
|
180
|
+
format?: string;
|
|
181
|
+
/**
|
|
182
|
+
* Where the family's primary face lives on disk.
|
|
183
|
+
*
|
|
184
|
+
* This is a deliberate, reversible decision and it was made the other way first. The original
|
|
185
|
+
* shape withheld paths on the grounds that `CTFontManagerCopyAvailableFontURLs()` is macOS-only
|
|
186
|
+
* — but that is the *enumeration* API; per-face file access exists on all three platforms
|
|
187
|
+
* (DirectWrite's `IDWriteFontFace::GetFiles`, fontconfig's `FC_FILE`), so portability was never
|
|
188
|
+
* the real objection. The real cost is that `~/Library/Fonts/…` names the user, and it is
|
|
189
|
+
* accepted here because "where does this font live" is what a font browser is *for*, and it is
|
|
190
|
+
* what makes revealing the file possible at all.
|
|
191
|
+
*/
|
|
192
|
+
path?: string;
|
|
193
|
+
};
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* The typeface's own classification, read from its OS/2 family class rather than guessed from
|
|
197
|
+
* the name. `unknown` is common and honest: plenty of fonts leave the field unset.
|
|
198
|
+
*/
|
|
199
|
+
export type FontClassification =
|
|
200
|
+
| "serif"
|
|
201
|
+
| "sans-serif"
|
|
202
|
+
| "monospace"
|
|
203
|
+
| "script"
|
|
204
|
+
| "display"
|
|
205
|
+
| "symbol"
|
|
206
|
+
| "unknown";
|
|
207
|
+
|
|
208
|
+
/** One face within a family. */
|
|
209
|
+
export type FontFace = {
|
|
210
|
+
/** Stable machine identity for this face. */
|
|
211
|
+
postscriptName: string;
|
|
212
|
+
fullName: string;
|
|
213
|
+
/** "Regular", "Bold Italic", … */
|
|
214
|
+
style: string;
|
|
215
|
+
/** Normalized 100–900, when the platform reports it. */
|
|
216
|
+
weight?: number;
|
|
217
|
+
italic?: boolean;
|
|
218
|
+
/** From the platform's symbolic traits. Worth having: "monospaced" is a real filter. */
|
|
219
|
+
monospaced?: boolean;
|
|
220
|
+
};
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* **Where this machine is.**
|
|
224
|
+
*
|
|
225
|
+
* Promoted out of `context.*` because it is not about the invocation: your position is true
|
|
226
|
+
* whether or not you opened the launcher. Denying it stays a first-class outcome — an
|
|
227
|
+
* extension must remain useful when this capability is absent (a weather extension takes a
|
|
228
|
+
* city name instead).
|
|
229
|
+
*/
|
|
230
|
+
export interface LocationCapability {
|
|
231
|
+
current(options?: { maxAgeMs?: number }): Promise<LocationSnapshot | null>;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
export interface FileHandle {
|
|
235
|
+
readonly id: string;
|
|
236
|
+
readonly name: string;
|
|
237
|
+
readonly __brand: "tapcue.file";
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/* --------------------------------------------------------------- process --- */
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* **The processes running on this machine.**
|
|
244
|
+
*
|
|
245
|
+
* Enumeration is a platform API — `libproc`/`sysctl` on macOS, `/proc` on Linux, the
|
|
246
|
+
* ToolHelp/NT query on Windows — and emphatically *not* something a sandboxed guest can
|
|
247
|
+
* reach: it has no child process to shell out to `ps`, no socket, no `/proc` read. So the
|
|
248
|
+
* host resolves it and hands back a normalized, host-sorted list, exactly as `fonts.list()`
|
|
249
|
+
* does — the guest never touches a platform API.
|
|
250
|
+
*
|
|
251
|
+
* The read and the effect are split across the two contexts, and the split is the whole
|
|
252
|
+
* security argument, mirroring `clipboard.read` vs `clipboard.write`:
|
|
253
|
+
*
|
|
254
|
+
* - **read** (`list`) is on `ReadonlyProcessManager`, reachable from a `query` — a keystroke
|
|
255
|
+
* may ask "what is running" to render rows, but a keystroke must never *act* on a process.
|
|
256
|
+
* - **the effect** (`kill`, `activate`) is on `WritableProcessManager`, reachable only from an
|
|
257
|
+
* `ActionContext`. An extension that reaches for `kill` from a `query` fails to compile.
|
|
258
|
+
*
|
|
259
|
+
* The manage grant is `process.kill`. It carries no manifest-time scope — a pid is assigned at
|
|
260
|
+
* runtime and recycled, so a manifest cannot name which processes may be killed — and that is
|
|
261
|
+
* deliberate: the confirm dialog, built by the host from its *own* process table, is the whole
|
|
262
|
+
* boundary. See `WritableProcessManager.kill`.
|
|
263
|
+
*/
|
|
264
|
+
export interface ReadonlyProcessManager {
|
|
265
|
+
/**
|
|
266
|
+
* Every running process, **host-sorted** (by pid) so launch order — which is entropy about
|
|
267
|
+
* the machine — never leaks, the same rule `fonts.list()` follows. `ProcessInfo.command`
|
|
268
|
+
* (argv) is present only when the separate `process.command` unit is granted.
|
|
269
|
+
*/
|
|
270
|
+
list(): Promise<ProcessInfo[]>;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/** Reading, plus the effects. Only an `ActionContext` has one, and only with `process.kill`. */
|
|
274
|
+
export interface WritableProcessManager extends ReadonlyProcessManager {
|
|
275
|
+
/**
|
|
276
|
+
* Ask Tapcue to end a process.
|
|
277
|
+
*
|
|
278
|
+
* **Fire-and-forget, and the extension does not decide how it dies.** This returns
|
|
279
|
+
* immediately — the isolate may be dropped the instant the action completes — and Tapcue
|
|
280
|
+
* shows a confirm it builds from its *own* process table (naming the process, never a string
|
|
281
|
+
* the extension supplied): **Cancel / Quit (⏎, SIGTERM) / Force Quit (⌘⏎, SIGKILL)**. The
|
|
282
|
+
* *user* picks the signal; the extension never learns the outcome. Its list simply shows the
|
|
283
|
+
* process gone on the next `list()`. The extension says *which*, Tapcue and the user say
|
|
284
|
+
* *whether* and *how* — the same shape as `clipboard.write` handing over a value the host
|
|
285
|
+
* decides how to land.
|
|
286
|
+
*
|
|
287
|
+
* The host coalesces every `kill` from one action into a single confirm (Cancel / Quit N /
|
|
288
|
+
* Force Quit N) and caps the batch, so an action that loops over the list cannot spray
|
|
289
|
+
* dialogs. It refuses to end its own process, the window server, or the init process
|
|
290
|
+
* whatever the pid, and the OS refuses another user's processes regardless.
|
|
291
|
+
*
|
|
292
|
+
* Present only with `process.kill`, and only in an action.
|
|
293
|
+
*/
|
|
294
|
+
kill?(pid: number): void;
|
|
295
|
+
/**
|
|
296
|
+
* Bring the process's application to the front — "jump to it". Benign, so no confirm; the
|
|
297
|
+
* host performs it (`NSRunningApplication.activate` and its cross-platform siblings), the
|
|
298
|
+
* extension cannot. A pid with no visible application is a no-op. The `process.kill` grant
|
|
299
|
+
* carries it too.
|
|
300
|
+
*/
|
|
301
|
+
activate?(pid: number): void;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/**
|
|
305
|
+
* One running process. A `type`, not an `interface`, so it is assignable to `JsonValue` — it
|
|
306
|
+
* rides as a scope subject and must round-trip.
|
|
307
|
+
*/
|
|
308
|
+
export type ProcessInfo = {
|
|
309
|
+
pid: number;
|
|
310
|
+
ppid: number;
|
|
311
|
+
/** Executable / display name, e.g. "node", "Google Chrome". */
|
|
312
|
+
name: string;
|
|
313
|
+
/**
|
|
314
|
+
* Instantaneous CPU share, as a percentage the platform reports (may exceed 100 across
|
|
315
|
+
* cores). It is a snapshot: a fresh `list()` re-reads it, so a view keeps it current by
|
|
316
|
+
* re-fetching on the events it cares about, not by the host polling behind its back.
|
|
317
|
+
*/
|
|
318
|
+
cpuPercent: number;
|
|
319
|
+
/** Resident memory in bytes. */
|
|
320
|
+
memoryBytes: number;
|
|
321
|
+
/** The owning user's login name, when the platform reports it. */
|
|
322
|
+
user?: string;
|
|
323
|
+
startedAtMs?: number;
|
|
324
|
+
/** TCP ports this process is listening on, folded in from the same platform enumeration. */
|
|
325
|
+
ports?: number[];
|
|
326
|
+
/**
|
|
327
|
+
* The full command line (argv, joined). Present **only when `process.command` is granted** —
|
|
328
|
+
* a separate, off-by-default unit, because argv routinely carries secrets (`mysql -p…`,
|
|
329
|
+
* `--token=…`, `ssh -i …`) that the benign `process.list` read must never leak. Splitting it
|
|
330
|
+
* out is the same move that separates `clipboard.read` from `clipboard.write`: the sensitive
|
|
331
|
+
* half is its own approval.
|
|
332
|
+
*/
|
|
333
|
+
command?: string;
|
|
334
|
+
};
|
|
335
|
+
|
|
336
|
+
/**
|
|
337
|
+
* Coarse by default: roughly city-level, resolved and reverse-geocoded by the
|
|
338
|
+
* native app so the extension needs no additional network host to name a place.
|
|
339
|
+
*/
|
|
340
|
+
export interface LocationSnapshot {
|
|
341
|
+
latitude: number;
|
|
342
|
+
longitude: number;
|
|
343
|
+
accuracyMeters: number;
|
|
344
|
+
/** Reverse-geocoded place name, when the platform can supply one. */
|
|
345
|
+
name?: string;
|
|
346
|
+
region?: string;
|
|
347
|
+
countryCode?: string;
|
|
348
|
+
/** IANA time zone id, e.g. "Asia/Shanghai". */
|
|
349
|
+
timeZone?: string;
|
|
350
|
+
capturedAtMs: number;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
/* ------------------------------------------------------------- clipboard --- */
|
|
354
|
+
|
|
355
|
+
/**
|
|
356
|
+
* Reading a clipboard can lift the password you copied a moment ago. Writing cannot. They
|
|
357
|
+
* are separate permissions, and — since they are also a read and an *effect* — they now sit
|
|
358
|
+
* on separate contexts: `read` is on every context, `write` only on an action's.
|
|
359
|
+
*/
|
|
360
|
+
export interface ClipboardReadCapability {
|
|
361
|
+
read?(): Promise<string | null>;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
/** Reading, plus the effect. Only an `ActionContext` has one. */
|
|
365
|
+
export interface ClipboardCapability extends ClipboardReadCapability {
|
|
366
|
+
/**
|
|
367
|
+
* Text, or a value Tapcue knows how to write.
|
|
368
|
+
*
|
|
369
|
+
* Handing over a `Color` rather than a string is not a convenience — it is the only way
|
|
370
|
+
* to be *correct*. "Which format do I want colours in" is a preference of the person
|
|
371
|
+
* using the launcher, not of the extension that happened to produce one; an extension
|
|
372
|
+
* that formatted its own would be overriding a choice that was never its to make, and
|
|
373
|
+
* two colour extensions would disagree about what `hsl()` rounds to. So the extension
|
|
374
|
+
* says *what*, Tapcue says *how*, and every colour anything copies comes out the same.
|
|
375
|
+
*
|
|
376
|
+
* The same argument is why an image is a typed `ClipboardImage`, not a byte blob with a
|
|
377
|
+
* flavour string the extension picked: the extension supplies the pixels, and which
|
|
378
|
+
* pasteboard flavours a PNG becomes (`public.png`, plus an `NSImage` on macOS) is the
|
|
379
|
+
* host's call. §23.5 recorded that the clipboard was never text-only — Tapcue just had
|
|
380
|
+
* nothing but text and colours to put on it. A drawing is the first image that needs it.
|
|
381
|
+
*/
|
|
382
|
+
write?(value: string | Color | ClipboardImage | ClipboardSecret): Promise<void>;
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
/** A raster image to place on the clipboard. The host owns which flavours it becomes. */
|
|
386
|
+
export type ClipboardImage = { png: Uint8Array };
|
|
387
|
+
|
|
388
|
+
/**
|
|
389
|
+
* **A secret on the clipboard** — a password, a one-time code, a token.
|
|
390
|
+
*
|
|
391
|
+
* The same argument as `Color` and `ClipboardImage`: the extension says *what*, the host says
|
|
392
|
+
* *how*. What "a secret" means on macOS is the `org.nspasteboard.org` Concealed and Transient
|
|
393
|
+
* markers, which every well-behaved clipboard manager reads as "do not remember this" —
|
|
394
|
+
* **Tapcue's own clipboard history among them.** That is the whole point: without a way to say
|
|
395
|
+
* this, a password manager extension copying a password files the password into the launcher's
|
|
396
|
+
* searchable history in plain text, and the extension has no way to stop it.
|
|
397
|
+
*
|
|
398
|
+
* It is not a flag on a string, because it is not a formatting choice: it is a different kind of
|
|
399
|
+
* value, and which markers a platform writes for it is the host's business. The bytes are still
|
|
400
|
+
* plain text on the pasteboard — a marker asks, it does not encrypt — so this is a request that
|
|
401
|
+
* the machine forget, not a promise that it cannot read.
|
|
402
|
+
*/
|
|
403
|
+
export type ClipboardSecret = { secret: string };
|
|
404
|
+
|
|
405
|
+
/* ----------------------------------------------------------------- files --- */
|
|
406
|
+
|
|
407
|
+
/**
|
|
408
|
+
* **Reading the places the manifest declared** (spec 016 §2). Read-only, and it is not a filesystem:
|
|
409
|
+
* there is no path here at all.
|
|
410
|
+
*
|
|
411
|
+
* The extension declared *where* at install time and the host resolved it, so what crosses at
|
|
412
|
+
* runtime is a **grant index and a leaf name** — never a path the guest composed. That is what makes
|
|
413
|
+
* escaping impossible rather than merely checked: there is nothing to escape *with*. The host
|
|
414
|
+
* re-validates the resolved real path on every read anyway, because a symlink inside the tree leads
|
|
415
|
+
* out of it.
|
|
416
|
+
*
|
|
417
|
+
* A read, so it is on every context: an extension whose rows come from a file needs them while a
|
|
418
|
+
* person is typing.
|
|
419
|
+
*/
|
|
420
|
+
export interface FilesCapability {
|
|
421
|
+
/**
|
|
422
|
+
* The files one `files.read` grant currently matches, freshest first. `grant` is the index of the
|
|
423
|
+
* entry in the manifest's `files.read` array — the extension declared the order, so it knows it.
|
|
424
|
+
*/
|
|
425
|
+
list(grant: number): Promise<FileEntry[]>;
|
|
426
|
+
/** The bytes of one of those files, by the `name` `list()` returned. */
|
|
427
|
+
read(grant: number, name: string): Promise<Uint8Array>;
|
|
428
|
+
/** The same, decoded as UTF-8 — what a JSON sidecar or a `.env` actually is. */
|
|
429
|
+
readText(grant: number, name: string): Promise<string>;
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
/* --------------------------------------------------------------- queries --- */
|
|
433
|
+
|
|
434
|
+
/**
|
|
435
|
+
* **Running the statements the manifest declared**, and getting rows back.
|
|
436
|
+
*
|
|
437
|
+
* Exactly `exec`'s shape, applied to a database instead of a program: the extension declared *what
|
|
438
|
+
* to ask* at install time and supplies only the values that fill the `?` holes. There is no SQL
|
|
439
|
+
* here for the same reason there is no path in `files` and no argv in `exec` — a runtime string
|
|
440
|
+
* cannot widen a template that was fixed before anything ran.
|
|
441
|
+
*
|
|
442
|
+
* It exists because a whole class of integration keeps its state in SQLite and nowhere else, and
|
|
443
|
+
* neither neighbour reaches it. `files.read` hands over bytes an extension would need a database
|
|
444
|
+
* engine to interpret, and the sandbox has no WebAssembly to bring one. `exec` needs the app to
|
|
445
|
+
* have shipped a CLI that answers the question — Codex, the integration that prompted this, ships
|
|
446
|
+
* one that can start a session and cannot list them. Handing over rows keeps the engine, the
|
|
447
|
+
* connection and every write path on Tapcue's side of the boundary.
|
|
448
|
+
*
|
|
449
|
+
* On every context, like `exec` and for the same reason: a `query` that lists a tool's sessions has
|
|
450
|
+
* to read something while a person is typing.
|
|
451
|
+
*/
|
|
452
|
+
export interface QueriesCapability {
|
|
453
|
+
/**
|
|
454
|
+
* Run a declared statement, filling its `?` holes in order. Rejects if the database is missing
|
|
455
|
+
* or the statement no longer prepares against it — a schema that moved is a fact the extension
|
|
456
|
+
* has to be told, not one to paper over with an empty list.
|
|
457
|
+
*/
|
|
458
|
+
run(id: string, values?: readonly QueryValue[]): Promise<QueryRow[]>;
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
/** What may fill a `?` hole. JSON's scalars, which is what SQLite binds without ceremony. */
|
|
462
|
+
export type QueryValue = string | number | boolean | null;
|
|
463
|
+
|
|
464
|
+
/**
|
|
465
|
+
* One row, keyed by the column names the statement selected.
|
|
466
|
+
*
|
|
467
|
+
* Blobs are absent rather than encoded: a query grant exists to read a tool's *state*, and a
|
|
468
|
+
* column an extension cannot interpret without a second parser is one this was not built for.
|
|
469
|
+
*/
|
|
470
|
+
export type QueryRow = Record<string, string | number | null>;
|
|
471
|
+
|
|
472
|
+
export interface FileEntry {
|
|
473
|
+
/** The leaf name, the only identity the guest ever sees. */
|
|
474
|
+
name: string;
|
|
475
|
+
/** Bytes. Present so an extension can skip a file it has no budget to parse. */
|
|
476
|
+
size: number;
|
|
477
|
+
/** Seconds since the epoch. */
|
|
478
|
+
modifiedAt: number;
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
/* ------------------------------------------------------------------ exec --- */
|
|
482
|
+
|
|
483
|
+
/**
|
|
484
|
+
* **Running the binaries the manifest declared** (spec 016 §3).
|
|
485
|
+
*
|
|
486
|
+
* Same shape as `files`: the extension names an `id` it declared, never a path, and the host
|
|
487
|
+
* resolves and verifies the binary's identity — a code signature for an external one, the app
|
|
488
|
+
* bundle for an `inApp` one. Arguments fill the `{}` holes of the declared template positionally;
|
|
489
|
+
* a hole is always exactly one argv element, and nothing is ever handed to a shell.
|
|
490
|
+
*
|
|
491
|
+
* On every context, not just an action's: a `query` that lists a vault has to run something. The
|
|
492
|
+
* costs are paid elsewhere — one child at a time per extension, killed at the invocation's
|
|
493
|
+
* deadline, with a cap on how much it may write back.
|
|
494
|
+
*/
|
|
495
|
+
export interface ExecCapability {
|
|
496
|
+
/** Run a declared argv template, filling its holes. Rejects on a non-zero exit. */
|
|
497
|
+
run(id: string, args?: string[]): Promise<ExecResult>;
|
|
498
|
+
/**
|
|
499
|
+
* Talk to a binary declared `protocol: "mcp"` as a long-lived stdio MCP server.
|
|
500
|
+
*
|
|
501
|
+
* MCP is JSON-RPC over stdin/stdout and involves **no model** — it is a calling convention, not a
|
|
502
|
+
* dependency. What it buys over `run` is that the surface is self-describing (so no argv template
|
|
503
|
+
* has to be designed), enumerable (so the install sheet can list the tools by name), and served by
|
|
504
|
+
* one reused process instead of a spawn per keystroke.
|
|
505
|
+
*/
|
|
506
|
+
mcp(id: string): Promise<McpSession>;
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
export interface ExecResult {
|
|
510
|
+
/** Decoded as UTF-8 and capped by the host; `truncated` says the cap was hit. */
|
|
511
|
+
stdout: string;
|
|
512
|
+
stderr: string;
|
|
513
|
+
/**
|
|
514
|
+
* **What the program said about itself.** A non-zero exit is a result, not an error — `run` only
|
|
515
|
+
* *rejects* when the binary could not be run at all (no such binary, wrong signature, past the
|
|
516
|
+
* deadline), which is a different thing to tell somebody than "the tool refused".
|
|
517
|
+
*
|
|
518
|
+
* A CLI that is installed but not signed in exits non-zero with a perfectly clear `stderr`, and
|
|
519
|
+
* an extension that cannot see the difference will send that person to install what they have.
|
|
520
|
+
*/
|
|
521
|
+
exitCode: number;
|
|
522
|
+
truncated: boolean;
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
/** A connected MCP server. The host owns the process; this is the conversation. */
|
|
526
|
+
export interface McpSession {
|
|
527
|
+
/** The tools it offers, each with the JSON Schema for its arguments. */
|
|
528
|
+
listTools(): Promise<McpTool[]>;
|
|
529
|
+
/** Call one. The result is whatever the server returned, as JSON. */
|
|
530
|
+
callTool(name: string, args?: Record<string, JsonValue>): Promise<JsonValue>;
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
export interface McpTool {
|
|
534
|
+
name: string;
|
|
535
|
+
description?: string;
|
|
536
|
+
inputSchema?: JsonValue;
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
/* ---------------------------------------------------------------- native --- */
|
|
540
|
+
|
|
541
|
+
export interface NativeActionsCapability {
|
|
542
|
+
/**
|
|
543
|
+
* Open a URL in the user's default handler. **Needs no permission** — it is an action-only
|
|
544
|
+
* effect (a keystroke cannot reach it), and the host restricts it to `http`/`https`, so it
|
|
545
|
+
* launches nothing and only opens a visible, reversible browser tab. Present on every
|
|
546
|
+
* `ActionContext`; the extension never gets a browser back.
|
|
547
|
+
*/
|
|
548
|
+
open?(url: string): Promise<void>;
|
|
549
|
+
/**
|
|
550
|
+
* Reveal a user-selected file in the platform file manager. **Needs no permission**: the
|
|
551
|
+
* `FileHandle` came from `context.selected-files`, so this only shows a file the user already
|
|
552
|
+
* handed over. Action-only. (Not yet built in the host — see architecture §23.3.)
|
|
553
|
+
*/
|
|
554
|
+
reveal?(file: FileHandle): Promise<void>;
|
|
555
|
+
/** Post a notification. Needs the `native.notify` unit — a notification is unsolicited. */
|
|
556
|
+
notify?(notification: { title: string; body?: string }): Promise<void>;
|
|
557
|
+
/**
|
|
558
|
+
* Write one file to a user-visible location. **The extension names the file and supplies
|
|
559
|
+
* the bytes; the host decides where it lands and sanitizes the name** — it never returns
|
|
560
|
+
* a path, and the extension can never target an arbitrary directory. On macOS the file
|
|
561
|
+
* goes to `~/Downloads`, the same place a browser download does.
|
|
562
|
+
*
|
|
563
|
+
* This is `native.*` and not a filesystem: like `open` and `reveal`, the host performs an
|
|
564
|
+
* effect the extension cannot perform itself. It is the only `native` verb that leaves
|
|
565
|
+
* bytes on disk, which is why it is its own permission unit (`native.save`).
|
|
566
|
+
*/
|
|
567
|
+
save?(file: SaveFile): Promise<void>;
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
export interface SaveFile {
|
|
571
|
+
/** A file name, not a path. The host strips any directory part and sanitizes the rest. */
|
|
572
|
+
name: string;
|
|
573
|
+
data: Uint8Array;
|
|
574
|
+
/** A MIME type the host maps to the platform's own file typing, e.g. `image/png`. */
|
|
575
|
+
type: string;
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
/* ---------------------------------------------------------------- bridge --- */
|
|
579
|
+
|
|
580
|
+
/**
|
|
581
|
+
* **The message bridge to a live WebView surface** (architecture §12.1).
|
|
582
|
+
*
|
|
583
|
+
* A `WebViewScene` runs the extension's own HTML/JS with no network of its own. Everything
|
|
584
|
+
* it does that leaves the sandbox — read the pixels it drew, hand them to `native.save` —
|
|
585
|
+
* flows through the extension isolate, and this is the channel. `request` sends a message
|
|
586
|
+
* to the surface currently up and awaits its reply.
|
|
587
|
+
*
|
|
588
|
+
* **The reply is untrusted input**, exactly like an HTTP body: the WebView is the
|
|
589
|
+
* extension's own code, but it is code the shell does not audit at each keystroke, so the
|
|
590
|
+
* isolate validates what comes back rather than trusting its shape.
|
|
591
|
+
*
|
|
592
|
+
* It is on `ActionContext` and nowhere else, and it needs `ui.webview`. The surface it talks
|
|
593
|
+
* to is **owned by the shell, not the isolate** — the isolate that presented the surface was
|
|
594
|
+
* very likely evicted while the user drew (§10.5), so a fresh `invoke` reconnects to the
|
|
595
|
+
* live surface through this capability rather than holding it across the call. Present only
|
|
596
|
+
* while a surface this extension declared is actually up; absent otherwise.
|
|
597
|
+
*/
|
|
598
|
+
export interface BridgeCapability {
|
|
599
|
+
request(message: BridgeValue): Promise<BridgeValue>;
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
/**
|
|
603
|
+
* What crosses the bridge: the same boundary vocabulary as everywhere else (§8) — JSON, plus
|
|
604
|
+
* `Uint8Array` for binary. A drawing comes back as `{ png: <Uint8Array> }`, so the isolate
|
|
605
|
+
* can hand it straight to `native.save` or `clipboard.write` without a base64 detour.
|
|
606
|
+
*/
|
|
607
|
+
export type BridgeValue =
|
|
608
|
+
| string
|
|
609
|
+
| number
|
|
610
|
+
| boolean
|
|
611
|
+
| null
|
|
612
|
+
| Uint8Array
|
|
613
|
+
| BridgeValue[]
|
|
614
|
+
| { [key: string]: BridgeValue | undefined };
|
|
615
|
+
|
|
616
|
+
/* ---------------------------------------------------------------- screen --- */
|
|
617
|
+
|
|
618
|
+
/**
|
|
619
|
+
* **The screen.** An overlay the extension draws on top of everything, the pointer
|
|
620
|
+
* events on it, and — separately granted — the pixels under it.
|
|
621
|
+
*
|
|
622
|
+
* This is not a colour-picker API. A colour picker is one program you can write with it;
|
|
623
|
+
* a ruler, alignment guides, a region selector, a window inspector, an annotation tool
|
|
624
|
+
* are others, and not one of them needs a new host verb. The host supplies the surface,
|
|
625
|
+
* the input, and the renderer; what appears on it is the extension's program.
|
|
626
|
+
*
|
|
627
|
+
* Two units, and the split is the whole security argument:
|
|
628
|
+
*
|
|
629
|
+
* - `screen.overlay` — draw, and receive pointer input. **No pixel access whatsoever.**
|
|
630
|
+
* A ruler asks for this and nothing else, and can never read your screen.
|
|
631
|
+
* - `screen.capture` — read pixels. On macOS this is the Screen Recording permission, and
|
|
632
|
+
* it should be: it is the same power.
|
|
633
|
+
*
|
|
634
|
+
* The host keeps what it must never hand over: the identity badge naming the extension
|
|
635
|
+
* (which an overlay cannot cover), the Esc that always closes (an extension never sees it
|
|
636
|
+
* and cannot suppress it), the deadline, and the rule that an overlay can never cover
|
|
637
|
+
* Tapcue's own permission UI.
|
|
638
|
+
*
|
|
639
|
+
* **The overlay receives no keyboard at all.** Pointer events, with their modifiers, and
|
|
640
|
+
* nothing else — arrow keys warp the cursor and arrive as pointer moves (see `OverlayEvent`).
|
|
641
|
+
* Nothing in this class of tool needs text entry, and without it, an overlay that paints a
|
|
642
|
+
* convincing fake password prompt still cannot collect what is typed into it.
|
|
643
|
+
*/
|
|
644
|
+
export interface ScreenCapability {
|
|
645
|
+
/**
|
|
646
|
+
* Ask Tapcue to put an overlay on the screen. Resolves once it is up.
|
|
647
|
+
*
|
|
648
|
+
* `create`, not `open`, and not `overlay()`: the extension is *asking for one to be made*.
|
|
649
|
+
* It cannot make one — a sandbox with a window-server connection is a sandbox with windows,
|
|
650
|
+
* and then the badge, the Esc, and the rule that an overlay may never cover Tapcue's own
|
|
651
|
+
* permission UI all stop being enforceable.
|
|
652
|
+
*
|
|
653
|
+
* It is on `ActionContext` and nowhere else, which is the type system saying what §9.4 used
|
|
654
|
+
* to say at runtime: a query runs on a keystroke, and a keystroke must never be able to
|
|
655
|
+
* cover the display.
|
|
656
|
+
*/
|
|
657
|
+
createOverlay?(spec?: OverlaySpec): Promise<OverlaySession>;
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
export interface OverlaySpec {
|
|
661
|
+
/**
|
|
662
|
+
* **What kind of thing is on the screen** — and from that, who holds focus, who gets the
|
|
663
|
+
* pointer, and how it ends. Not a styling choice: the two modes are opposites on every one.
|
|
664
|
+
*
|
|
665
|
+
* - `"tool"` (the default) — the ruler, the eyedropper, the region selector. It spans every
|
|
666
|
+
* display, it *is* what you are working in, so it takes focus and keeps it, it receives the
|
|
667
|
+
* pointer, and a watchdog closes it if you stop interacting. The user is using the overlay.
|
|
668
|
+
*
|
|
669
|
+
* - `"coach"` — a card that says what to do **somewhere else**. Turn on a setting in the app
|
|
670
|
+
* this extension integrates with, grant a permission in System Settings, install a browser
|
|
671
|
+
* extension. The user is using *the other app*, and every property flips to serve that: it
|
|
672
|
+
* is card-sized rather than screen-sized (so every click outside it reaches the app
|
|
673
|
+
* underneath, because outside it there is no window of ours at all), it never activates
|
|
674
|
+
* Tapcue or takes key, and it receives **no pointer events whatsoever** — the extension
|
|
675
|
+
* learns nothing about where you clicked while it is up.
|
|
676
|
+
*
|
|
677
|
+
* A coach card cannot trap anybody — it covers a few hundred points and swallows nothing — so
|
|
678
|
+
* it is not on the tool's 30-second leash. It lives until the extension closes it, the user
|
|
679
|
+
* clicks its close button, or a long deadline expires.
|
|
680
|
+
*/
|
|
681
|
+
mode?: "tool" | "coach";
|
|
682
|
+
|
|
683
|
+
/**
|
|
684
|
+
* **Put the card where the work is.** `coach` only.
|
|
685
|
+
*
|
|
686
|
+
* The bundle id of an app in the manifest's `integratesWith` — the same list `when.bundleId`,
|
|
687
|
+
* an `exec` grant's `inApp`, and a custom-scheme `native.open` resolve against. The host finds
|
|
688
|
+
* that app's frontmost window and places the card against it; if the app is not running or has
|
|
689
|
+
* no window, the card goes to the bottom of the main display, which is where the user is
|
|
690
|
+
* looking anyway once the app they were sent to opens.
|
|
691
|
+
*
|
|
692
|
+
* The extension names an app and gets a coordinate space back — never the window's position.
|
|
693
|
+
* Where somebody's windows are is a fact about their screen, and reading the screen is
|
|
694
|
+
* `screen.capture`, a unit this is deliberately not.
|
|
695
|
+
*/
|
|
696
|
+
anchor?: { app: string };
|
|
697
|
+
|
|
698
|
+
/**
|
|
699
|
+
* Which chrome Tapcue puts around it.
|
|
700
|
+
*
|
|
701
|
+
* - `"tapcue"` (the default) — Tapcue's own frame: a window that names the extension and
|
|
702
|
+
* carries a close button. What a ruler or an annotation tool wants: it is a *tool*, and
|
|
703
|
+
* it should look like one.
|
|
704
|
+
* - `"none"` — full-bleed, no chrome. What an eyedropper wants, because a frame around the
|
|
705
|
+
* screen you are sampling is a frame over the thing you are trying to see.
|
|
706
|
+
*
|
|
707
|
+
* The choice is the extension's; what the choice cannot change is Tapcue's. **Esc closes
|
|
708
|
+
* either one**, the extension never sees the key, and a frameless overlay still carries the
|
|
709
|
+
* badge naming it. Frameless buys a clean screen, never anonymity.
|
|
710
|
+
*
|
|
711
|
+
* Ignored in `coach` mode, which has exactly one chrome: the card. A card that could take its
|
|
712
|
+
* frame off would be a rectangle of unattributed instructions floating over somebody else's
|
|
713
|
+
* app, which is the shape of every phishing overlay ever written — so the name and the close
|
|
714
|
+
* button are not the extension's to remove.
|
|
715
|
+
*/
|
|
716
|
+
frame?: "tapcue" | "none";
|
|
717
|
+
/** The pointer over the overlay. `none` for a picker drawing its own loupe. */
|
|
718
|
+
cursor?: "crosshair" | "none" | "default";
|
|
719
|
+
/** Dim the desktop behind, 0–1. The shell owns what "dimmed" looks like. */
|
|
720
|
+
dim?: number;
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
/**
|
|
724
|
+
* A live overlay. Ends when the user dismisses it, the invocation is cancelled, or
|
|
725
|
+
* `close()` is called. Every method is dead after that.
|
|
726
|
+
*/
|
|
727
|
+
export interface OverlaySession {
|
|
728
|
+
/**
|
|
729
|
+
* What there is to draw on. For a `tool`, the whole desktop in points — one rect spanning every
|
|
730
|
+
* display. For a `coach` card, the card's content area at the origin: the extension is told how
|
|
731
|
+
* much room it has, and nothing about where on the screen that room is.
|
|
732
|
+
*/
|
|
733
|
+
readonly bounds: Rect;
|
|
734
|
+
|
|
735
|
+
/**
|
|
736
|
+
* Pointer and non-printable key events, in order, until the overlay closes. The stream
|
|
737
|
+
* ends with `cancelled` if the user dismissed it — Esc is the host's, not yours.
|
|
738
|
+
*
|
|
739
|
+
* A `coach` card produces no pointer events at all: the whole point is that the user is
|
|
740
|
+
* working in another app, and an extension that could watch the clicks that land there has
|
|
741
|
+
* been handed something no card needs. The stream yields exactly one `cancelled`, when it ends.
|
|
742
|
+
*/
|
|
743
|
+
events(): AsyncIterable<OverlayEvent>;
|
|
744
|
+
|
|
745
|
+
/**
|
|
746
|
+
* Draw a **retained** layer. It stays until you update or remove it — an overlay is not
|
|
747
|
+
* an immediate-mode canvas, and most tools draw once and then only `move()`.
|
|
748
|
+
*/
|
|
749
|
+
draw(nodes: SvgNode[]): Layer;
|
|
750
|
+
|
|
751
|
+
/**
|
|
752
|
+
* Capture a region of the screen. Requires `screen.capture`.
|
|
753
|
+
*
|
|
754
|
+
* The pixels stay in the host: you get a handle, an `<image>` node can draw it, and
|
|
755
|
+
* `colorAt` reads one pixel out of it. A magnifier therefore costs no pixel traffic at
|
|
756
|
+
* all — the shell captured it and the shell draws it.
|
|
757
|
+
*/
|
|
758
|
+
capture?(rect: Rect): Promise<ImageHandle>;
|
|
759
|
+
|
|
760
|
+
/** Idempotent. `finally` is the right place. */
|
|
761
|
+
close(): void;
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
/**
|
|
765
|
+
* A retained layer. `move` is the cheap one: it re-transforms what is already there
|
|
766
|
+
* rather than re-serializing it, which is what makes a ruler that follows the cursor
|
|
767
|
+
* cost nothing.
|
|
768
|
+
*/
|
|
769
|
+
export interface Layer {
|
|
770
|
+
move(to: Point): void;
|
|
771
|
+
update(nodes: SvgNode[]): void;
|
|
772
|
+
remove(): void;
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
/** Host-side pixels. The bytes cross into the isolate only if you ask for them. */
|
|
776
|
+
export interface ImageHandle {
|
|
777
|
+
readonly width: number;
|
|
778
|
+
readonly height: number;
|
|
779
|
+
/** One pixel, in the image's own coordinates. */
|
|
780
|
+
colorAt(point: Point): Promise<Color>;
|
|
781
|
+
/** The whole thing, RGBA8. A screenshot tool pays this; a colour picker never does. */
|
|
782
|
+
bytes(): Promise<Uint8Array>;
|
|
783
|
+
readonly __brand: "tapcue.image";
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
export interface Point {
|
|
787
|
+
x: number;
|
|
788
|
+
y: number;
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
export interface Rect {
|
|
792
|
+
x: number;
|
|
793
|
+
y: number;
|
|
794
|
+
width: number;
|
|
795
|
+
height: number;
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
/**
|
|
799
|
+
* **There is no key event, and writing the colour picker is what proved there must not be.**
|
|
800
|
+
*
|
|
801
|
+
* A picker needs pixel-precise aim, so the obvious move is to hand the extension arrow
|
|
802
|
+
* keys. But the loupe is glued to the pointer by `translate($pointer)`, and a *logical*
|
|
803
|
+
* cursor the extension moves with arrow keys is a cursor `$pointer` no longer agrees with —
|
|
804
|
+
* the 60 fps binding breaks the moment the feature is used.
|
|
805
|
+
*
|
|
806
|
+
* So the host warps the **real** cursor instead, one point per press and ten with shift,
|
|
807
|
+
* which is what the platform's own picker does. `$pointer` stays the single source of truth,
|
|
808
|
+
* the extension receives an ordinary `pointer-move`, and the key event it would have needed
|
|
809
|
+
* does not have to exist. Modifiers ride on the pointer events, for the tools that constrain
|
|
810
|
+
* with them — a ruler holding shift to snap to 45°.
|
|
811
|
+
*
|
|
812
|
+
* The security note survives it: no printable key ever reaches an overlay, so one that paints
|
|
813
|
+
* a convincing fake password prompt still cannot collect what is typed into it.
|
|
814
|
+
*/
|
|
815
|
+
export type OverlayEvent =
|
|
816
|
+
| { kind: "pointer-move"; at: Point; shift: boolean; alt: boolean }
|
|
817
|
+
| { kind: "pointer-down"; at: Point; shift: boolean; alt: boolean }
|
|
818
|
+
| { kind: "pointer-up"; at: Point; shift: boolean; alt: boolean }
|
|
819
|
+
/** Esc, focus lost, deadline, revocation. Always the last event. */
|
|
820
|
+
| { kind: "cancelled" };
|
|
821
|
+
|
|
822
|
+
/* ------------------------------------------------------------ svg subset --- */
|
|
823
|
+
|
|
824
|
+
/**
|
|
825
|
+
* What an overlay draws: **the core of SVG, as JSON**.
|
|
826
|
+
*
|
|
827
|
+
* Not invented here. SVG is a specified, mature, declaratively-rendered 2D model with
|
|
828
|
+
* implementations on every platform, and its dangerous parts are exactly the ones that
|
|
829
|
+
* are absent below: no `script`, no `foreignObject`, no external or remote `href`, no CSS,
|
|
830
|
+
* no filters. What remains draws every tool in this class — a magnifier, a ruler, a
|
|
831
|
+
* marquee, an arrow, a freehand stroke — and needs no new node the next time.
|
|
832
|
+
*
|
|
833
|
+
* JSON rather than XML because an overlay re-draws while the pointer moves; parsing XML at
|
|
834
|
+
* frame rate would be a waste and would inherit XML's attack surface for nothing.
|
|
835
|
+
*
|
|
836
|
+
* Author it in JSX: `@tapcue/extension-sdk/overlay` ships the **primitives** — `<G>`, `<Rect>`,
|
|
837
|
+
* `<Circle>`, `<Line>`, `<Path>`, `<Text>`, `<OverlayImage>`, and the live bindings
|
|
838
|
+
* (`translate(pointer)`, `pointerColor`, `livePointer`) — compiling to exactly these nodes. It
|
|
839
|
+
* does **not** ship `Magnifier` / `Ruler` components: an SDK betting on what extensions draw is the
|
|
840
|
+
* `pick.color`-verb mistake one layer up. A magnifier is thirty JSX lines that live in the
|
|
841
|
+
* extension that wants one; the host knows only the nodes, so it costs nothing to audit or port.
|
|
842
|
+
*
|
|
843
|
+
* **The live colour binding.** A `fill`, a `stroke`, or the body of a `text` may be the
|
|
844
|
+
* literal string `$pointerColor` instead of a colour. The shell fills it, every frame, with
|
|
845
|
+
* the pixel under the cursor — the same pixel `translate($pointer)` centres a loupe on — and
|
|
846
|
+
* substitutes it as `#RRGGBB`. It is to colour what `$pointer` is to position: a colour
|
|
847
|
+
* picker's swatch and hex readout track the cursor with **zero IPC**, so they never lag the
|
|
848
|
+
* loupe the way a colour the extension read and drew a few frames earlier does. And like
|
|
849
|
+
* `$pointer`, it hands the extension nothing — an extension that needs the committed colour
|
|
850
|
+
* still reads one pixel with `screen.capture` on click.
|
|
851
|
+
*/
|
|
852
|
+
export type SvgNode =
|
|
853
|
+
| { tag: "g"; transform?: Transform; opacity?: number; clip?: CircleClip; children: SvgNode[] }
|
|
854
|
+
| { tag: "rect"; x: number; y: number; width: number; height: number; rx?: number; fill?: string; stroke?: string; strokeWidth?: number }
|
|
855
|
+
| { tag: "circle"; cx: number; cy: number; r: number; fill?: string; stroke?: string; strokeWidth?: number }
|
|
856
|
+
| { tag: "line"; x1: number; y1: number; x2: number; y2: number; stroke?: string; strokeWidth?: number; dash?: number[] }
|
|
857
|
+
| { tag: "path"; d: string; fill?: string; stroke?: string; strokeWidth?: number; dash?: number[] }
|
|
858
|
+
| { tag: "text"; x: number; y: number; text: string; fill?: string; fontSize?: number; anchor?: "start" | "middle" | "end" }
|
|
859
|
+
| { tag: "image"; href: ImageSource; x: number; y: number; width: number; height: number; rendering?: "pixelated" | "smooth" };
|
|
860
|
+
|
|
861
|
+
/**
|
|
862
|
+
* A circular clip on a `g`, in the group's own coordinates: its children draw only inside the
|
|
863
|
+
* circle. The vocabulary grew here on evidence — a magnifier is a *round* lens over square pixels,
|
|
864
|
+
* so the square `live: pointer` capture and its grid are clipped to the circle, while the crosshair
|
|
865
|
+
* and the rim are drawn as siblings on top, unclipped. `rotate`/`scale` on the same group compose
|
|
866
|
+
* with it, and `translate($pointer)` carries the whole lens along with no IPC.
|
|
867
|
+
*/
|
|
868
|
+
export type CircleClip = { cx: number; cy: number; r: number };
|
|
869
|
+
|
|
870
|
+
/**
|
|
871
|
+
* `translate(x y)` / `scale(k)` / `rotate(deg)`, or the one **live binding**:
|
|
872
|
+
* `translate($pointer)`.
|
|
873
|
+
*
|
|
874
|
+
* The binding exists because **the extension cannot be in the 60 fps loop**. A round trip
|
|
875
|
+
* to the isolate is microseconds, but a loupe that trails the cursor by a frame is a loupe
|
|
876
|
+
* nobody can aim. So a subtree that must track the pointer says so, and the shell moves it
|
|
877
|
+
* locally, every frame, with no IPC at all.
|
|
878
|
+
*
|
|
879
|
+
* It hands the extension nothing: `$pointer` is a coordinate the *shell* substitutes. An
|
|
880
|
+
* extension that wants to know where the cursor is still has to subscribe to `pointer-move`
|
|
881
|
+
* like everyone else. There is a colour counterpart for `fill`/`stroke`/`text` bodies,
|
|
882
|
+
* `$pointerColor` — see the `SvgNode` docs.
|
|
883
|
+
*/
|
|
884
|
+
export type Transform = string;
|
|
885
|
+
|
|
886
|
+
/**
|
|
887
|
+
* `<image href>`: either a capture you took, or the screen around the cursor resolved by
|
|
888
|
+
* the shell every frame. The second is how a magnifier stays glued to the pointer — and
|
|
889
|
+
* either way the pixels never enter the isolate.
|
|
890
|
+
*/
|
|
891
|
+
export type ImageSource = ImageHandle | { live: "pointer"; width: number; height: number };
|
|
892
|
+
|
|
893
|
+
/**
|
|
894
|
+
* A colour — the same value Tapcue itself understands (`color` is a native subject type,
|
|
895
|
+
* like `file`). The extension formats none of it: hex, RGB, HSL and the swatch are the
|
|
896
|
+
* shell's, and they are the same for every colour whoever produced it.
|
|
897
|
+
*
|
|
898
|
+
* A `type`, not an `interface`: an interface gets no implicit index signature and so is not
|
|
899
|
+
* assignable to `JsonValue` — which this must be, because it is a scope subject and it goes
|
|
900
|
+
* into `storage`.
|
|
901
|
+
*
|
|
902
|
+
* **Components in their own colour space, not 8-bit.** Displays are wide-gamut: an orange
|
|
903
|
+
* sampled off a P3 screen has no sRGB byte triple, and rounding it into one is a loss a
|
|
904
|
+
* designer can see. The host reports what it measured and converts nothing.
|
|
905
|
+
*/
|
|
906
|
+
export type Color = {
|
|
907
|
+
/** 0–1, in `colorSpace`. May exceed sRGB's gamut when `colorSpace` is `display-p3`. */
|
|
908
|
+
red: number;
|
|
909
|
+
green: number;
|
|
910
|
+
blue: number;
|
|
911
|
+
/** The space `red`/`green`/`blue` are in. Widen this union, never silently convert. */
|
|
912
|
+
colorSpace: "srgb" | "display-p3";
|
|
913
|
+
};
|
|
914
|
+
|
|
915
|
+
/* -------------------------------------------------- settings & environment --- */
|
|
916
|
+
|
|
917
|
+
/** A validated snapshot of the extension's own manifest-declared settings. */
|
|
918
|
+
export interface SettingsSnapshot {
|
|
919
|
+
/** Declared keys always resolve (the host fills manifest defaults). */
|
|
920
|
+
get<T extends JsonValue = JsonValue>(key: string): T;
|
|
921
|
+
all(): Readonly<Record<string, JsonValue>>;
|
|
922
|
+
/**
|
|
923
|
+
* **The same setting, as a signal you can watch.**
|
|
924
|
+
*
|
|
925
|
+
* `get` is a snapshot — right at the moment it is called, and wrong from the moment anything
|
|
926
|
+
* changes it. That is fine for a value the view merely renders, and wrong for one the view is
|
|
927
|
+
* *governed by*: a refresh interval read once by a `whileVisible` setup keeps a `setInterval`
|
|
928
|
+
* ticking at the old rate no matter what anyone chooses afterwards.
|
|
929
|
+
*
|
|
930
|
+
* So this returns a real `Signal`, with two things bolted to it:
|
|
931
|
+
*
|
|
932
|
+
* - **Bind it** anywhere a signal goes (a title, an `<Action icon>` through `lookup`) and the
|
|
933
|
+
* shell re-renders locally when the value changes, with no isolate wake.
|
|
934
|
+
* - **Read it in a `whileVisible` setup** and the setup is *restarted* when the value changes,
|
|
935
|
+
* so whatever it armed is re-armed against the new one. A setup has always had to be
|
|
936
|
+
* idempotent — it re-runs on every reopen — so this asks nothing new of it.
|
|
937
|
+
*
|
|
938
|
+
* Only inside a `view` handler (it creates a cell), and only for a key the manifest declares.
|
|
939
|
+
* Create it unconditionally at the top of the view, like every other `signal` — the cells are
|
|
940
|
+
* positional and the order has to be stable across a render and an action dispatch.
|
|
941
|
+
*/
|
|
942
|
+
signal<T extends JsonValue = JsonValue>(key: string): Signal<T>;
|
|
943
|
+
}
|
|
944
|
+
|
|
945
|
+
/**
|
|
946
|
+
* Reading, plus writing the extension's **own** declared keys.
|
|
947
|
+
*
|
|
948
|
+
* Only an `ActionContext` has one, for the reason every effect is action-tier: a `query` runs
|
|
949
|
+
* on a keystroke, and a keystroke must not change a preference. The write itself needs no
|
|
950
|
+
* permission unit — a setting is the extension's own configuration, not a host resource — but
|
|
951
|
+
* it is not unbounded either: the host accepts only keys the manifest declares, coerces the
|
|
952
|
+
* value to that key's declared type, and rejects a `select` value that is not one of its
|
|
953
|
+
* options. An undeclared key is a no-op, not a new setting.
|
|
954
|
+
*
|
|
955
|
+
* **There is one value, and Settings owns it.** The same key the Preferences window renders as
|
|
956
|
+
* a field is the key this writes, so a scope command that changes a preference and the field
|
|
957
|
+
* that changes it are two ways to move one number — never two numbers that drift. That is the
|
|
958
|
+
* whole reason this exists: without it an extension that wants both has to keep its own copy in
|
|
959
|
+
* `storage`, and the copy in Settings becomes a lie.
|
|
960
|
+
*
|
|
961
|
+
* Fire-and-forget, like `clipboard.write`: the extension says *what*, the host says *whether* it
|
|
962
|
+
* is persisted. What happens *inside* the isolate is immediate and local — the value lands in
|
|
963
|
+
* this isolate's own copy, and every `settings.signal` following the key is written and its
|
|
964
|
+
* view's `whileVisible` setups re-armed — so a table already on screen changes cadence rather
|
|
965
|
+
* than finishing the session on the old one. No round trip, no re-boot, nothing torn down.
|
|
966
|
+
*/
|
|
967
|
+
export interface WritableSettings extends SettingsSnapshot {
|
|
968
|
+
set(key: string, value: JsonValue): void;
|
|
969
|
+
}
|
|
970
|
+
|
|
971
|
+
|
|
972
|
+
export interface EnvironmentSnapshot {
|
|
973
|
+
/** BCP-47 tag, e.g. "en-US". */
|
|
974
|
+
locale: string;
|
|
975
|
+
/**
|
|
976
|
+
* IANA time zone id, e.g. "Asia/Shanghai".
|
|
977
|
+
*
|
|
978
|
+
* An environment fact, not a location: it needs no permission, it is not a fix, and
|
|
979
|
+
* it must not be treated as one. What an extension *does* with it is the extension's
|
|
980
|
+
* decision — a weather extension can reasonably guess a city from it when location is
|
|
981
|
+
* denied; a "restaurants near me" extension must not.
|
|
982
|
+
*/
|
|
983
|
+
timeZone: string;
|
|
984
|
+
appearance: "light" | "dark";
|
|
985
|
+
platform: "macos" | "windows" | "linux";
|
|
986
|
+
/**
|
|
987
|
+
* **Which of the apps you declared in `integratesWith` are actually installed**, in the order you
|
|
988
|
+
* declared them.
|
|
989
|
+
*
|
|
990
|
+
* It carries no new authority and needs no permission: these are apps the extension *named*, and
|
|
991
|
+
* the host is already resolving them to run an `inApp` binary, read a container, or check who owns
|
|
992
|
+
* a custom scheme. What it adds is the ability to ask before doing — which matters whenever an
|
|
993
|
+
* extension supports more than one version of the same product, because the alternative is
|
|
994
|
+
* *guessing by trying*: spawn the CLI and see if it fails, read a directory and see if it is
|
|
995
|
+
* empty. That reports "the tool is missing" to somebody whose actual problem is that they have a
|
|
996
|
+
* different version.
|
|
997
|
+
*
|
|
998
|
+
* Empty when nothing was declared, or when none of it is here.
|
|
999
|
+
*/
|
|
1000
|
+
installedApps: string[];
|
|
1001
|
+
}
|
|
1002
|
+
|
|
1003
|
+
/**
|
|
1004
|
+
* A colour's canonical `#rrggbb` — its identity, and the only string an extension needs
|
|
1005
|
+
* to make of it: a row title, a `color:` chip, a clipboard write.
|
|
1006
|
+
*
|
|
1007
|
+
* Every *other* way of writing a colour — `rgb()`, `hsl()`, `color(display-p3 …)`, its
|
|
1008
|
+
* nearest name — belongs to the `color` scope, which Tapcue owns. An extension that
|
|
1009
|
+
* produces a colour inherits all of them and implements none of them.
|
|
1010
|
+
*
|
|
1011
|
+
* Wide-gamut components are projected into sRGB and clamped, because a hex string has
|
|
1012
|
+
* no other option. The `Color` itself keeps its space, so nothing is lost downstream.
|
|
1013
|
+
*/
|
|
1014
|
+
export function colorHex(color: Color): string {
|
|
1015
|
+
const { red, green, blue } = toSrgb(color);
|
|
1016
|
+
const byte = (channel: number) =>
|
|
1017
|
+
Math.min(255, Math.max(0, Math.round(channel * 255)))
|
|
1018
|
+
.toString(16)
|
|
1019
|
+
.padStart(2, "0");
|
|
1020
|
+
return `#${byte(red)}${byte(green)}${byte(blue)}`;
|
|
1021
|
+
}
|
|
1022
|
+
|
|
1023
|
+
function toSrgb(color: Color): { red: number; green: number; blue: number } {
|
|
1024
|
+
if (color.colorSpace !== "display-p3") return color;
|
|
1025
|
+
const linear = (c: number) => (c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4);
|
|
1026
|
+
const gamma = (c: number) => {
|
|
1027
|
+
const v = Math.min(1, Math.max(0, c));
|
|
1028
|
+
return v <= 0.0031308 ? 12.92 * v : 1.055 * v ** (1 / 2.4) - 0.055;
|
|
1029
|
+
};
|
|
1030
|
+
const r = linear(color.red), g = linear(color.green), b = linear(color.blue);
|
|
1031
|
+
// Linear Display-P3 → linear sRGB (CSS Color 4).
|
|
1032
|
+
return {
|
|
1033
|
+
red: gamma(1.2249401762805 * r - 0.2249401762805 * g),
|
|
1034
|
+
green: gamma(-0.0420569547896 * r + 1.0420569547896 * g),
|
|
1035
|
+
blue: gamma(-0.0196375546626 * r - 0.0786360179164 * g + 1.0982735725791 * b),
|
|
1036
|
+
};
|
|
1037
|
+
}
|