@medeo-plugin/sdk 0.7.0 → 0.9.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/src/index.ts CHANGED
@@ -13,7 +13,7 @@ export interface PluginAPI {
13
13
  post(message:unknown):Promise<void>;
14
14
  onMessage(callback:(message:unknown)=>void):()=>void;
15
15
  invoke(method: MethodName, args?: Record<string, unknown>): Promise<unknown>;
16
- run(command: string): Promise<unknown>;
16
+ run(command: string, args?: JsonValue): Promise<unknown>;
17
17
  }
18
18
  declare global { var medeoPlugin: PluginAPI; interface Window { medeoPlugin: PluginAPI } }
19
19
 
@@ -55,14 +55,14 @@ export const storage = {
55
55
  set: (key: string, value: unknown) => globalThis.medeoPlugin.invoke('storage.set', { key, value }),
56
56
  };
57
57
  export const creation = { open: () => globalThis.medeoPlugin.invoke('creation.open') };
58
- export const commands = { run: (command: string) => globalThis.medeoPlugin.run(command) };
58
+ export const commands = { run: <T = unknown>(command: string, args?: JsonValue) => globalThis.medeoPlugin.run(command, args) as Promise<T> };
59
59
  export interface Disposable { dispose(): unknown }
60
60
  /** Passed to `activate` (VS Code style), in both runtimes. */
61
61
  export interface PluginContext {
62
62
  /** Disposed, in reverse order, when the plugin stops. */
63
63
  subscriptions: Disposable[];
64
64
  /** Register a handler for each command the manifest declares; all must be registered when `activate` returns. */
65
- commands: { register(id: string, handler: () => unknown | Promise<unknown>): Disposable };
65
+ commands: { register(id: string, handler: (args?: JsonValue) => unknown | Promise<unknown>): Disposable };
66
66
  /** `runtime: "node"` only: the installed, verified package directory (read-only). */
67
67
  extensionPath?: string;
68
68
  /** `runtime: "node"` only: a private writable directory that survives updates. */
@@ -133,8 +133,14 @@ export const shortcuts = {list:()=>globalThis.medeoPlugin.invoke('shortcuts.list
133
133
  /** `panel: true` (macOS): a frameless window floating over every app and Space that does not
134
134
  * activate Medeo — the app the user was in keeps focus — opened at the pointer and hidden (not
135
135
  * closed) when it loses focus; opening it again moves it to the pointer. `hide` keeps a window
136
- * and its page alive for the next `open`/`focus`. */
137
- export const appWindows = {open:(options:{id:string;entry:string;title:string;width:number;height:number;panel?:boolean})=>globalThis.medeoPlugin.invoke('windows.open',options),close:(id:string)=>globalThis.medeoPlugin.invoke('windows.close',{id}),focus:(id:string)=>globalThis.medeoPlugin.invoke('windows.focus',{id}),hide:(id:string)=>globalThis.medeoPlugin.invoke('windows.hide',{id}),list:()=>globalThis.medeoPlugin.invoke('windows.list') as Promise<{id:string;title:string;visible:boolean}[]>};
136
+ * and its page alive for the next `open`/`focus`.
137
+ * `overlay: true` (macOS, needs `window_management` too): a transparent, shadowless panel covering
138
+ * the whole display under the pointer (menu bar included), above full-screen apps — for previews
139
+ * and radial menus. It takes keyboard focus without activating Medeo, hides when it loses focus,
140
+ * and each `open` moves it to the pointer's display; `width`/`height` are ignored. The page's
141
+ * `window.screenX/screenY` is the display's top-left in the same global points as
142
+ * `windowManagement`. `panel` and `overlay` are exclusive. */
143
+ export const appWindows = {open:(options:{id:string;entry:string;title:string;width:number;height:number;panel?:boolean;overlay?:boolean})=>globalThis.medeoPlugin.invoke('windows.open',options),close:(id:string)=>globalThis.medeoPlugin.invoke('windows.close',{id}),focus:(id:string)=>globalThis.medeoPlugin.invoke('windows.focus',{id}),hide:(id:string)=>globalThis.medeoPlugin.invoke('windows.hide',{id}),list:()=>globalThis.medeoPlugin.invoke('windows.list') as Promise<{id:string;title:string;visible:boolean}[]>};
138
144
  /** A menu entry, a checkmarked toggle, or a divider. */
139
145
  export type TrayItem = {id:string;title:string;checked?:boolean}|{separator:true};
140
146
  /** `icon`: a PNG in the package drawn as a menu-bar template (black + alpha, about 36 px tall);
@@ -229,10 +235,11 @@ export const inputMethod={
229
235
  * Closing UI preserves jobs; disable/revoke cancels. Mutations are never replayed.
230
236
  * Release completed results to return the per-instance memory quota.
231
237
  */
232
- export interface NetworkRequest {url:string;method?:'GET'|'HEAD'|'POST'|'PUT'|'PATCH'|'DELETE'|'OPTIONS';headers?:Record<string,string>;body?:string;body_base64?:string;timeout_ms?:number}
233
- export interface NetworkJob {id:string;status:'running'|'completed'|'failed'|'cancelled';received?:number;http_status?:number|null;headers?:[string,string][];error?:string|null}
238
+ export interface NetworkRequest {url:string;method?:'GET'|'HEAD'|'POST'|'PUT'|'PATCH'|'DELETE'|'OPTIONS';headers?:Record<string,string>;body?:string;body_base64?:string;timeout_ms?:number;stream?:boolean}
239
+ export interface NetworkJob {id:string;status:'running'|'completed'|'failed'|'cancelled';received?:number;http_status?:number|null;headers?:[string,string][];error?:string|null;stream?:boolean}
234
240
  export interface NetworkChunk {data_base64:string;next_offset:number;eof:boolean}
235
241
  export interface NetworkOrigin {origin:string;declared:boolean;allowed:boolean}
242
+ export interface NetworkServerEvent {data:string;event:string;id:string;retry?:number}
236
243
  export const network = {
237
244
  origins:()=>capabilities.invoke<NetworkOrigin[]>('network.origins'),
238
245
  requestOrigin:(origin:string,reason:string)=>capabilities.invoke<{origin:string;granted:boolean}>('network.request_origin',{origin,reason}),
@@ -244,6 +251,80 @@ export const network = {
244
251
  release:(id:string)=>operations.release(id) as Promise<{released:boolean}>,
245
252
  /** Resolves once the response has fully arrived; the job stays readable until released. */
246
253
  async settled(id:string):Promise<NetworkJob>{for(;;){const change=nextChange(id,2000);const state=await network.state(id);if(state.status!=='running'){change.off();return state}await change.promise;change.off()}},
254
+ /** Single consumer for a stream:true job. Break/throw/abort cancels and releases it. */
255
+ async *bytes(id:string,signal?:AbortSignal):AsyncGenerator<Uint8Array>{
256
+ let offset=0;
257
+ let abortCleanup:Promise<void>|undefined;
258
+ const abort=()=>{abortCleanup=network.dispose(id);void abortCleanup.catch(()=>{})};
259
+ signal?.addEventListener('abort',abort,{once:true});
260
+ try {
261
+ for(;;){
262
+ let wake!:()=>void;
263
+ const changed=new Promise<void>(resolve=>{wake=resolve});
264
+ const off=events.on(event=>{if(event.type==='operation'&&event.id===id)wake()});
265
+ signal?.addEventListener('abort',wake,{once:true});
266
+ try {
267
+ if(signal?.aborted)throw new Error('network_cancelled');
268
+ const state=await network.state(id);
269
+ if(state.status==='failed'||state.status==='cancelled')throw new Error(state.error??state.status);
270
+ const part=await network.read(id,offset);
271
+ offset=part.next_offset;
272
+ if(signal?.aborted)throw new Error('network_cancelled');
273
+ if(part.data_base64)yield Uint8Array.from(atob(part.data_base64),c=>c.charCodeAt(0));
274
+ if(part.eof)return;
275
+ if(!part.data_base64)await changed;
276
+ } finally {off();signal?.removeEventListener('abort',wake)}
277
+ }
278
+ } finally {
279
+ signal?.removeEventListener('abort',abort);
280
+ await (abortCleanup??network.dispose(id));
281
+ }
282
+ },
283
+ /** Incremental UTF-8 decoder, including code points split across HTTP chunks. */
284
+ async *textStream(id:string,signal?:AbortSignal):AsyncGenerator<string>{
285
+ const decoder=new TextDecoder();
286
+ for await(const bytes of network.bytes(id,signal)){
287
+ const text=decoder.decode(bytes,{stream:true});if(text)yield text;
288
+ }
289
+ const tail=decoder.decode();if(tail)yield tail;
290
+ },
291
+ /** SSE framing only: no automatic reconnect/retry or Last-Event-ID requests. */
292
+ async *sse(id:string,signal?:AbortSignal):AsyncGenerator<NetworkServerEvent>{
293
+ let pending='',data:string[]=[],event='',lastId='',retry:number|undefined,size=0,skipLf=false;
294
+ for await(const text of network.textStream(id,signal)){
295
+ pending+=skipLf&&text.startsWith('\n')?text.slice(1):text;
296
+ skipLf=false;
297
+ for(;;){
298
+ const at=pending.search(/[\r\n]/);
299
+ if(at<0)break;
300
+ skipLf=pending[at]==='\r'&&at===pending.length-1;
301
+ const line=pending.slice(0,at);
302
+ pending=pending.slice(at+(pending[at]==='\r'&&pending[at+1]==='\n'?2:1));
303
+ size+=line.length;
304
+ if(size>262144)throw new Error('network_sse_event_too_large');
305
+ if(!line){
306
+ if(data.length)yield {data:data.join('\n'),event:event||'message',id:lastId,...(retry===undefined?{}:{retry})};
307
+ data=[];event='';size=0;continue;
308
+ }
309
+ if(line.startsWith(':'))continue;
310
+ const colon=line.indexOf(':');
311
+ const field=colon<0?line:line.slice(0,colon);
312
+ let value=colon<0?'':line.slice(colon+1);if(value.startsWith(' '))value=value.slice(1);
313
+ if(field==='data')data.push(value);
314
+ else if(field==='event')event=value;
315
+ else if(field==='id'&&!value.includes('\0'))lastId=value;
316
+ else if(field==='retry'&&/^\d+$/.test(value)&&Number.isSafeInteger(Number(value)))retry=Number(value);
317
+ }
318
+ if(size+pending.length>262144)throw new Error('network_sse_event_too_large');
319
+ }
320
+ // SSE requires a blank line to dispatch; incomplete final events are discarded.
321
+ },
322
+ /** Explicitly dispose a stream that was never iterated (or has another owner). */
323
+ async dispose(id:string):Promise<void>{
324
+ const ignoreMissing=(error:unknown)=>{if(!/^(operation_missing|network_job_missing|permission_denied)$/.test(error instanceof Error?error.message:String(error)))throw error};
325
+ try{await network.cancel(id)}catch(error){ignoreMissing(error)}
326
+ try{await network.release(id)}catch(error){ignoreMissing(error)}
327
+ },
247
328
  async text(id:string):Promise<string>{
248
329
  const decoder=new TextDecoder();let offset=0,text='';
249
330
  for(;;){const part=await network.read(id,offset);const bytes=Uint8Array.from(atob(part.data_base64),c=>c.charCodeAt(0));text+=decoder.decode(bytes,{stream:!part.eof});if(part.eof)return text;offset=part.next_offset;}
@@ -382,6 +463,28 @@ function mediaProvider(kind:'camera'|'microphone'|'system_audio'){return{
382
463
  }}
383
464
  export const camera=mediaProvider('camera'),microphone=mediaProvider('microphone'),systemAudio=mediaProvider('system_audio');
384
465
  export const accessibility={status:()=>nativeCall('accessibility.status'),request:()=>nativeCall('accessibility.request'),windows:()=>nativeCall<{id:string;title:string;application:string}[]>('accessibility.windows'),focus:(id:string)=>nativeCall('accessibility.focus',{id}),selectedText:(id:string)=>nativeCall<{id:string;text:string}>('accessibility.selected_text',{id}),insertText:(focus:string,text:string)=>nativeCall('accessibility.insert_text',{id:focus,text})};
466
+ /** A rectangle in global points: origin at the top-left of the main display, y grows downwards
467
+ * (displays left of or above the main one have negative coordinates). */
468
+ export interface WindowFrame {x:number;y:number;width:number;height:number}
469
+ /** The frontmost window of another app. `id` stays the same for the same window while it lives. */
470
+ export interface ManagedWindow {id:string;title:string;application:string;bundle_id:string;frame:WindowFrame;resizable:boolean;fullscreen:boolean;minimized:boolean;standard:boolean}
471
+ /** `visible_frame` excludes the menu bar and the Dock. `id` is the display number for this session,
472
+ * `uuid` survives restarts and reconnecting. The first entry is the main display. */
473
+ export interface ManagedScreen {id:string;uuid:string;name:string;primary:boolean;scale:number;frame:WindowFrame;visible_frame:WindowFrame}
474
+ /** Window management (macOS, permission `window_management`, OS Accessibility). All calls are
475
+ * operations: `operations.wait(await windowManagement.focused())`. `request` opens the system
476
+ * prompt and is window-only; the others work in the worker too. `focused` never returns a Medeo window:
477
+ * when Medeo is in front (a plugin's own page) it is the window of the app right behind it, and `null`
478
+ * when there is none or the frontmost app has no window; `set_frame` reports the frame the app
479
+ * actually accepted (apps may clamp sizes) and fails with `resource_missing` once the window is gone. */
480
+ export const windowManagement={
481
+ status:()=>nativeCall<{authorization:'granted'|'denied'}>('window_management.status'),
482
+ request:()=>nativeCall<{authorization:'granted'|'denied'}>('window_management.request'),
483
+ focused:()=>nativeCall<ManagedWindow|null>('window_management.focused'),
484
+ setFrame:(id:string,frame:WindowFrame)=>nativeCall<{frame:WindowFrame;resized:boolean}>('window_management.set_frame',{id,x:frame.x,y:frame.y,width:frame.width,height:frame.height}),
485
+ screens:()=>nativeCall<ManagedScreen[]>('window_management.screens'),
486
+ pointer:()=>nativeCall<{x:number;y:number}>('window_management.pointer'),
487
+ };
385
488
  export const browserView={open:(id:string,url:string,width=1000,height=720)=>capabilities.invoke('browser.open',{id,url,width,height}),state:(id:string)=>capabilities.invoke('browser.state',{id}),close:(id:string)=>capabilities.invoke('browser.close',{id})};
386
489
  export const printing={print:(text:string)=>capabilities.invoke('printing.print',{text})};
387
490
  export const processes={start:(executable:string,args:string[]=[],timeoutMs=60000)=>nativeCall<{id:string;status:string}>('process.start',{executable,arguments:args,timeout_ms:timeoutMs}),state:(id:string)=>nativeCall<{status:string;exit_code:number|null;stdout:string;stderr:string;truncated:boolean}>('process.state',{id}),write:(id:string,text:string)=>nativeCall<{written:number}>('process.write',{id,text}),stop:(id:string)=>nativeCall('process.stop',{id})};
package/src/react.ts ADDED
@@ -0,0 +1,50 @@
1
+ /** React is only imported by the optional @medeo-plugin/sdk/ui/react entry. */
2
+ import { createElement as h, useEffect, type ReactNode, type KeyboardEvent } from 'react';
3
+ import { segmentTarget, segmentTabIndex, type CardOptions, type PillOptions, type ToggleOptions, type SegmentedOptions, type ListOptions } from './ui.js';
4
+ import { installStyles } from './ui/styles.js';
5
+ export { uiStyles, installStyles } from './ui/styles.js';
6
+ export type { CardOptions, PillOptions, ToggleOptions, SegmentedOptions, ListOptions, Segment } from './ui.js';
7
+
8
+ /** Call once near your root. SSR-safe; style installation is idempotent. */
9
+ export function useUIStyles(): void { useEffect(() => { installStyles(); }, []); }
10
+ export function Card({ title, description, testid, children }: CardOptions & { children?: ReactNode }) {
11
+ return h('section', { className: 'medeo-ui medeo-card', 'data-testid': testid, 'aria-label': title },
12
+ h('h2', { 'data-testid': `${testid}.title` }, title),
13
+ description !== undefined && h('p', { className: 'medeo-description' }, description), children);
14
+ }
15
+ export function Pill({ label, tone = 'info', testid }: PillOptions) {
16
+ return h('span', { className: 'medeo-ui medeo-pill', 'data-testid': testid, 'data-tone': tone }, label);
17
+ }
18
+ /** Controlled: update checked in onChange. Native input supplies name and Space handling. */
19
+ export function Toggle({ label, checked, disabled, onChange, testid }: ToggleOptions) {
20
+ return h('label', { className: 'medeo-ui medeo-toggle', 'data-testid': `${testid}.root` },
21
+ h('input', { type: 'checkbox', role: 'switch', checked, disabled, 'data-testid': testid, onChange: (event: React.ChangeEvent<HTMLInputElement>) => onChange(event.currentTarget.checked) }),
22
+ h('span', null, label));
23
+ }
24
+ /** Controlled radio group with a single tab stop and arrow/Home/End navigation. */
25
+ export function Segmented<T extends string>({ label, value, options, disabled, onChange, testid }: SegmentedOptions<T>) {
26
+ return h('div', { className: 'medeo-ui medeo-segmented', role: 'radiogroup', 'aria-label': label, 'data-testid': testid },
27
+ options.map((option, index) => h('button', {
28
+ key: option.value, type: 'button', role: 'radio', 'aria-checked': option.value === value,
29
+ 'data-testid': `${testid}.${index}`, disabled: disabled || option.disabled,
30
+ tabIndex: disabled ? -1 : segmentTabIndex(options, value, index),
31
+ onClick: () => { if (!disabled && !option.disabled) onChange(option.value); },
32
+ onKeyDown: (event: KeyboardEvent<HTMLButtonElement>) => {
33
+ if (disabled) return;
34
+ const group = event.currentTarget.parentElement!;
35
+ const target = segmentTarget(options, index, event.key, getComputedStyle(group).direction === 'rtl');
36
+ if (target === undefined) return;
37
+ event.preventDefault();
38
+ (group.children[target] as HTMLButtonElement).focus();
39
+ onChange(options[target].value);
40
+ },
41
+ }, option.label)));
42
+ }
43
+ export function List({ label, testid, items }: ListOptions & { items: readonly { key: string; content: ReactNode }[] }) {
44
+ return h('ul', { className: 'medeo-ui medeo-list', 'data-testid': testid, 'aria-label': label },
45
+ items.map(item => h('li', { key: item.key, 'data-testid': `${testid}.${item.key}` }, item.content)));
46
+ }
47
+ export function EmptyState({ title, description, testid }: CardOptions) {
48
+ return h('section', { className: 'medeo-ui medeo-empty', 'data-testid': testid, 'aria-label': title },
49
+ h('h2', null, title), description !== undefined && h('p', { className: 'medeo-description' }, description));
50
+ }
package/src/registry.ts CHANGED
@@ -102,6 +102,12 @@ export const METHODS = {
102
102
  "accessibility.focus": { permission: "accessibility", provider: "medeo.system-services", ui: true, confirm: false, adapter: "host" },
103
103
  "accessibility.selected_text": { permission: "accessibility", provider: "medeo.system-services", ui: true, confirm: false, adapter: "host" },
104
104
  "accessibility.insert_text": { permission: "accessibility", provider: "medeo.system-services", ui: true, confirm: false, adapter: "host" },
105
+ "window_management.status": { permission: "window_management", provider: "medeo.system-services", ui: false, confirm: false, adapter: "host" },
106
+ "window_management.request": { permission: "window_management", provider: "medeo.system-services", ui: true, confirm: false, adapter: "host" },
107
+ "window_management.focused": { permission: "window_management", provider: "medeo.system-services", ui: false, confirm: false, adapter: "host" },
108
+ "window_management.set_frame": { permission: "window_management", provider: "medeo.system-services", ui: false, confirm: false, adapter: "host" },
109
+ "window_management.screens": { permission: "window_management", provider: "medeo.system-services", ui: false, confirm: false, adapter: "host" },
110
+ "window_management.pointer": { permission: "window_management", provider: "medeo.system-services", ui: false, confirm: false, adapter: "host" },
105
111
  "input_method.status": { permission: "input_method", provider: "medeo.input-method", ui: false, confirm: false, adapter: "host" },
106
112
  "input_method.configure": { permission: "input_method", provider: "medeo.input-method", ui: false, confirm: false, adapter: "host" },
107
113
  "input_method.enable": { permission: "input_method", provider: "medeo.input-method", ui: true, confirm: false, adapter: "host" },
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
+ }