@medeo-plugin/sdk 0.0.0-stage → 0.8.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.
@@ -0,0 +1,173 @@
1
+ // Generated by platform-core from the capability registry; do not edit.
2
+ export const METHODS = {
3
+ "capabilities.list": { permission: null, provider: null, ui: false, confirm: false, adapter: "host" },
4
+ "capabilities.labels": { permission: null, provider: null, ui: false, confirm: false, adapter: "host" },
5
+ "providers.list": { permission: null, provider: null, ui: false, confirm: false, adapter: "host" },
6
+ "permissions.request": { permission: null, provider: null, ui: true, confirm: true, adapter: "host" },
7
+ "i18n.state": { permission: null, provider: null, ui: false, confirm: false, adapter: "host" },
8
+ "i18n.setLocale": { permission: null, provider: null, ui: false, confirm: false, adapter: "host" },
9
+ "i18n.t": { permission: null, provider: null, ui: false, confirm: false, adapter: "host" },
10
+ "i18n.catalog": { permission: null, provider: null, ui: false, confirm: false, adapter: "host" },
11
+ "operations.state": { permission: null, provider: null, ui: false, confirm: false, adapter: "host" },
12
+ "operations.list": { permission: null, provider: null, ui: false, confirm: false, adapter: "host" },
13
+ "operations.cancel": { permission: null, provider: null, ui: false, confirm: false, adapter: "host" },
14
+ "operations.release": { permission: null, provider: null, ui: false, confirm: false, adapter: "host" },
15
+ "config.get": { permission: null, provider: null, ui: false, confirm: false, adapter: "host" },
16
+ "config.set": { permission: null, provider: null, ui: false, confirm: false, adapter: "host" },
17
+ "storage.get": { permission: "storage", provider: "medeo.storage", ui: false, confirm: false, adapter: "host" },
18
+ "storage.set": { permission: "storage", provider: "medeo.storage", ui: false, confirm: false, adapter: "host" },
19
+ "storage.remove": { permission: "storage", provider: "medeo.storage", ui: false, confirm: false, adapter: "host" },
20
+ "creation.open": { permission: "creation_open", provider: "medeo.creation", ui: false, confirm: false, adapter: "host" },
21
+ "resources.choose_directories": { permission: "file_access", provider: "medeo.workspace", ui: true, confirm: false, adapter: "host" },
22
+ "resources.request_directory": { permission: "file_access", provider: "medeo.workspace", ui: true, confirm: true, adapter: "host" },
23
+ "resources.list": { permission: "file_access", provider: "medeo.workspace", ui: false, confirm: false, adapter: "host" },
24
+ "resources.revoke": { permission: "file_access", provider: "medeo.workspace", ui: true, confirm: false, adapter: "host" },
25
+ "files.roots": { permission: "file_access", provider: "medeo.workspace", ui: false, confirm: false, adapter: "host" },
26
+ "files.mkdir": { permission: "file_access", provider: "medeo.workspace", ui: false, confirm: false, adapter: "host" },
27
+ "files.history": { permission: "file_access", provider: "medeo.workspace", ui: false, confirm: false, adapter: "host" },
28
+ "files.history_page": { permission: "file_access", provider: "medeo.workspace", ui: false, confirm: false, adapter: "host" },
29
+ "files.backup_preview": { permission: "file_access", provider: "medeo.workspace", ui: false, confirm: false, adapter: "host" },
30
+ "files.archive_backup": { permission: "file_access", provider: "medeo.workspace", ui: true, confirm: true, adapter: "host" },
31
+ "files.forget_preview": { permission: "file_access", provider: "medeo.workspace", ui: false, confirm: false, adapter: "host" },
32
+ "files.forget_record": { permission: "file_access", provider: "medeo.workspace", ui: true, confirm: true, adapter: "host" },
33
+ "files.restore": { permission: "file_access", provider: "medeo.workspace", ui: false, confirm: false, adapter: "host" },
34
+ "files.list": { permission: "file_access", provider: "medeo.workspace", ui: false, confirm: false, adapter: "host" },
35
+ "files.stat": { permission: "file_access", provider: "medeo.workspace", ui: false, confirm: false, adapter: "host" },
36
+ "files.read_text": { permission: "file_access", provider: "medeo.workspace", ui: false, confirm: false, adapter: "host" },
37
+ "files.write_text": { permission: "file_access", provider: "medeo.workspace", ui: false, confirm: false, adapter: "host" },
38
+ "files.move": { permission: "file_access", provider: "medeo.workspace", ui: false, confirm: false, adapter: "host" },
39
+ "files.trash": { permission: "file_access", provider: "medeo.workspace", ui: true, confirm: true, adapter: "host" },
40
+ "files.trash_preview": { permission: "file_access", provider: "medeo.workspace", ui: false, confirm: false, adapter: "host" },
41
+ "files.discard_preview": { permission: "file_access", provider: "medeo.workspace", ui: false, confirm: false, adapter: "host" },
42
+ "files.watch": { permission: "file_access", provider: "medeo.workspace", ui: false, confirm: false, adapter: "host" },
43
+ "files.events": { permission: "file_access", provider: "medeo.workspace", ui: false, confirm: false, adapter: "host" },
44
+ "files.watches": { permission: "file_access", provider: "medeo.workspace", ui: false, confirm: false, adapter: "host" },
45
+ "files.unwatch": { permission: "file_access", provider: "medeo.workspace", ui: false, confirm: false, adapter: "host" },
46
+ "files.read_link": { permission: "file_access", provider: "medeo.workspace", ui: false, confirm: false, adapter: "host" },
47
+ "files.measure": { permission: "file_access", provider: "medeo.workspace", ui: false, confirm: false, adapter: "host" },
48
+ "files.list_tree": { permission: "file_access", provider: "medeo.workspace", ui: false, confirm: false, adapter: "host" },
49
+ "files.trash_direct": { permission: "file_full_access", provider: "medeo.workspace", ui: false, confirm: false, adapter: "host" },
50
+ "network.request": { permission: "network", provider: "medeo.network", ui: false, confirm: false, adapter: "host" },
51
+ "network.read": { permission: "network", provider: "medeo.network", ui: false, confirm: false, adapter: "host" },
52
+ "network.origins": { permission: "network", provider: "medeo.network", ui: false, confirm: false, adapter: "host" },
53
+ "network.request_origin": { permission: "network", provider: "medeo.network", ui: true, confirm: true, adapter: "host" },
54
+ "network.revoke_origin": { permission: "network", provider: "medeo.network", ui: true, confirm: false, adapter: "host" },
55
+ "agent.models": { permission: "agent_api", provider: "medeo.agent", ui: false, confirm: false, adapter: "host" },
56
+ "agent.start": { permission: "agent_api", provider: "medeo.agent", ui: false, confirm: false, adapter: "host" },
57
+ "clipboard.read": { permission: "clipboard_read", provider: "medeo.desktop", ui: false, confirm: false, adapter: "electron" },
58
+ "clipboard.write": { permission: "clipboard_write", provider: "medeo.desktop", ui: false, confirm: false, adapter: "electron" },
59
+ "capture.status": { permission: "screen_capture", provider: "medeo.capture", ui: false, confirm: false, adapter: "host" },
60
+ "capture.sources": { permission: "screen_capture", provider: "medeo.capture", ui: true, confirm: false, adapter: "host" },
61
+ "capture.start": { permission: "screen_capture", provider: "medeo.capture", ui: true, confirm: true, adapter: "host" },
62
+ "capture.state": { permission: "screen_capture", provider: "medeo.capture", ui: false, confirm: false, adapter: "host" },
63
+ "capture.stop": { permission: "screen_capture", provider: "medeo.capture", ui: false, confirm: false, adapter: "host" },
64
+ "capture.cancel": { permission: "screen_capture", provider: "medeo.capture", ui: false, confirm: false, adapter: "host" },
65
+ "capture.open": { permission: "screen_capture", provider: "medeo.capture", ui: true, confirm: false, adapter: "electron" },
66
+ "capture.recover": { permission: "screen_capture", provider: "medeo.capture", ui: true, confirm: false, adapter: "host" },
67
+ "camera.status": { permission: "camera", provider: "medeo.system-services", ui: false, confirm: false, adapter: "host" },
68
+ "camera.start": { permission: "camera", provider: "medeo.system-services", ui: true, confirm: false, adapter: "host" },
69
+ "camera.stop": { permission: "camera", provider: "medeo.system-services", ui: false, confirm: false, adapter: "host" },
70
+ "camera.state": { permission: "camera", provider: "medeo.system-services", ui: false, confirm: false, adapter: "host" },
71
+ "camera.read": { permission: "camera", provider: "medeo.system-services", ui: false, confirm: false, adapter: "host" },
72
+ "microphone.status": { permission: "microphone", provider: "medeo.system-services", ui: false, confirm: false, adapter: "host" },
73
+ "microphone.start": { permission: "microphone", provider: "medeo.system-services", ui: true, confirm: false, adapter: "host" },
74
+ "microphone.stop": { permission: "microphone", provider: "medeo.system-services", ui: false, confirm: false, adapter: "host" },
75
+ "microphone.state": { permission: "microphone", provider: "medeo.system-services", ui: false, confirm: false, adapter: "host" },
76
+ "microphone.read": { permission: "microphone", provider: "medeo.system-services", ui: false, confirm: false, adapter: "host" },
77
+ "system_audio.status": { permission: "system_audio", provider: "medeo.system-services", ui: false, confirm: false, adapter: "host" },
78
+ "system_audio.start": { permission: "system_audio", provider: "medeo.system-services", ui: true, confirm: false, adapter: "host" },
79
+ "system_audio.stop": { permission: "system_audio", provider: "medeo.system-services", ui: false, confirm: false, adapter: "host" },
80
+ "system_audio.state": { permission: "system_audio", provider: "medeo.system-services", ui: false, confirm: false, adapter: "host" },
81
+ "system_audio.read": { permission: "system_audio", provider: "medeo.system-services", ui: false, confirm: false, adapter: "host" },
82
+ "applications.list": { permission: "applications", provider: "medeo.applications", ui: false, confirm: false, adapter: "host" },
83
+ "applications.launch": { permission: "applications", provider: "medeo.applications", ui: false, confirm: false, adapter: "host" },
84
+ "applications.state": { permission: "applications", provider: "medeo.applications", ui: false, confirm: false, adapter: "host" },
85
+ "shortcuts.register": { permission: "global_shortcuts", provider: "medeo.desktop", ui: false, confirm: false, adapter: "electron" },
86
+ "shortcuts.unregister": { permission: "global_shortcuts", provider: "medeo.desktop", ui: false, confirm: false, adapter: "electron" },
87
+ "shortcuts.events": { permission: "global_shortcuts", provider: "medeo.desktop", ui: false, confirm: false, adapter: "electron" },
88
+ "shortcuts.list": { permission: "global_shortcuts", provider: "medeo.desktop", ui: false, confirm: false, adapter: "electron" },
89
+ "notifications.show": { permission: "notifications", provider: "medeo.desktop", ui: false, confirm: false, adapter: "electron" },
90
+ "notifications.close": { permission: "notifications", provider: "medeo.desktop", ui: false, confirm: false, adapter: "electron" },
91
+ "tray.set": { permission: "tray", provider: "medeo.desktop", ui: false, confirm: false, adapter: "electron" },
92
+ "tray.remove": { permission: "tray", provider: "medeo.desktop", ui: false, confirm: false, adapter: "electron" },
93
+ "tray.events": { permission: "tray", provider: "medeo.desktop", ui: false, confirm: false, adapter: "electron" },
94
+ "windows.open": { permission: "app_windows", provider: "medeo.desktop", ui: false, confirm: false, adapter: "electron" },
95
+ "windows.close": { permission: "app_windows", provider: "medeo.desktop", ui: false, confirm: false, adapter: "electron" },
96
+ "windows.focus": { permission: "app_windows", provider: "medeo.desktop", ui: false, confirm: false, adapter: "electron" },
97
+ "windows.hide": { permission: "app_windows", provider: "medeo.desktop", ui: false, confirm: false, adapter: "electron" },
98
+ "windows.list": { permission: "app_windows", provider: "medeo.desktop", ui: false, confirm: false, adapter: "electron" },
99
+ "accessibility.status": { permission: "accessibility", provider: "medeo.system-services", ui: false, confirm: false, adapter: "host" },
100
+ "accessibility.request": { permission: "accessibility", provider: "medeo.system-services", ui: true, confirm: false, adapter: "host" },
101
+ "accessibility.windows": { permission: "accessibility", provider: "medeo.system-services", ui: false, confirm: false, adapter: "host" },
102
+ "accessibility.focus": { permission: "accessibility", provider: "medeo.system-services", ui: true, confirm: false, adapter: "host" },
103
+ "accessibility.selected_text": { permission: "accessibility", provider: "medeo.system-services", ui: true, confirm: false, adapter: "host" },
104
+ "accessibility.insert_text": { permission: "accessibility", provider: "medeo.system-services", ui: true, confirm: false, adapter: "host" },
105
+ "input_method.status": { permission: "input_method", provider: "medeo.input-method", ui: false, confirm: false, adapter: "host" },
106
+ "input_method.configure": { permission: "input_method", provider: "medeo.input-method", ui: false, confirm: false, adapter: "host" },
107
+ "input_method.enable": { permission: "input_method", provider: "medeo.input-method", ui: true, confirm: false, adapter: "host" },
108
+ "input_method.disable": { permission: "input_method", provider: "medeo.input-method", ui: false, confirm: false, adapter: "host" },
109
+ "input_method.events": { permission: "input_method", provider: "medeo.input-method", ui: false, confirm: false, adapter: "host" },
110
+ "input_method.respond": { permission: "input_method", provider: "medeo.input-method", ui: false, confirm: false, adapter: "host" },
111
+ "input_method.system": { permission: "input_method", provider: "medeo.input-method", ui: false, confirm: false, adapter: "host" },
112
+ "input_method.setup": { permission: "input_method", provider: "medeo.input-method", ui: true, confirm: true, adapter: "host" },
113
+ "input_method.remove": { permission: "input_method", provider: "medeo.input-method", ui: true, confirm: true, adapter: "host" },
114
+ "input_method.settings": { permission: "input_method", provider: "medeo.input-method", ui: true, confirm: false, adapter: "host" },
115
+ "external.open": { permission: "external_open", provider: "medeo.desktop", ui: false, confirm: false, adapter: "electron" },
116
+ "browser.open": { permission: "browser_view", provider: "medeo.desktop", ui: true, confirm: false, adapter: "electron" },
117
+ "browser.close": { permission: "browser_view", provider: "medeo.desktop", ui: false, confirm: false, adapter: "electron" },
118
+ "browser.state": { permission: "browser_view", provider: "medeo.desktop", ui: false, confirm: false, adapter: "electron" },
119
+ "system.info": { permission: "system_info", provider: "medeo.desktop", ui: false, confirm: false, adapter: "electron" },
120
+ "system.running": { permission: "system_info", provider: "medeo.desktop", ui: false, confirm: false, adapter: "host" },
121
+ "secrets.get": { permission: "secrets", provider: "medeo.secrets", ui: false, confirm: false, adapter: "host" },
122
+ "secrets.set": { permission: "secrets", provider: "medeo.secrets", ui: false, confirm: false, adapter: "host" },
123
+ "secrets.remove": { permission: "secrets", provider: "medeo.secrets", ui: false, confirm: false, adapter: "host" },
124
+ "secrets.result": { permission: "secrets", provider: "medeo.secrets", ui: false, confirm: false, adapter: "host" },
125
+ "background.schedule": { permission: "background_tasks", provider: "medeo.background", ui: false, confirm: false, adapter: "host" },
126
+ "background.list": { permission: "background_tasks", provider: "medeo.background", ui: false, confirm: false, adapter: "host" },
127
+ "background.cancel": { permission: "background_tasks", provider: "medeo.background", ui: false, confirm: false, adapter: "host" },
128
+ "background.events": { permission: "background_tasks", provider: "medeo.background", ui: false, confirm: false, adapter: "host" },
129
+ "power.start": { permission: "power", provider: "medeo.desktop", ui: false, confirm: false, adapter: "electron" },
130
+ "power.stop": { permission: "power", provider: "medeo.desktop", ui: false, confirm: false, adapter: "electron" },
131
+ "power.state": { permission: "power", provider: "medeo.desktop", ui: false, confirm: false, adapter: "electron" },
132
+ "process.start": { permission: "process_spawn", provider: "medeo.process", ui: false, confirm: false, adapter: "host" },
133
+ "process.state": { permission: "process_spawn", provider: "medeo.process", ui: false, confirm: false, adapter: "host" },
134
+ "process.write": { permission: "process_spawn", provider: "medeo.process", ui: false, confirm: false, adapter: "host" },
135
+ "process.stop": { permission: "process_spawn", provider: "medeo.process", ui: false, confirm: false, adapter: "host" },
136
+ "system.trash": { permission: "node", provider: "medeo.node", ui: false, confirm: false, adapter: "electron" },
137
+ "approvals.request": { permission: "approvals", provider: "medeo.approvals", ui: true, confirm: true, adapter: "host" },
138
+ "approvals.rules": { permission: "approvals", provider: "medeo.approvals", ui: false, confirm: false, adapter: "host" },
139
+ "approvals.forget": { permission: "approvals", provider: "medeo.approvals", ui: false, confirm: false, adapter: "host" },
140
+ "server.start": { permission: "local_server", provider: "medeo.local-server", ui: false, confirm: false, adapter: "host" },
141
+ "server.state": { permission: "local_server", provider: "medeo.local-server", ui: false, confirm: false, adapter: "host" },
142
+ "server.stop": { permission: "local_server", provider: "medeo.local-server", ui: false, confirm: false, adapter: "host" },
143
+ "server.events": { permission: "local_server", provider: "medeo.local-server", ui: false, confirm: false, adapter: "host" },
144
+ "server.respond": { permission: "local_server", provider: "medeo.local-server", ui: false, confirm: false, adapter: "host" },
145
+ "printing.print": { permission: "printing", provider: "medeo.desktop", ui: true, confirm: false, adapter: "electron" },
146
+ "contacts.status": { permission: "contacts", provider: "medeo.system-services", ui: false, confirm: false, adapter: "host" },
147
+ "contacts.request": { permission: "contacts", provider: "medeo.system-services", ui: true, confirm: false, adapter: "host" },
148
+ "contacts.list": { permission: "contacts", provider: "medeo.system-services", ui: false, confirm: false, adapter: "host" },
149
+ "contacts.save": { permission: "contacts", provider: "medeo.system-services", ui: true, confirm: false, adapter: "host" },
150
+ "calendar.status": { permission: "calendar", provider: "medeo.system-services", ui: false, confirm: false, adapter: "host" },
151
+ "calendar.request": { permission: "calendar", provider: "medeo.system-services", ui: true, confirm: false, adapter: "host" },
152
+ "calendar.list": { permission: "calendar", provider: "medeo.system-services", ui: false, confirm: false, adapter: "host" },
153
+ "calendar.save": { permission: "calendar", provider: "medeo.system-services", ui: true, confirm: false, adapter: "host" },
154
+ "location.status": { permission: "location", provider: "medeo.system-services", ui: false, confirm: false, adapter: "host" },
155
+ "location.request": { permission: "location", provider: "medeo.system-services", ui: true, confirm: false, adapter: "host" },
156
+ "location.current": { permission: "location", provider: "medeo.system-services", ui: true, confirm: false, adapter: "host" },
157
+ "devices.choose": { permission: "device_access", provider: "medeo.system-services", ui: true, confirm: false, adapter: "host" },
158
+ "devices.open": { permission: "device_access", provider: "medeo.system-services", ui: true, confirm: false, adapter: "host" },
159
+ "devices.read": { permission: "device_access", provider: "medeo.system-services", ui: false, confirm: false, adapter: "host" },
160
+ "devices.write": { permission: "device_access", provider: "medeo.system-services", ui: false, confirm: false, adapter: "host" },
161
+ "devices.close": { permission: "device_access", provider: "medeo.system-services", ui: false, confirm: false, adapter: "host" },
162
+ "devices.list": { permission: "device_access", provider: "medeo.system-services", ui: false, confirm: false, adapter: "host" },
163
+ } as const;
164
+ export type MethodName = keyof typeof METHODS;
165
+ export const LIMITS = {
166
+ notifications: 16,
167
+ power: 8,
168
+ shortcuts: 16,
169
+ windows: 8,
170
+ browser_windows: 4,
171
+ media_streams: 4,
172
+ queued_events: 64,
173
+ } as const;
package/src/rpc.ts ADDED
@@ -0,0 +1,136 @@
1
+ import { channel, type Disposable } from './index.ts';
2
+
3
+ /** A transport is injectable for offline tests; the default is this plugin's own channel. */
4
+ export interface RpcTransport {
5
+ post(message: unknown): Promise<void>;
6
+ onMessage(listener: (message: unknown) => void): () => void;
7
+ }
8
+ export interface RpcOptions { transport?: RpcTransport; timeoutMs?: number; signal?: AbortSignal }
9
+ type Handler = (...args: any[]) => unknown;
10
+ export type WorkerApiClient<T> = { [K in keyof T]: T[K] extends (...args: infer A) => infer R ? (...args: A) => Promise<Awaited<R>> : never } & { $dispose(): void };
11
+ const PROTOCOL = 'medeo.rpc.v1';
12
+ const MAX_PENDING = 64;
13
+ const MAX_BYTES = 60 * 1024;
14
+ interface Packet { protocol: typeof PROTOCOL; kind: 'request' | 'result' | 'error'; client: string; id: string; method?: string; args?: unknown[]; value?: unknown; error?: string }
15
+ function packet(value: unknown): value is Packet {
16
+ if (!value || typeof value !== 'object') return false;
17
+ const p = value as Packet;
18
+ return p.protocol === PROTOCOL && ['request', 'result', 'error'].includes(p.kind) && typeof p.client === 'string' && p.client.length <= 80 && typeof p.id === 'string' && p.id.length <= 80;
19
+ }
20
+ /** Reject values JSON would silently drop or change, rather than corrupting an RPC argument. */
21
+ function checked<T>(value: T): T {
22
+ const seen = new Set<object>();
23
+ function visit(v: unknown): void {
24
+ if (v === null || typeof v === 'string' || typeof v === 'boolean') return;
25
+ if (typeof v === 'number' && Number.isFinite(v)) return;
26
+ if (!v || typeof v !== 'object' || (!Array.isArray(v) && Object.getPrototypeOf(v) !== Object.prototype && Object.getPrototypeOf(v) !== null) || seen.has(v)) throw new Error('rpc_invalid_json');
27
+ seen.add(v);
28
+ const array = Array.isArray(v);
29
+ if (array && Object.keys(v).length !== v.length) throw new Error('rpc_invalid_json');
30
+ for (const key of Reflect.ownKeys(v)) {
31
+ if (array && key === 'length') continue;
32
+ const descriptor = Object.getOwnPropertyDescriptor(v, key)!;
33
+ if (typeof key !== 'string' || !descriptor.enumerable || !('value' in descriptor) ||
34
+ (array && (!/^(0|[1-9]\d*)$/.test(key) || Number(key) >= v.length))) throw new Error('rpc_invalid_json');
35
+ visit(descriptor.value);
36
+ }
37
+ seen.delete(v);
38
+ }
39
+ visit(value);
40
+ const json = JSON.stringify(value);
41
+ if (new TextEncoder().encode(json).length > MAX_BYTES) throw new Error('rpc_message_too_large');
42
+ return JSON.parse(json) as T;
43
+ }
44
+
45
+ /** Register in activate, then push the returned disposable into context.subscriptions.
46
+ * Requests only reach this plugin's worker; this is not a cross-plugin service API.
47
+ */
48
+ export function defineWorkerApi<T extends { [K in keyof T]: Handler }>(handlers: T, options: Pick<RpcOptions, 'transport'> = {}): Disposable {
49
+ for (const name of ['then', '$dispose']) {
50
+ if (Object.hasOwn(handlers, name)) throw new Error(`rpc_reserved_method: ${name}`);
51
+ }
52
+ const transport = options.transport ?? channel;
53
+ const active = new Set<string>();
54
+ let stopped = false;
55
+ const respond = (p: Packet) => {
56
+ if (stopped) return;
57
+ const message = checked(p);
58
+ void Promise.resolve().then(() => { if (!stopped) return transport.post(message); }).catch(() => {});
59
+ };
60
+ const off = transport.onMessage(message => {
61
+ if (stopped || !packet(message) || message.kind !== 'request') return;
62
+ const key = JSON.stringify([message.client, message.id]);
63
+ if (active.has(key)) return;
64
+ const reply = { protocol: PROTOCOL, client: message.client, id: message.id } as const;
65
+ if (active.size >= MAX_PENDING) { respond({ ...reply, kind: 'error', error: 'rpc_busy' }); return; }
66
+ if (typeof message.method !== 'string' || !Object.hasOwn(handlers, message.method) || typeof handlers[message.method as keyof T] !== 'function' || !Array.isArray(message.args)) {
67
+ respond({ ...reply, kind: 'error', error: 'rpc_method_not_found' }); return;
68
+ }
69
+ active.add(key);
70
+ void Promise.resolve().then(() => {
71
+ if (stopped) return;
72
+ const request = checked(message);
73
+ return handlers[request.method as keyof T](...request.args!);
74
+ }).then(value => {
75
+ respond({ ...reply, kind: 'result', value: checked(value === undefined ? null : value) });
76
+ }).catch(error => {
77
+ let detail = 'rpc_failed';
78
+ try { detail = String(error instanceof Error ? error.message : error).slice(0, 1000); } catch {}
79
+ respond({ ...reply, kind: 'error', error: detail });
80
+ }).finally(() => active.delete(key));
81
+ });
82
+ return { dispose() { stopped = true; off(); active.clear(); } };
83
+ }
84
+
85
+ /** Create once per window, dispose on unmount. Timeout/disposal cancels waiting,
86
+ * not a worker side effect that already started. All arguments/results are JSON.
87
+ */
88
+ export function useWorkerApi<T>(options: RpcOptions = {}): WorkerApiClient<T> {
89
+ const transport = options.transport ?? channel;
90
+ const timeoutMs = options.timeoutMs ?? 30_000;
91
+ if (!Number.isFinite(timeoutMs) || timeoutMs <= 0 || timeoutMs > 300_000) throw new Error('rpc_invalid_timeout');
92
+ const client = globalThis.crypto.randomUUID();
93
+ let sequence = 0, stopped = false;
94
+ const pending = new Map<string, { resolve(value: unknown): void; reject(error: Error): void; timer: ReturnType<typeof setTimeout> }>();
95
+ const settle = (id: string, value: unknown, error?: string) => {
96
+ const request = pending.get(id); if (!request) return;
97
+ pending.delete(id); clearTimeout(request.timer);
98
+ if (error !== undefined) request.reject(new Error(error)); else request.resolve(value);
99
+ };
100
+ const off = transport.onMessage(message => {
101
+ if (!packet(message) || message.client !== client || message.kind === 'request') return;
102
+ try {
103
+ const response = checked(message);
104
+ if (response.kind === 'error') settle(response.id, null, typeof response.error === 'string' ? response.error : 'rpc_failed');
105
+ else settle(response.id, checked(response.value));
106
+ } catch (error) { settle(message.id, null, error instanceof Error ? error.message : 'rpc_invalid_json'); }
107
+ });
108
+ const dispose = () => {
109
+ if (stopped) return;
110
+ stopped = true; off(); options.signal?.removeEventListener('abort', dispose);
111
+ for (const id of pending.keys()) settle(id, null, 'rpc_disposed');
112
+ };
113
+ options.signal?.addEventListener('abort', dispose, { once: true });
114
+ if (options.signal?.aborted) dispose();
115
+ return new Proxy(Object.create(null), {
116
+ get(_target, method) {
117
+ if (method === '$dispose') return dispose;
118
+ if (method === 'then' || typeof method !== 'string') return undefined;
119
+ return (...args: unknown[]) => {
120
+ if (stopped) return Promise.reject(new Error('rpc_disposed'));
121
+ if (pending.size >= MAX_PENDING) return Promise.reject(new Error('rpc_busy'));
122
+ const id = String(++sequence);
123
+ let message: Packet;
124
+ try { message = checked({ protocol: PROTOCOL, kind: 'request', client, id, method, args }); }
125
+ catch (error) { return Promise.reject(error); }
126
+ return new Promise((resolve, reject) => {
127
+ const timer = setTimeout(() => settle(id, null, 'rpc_timeout'), timeoutMs);
128
+ pending.set(id, { resolve, reject, timer });
129
+ void Promise.resolve().then(() => {
130
+ if (!stopped && pending.has(id)) return transport.post(message);
131
+ }).catch(error => settle(id, null, error instanceof Error ? error.message : 'rpc_send_failed'));
132
+ });
133
+ };
134
+ },
135
+ }) as WorkerApiClient<T>;
136
+ }
package/src/testing.ts ADDED
@@ -0,0 +1,144 @@
1
+ import type { PluginAPI, PluginContext, PlatformEvent, PluginLocaleState } from './index.ts';
2
+ import { METHODS } from './registry.ts';
3
+
4
+ export type FakeHandler = (args: Record<string, unknown>) => unknown | Promise<unknown>;
5
+ type CommandHandler = Parameters<PluginContext['commands']['register']>[1];
6
+ export interface FakeHostOptions {
7
+ storage?: Record<string, unknown>;
8
+ /** Declared configuration keys and their initial/default values. */
9
+ config?: Record<string, unknown>;
10
+ handlers?: Record<string, FakeHandler>;
11
+ commands?: Record<string, CommandHandler>;
12
+ }
13
+
14
+ // Preserve descriptors as well as values, including an originally absent global.
15
+ // Removed nested installations are skipped so out-of-order cleanup cannot revive one.
16
+ type Installation = { api: PluginAPI; previous?: PropertyDescriptor; removed: boolean };
17
+ const installations: Installation[] = [];
18
+ function installAPI(api: PluginAPI): () => void {
19
+ const entry: Installation = { api, previous: Object.getOwnPropertyDescriptor(globalThis, 'medeoPlugin'), removed: false };
20
+ Object.defineProperty(globalThis, 'medeoPlugin', { configurable: true, writable: true, value: api });
21
+ installations.push(entry);
22
+ return () => {
23
+ if (entry.removed) return;
24
+ entry.removed = true;
25
+ while (installations.at(-1)?.removed) {
26
+ const top = installations.pop()!;
27
+ // Never overwrite a bridge installed by unrelated test code.
28
+ if (Object.getOwnPropertyDescriptor(globalThis, 'medeoPlugin')?.value !== top.api) continue;
29
+ if (top.previous) Object.defineProperty(globalThis, 'medeoPlugin', top.previous);
30
+ else Reflect.deleteProperty(globalThis, 'medeoPlugin');
31
+ }
32
+ };
33
+ }
34
+
35
+ /** Offline bridge for real SDK calls. No OS, permissions, persistence or manifest
36
+ * validation is simulated. Channel messages loop back to this host's listeners.
37
+ * Install only within a serial test scope (globals cannot isolate concurrent tests).
38
+ * Every unimplemented capability fails unless supplied in `handlers`.
39
+ */
40
+ export function createFakeHost(options: FakeHostOptions = {}) {
41
+ const clone = <T>(value: T): T => structuredClone(value);
42
+ const storage = new Map(Object.entries(clone(options.storage ?? {})));
43
+ const config = new Map(Object.entries(clone(options.config ?? {})));
44
+ const handlers = new Map(Object.entries(options.handlers ?? {}));
45
+ const commands = new Map<string, CommandHandler>();
46
+ const messages = new Set<(message: unknown) => void>();
47
+ const events = new Set<(event: PlatformEvent) => void>();
48
+ const locales = new Set<(state: PluginLocaleState) => void>();
49
+ const restores = new Set<() => void>();
50
+ let disposed = false;
51
+ const assertActive = () => { if (disposed) throw new Error('fake_host_disposed'); };
52
+ const subscribe = <T>(listeners: Set<(value: T) => void>, callback: (value: T) => void) => {
53
+ assertActive(); listeners.add(callback); return () => { listeners.delete(callback); };
54
+ };
55
+ const emit = <T>(listeners: Set<(value: T) => void>, value: T) => {
56
+ assertActive(); for (const listener of [...listeners]) listener(clone(value));
57
+ };
58
+ const key = (args: Record<string, unknown>) => {
59
+ if (typeof args.key !== 'string') throw new Error('invalid_key');
60
+ return args.key;
61
+ };
62
+ const builtins: Record<string, FakeHandler> = {
63
+ 'storage.get': args => clone(storage.get(key(args)) ?? null),
64
+ 'storage.set': args => { storage.set(key(args), clone(args.value)); return null; },
65
+ 'storage.remove': args => { storage.delete(key(args)); return null; },
66
+ 'config.get': args => {
67
+ if (args.key === undefined) return Object.fromEntries(config);
68
+ const name = key(args);
69
+ if (!config.has(name)) throw new Error(`unknown_configuration: ${name}`);
70
+ return config.get(name);
71
+ },
72
+ 'config.set': args => {
73
+ const name = key(args);
74
+ if (!config.has(name)) throw new Error(`unknown_configuration: ${name}`);
75
+ config.set(name, clone(args.value));
76
+ emit(events, { type: 'signal', channel: 'config' });
77
+ return null;
78
+ },
79
+ };
80
+ const api: PluginAPI = {
81
+ async invoke(method, args = {}) {
82
+ assertActive();
83
+ const handler = handlers.get(method) ?? (Object.hasOwn(builtins, method) ? builtins[method] : undefined);
84
+ if (!handler) throw new Error(`${Object.hasOwn(METHODS, method) ? 'unimplemented_method' : 'unknown_method'}: ${method}`);
85
+ return clone(await handler(clone(args)));
86
+ },
87
+ async run(command, args) {
88
+ assertActive();
89
+ const handler = commands.get(command);
90
+ if (!handler) throw new Error(`unregistered_command: ${command}`);
91
+ return clone(await handler(clone(args)));
92
+ },
93
+ async post(message) { emit(messages, message); },
94
+ onMessage: callback => subscribe(messages, callback),
95
+ onEvent: callback => subscribe(events, callback),
96
+ onLocaleChanged: callback => subscribe(locales, callback),
97
+ };
98
+ const context: PluginContext = {
99
+ subscriptions: [],
100
+ commands: {
101
+ register(id, handler) {
102
+ assertActive();
103
+ if (commands.has(id)) throw new Error(`duplicate_command: ${id}`);
104
+ commands.set(id, handler);
105
+ const disposable = { dispose() { if (commands.get(id) === handler) commands.delete(id); } };
106
+ context.subscriptions.push(disposable);
107
+ return disposable;
108
+ },
109
+ },
110
+ };
111
+ for (const [id, handler] of Object.entries(options.commands ?? {})) context.commands.register(id, handler);
112
+ return {
113
+ api, context,
114
+ /** Explicit installation: importing this module never changes the global. */
115
+ install() {
116
+ assertActive();
117
+ const restore = installAPI(api);
118
+ const off = () => { restore(); restores.delete(off); };
119
+ restores.add(off);
120
+ return off;
121
+ },
122
+ emitEvent: (event: PlatformEvent) => emit(events, event),
123
+ emitMessage: (message: unknown) => emit(messages, message),
124
+ emitLocaleChanged: (state: PluginLocaleState) => emit(locales, state),
125
+ async dispose() {
126
+ if (disposed) return;
127
+ disposed = true;
128
+ for (const restore of [...restores].reverse()) restore();
129
+ messages.clear(); events.clear(); locales.clear(); commands.clear();
130
+ const errors: unknown[] = [];
131
+ for (const subscription of context.subscriptions.splice(0).reverse()) {
132
+ try { await subscription.dispose(); } catch (error) { errors.push(error); }
133
+ }
134
+ if (errors.length) throw new AggregateError(errors, 'fake_host_cleanup_failed');
135
+ },
136
+ };
137
+ }
138
+
139
+ /** Installs a bridge for a callback and restores it even when the callback throws. */
140
+ export async function withFakeHost<T>(options: FakeHostOptions, run: (host: ReturnType<typeof createFakeHost>) => T | Promise<T>): Promise<T> {
141
+ const host = createFakeHost(options);
142
+ host.install();
143
+ try { return await run(host); } finally { await host.dispose(); }
144
+ }
@@ -0,0 +1,31 @@
1
+ /** Layout only: colors, type and dimensions remain owned by /_medeo/theme.css. */
2
+ export const uiStyles = `
3
+ .medeo-ui{font-family:var(--font-family-plain);color:var(--color-surface-on-surface);font-size:var(--type-label-large-size);line-height:var(--type-label-large-leading)}
4
+ .medeo-card{padding:var(--space-s5);border:1px solid var(--color-surface-outline-variant);border-radius:var(--shape-radius-16);background:var(--color-surface-surface-container-lowest);display:grid;gap:var(--space-s3)}
5
+ .medeo-card h2,.medeo-empty h2{margin:0;font-size:var(--type-title-medium-size);line-height:var(--type-title-medium-leading);font-weight:var(--type-title-medium-weight);letter-spacing:var(--type-title-medium-tracking)}
6
+ .medeo-description{margin:0;color:var(--color-surface-on-surface-variant)}
7
+ .medeo-pill{display:inline-flex;align-items:center;justify-self:start;padding:var(--space-s1) var(--space-s3);border-radius:var(--shape-radius-full);background:var(--medeo-selected);font-size:var(--type-label-medium-size);line-height:var(--type-label-medium-leading);font-weight:var(--type-label-medium-prominent-weight)}
8
+ .medeo-pill[data-tone=error]{color:var(--color-schemes-error);background:var(--medeo-error-wash)}
9
+ .medeo-pill[data-tone=neutral]{background:var(--medeo-hover)}
10
+ .medeo-toggle{display:inline-flex;align-items:center;gap:var(--space-s2);min-height:var(--space-s8);cursor:pointer}
11
+ .medeo-toggle input{width:var(--space-s4);height:var(--space-s4);accent-color:var(--color-schemes-primary)}
12
+ .medeo-toggle:has(input:disabled){opacity:.45;cursor:default}
13
+ .medeo-segmented{display:flex;flex-wrap:wrap;gap:var(--space-s1);padding:var(--space-s1);border:1px solid var(--color-surface-outline-variant);border-radius:var(--shape-radius-12)}
14
+ .medeo-segmented button{font:inherit;color:inherit;border:0;border-radius:var(--shape-radius-8);background:transparent;min-height:var(--space-s8);padding:var(--space-s2) var(--space-s3);cursor:pointer}
15
+ .medeo-segmented button[aria-checked=true]{background:var(--color-schemes-primary);color:var(--color-schemes-on-primary)}
16
+ .medeo-segmented button:hover:not(:disabled):not([aria-checked=true]){background:var(--medeo-hover)}
17
+ .medeo-segmented button:disabled{opacity:.45;cursor:default}
18
+ .medeo-ui :focus-visible{outline:2px solid var(--color-schemes-primary);outline-offset:2px}
19
+ .medeo-list{list-style:none;margin:0;padding:0;display:grid;gap:var(--space-s2)}
20
+ .medeo-list>li{padding:var(--space-s3);border-bottom:1px solid var(--color-surface-outline-variant)}
21
+ .medeo-empty{display:grid;gap:var(--space-s2);padding:var(--space-s7);text-align:center;border:1px dashed var(--color-surface-outline);border-radius:var(--shape-radius-12)}
22
+ `;
23
+
24
+ /** Explicit and idempotent; importing this module has no DOM side effects. */
25
+ export function installStyles(doc: Document = document): void {
26
+ if (doc.querySelector('style[data-medeo-ui]')) return;
27
+ const style = doc.createElement('style');
28
+ style.dataset.medeoUi = '';
29
+ style.textContent = uiStyles;
30
+ doc.head.append(style);
31
+ }
package/src/ui.ts ADDED
@@ -0,0 +1,102 @@
1
+ export { installStyles, uiStyles } from './ui/styles.js';
2
+
3
+ export interface Identified { testid: string }
4
+ export interface CardOptions extends Identified { title: string; description?: string }
5
+ export interface PillOptions extends Identified { label: string; tone?: 'neutral' | 'info' | 'error' }
6
+ export interface ToggleOptions extends Identified { label: string; checked: boolean; disabled?: boolean; onChange: (checked: boolean) => void }
7
+ export interface Segment<T extends string = string> { value: T; label: string; disabled?: boolean }
8
+ export interface SegmentedOptions<T extends string = string> extends Identified { label: string; options: readonly Segment<T>[]; value: T; disabled?: boolean; onChange: (value: T) => void }
9
+ export interface ListOptions extends Identified { label: string }
10
+ export type Child = Node | string | number | null | undefined | false;
11
+
12
+ function node<K extends keyof HTMLElementTagNameMap>(tag: K, className: string, testid?: string, text?: string): HTMLElementTagNameMap[K] {
13
+ const el = document.createElement(tag);
14
+ el.className = className;
15
+ if (testid !== undefined) el.dataset.testid = testid;
16
+ if (text !== undefined) el.textContent = text;
17
+ return el;
18
+ }
19
+ function append(el: HTMLElement, children: readonly Child[]): void {
20
+ for (const child of children) if (child !== null && child !== undefined && child !== false) el.append(typeof child === 'number' ? String(child) : child);
21
+ }
22
+ export function card(options: CardOptions, ...children: Child[]): HTMLElement {
23
+ const root = node('section', 'medeo-ui medeo-card', options.testid);
24
+ root.setAttribute('aria-label', options.title);
25
+ root.append(node('h2', '', `${options.testid}.title`, options.title));
26
+ if (options.description !== undefined) root.append(node('p', 'medeo-description', undefined, options.description));
27
+ append(root, children);
28
+ return root;
29
+ }
30
+ export function pill(options: PillOptions): HTMLSpanElement {
31
+ const root = node('span', 'medeo-ui medeo-pill', options.testid, options.label);
32
+ root.dataset.tone = options.tone ?? 'info';
33
+ return root;
34
+ }
35
+ /** Native checkbox keyboard behavior (Space), with switch semantics. */
36
+ export function toggle(options: ToggleOptions): HTMLLabelElement & { input: HTMLInputElement } {
37
+ const root = node('label', 'medeo-ui medeo-toggle', `${options.testid}.root`);
38
+ const input = node('input', '', options.testid);
39
+ input.type = 'checkbox'; input.setAttribute('role', 'switch');
40
+ input.checked = options.checked; input.disabled = options.disabled ?? false;
41
+ input.addEventListener('change', () => options.onChange(input.checked));
42
+ root.append(input, node('span', '', undefined, options.label));
43
+ return Object.assign(root, { input });
44
+ }
45
+ /** Shared radio keyboard navigation; disabled choices are skipped, edges wrap. */
46
+ export function segmentTarget<T extends string>(options: readonly Segment<T>[], index: number, key: string, rtl = false): number | undefined {
47
+ const enabled = options.map((o, i) => o.disabled ? -1 : i).filter(i => i >= 0);
48
+ if (!enabled.length) return undefined;
49
+ if (key === 'Home') return enabled[0];
50
+ if (key === 'End') return enabled[enabled.length - 1];
51
+ let direction = key === 'ArrowDown' ? 1 : key === 'ArrowUp' ? -1 : key === 'ArrowRight' ? (rtl ? -1 : 1) : key === 'ArrowLeft' ? (rtl ? 1 : -1) : 0;
52
+ if (!direction) return undefined;
53
+ return enabled[(enabled.indexOf(index) + direction + enabled.length) % enabled.length];
54
+ }
55
+ export function segmentTabIndex<T extends string>(options: readonly Segment<T>[], value: T, index: number): number {
56
+ let active = options.findIndex(o => o.value === value && !o.disabled);
57
+ if (active < 0) active = options.findIndex(o => !o.disabled);
58
+ return index === active ? 0 : -1;
59
+ }
60
+ export function segmented<T extends string>(options: SegmentedOptions<T>): HTMLDivElement & { setValue(value: T): void; setDisabled(disabled: boolean): void } {
61
+ const root = node('div', 'medeo-ui medeo-segmented', options.testid);
62
+ root.setAttribute('role', 'radiogroup'); root.setAttribute('aria-label', options.label);
63
+ let value = options.value, disabled = options.disabled ?? false;
64
+ const buttons = options.options.map((option, index) => {
65
+ const button = node('button', '', `${options.testid}.${index}`, option.label);
66
+ button.type = 'button'; button.setAttribute('role', 'radio');
67
+ button.addEventListener('click', () => {
68
+ if (disabled || option.disabled) return;
69
+ value = option.value; render(); options.onChange(value);
70
+ });
71
+ button.addEventListener('keydown', event => {
72
+ if (disabled) return;
73
+ const target = segmentTarget(options.options, index, event.key, getComputedStyle(root).direction === 'rtl');
74
+ if (target === undefined) return;
75
+ event.preventDefault(); buttons[target].focus(); buttons[target].click();
76
+ });
77
+ return button;
78
+ });
79
+ function render() {
80
+ buttons.forEach((button, index) => {
81
+ button.disabled = disabled || !!options.options[index].disabled;
82
+ button.tabIndex = disabled ? -1 : segmentTabIndex(options.options, value, index);
83
+ button.setAttribute('aria-checked', String(options.options[index].value === value));
84
+ });
85
+ }
86
+ root.append(...buttons); render();
87
+ return Object.assign(root, { setValue(next: T) { value = next; render(); }, setDisabled(next: boolean) { disabled = next; render(); } });
88
+ }
89
+ /** Each child becomes one semantic list item; caller owns empty-state wording. */
90
+ export function list(options: ListOptions, ...items: Child[]): HTMLUListElement {
91
+ const root = node('ul', 'medeo-ui medeo-list', options.testid);
92
+ root.setAttribute('aria-label', options.label);
93
+ items.forEach((item, index) => { const li = node('li', '', `${options.testid}.${index}`); append(li, [item]); root.append(li); });
94
+ return root;
95
+ }
96
+ export function emptyState(options: CardOptions): HTMLElement {
97
+ const root = node('section', 'medeo-ui medeo-empty', options.testid);
98
+ root.setAttribute('aria-label', options.title);
99
+ root.append(node('h2', '', undefined, options.title));
100
+ if (options.description !== undefined) root.append(node('p', 'medeo-description', undefined, options.description));
101
+ return root;
102
+ }