@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,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The overlay's automatic-JSX factory. A file with `@jsxImportSource @tapcue/extension-sdk/overlay`
|
|
3
|
+
* compiles `<G/>` through here into an `SvgNode` (capabilities.ts) — a separate JSX world from the
|
|
4
|
+
* view runtime, so `JSX.Element` is `SvgNode`, and `overlay.draw([<G/>])` type-checks with no cast.
|
|
5
|
+
* Props are literal wire values (numbers, colours, a `translate($pointer)` string), so the factory
|
|
6
|
+
* only reshapes them into `{ tag, … }`; the bytes are what a hand-written `SvgNode` produced.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type { SvgNode } from "./capabilities.js";
|
|
10
|
+
|
|
11
|
+
const FRAGMENT_TAG = "__fragment";
|
|
12
|
+
|
|
13
|
+
function isSvgNode(value: unknown): value is SvgNode {
|
|
14
|
+
return typeof value === "object" && value !== null && typeof (value as { tag?: unknown }).tag === "string";
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function normalizeChildren(children: unknown): SvgNode[] {
|
|
18
|
+
const out: SvgNode[] = [];
|
|
19
|
+
const visit = (child: unknown): void => {
|
|
20
|
+
if (child === null || child === undefined || child === false || child === true) return;
|
|
21
|
+
if (Array.isArray(child)) {
|
|
22
|
+
for (const nested of child) visit(nested);
|
|
23
|
+
return;
|
|
24
|
+
}
|
|
25
|
+
if (isSvgNode(child)) {
|
|
26
|
+
if ((child as { tag: string }).tag === FRAGMENT_TAG) {
|
|
27
|
+
visit((child as unknown as { children?: unknown }).children);
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
out.push(child);
|
|
31
|
+
}
|
|
32
|
+
};
|
|
33
|
+
visit(children);
|
|
34
|
+
return out;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function createSvg(type: unknown, rawProps: Record<string, unknown> | null): SvgNode {
|
|
38
|
+
const props = rawProps ?? {};
|
|
39
|
+
const svgTag = (type as { __svgTag?: string }).__svgTag;
|
|
40
|
+
if (typeof svgTag !== "string") {
|
|
41
|
+
throw new Error("overlay JSX accepts only the exported SVG primitive components");
|
|
42
|
+
}
|
|
43
|
+
if (svgTag === FRAGMENT_TAG) {
|
|
44
|
+
return { tag: FRAGMENT_TAG, children: normalizeChildren(props.children) } as unknown as SvgNode;
|
|
45
|
+
}
|
|
46
|
+
const { children, ...rest } = props;
|
|
47
|
+
const node: Record<string, unknown> = { tag: svgTag, ...rest };
|
|
48
|
+
// `g` is the only container; every leaf carries its geometry in props alone.
|
|
49
|
+
if (svgTag === "g") node.children = normalizeChildren(children);
|
|
50
|
+
return node as unknown as SvgNode;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function jsx(type: unknown, props: Record<string, unknown>): SvgNode {
|
|
54
|
+
return createSvg(type, props);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export const jsxs = jsx;
|
|
58
|
+
|
|
59
|
+
export function jsxDEV(type: unknown, props: Record<string, unknown>): SvgNode {
|
|
60
|
+
return createSvg(type, props);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export const Fragment: { __svgTag: string } = { __svgTag: FRAGMENT_TAG };
|
|
64
|
+
|
|
65
|
+
// eslint-disable-next-line @typescript-eslint/no-namespace
|
|
66
|
+
export namespace JSX {
|
|
67
|
+
export type Element = SvgNode;
|
|
68
|
+
export interface ElementChildrenAttribute {
|
|
69
|
+
children: Record<string, never>;
|
|
70
|
+
}
|
|
71
|
+
export type IntrinsicElements = Record<string, never>;
|
|
72
|
+
}
|
package/src/overlay.ts
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* JSX authoring for the overlay's SVG subset (capabilities.ts `SvgNode`).
|
|
3
|
+
*
|
|
4
|
+
* The host ships the **primitives** — the SVG contract — never a component library: an SDK that
|
|
5
|
+
* shipped `Magnifier()` / `Ruler()` would be betting on what extensions draw, the same mistake as
|
|
6
|
+
* a `pick.color` verb one layer up. So these are the seven tags and nothing semantic; a loupe or a
|
|
7
|
+
* ruler is thirty lines that live in the extension that wants one, built from these.
|
|
8
|
+
*
|
|
9
|
+
* Author with a per-file pragma so `<G/>` compiles to an `SvgNode`, not a view node:
|
|
10
|
+
*
|
|
11
|
+
* /** @jsxImportSource @tapcue/extension-sdk/overlay *\/
|
|
12
|
+
* import { G, Rect, translate, pointer } from "@tapcue/extension-sdk/overlay";
|
|
13
|
+
* const ruler = <G transform={translate(pointer)}>…</G>; // ruler: SvgNode
|
|
14
|
+
*
|
|
15
|
+
* The live bindings (`translate(pointer)`, `pointerColor`, `livePointer`) are typed sugar over the
|
|
16
|
+
* exact strings the shell resolves every frame with no IPC — the wire is byte-for-byte what a hand
|
|
17
|
+
* written `SvgNode` produced, so the host renderer is unchanged.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import type { CircleClip, ImageSource, Point, SvgNode, Transform } from "./capabilities.js";
|
|
21
|
+
|
|
22
|
+
// ── Live bindings (the shell resolves these every frame, locally, with no isolate wake) ──────────
|
|
23
|
+
|
|
24
|
+
/** The live cursor position. Pass to `translate` to glue a subtree to the pointer. */
|
|
25
|
+
export const pointer: unique symbol = Symbol.for("tapcue.overlay.pointer") as never;
|
|
26
|
+
|
|
27
|
+
/** `translate(pointer)` → the live binding; `translate({x,y})` → a fixed offset. */
|
|
28
|
+
export function translate(target: typeof pointer | Point): Transform {
|
|
29
|
+
return target === pointer ? "translate($pointer)" : `translate(${target.x} ${target.y})`;
|
|
30
|
+
}
|
|
31
|
+
export function scale(k: number): Transform {
|
|
32
|
+
return `scale(${k})`;
|
|
33
|
+
}
|
|
34
|
+
export function rotate(degrees: number): Transform {
|
|
35
|
+
return `rotate(${degrees})`;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** The live pixel colour under the cursor. Use as a `fill`, `stroke`, or `<Text text>` value. */
|
|
39
|
+
export const pointerColor = "$pointerColor";
|
|
40
|
+
|
|
41
|
+
/** The magnified screen around the cursor, resolved by the shell every frame — no pixel ever
|
|
42
|
+
* enters the isolate. Use as an `<OverlayImage href>`. */
|
|
43
|
+
export function livePointer(width: number, height: number): ImageSource {
|
|
44
|
+
return { live: "pointer", width, height };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// ── SVG primitive components (compile to `SvgNode` via overlay-jsx-runtime.ts) ───────────────────
|
|
48
|
+
|
|
49
|
+
type SvgChild = SvgNode | SvgNode[] | false | null | undefined;
|
|
50
|
+
type SvgChildren = SvgChild | SvgChild[];
|
|
51
|
+
|
|
52
|
+
interface Tag<P> {
|
|
53
|
+
(props: P): SvgNode;
|
|
54
|
+
readonly __svgTag: string;
|
|
55
|
+
}
|
|
56
|
+
function tag<P>(name: string): Tag<P> {
|
|
57
|
+
const component = ((_props: P) => ({ tag: name }) as SvgNode) as Tag<P> & { __svgTag: string };
|
|
58
|
+
component.__svgTag = name;
|
|
59
|
+
return component;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export interface GProps {
|
|
63
|
+
transform?: Transform;
|
|
64
|
+
opacity?: number;
|
|
65
|
+
clip?: CircleClip;
|
|
66
|
+
children?: SvgChildren;
|
|
67
|
+
}
|
|
68
|
+
export interface RectProps {
|
|
69
|
+
x: number;
|
|
70
|
+
y: number;
|
|
71
|
+
width: number;
|
|
72
|
+
height: number;
|
|
73
|
+
rx?: number;
|
|
74
|
+
fill?: string;
|
|
75
|
+
stroke?: string;
|
|
76
|
+
strokeWidth?: number;
|
|
77
|
+
}
|
|
78
|
+
export interface CircleProps {
|
|
79
|
+
cx: number;
|
|
80
|
+
cy: number;
|
|
81
|
+
r: number;
|
|
82
|
+
fill?: string;
|
|
83
|
+
stroke?: string;
|
|
84
|
+
strokeWidth?: number;
|
|
85
|
+
}
|
|
86
|
+
export interface LineProps {
|
|
87
|
+
x1: number;
|
|
88
|
+
y1: number;
|
|
89
|
+
x2: number;
|
|
90
|
+
y2: number;
|
|
91
|
+
stroke?: string;
|
|
92
|
+
strokeWidth?: number;
|
|
93
|
+
dash?: number[];
|
|
94
|
+
}
|
|
95
|
+
export interface PathProps {
|
|
96
|
+
d: string;
|
|
97
|
+
fill?: string;
|
|
98
|
+
stroke?: string;
|
|
99
|
+
strokeWidth?: number;
|
|
100
|
+
dash?: number[];
|
|
101
|
+
}
|
|
102
|
+
export interface TextProps {
|
|
103
|
+
x: number;
|
|
104
|
+
y: number;
|
|
105
|
+
text: string;
|
|
106
|
+
/**
|
|
107
|
+
* `#rrggbb`, or **`currentColor`** — the host's own label colour, resolved when the frame is
|
|
108
|
+
* drawn. A card lives in Tapcue's chrome on the user's theme, so text with a hard-coded colour
|
|
109
|
+
* is text that disappears in the other one.
|
|
110
|
+
*/
|
|
111
|
+
fill?: string;
|
|
112
|
+
fontSize?: number;
|
|
113
|
+
anchor?: "start" | "middle" | "end";
|
|
114
|
+
}
|
|
115
|
+
export interface OverlayImageProps {
|
|
116
|
+
href: ImageSource;
|
|
117
|
+
x: number;
|
|
118
|
+
y: number;
|
|
119
|
+
width: number;
|
|
120
|
+
height: number;
|
|
121
|
+
rendering?: "pixelated" | "smooth";
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export const G = tag<GProps>("g");
|
|
125
|
+
export const Rect = tag<RectProps>("rect");
|
|
126
|
+
export const Circle = tag<CircleProps>("circle");
|
|
127
|
+
export const Line = tag<LineProps>("line");
|
|
128
|
+
export const Path = tag<PathProps>("path");
|
|
129
|
+
export const Text = tag<TextProps>("text");
|
|
130
|
+
export const OverlayImage = tag<OverlayImageProps>("image");
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The permission-unit table, as TypeScript sees it.
|
|
3
|
+
*
|
|
4
|
+
* The vocabulary itself lives in `proto/permission-units.json` — one file for all three languages
|
|
5
|
+
* (see its `$comment`). This is TypeScript's copy of it, kept as a `const` array rather than
|
|
6
|
+
* imported from the JSON because the union type below has to exist at compile time, and a JSON
|
|
7
|
+
* import widens to `string`. `tests/permission-units.test.ts` reads the JSON and asserts this
|
|
8
|
+
* matches it entry for entry, so the copy cannot drift without failing.
|
|
9
|
+
*
|
|
10
|
+
* Everything else in the SDK that needs to know the vocabulary is derived from here: the
|
|
11
|
+
* `PermissionUnit` union, `permissionUnits()`, and the boolean half of merging and intersecting
|
|
12
|
+
* two permission blocks.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
/** How a unit's value narrows when a declaration meets an approval. */
|
|
16
|
+
export type PermissionKind =
|
|
17
|
+
| "boolean"
|
|
18
|
+
| "hosts"
|
|
19
|
+
| "quota"
|
|
20
|
+
| "names"
|
|
21
|
+
| "precision"
|
|
22
|
+
/** A list of `FileReadGrant`s — where the extension may read (spec 016 §2). */
|
|
23
|
+
| "paths"
|
|
24
|
+
/** A list of `QueryGrant`s — which stored database queries it may run. */
|
|
25
|
+
| "queries"
|
|
26
|
+
/** A list of `ExecGrant`s — which binaries it may run (spec 016 §3). */
|
|
27
|
+
| "binaries";
|
|
28
|
+
|
|
29
|
+
export interface PermissionUnitSpec {
|
|
30
|
+
readonly unit: string;
|
|
31
|
+
/** The object it sits under in a permissions block. */
|
|
32
|
+
readonly group: string;
|
|
33
|
+
/** The field inside that object, or `null` when the group *is* the unit. */
|
|
34
|
+
readonly key: string | null;
|
|
35
|
+
readonly kind: PermissionKind;
|
|
36
|
+
/** The host load frame's field name, or `null` for a unit nothing enforces yet. */
|
|
37
|
+
readonly wire: string | null;
|
|
38
|
+
/** Whether an op actually checks it today. See the JSON's note on declared-but-unenforced. */
|
|
39
|
+
readonly enforced: boolean;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export const PERMISSION_UNITS = [
|
|
43
|
+
{ unit: "http", group: "http", key: null, kind: "hosts", wire: "hosts", enforced: true },
|
|
44
|
+
{ unit: "storage", group: "storage", key: null, kind: "quota", wire: "storage", enforced: true },
|
|
45
|
+
{ unit: "cache", group: "cache", key: null, kind: "quota", wire: "cache", enforced: true },
|
|
46
|
+
{ unit: "files.read", group: "files", key: "read", kind: "paths", wire: "filesRead", enforced: true },
|
|
47
|
+
{ unit: "exec", group: "exec", key: null, kind: "binaries", wire: "exec", enforced: true },
|
|
48
|
+
{ unit: "queries", group: "queries", key: null, kind: "queries", wire: "queries", enforced: true },
|
|
49
|
+
{ unit: "secrets", group: "secrets", key: null, kind: "names", wire: null, enforced: false },
|
|
50
|
+
{ unit: "clipboard.read", group: "clipboard", key: "read", kind: "boolean", wire: null, enforced: false },
|
|
51
|
+
{ unit: "clipboard.write", group: "clipboard", key: "write", kind: "boolean", wire: "clipboardWrite", enforced: true },
|
|
52
|
+
{ unit: "recents.record", group: "recents", key: "record", kind: "boolean", wire: "recentsRecord", enforced: true },
|
|
53
|
+
{ unit: "recents.list", group: "recents", key: "list", kind: "boolean", wire: "recentsList", enforced: true },
|
|
54
|
+
{ unit: "recents.remove", group: "recents", key: "remove", kind: "boolean", wire: "recentsRemove", enforced: true },
|
|
55
|
+
{ unit: "workspace.roots", group: "workspace", key: "roots", kind: "boolean", wire: "workspaceRoots", enforced: true },
|
|
56
|
+
{ unit: "context.frontmost-app", group: "context", key: "frontmostApp", kind: "boolean", wire: null, enforced: false },
|
|
57
|
+
{ unit: "context.selected-text", group: "context", key: "selectedText", kind: "boolean", wire: null, enforced: false },
|
|
58
|
+
{ unit: "context.selected-files", group: "context", key: "selectedFiles", kind: "boolean", wire: null, enforced: false },
|
|
59
|
+
{ unit: "fonts", group: "fonts", key: null, kind: "boolean", wire: "fonts", enforced: true },
|
|
60
|
+
{ unit: "location", group: "location", key: null, kind: "precision", wire: "location", enforced: true },
|
|
61
|
+
{ unit: "process.list", group: "process", key: "list", kind: "boolean", wire: "process", enforced: true },
|
|
62
|
+
{ unit: "process.command", group: "process", key: "command", kind: "boolean", wire: "processCommand", enforced: true },
|
|
63
|
+
{ unit: "process.kill", group: "process", key: "kill", kind: "boolean", wire: "processKill", enforced: true },
|
|
64
|
+
{ unit: "native.notify", group: "native", key: "notify", kind: "boolean", wire: null, enforced: false },
|
|
65
|
+
{ unit: "native.save", group: "native", key: "save", kind: "boolean", wire: "nativeSave", enforced: true },
|
|
66
|
+
{ unit: "screen.overlay", group: "screen", key: "overlay", kind: "boolean", wire: "screenOverlay", enforced: true },
|
|
67
|
+
{ unit: "screen.capture", group: "screen", key: "capture", kind: "boolean", wire: "screenCapture", enforced: true },
|
|
68
|
+
{ unit: "ui.webview", group: "ui", key: "webview", kind: "boolean", wire: "uiWebview", enforced: true },
|
|
69
|
+
{ unit: "background", group: "background", key: null, kind: "boolean", wire: "background", enforced: true },
|
|
70
|
+
{ unit: "suggestions.rules", group: "suggestions", key: "rules", kind: "boolean", wire: "suggestionRules", enforced: true },
|
|
71
|
+
] as const satisfies readonly PermissionUnitSpec[];
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Every unit a manifest may declare.
|
|
75
|
+
*
|
|
76
|
+
* `native.open` and `native.reveal` are deliberately absent: they are action-only effects (a
|
|
77
|
+
* keystroke can reach neither), and both are low-risk — `open` launches only http/https in the
|
|
78
|
+
* browser, visible and reversible, and `reveal` only shows a file the user already handed over.
|
|
79
|
+
* They are ambient on `ActionContext`, needing no declaration and no grant, which keeps the
|
|
80
|
+
* permission list short enough to actually read. `notify` and `save` stay units: a notification is
|
|
81
|
+
* unsolicited, and `save` leaves bytes on disk.
|
|
82
|
+
*/
|
|
83
|
+
export type PermissionUnit = (typeof PERMISSION_UNITS)[number]["unit"];
|
|
84
|
+
|
|
85
|
+
/** The units whose group/key pair is a plain boolean — the ones that merge and intersect by hand. */
|
|
86
|
+
export const BOOLEAN_PERMISSION_UNITS = PERMISSION_UNITS.filter(
|
|
87
|
+
(spec): spec is Extract<(typeof PERMISSION_UNITS)[number], { kind: "boolean"; key: string }> =>
|
|
88
|
+
spec.kind === "boolean" && spec.key !== null,
|
|
89
|
+
);
|
|
90
|
+
|
|
91
|
+
/** Units that sit directly on a permissions block as a bare `true`, with no object around them. */
|
|
92
|
+
export const FLAG_PERMISSION_UNITS = PERMISSION_UNITS.filter(
|
|
93
|
+
(spec) => spec.kind === "boolean" && spec.key === null,
|
|
94
|
+
);
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* The boolean units, gathered by the object they live under — `clipboard` → `read`/`write`, and so
|
|
98
|
+
* on. Merging and intersecting two permission blocks walk this rather than naming each field, so a
|
|
99
|
+
* new boolean unit is one row in the table above and nothing else.
|
|
100
|
+
*/
|
|
101
|
+
export const BOOLEAN_PERMISSION_GROUPS: ReadonlyMap<string, readonly string[]> = (() => {
|
|
102
|
+
const groups = new Map<string, string[]>();
|
|
103
|
+
for (const spec of BOOLEAN_PERMISSION_UNITS) {
|
|
104
|
+
const keys = groups.get(spec.group) ?? [];
|
|
105
|
+
keys.push(spec.key);
|
|
106
|
+
groups.set(spec.group, keys);
|
|
107
|
+
}
|
|
108
|
+
return groups;
|
|
109
|
+
})();
|