@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.
package/src/index.ts ADDED
@@ -0,0 +1,468 @@
1
+ import type {JsonValue} from './generated.ts';
2
+ import type {MethodName} from './registry.ts';
3
+ export {METHODS, LIMITS, type MethodName} from './registry.ts';
4
+ /** One SDK for both runtimes: a sandboxed worker/window, and a `runtime: "node"`
5
+ * plugin, whose bootstrap installs the same `medeoPlugin` surface. The SDK itself
6
+ * imports nothing from Node and asserts no identity of its own. */
7
+ export type { Manifest, PluginCommand, Permission, CapabilityDescriptor } from './generated.ts';
8
+ /** The `api_version` this SDK targets; `spec/api-v1.json` carries the same number. */
9
+ export const API_VERSION = 1;
10
+ export interface PluginAPI {
11
+ onLocaleChanged(callback:(state:PluginLocaleState)=>void):()=>void;
12
+ onEvent(callback:(event:PlatformEvent)=>void):()=>void;
13
+ post(message:unknown):Promise<void>;
14
+ onMessage(callback:(message:unknown)=>void):()=>void;
15
+ invoke(method: MethodName, args?: Record<string, unknown>): Promise<unknown>;
16
+ run(command: string, args?: JsonValue): Promise<unknown>;
17
+ }
18
+ declare global { var medeoPlugin: PluginAPI; interface Window { medeoPlugin: PluginAPI } }
19
+
20
+ /** Pushed by the Host: an operation changed status (or streamed progress), or a
21
+ * plugin-owned queue has entries to pull. Events carry no payload data. */
22
+ export type PlatformEvent =
23
+ | {type:'operation';id:string;method:string;status:string;revision?:number}
24
+ | {type:'signal';channel:'files.watch'|'server'|'input_method'|'background'|'permissions'|'config'|'shortcuts'|'tray'};
25
+ /** Messages between this plugin's background worker and its own windows
26
+ * (64 KiB each, JSON). A worker message reaches every open window; a window
27
+ * message reaches the worker. Nothing is persisted or sent elsewhere. */
28
+ export const channel = {
29
+ post:(message:unknown)=>globalThis.medeoPlugin.post(message),
30
+ onMessage:<T=unknown>(callback:(message:T)=>void)=>globalThis.medeoPlugin.onMessage(m=>callback(m as T)),
31
+ };
32
+ export const events = {
33
+ on:(callback:(event:PlatformEvent)=>void)=>globalThis.medeoPlugin.onEvent(callback),
34
+ /** Call `pull` now and again whenever the channel signals new entries. A
35
+ * failed pull (e.g. a permission withdrawn meanwhile) waits for the next signal. */
36
+ onSignal(channel:Extract<PlatformEvent,{type:'signal'}>['channel'],pull:()=>unknown){
37
+ const run=()=>{void Promise.resolve().then(pull).catch(()=>{})};
38
+ const off=globalThis.medeoPlugin.onEvent(event=>{if(event.type==='signal'&&event.channel===channel)run()});
39
+ run();return off;
40
+ },
41
+ };
42
+ /** Resolves on the next event for `id`, or after `fallbackMs` as a safety net. */
43
+ function nextChange(id:string,fallbackMs:number):{promise:Promise<void>;off:()=>void}{
44
+ let off=()=>{};
45
+ const promise=new Promise<void>(resolve=>{
46
+ const timer=setTimeout(resolve,fallbackMs);
47
+ const stop=globalThis.medeoPlugin.onEvent(event=>{if(event.type==='operation'&&event.id===id){clearTimeout(timer);resolve()}});
48
+ off=()=>{clearTimeout(timer);stop()};
49
+ });
50
+ return {promise,off};
51
+ }
52
+ export const storage = {
53
+ get: <T = unknown>(key: string) => globalThis.medeoPlugin.invoke('storage.get', { key }) as Promise<T | null>,
54
+ remove: (key:string) => globalThis.medeoPlugin.invoke('storage.remove',{key}),
55
+ set: (key: string, value: unknown) => globalThis.medeoPlugin.invoke('storage.set', { key, value }),
56
+ };
57
+ export const creation = { open: () => globalThis.medeoPlugin.invoke('creation.open') };
58
+ export const commands = { run: <T = unknown>(command: string, args?: JsonValue) => globalThis.medeoPlugin.run(command, args) as Promise<T> };
59
+ export interface Disposable { dispose(): unknown }
60
+ /** Passed to `activate` (VS Code style), in both runtimes. */
61
+ export interface PluginContext {
62
+ /** Disposed, in reverse order, when the plugin stops. */
63
+ subscriptions: Disposable[];
64
+ /** Register a handler for each command the manifest declares; all must be registered when `activate` returns. */
65
+ commands: { register(id: string, handler: (args?: JsonValue) => unknown | Promise<unknown>): Disposable };
66
+ /** `runtime: "node"` only: the installed, verified package directory (read-only). */
67
+ extensionPath?: string;
68
+ /** `runtime: "node"` only: a private writable directory that survives updates. */
69
+ storagePath?: string;
70
+ }
71
+ /** A plugin's background entry: export the result, e.g.
72
+ * `export const { activate, deactivate } = definePlugin({ activate(context) { … } })`. */
73
+ export interface PluginDefinition {
74
+ activate(context: PluginContext): unknown | Promise<unknown>;
75
+ deactivate?(): unknown | Promise<unknown>;
76
+ }
77
+ export function definePlugin(definition: PluginDefinition): Required<PluginDefinition> {
78
+ return { deactivate: () => undefined, ...definition };
79
+ }
80
+ /** Settings declared under `contributes.configuration`; the user edits them in the plugin center. */
81
+ export const config = {
82
+ get: <T = unknown>(key: string) => globalThis.medeoPlugin.invoke('config.get', { key }) as Promise<T>,
83
+ all: () => globalThis.medeoPlugin.invoke('config.get') as Promise<Record<string, unknown>>,
84
+ set: (key: string, value: unknown) => globalThis.medeoPlugin.invoke('config.set', { key, value }),
85
+ /** Calls `listener` with all values after each change, from the user or the plugin. */
86
+ onDidChange(listener: (values: Record<string, unknown>) => void): () => void {
87
+ return globalThis.medeoPlugin.onEvent(event => {
88
+ if (event.type === 'signal' && event.channel === 'config') void config.all().then(listener).catch(() => {});
89
+ });
90
+ },
91
+ };
92
+
93
+ /** Native providers return quickly; subscribe to progress instead of holding a command open. */
94
+ export interface Provider { id: string; version: number; available: boolean; capabilities: string[] }
95
+ /** `full`: the user approved full access (view and direct Trash) for this directory. */
96
+ export interface Resource { id: string; name: string; available: boolean; full?: boolean }
97
+ /** Ask for full access (requires the `file_full_access` permission). */
98
+ export interface DirectoryAccess { full?: boolean }
99
+ export const providers = {
100
+ list: () => globalThis.medeoPlugin.invoke('providers.list') as Promise<Provider[]>,
101
+ require: async (id: string, version = 1) => {
102
+ const provider = (await providers.list()).find(p => p.id === id && p.version === version && p.available);
103
+ if (!provider) throw new Error('provider_unavailable');
104
+ return provider;
105
+ },
106
+ };
107
+ export const resources = {
108
+ chooseDirectories: (access: DirectoryAccess = {}) => globalThis.medeoPlugin.invoke('resources.choose_directories', access.full ? { full: true } : {}) as Promise<Resource[]>,
109
+ list: () => globalThis.medeoPlugin.invoke('resources.list') as Promise<Resource[]>,
110
+ revoke: (id: string) => globalThis.medeoPlugin.invoke('resources.revoke', { id }) as Promise<Resource[]>,
111
+ requestDirectory: (path: string, reason: string, access: DirectoryAccess = {}) => globalThis.medeoPlugin.invoke('resources.request_directory', { path, reason, ...(access.full ? { full: true } : {}) }) as Promise<Resource[]>,
112
+ };
113
+ /** Generic registered-provider entry point; the Host remains the authority. */
114
+ export const capabilities = {
115
+ list: () => globalThis.medeoPlugin.invoke('capabilities.list') as Promise<import('./generated').CapabilityDescriptor[]>,
116
+ labels: () => globalThis.medeoPlugin.invoke('capabilities.labels') as Promise<Record<string,string>>,
117
+ invoke: <T=unknown>(method:string,args:Record<string,unknown>={}) => globalThis.medeoPlugin.invoke(method as MethodName,args) as Promise<T>,
118
+ };
119
+ export const clipboard = {read:()=>globalThis.medeoPlugin.invoke('clipboard.read') as Promise<{text:string}>,write:(text:string)=>globalThis.medeoPlugin.invoke('clipboard.write',{text})};
120
+ /** `running` reports, per rule, whether a matching process exists; it never
121
+ * returns the process list. Rules match an exact executable name or a path fragment. */
122
+ export interface ProcessRule {name?:string;path_contains?:string}
123
+ export interface SystemInfo {platform:string;architecture:string;release:string;locale:string;/** The Medeo running the plugin (0.1.23+; absent on older Medeo). */medeo?:{version:string;channel:'stg'|'prd'};displays:{id:string;bounds:{x:number;y:number;width:number;height:number};scale:number}[]}
124
+ export const system = {info:()=>globalThis.medeoPlugin.invoke('system.info') as Promise<SystemInfo>,running:(rules:ProcessRule[])=>capabilities.invoke<NativeOperation<boolean[]>>('system.running',{rules})};
125
+ /** One outcome per path of `trash`, in the order given. */
126
+ export interface TrashResult {path:string;trashed:boolean;error?:string}
127
+ /** `runtime: "node"` plugins only (the `node` permission): moves up to 256 absolute
128
+ * paths to the OS Trash, the one thing Node cannot do itself. A failure does not stop the rest. */
129
+ export const trash = (paths:string[]) => globalThis.medeoPlugin.invoke('system.trash',{paths}) as Promise<TrashResult[]>;
130
+ export const notifications = {show:(id:string,title:string,body:string)=>globalThis.medeoPlugin.invoke('notifications.show',{id,title,body}),close:(id:string)=>globalThis.medeoPlugin.invoke('notifications.close',{id})};
131
+ export const power = {start:(type:'prevent-app-suspension'|'prevent-display-sleep')=>globalThis.medeoPlugin.invoke('power.start',{type}) as Promise<{id:string}>,stop:(id:string)=>globalThis.medeoPlugin.invoke('power.stop',{id}),state:()=>globalThis.medeoPlugin.invoke('power.state')};
132
+ export const shortcuts = {list:()=>globalThis.medeoPlugin.invoke('shortcuts.list') as Promise<{id:string;accelerator:string;open_ui:boolean}[]>,register:(id:string,accelerator:string,openUI=false)=>globalThis.medeoPlugin.invoke('shortcuts.register',{id,accelerator,open_ui:openUI}),unregister:(id:string)=>globalThis.medeoPlugin.invoke('shortcuts.unregister',{id}),events:()=>globalThis.medeoPlugin.invoke('shortcuts.events')};
133
+ /** `panel: true` (macOS): a frameless window floating over every app and Space that does not
134
+ * activate Medeo — the app the user was in keeps focus — opened at the pointer and hidden (not
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}[]>};
138
+ /** A menu entry, a checkmarked toggle, or a divider. */
139
+ export type TrayItem = {id:string;title:string;checked?:boolean}|{separator:true};
140
+ /** `icon`: a PNG in the package drawn as a menu-bar template (black + alpha, about 36 px tall);
141
+ * `click`: an id reported on a left click, which then leaves the menu to a right click. */
142
+ export interface TrayOptions {icon?:string;click?:string}
143
+ export const tray = {set:(title:string,items:TrayItem[],options:TrayOptions={})=>globalThis.medeoPlugin.invoke('tray.set',{title,items,...options}),remove:()=>globalThis.medeoPlugin.invoke('tray.remove'),events:()=>globalThis.medeoPlugin.invoke('tray.events')};
144
+ export const external = {open:(url:string)=>globalThis.medeoPlugin.invoke('external.open',{url})};
145
+
146
+ /** `scope` says how long an approval lasts: `always` (stored), `run` (until the plugin stops),
147
+ * `never` (refused with "don't ask again"). `remembered`: refused earlier, no dialog was shown. */
148
+ export interface PermissionAnswer { granted: boolean; scope?: 'always' | 'run' | 'never' | 'none'; remembered?: boolean }
149
+ export const permissions = {request:(permission:string,reason:string)=>globalThis.medeoPlugin.invoke('permissions.request',{permission,reason}) as Promise<PermissionAnswer>};
150
+
151
+ /** One of the plugin's own actions for the user to approve (`approvals` permission; `action` must be declared).
152
+ * `rule_key` identifies it (`bash:git status`); `rule_pattern` (a prefix of it ending in `*`) is what
153
+ * "always" remembers instead. Medeo draws the dialog and keeps the rules. In a Node plugin nothing
154
+ * forces you to ask: it is up to the plugin to call this before acting. */
155
+ export interface ApprovalRequest {
156
+ action: string; title: string; detail?: string;
157
+ preview?: { kind: 'text' | 'command' | 'diff'; text: string };
158
+ rule_key: string; rule_pattern?: string;
159
+ }
160
+ /** `scope`: `once`, `run`, `always`, or `rule` when a remembered answer decided without a dialog. */
161
+ export interface ApprovalAnswer { decision: 'allow' | 'deny'; scope: 'once' | 'run' | 'always' | 'rule'; rule?: string }
162
+ export interface ApprovalRule { action: string; pattern: string; allow: boolean; scope: 'run' | 'always' }
163
+ export const approvals = {
164
+ request:(request:ApprovalRequest)=>globalThis.medeoPlugin.invoke('approvals.request',{...request}) as Promise<ApprovalAnswer>,
165
+ rules:()=>globalThis.medeoPlugin.invoke('approvals.rules') as Promise<ApprovalRule[]>,
166
+ forget:(action:string,pattern:string)=>globalThis.medeoPlugin.invoke('approvals.forget',{action,pattern}) as Promise<ApprovalRule[]>,
167
+ };
168
+
169
+
170
+ // Text content stays in Host memory. Closing a window does not cancel jobs;
171
+ // state() without an id lists jobs owned by this active plugin instance.
172
+ export interface AgentJob {
173
+ id: string; status: 'running' | 'completed' | 'failed' | 'cancelled';
174
+ text?: string; models?: string[]; error?: string | null;
175
+ }
176
+ export const agent = {
177
+ models: () => capabilities.invoke<AgentJob>('agent.models', {}),
178
+ start: (messages: {role:'system'|'user'|'assistant';content:string}[], model?:string) =>
179
+ capabilities.invoke<AgentJob>('agent.start', {messages, ...(model ? {model} : {})}),
180
+ state: (id:string) => operations.state(id) as Promise<AgentJob>,
181
+ cancel: (id:string) => operations.cancel(id) as Promise<AgentJob>,
182
+ release: (id:string) => operations.release(id),
183
+ /** Calls `onText` as the reply streams in; resolves with the final job. */
184
+ async follow(id:string,onText:(job:AgentJob)=>void):Promise<AgentJob>{
185
+ for(;;){const change=nextChange(id,2000);const job=await agent.state(id);onText(job);if(job.status!=='running'){change.off();return job}await change.promise;change.off()}
186
+ },
187
+ };
188
+
189
+ export interface ApplicationEntry { id:string;title:string;path:string;bundle_id:string }
190
+ export interface ApplicationList { status:string;error:string|null;incomplete:boolean;total:number;entries:ApplicationEntry[] }
191
+ export interface ApplicationState { status:string;error:string|null;launch:{id:string;status:string;error:string|null;pid?:number}|null }
192
+ export const applications = {
193
+ /** The whole inventory; plugins rank and filter it themselves. */
194
+ list: (refresh=false) => capabilities.invoke<ApplicationList>('applications.list',{refresh}),
195
+ launch: (id:string) => capabilities.invoke<{id:string;status:string}>('applications.launch',{id}),
196
+ state: () => capabilities.invoke<ApplicationState>('applications.state',{}),
197
+ };
198
+
199
+ export interface CaptureSource {id:string;title:string;width:number;height:number}
200
+ export interface CaptureRecord {id:string;source_title:string;status:string;active:boolean;can_stop:boolean;can_recover:boolean;error?:string|null;playable:boolean;duration_ms:number;bytes:number;microphone:boolean;system_audio:boolean;audio_tracks?:number}
201
+ export const capture = {
202
+ status:(refresh=true)=>capabilities.invoke<{status:string;os:Record<string,string>}>('capture.status',{refresh}),
203
+ sources:(refresh=false,requestPermission=false)=>capabilities.invoke<{status:string;error?:string;os:Record<string,string>;sources:CaptureSource[]}>('capture.sources',{refresh,request_permission:requestPermission}),
204
+ start:(source:string,options:{microphone?:boolean;systemAudio?:boolean}={})=>capabilities.invoke<{id?:string;cancelled?:boolean}>('capture.start',{source,microphone:options.microphone??false,system_audio:options.systemAudio??false}),
205
+ state:()=>capabilities.invoke<{records:CaptureRecord[];os:Record<string,string>}>('capture.state',{}),
206
+ stop:(id:string)=>capabilities.invoke('capture.stop',{id}),
207
+ cancel:(id:string)=>capabilities.invoke('capture.cancel',{id}),
208
+ recover:(id:string)=>capabilities.invoke('capture.recover',{id}),
209
+ open:(id:string,mode:'play'|'reveal'='play')=>capabilities.invoke('capture.open',{id,mode}),
210
+ previewURL:(id:string)=>location.origin+'/_capture/'+encodeURIComponent(id)+'.mp4',
211
+ };
212
+
213
+ export interface InputMethodState {enabled:boolean;provider_ready:boolean;configured_name:string;occupied:boolean;system_source:string}
214
+ export interface InputSourceState {status:string;state_key:string;can_setup:boolean;can_remove:boolean;installed?:boolean;registered?:boolean;enabled?:boolean;selected?:boolean;update_available?:boolean;error?:string;last_operation?:{id:string;status:string;error?:string};task?:string}
215
+ export const inputMethod={
216
+ system:(refresh=false)=>capabilities.invoke<InputSourceState>('input_method.system',{refresh}),
217
+ setup:()=>capabilities.invoke<{task?:string;cancelled?:boolean}>('input_method.setup'),
218
+ remove:()=>capabilities.invoke<{task?:string;cancelled?:boolean}>('input_method.remove'),
219
+ settings:()=>capabilities.invoke<{task:string}>('input_method.settings'),
220
+ status:()=>capabilities.invoke<InputMethodState>('input_method.status'),
221
+ configure:(name:string)=>capabilities.invoke('input_method.configure',{name}),
222
+ enable:()=>capabilities.invoke<InputMethodState>('input_method.enable'),
223
+ disable:()=>capabilities.invoke('input_method.disable'),
224
+ events:()=>capabilities.invoke<{id:string;text:string}[]>('input_method.events'),
225
+ respond:(id:string,candidates:string[])=>capabilities.invoke('input_method.respond',{id,candidates}),
226
+ };
227
+
228
+ /** HTTP uses only approved origins; no redirects, browser cookies, or Medeo credentials.
229
+ * Closing UI preserves jobs; disable/revoke cancels. Mutations are never replayed.
230
+ * Release completed results to return the per-instance memory quota.
231
+ */
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;stream?:boolean}
233
+ 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
+ export interface NetworkChunk {data_base64:string;next_offset:number;eof:boolean}
235
+ export interface NetworkOrigin {origin:string;declared:boolean;allowed:boolean}
236
+ export interface NetworkServerEvent {data:string;event:string;id:string;retry?:number}
237
+ export const network = {
238
+ origins:()=>capabilities.invoke<NetworkOrigin[]>('network.origins'),
239
+ requestOrigin:(origin:string,reason:string)=>capabilities.invoke<{origin:string;granted:boolean}>('network.request_origin',{origin,reason}),
240
+ revokeOrigin:(origin:string)=>capabilities.invoke<NetworkOrigin[]>('network.revoke_origin',{origin}),
241
+ request:(request:NetworkRequest)=>capabilities.invoke<NetworkJob>('network.request',{...request}),
242
+ state:(id:string)=>operations.state(id) as Promise<NetworkJob>,
243
+ read:(id:string,offset=0,length=32768)=>capabilities.invoke<NetworkChunk>('network.read',{id,offset,length}),
244
+ cancel:(id:string)=>operations.cancel(id) as Promise<NetworkJob>,
245
+ release:(id:string)=>operations.release(id) as Promise<{released:boolean}>,
246
+ /** Resolves once the response has fully arrived; the job stays readable until released. */
247
+ 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()}},
248
+ /** Single consumer for a stream:true job. Break/throw/abort cancels and releases it. */
249
+ async *bytes(id:string,signal?:AbortSignal):AsyncGenerator<Uint8Array>{
250
+ let offset=0;
251
+ let abortCleanup:Promise<void>|undefined;
252
+ const abort=()=>{abortCleanup=network.dispose(id);void abortCleanup.catch(()=>{})};
253
+ signal?.addEventListener('abort',abort,{once:true});
254
+ try {
255
+ for(;;){
256
+ let wake!:()=>void;
257
+ const changed=new Promise<void>(resolve=>{wake=resolve});
258
+ const off=events.on(event=>{if(event.type==='operation'&&event.id===id)wake()});
259
+ signal?.addEventListener('abort',wake,{once:true});
260
+ try {
261
+ if(signal?.aborted)throw new Error('network_cancelled');
262
+ const state=await network.state(id);
263
+ if(state.status==='failed'||state.status==='cancelled')throw new Error(state.error??state.status);
264
+ const part=await network.read(id,offset);
265
+ offset=part.next_offset;
266
+ if(signal?.aborted)throw new Error('network_cancelled');
267
+ if(part.data_base64)yield Uint8Array.from(atob(part.data_base64),c=>c.charCodeAt(0));
268
+ if(part.eof)return;
269
+ if(!part.data_base64)await changed;
270
+ } finally {off();signal?.removeEventListener('abort',wake)}
271
+ }
272
+ } finally {
273
+ signal?.removeEventListener('abort',abort);
274
+ await (abortCleanup??network.dispose(id));
275
+ }
276
+ },
277
+ /** Incremental UTF-8 decoder, including code points split across HTTP chunks. */
278
+ async *textStream(id:string,signal?:AbortSignal):AsyncGenerator<string>{
279
+ const decoder=new TextDecoder();
280
+ for await(const bytes of network.bytes(id,signal)){
281
+ const text=decoder.decode(bytes,{stream:true});if(text)yield text;
282
+ }
283
+ const tail=decoder.decode();if(tail)yield tail;
284
+ },
285
+ /** SSE framing only: no automatic reconnect/retry or Last-Event-ID requests. */
286
+ async *sse(id:string,signal?:AbortSignal):AsyncGenerator<NetworkServerEvent>{
287
+ let pending='',data:string[]=[],event='',lastId='',retry:number|undefined,size=0,skipLf=false;
288
+ for await(const text of network.textStream(id,signal)){
289
+ pending+=skipLf&&text.startsWith('\n')?text.slice(1):text;
290
+ skipLf=false;
291
+ for(;;){
292
+ const at=pending.search(/[\r\n]/);
293
+ if(at<0)break;
294
+ skipLf=pending[at]==='\r'&&at===pending.length-1;
295
+ const line=pending.slice(0,at);
296
+ pending=pending.slice(at+(pending[at]==='\r'&&pending[at+1]==='\n'?2:1));
297
+ size+=line.length;
298
+ if(size>262144)throw new Error('network_sse_event_too_large');
299
+ if(!line){
300
+ if(data.length)yield {data:data.join('\n'),event:event||'message',id:lastId,...(retry===undefined?{}:{retry})};
301
+ data=[];event='';size=0;continue;
302
+ }
303
+ if(line.startsWith(':'))continue;
304
+ const colon=line.indexOf(':');
305
+ const field=colon<0?line:line.slice(0,colon);
306
+ let value=colon<0?'':line.slice(colon+1);if(value.startsWith(' '))value=value.slice(1);
307
+ if(field==='data')data.push(value);
308
+ else if(field==='event')event=value;
309
+ else if(field==='id'&&!value.includes('\0'))lastId=value;
310
+ else if(field==='retry'&&/^\d+$/.test(value)&&Number.isSafeInteger(Number(value)))retry=Number(value);
311
+ }
312
+ if(size+pending.length>262144)throw new Error('network_sse_event_too_large');
313
+ }
314
+ // SSE requires a blank line to dispatch; incomplete final events are discarded.
315
+ },
316
+ /** Explicitly dispose a stream that was never iterated (or has another owner). */
317
+ async dispose(id:string):Promise<void>{
318
+ const ignoreMissing=(error:unknown)=>{if(!/^(operation_missing|network_job_missing|permission_denied)$/.test(error instanceof Error?error.message:String(error)))throw error};
319
+ try{await network.cancel(id)}catch(error){ignoreMissing(error)}
320
+ try{await network.release(id)}catch(error){ignoreMissing(error)}
321
+ },
322
+ async text(id:string):Promise<string>{
323
+ const decoder=new TextDecoder();let offset=0,text='';
324
+ 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;}
325
+ },
326
+ };
327
+
328
+ /** Workspace operations use current directory references and asynchronous jobs. */
329
+ export interface FileRoot {id:string|null;name:string;available:boolean;error?:string}
330
+ export interface FileEntry {path:string;name:string;kind:'file'|'directory'|'blocked';size:number;revision:string;accessible:boolean}
331
+ export interface FileRecord {id:string;operation:string;source:string;destination:string;status:string;has_original:boolean;in_trash?:boolean|null;created_ms:number|null;retention:"workspace"|"trash"|"none"|"unknown"}
332
+ export interface FileJob<T=unknown> {id:string;status:'running'|'completed'|'failed'|'cancelled';result:T|null;error:string|null;method?:string}
333
+ export interface FileWatch {id:string;status:'starting'|'watching'|'stopped'|'failed';cursor?:number;error?:string|null}
334
+ export interface FileWatchEvents extends FileWatch {cursor:number;rescan_required:boolean;events:{cursor:number;kind:'ready'|'created'|'changed'|'removed'|'rescan_required';path:string|null}[]}
335
+ export interface FilePreview {id:string;path:string;bytes:number;count:number;revision:string|null;purpose:string;record:string|null}
336
+ export interface FileHistoryPage {items:FileRecord[];next_cursor:string|null;total:number}
337
+ export const files = {
338
+ backupPreview:(root:string,record:string)=>capabilities.invoke<FileJob<FilePreview>>('files.backup_preview',{root,record}),
339
+ archiveBackup:(preview:string)=>capabilities.invoke<FileJob<FileRecord>>('files.archive_backup',{preview}),
340
+ forgetPreview:(root:string,record:string)=>capabilities.invoke<FileJob<FilePreview>>('files.forget_preview',{root,record}),
341
+ forgetRecord:(preview:string)=>capabilities.invoke<FileJob<{forgotten:string}>>('files.forget_record',{preview}),
342
+ historyPage:(root:string,cursor?:string,limit=50)=>capabilities.invoke<FileJob<FileHistoryPage>>('files.history_page',{root,...(cursor?{cursor}:{}),limit}),
343
+ trashPreview:(root:string,path:string)=>capabilities.invoke<FileJob<FilePreview>>('files.trash_preview',{root,path}),
344
+ discardPreview:(preview:string)=>capabilities.invoke<{discarded:boolean}>('files.discard_preview',{preview}),
345
+ trash:(preview:string)=>capabilities.invoke<FileJob<FileRecord>>('files.trash',{preview}),
346
+ /** Breadth-first listings of a whole tree in one call. Hidden and `skip`
347
+ * directories are listed by their parent but not entered. */
348
+ listTree:(root:string,path='',options:{depth?:number;skip?:string[];hidden?:boolean;limit?:number}={})=>capabilities.invoke<FileJob<{directories:{path:string;entries?:FileEntry[];error?:string}[];truncated:boolean}>>('files.list_tree',{root,path,...options}),
349
+ /** Full-access roots only: move entries to the Trash without a Host confirmation.
350
+ * `stamp` is what `files.measure` returned; a changed entry is refused, not moved. */
351
+ trashDirect:(items:{root:string;path:string;stamp:{revision:string;digest:string;bytes:number;count:number}}[])=>capabilities.invoke<FileJob<{path:string;record?:FileRecord;error?:string}[]>>('files.trash_direct',{items}),
352
+ watch:(root:string,path='',recursive=false)=>capabilities.invoke<FileWatch>('files.watch',{root,path,recursive}),
353
+ watches:()=>capabilities.invoke<FileWatch[]>('files.watches'),
354
+ events:(id:string,after=0)=>capabilities.invoke<FileWatchEvents>('files.events',{id,after}),
355
+ unwatch:(id:string)=>capabilities.invoke<{stopped:boolean}>('files.unwatch',{id}),
356
+ roots:()=>capabilities.invoke<FileJob<FileRoot[]>>('files.roots'),
357
+ list:(root:string,path='')=>capabilities.invoke<FileJob<FileEntry[]>>('files.list',{root,path}),
358
+ stat:(root:string,path='')=>capabilities.invoke<FileJob<FileEntry>>('files.stat',{root,path}),
359
+ readText:(root:string,path:string,revision?:string)=>capabilities.invoke<FileJob<{text:string;entry:FileEntry}>>('files.read_text',{root,path,...(revision?{revision}:{})}),
360
+ writeText:(root:string,path:string,text:string,revision:string)=>capabilities.invoke<FileJob<{record:FileRecord;entry:FileEntry|null}>>('files.write_text',{root,path,text,revision}),
361
+ mkdir:(root:string,path:string)=>capabilities.invoke<FileJob<FileEntry>>('files.mkdir',{root,path}),
362
+ move:(root:string,path:string,destination:string,revision:string)=>capabilities.invoke<FileJob<FileRecord>>('files.move',{root,path,destination,revision}),
363
+ history:(root:string)=>capabilities.invoke<FileJob<FileRecord[]>>('files.history',{root}),
364
+ restore:(root:string,record:string)=>capabilities.invoke<FileJob<FileRecord>>('files.restore',{root,record}),
365
+ state:<T=unknown>(id:string)=>operations.state<T>(id) as Promise<FileJob<T>>,
366
+ cancel:(id:string)=>operations.cancel(id),
367
+ release:(id:string)=>operations.release(id),
368
+ result:<T>(job:FileJob<T>):Promise<T>=>operations.wait<T>(job),
369
+ };
370
+
371
+ /** Plugin-owned credentials. Values are returned only to this granted plugin;
372
+ * Agent/Medeo credentials are never part of this vault. No automatic retry. */
373
+ export interface SecretOperation { id:string; status:'running'|'completed'|'failed'; value?:string|null; error?:string }
374
+ async function secretOperation(method:'get'|'set'|'remove',args:Record<string,unknown>):Promise<string|null>{
375
+ const job=await capabilities.invoke<SecretOperation>('secrets.'+method,args);
376
+ const deadline=Date.now()+30000;
377
+ for(;;){
378
+ const change=nextChange(job.id,1000);
379
+ const result=await capabilities.invoke<SecretOperation>('secrets.result',{id:job.id});
380
+ if(result.status!=='running')change.off();
381
+ if(result.status==='completed')return result.value??null;
382
+ if(result.status==='failed')throw Error(result.error??'secrets_storage_failed');
383
+ if(Date.now()>=deadline){change.off();throw Error('secrets_outcome_unknown')}
384
+ await change.promise;change.off();
385
+ }
386
+ }
387
+ export const secrets={
388
+ get:(key:string)=>secretOperation('get',{key}),
389
+ set:async(key:string,value:string)=>{await secretOperation('set',{key,value})},
390
+ remove:async(key:string)=>{await secretOperation('remove',{key})},
391
+ };
392
+
393
+ export interface LocalServerOptions { port?:number; routes:{method:'GET'|'HEAD'|'POST'|'PUT'|'PATCH'|'DELETE';path:string}[]; origins?:string[]; timeout_ms?:number }
394
+ export interface LocalServerState { id:string; status:'starting'|'running'|'stopping'|'stopped'|'failed'; origin:string|null; error:string|null; pending:number; accepted:number }
395
+ export interface LocalRequest { id:string; method:string; path:string; query:string; headers:Record<string,string>; body_base64:string }
396
+ /** A loopback server is instance-owned and never survives Host restart. Keep the
397
+ * start token in private memory and serve requests in a worker for background use. */
398
+ export const server={
399
+ start:(options:LocalServerOptions)=>capabilities.invoke<{id:string;status:'starting';access_token:string}>('server.start',{...options}),
400
+ state:(id:string)=>capabilities.invoke<LocalServerState>('server.state',{id}),
401
+ list:()=>capabilities.invoke<LocalServerState[]>('server.state'),
402
+ events:(id:string,limit=8)=>capabilities.invoke<{requests:LocalRequest[]}>('server.events',{id,limit}),
403
+ respond:(id:string,request:string,response:{status?:number;body_base64?:string;content_type?:string})=>capabilities.invoke<{responded:boolean}>('server.respond',{id,request,...response}),
404
+ stop:(id:string)=>capabilities.invoke<LocalServerState>('server.stop',{id}),
405
+ };
406
+
407
+
408
+ export interface PluginLocaleState {preference:string;locale:string;system_locale:string;direction:'ltr';supported_locales:string[];catalog_locales:string[];catalog_locale:string|null;fallback_locale:string|null;name:string;commands:Record<string,string>}
409
+ /** Text is formatted in Rust from the signed package. Render with textContent,
410
+ * never innerHTML. Register listeners before initial state fetch to avoid races. */
411
+ export const i18n={
412
+ state:()=>capabilities.invoke<PluginLocaleState>('i18n.state'),
413
+ setLocale:(preference:string)=>capabilities.invoke<PluginLocaleState>('i18n.setLocale',{preference}),
414
+ t:(key:string,params:Record<string,string|number>={})=>capabilities.invoke<string>('i18n.t',{key,params}),
415
+ /** Every message of the effective locale (default-locale fallback merged in). */
416
+ catalog:()=>capabilities.invoke<Record<string,string>>('i18n.catalog'),
417
+ onDidChangeLocale:(callback:(state:PluginLocaleState)=>void)=>globalThis.medeoPlugin.onLocaleChanged(callback),
418
+ };
419
+
420
+ /** Every asynchronous call returns an owned operation handle. Status changes
421
+ * are pushed; `wait` resolves on them and releases the result. */
422
+ export interface NativeOperation<T=unknown>{id:string;status:'running'|'completed'|'failed'|'cancelled'|'interrupted';result:T|null;error:string|null}
423
+ export const operations={
424
+ state:<T=unknown>(id:string)=>capabilities.invoke<NativeOperation<T>>('operations.state',{id}),
425
+ list:()=>capabilities.invoke<{id:string;method:string;status:string}[]>('operations.list'),
426
+ cancel:(id:string)=>capabilities.invoke('operations.cancel',{id}),
427
+ release:(id:string)=>capabilities.invoke('operations.release',{id}),
428
+ async wait<T>(operation:{id:string;status:string},signal?:AbortSignal):Promise<T>{
429
+ let value=operation as NativeOperation<T>;
430
+ while(value.status==='running'){
431
+ if(signal?.aborted){await operations.cancel(value.id);throw Error('cancelled')}
432
+ const change=nextChange(value.id,2000);
433
+ value=await operations.state<T>(value.id);
434
+ if(value.status==='running')await change.promise;
435
+ change.off();
436
+ }
437
+ // Quick reads may come back settled and already released by the Host.
438
+ if(!(value as {released?:boolean}).released)await operations.release(value.id);
439
+ if(value.status!=='completed')throw Error(value.error??value.status);
440
+ return value.result as T;
441
+ },
442
+ };
443
+ const nativeCall=<T=unknown>(method:string,args:Record<string,JsonValue>={})=>capabilities.invoke<NativeOperation<T>>(method,args);
444
+ /** A camera / microphone / system audio recording. `waveform` (240 peaks, 0…1) is in `state(id)` once
445
+ * completed; listings carry a 40-point `waveform_preview` instead. Live fields exist only while recording:
446
+ * `levels` holds the latest input levels (0…1, about 20 a second) and `levels_seq` counts every level so far;
447
+ * `frame` is the camera's latest preview as base64 JPEG. "completed" means `previewURL` can play it. */
448
+ export interface MediaRecord {id:string;kind:'camera'|'microphone'|'system_audio';status:'starting'|'recording'|'stopping'|'completed'|'failed'|'interrupted';error?:string|null;created_ms:number;extension:string;duration_ms?:number;bytes?:number;waveform?:number[];waveform_preview?:number[];elapsed_ms?:number;level?:number;peak?:number;levels?:number[];levels_seq?:number;frame?:string}
449
+ function mediaProvider(kind:'camera'|'microphone'|'system_audio'){return{
450
+ status:()=>nativeCall<{authorization:string}>(kind+'.status'),
451
+ start:(maxSeconds=300)=>nativeCall<{id:string;status:string}>(kind+'.start',{max_seconds:maxSeconds}),
452
+ stop:(id:string)=>nativeCall(kind+'.stop',{id}),
453
+ state:(id?:string)=>nativeCall(kind+'.state',id?{id}:{}),
454
+ read:(id:string,offset=0,length=32768)=>nativeCall<NetworkChunk>(kind+'.read',{id,offset,length}),
455
+ /** Same-origin URL for an `<audio>` / `<video>` element; plays once the record is "completed". */
456
+ previewURL:(record:Pick<MediaRecord,'id'|'extension'>)=>location.origin+'/_media/'+encodeURIComponent(record.id)+(record.extension==='m4a'?'.m4a':'.mp4'),
457
+ }}
458
+ export const camera=mediaProvider('camera'),microphone=mediaProvider('microphone'),systemAudio=mediaProvider('system_audio');
459
+ 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})};
460
+ 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})};
461
+ export const printing={print:(text:string)=>capabilities.invoke('printing.print',{text})};
462
+ 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})};
463
+ export interface BackgroundEvent{id:string;schedule:string;due_ms:number;payload:JsonValue}
464
+ export const background={schedule:(id:string,delayMs:number,payload:JsonValue=null,intervalMs=0)=>capabilities.invoke('background.schedule',{id,delay_ms:delayMs,interval_ms:intervalMs,payload}),list:()=>capabilities.invoke('background.list'),cancel:(id:string)=>capabilities.invoke('background.cancel',{id}),events:()=>capabilities.invoke<BackgroundEvent[]>('background.events')};
465
+ export const contacts={status:()=>nativeCall('contacts.status'),request:()=>nativeCall('contacts.request'),list:(query='',limit=50)=>nativeCall('contacts.list',{query,limit}),save:(contact:{given_name:string;family_name:string;email:string;phone:string})=>nativeCall<{id:string;created:boolean}>('contacts.save',contact)};
466
+ export const calendar={status:()=>nativeCall('calendar.status'),request:()=>nativeCall('calendar.request'),list:(startMs:number,endMs:number,limit=50)=>nativeCall('calendar.list',{start_ms:startMs,end_ms:endMs,limit}),save:(event:{title:string;notes:string;start_ms:number;end_ms:number})=>nativeCall<{id:string;created:boolean}>('calendar.save',event)};
467
+ export const deviceLocation={status:()=>nativeCall('location.status'),request:()=>nativeCall('location.request'),current:()=>nativeCall<{latitude:number;longitude:number;accuracy_m:number;timestamp_ms:number}>('location.current')};
468
+ export const devices={choose:(transport:'serial'|'hid'|'ble')=>nativeCall('devices.choose',{transport}),open:(id:string,baudRate=115200)=>nativeCall('devices.open',{id,baud_rate:baudRate}),list:()=>nativeCall('devices.list'),read:(id:string,length=4096)=>nativeCall<{data_base64:string;bytes:number}>('devices.read',{id,length}),write:(id:string,dataBase64:string)=>nativeCall<{written:number}>('devices.write',{id,data_base64:dataBase64}),close:(id:string)=>nativeCall('devices.close',{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
+ }