@medeo-plugin/sdk 0.7.0 → 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/dist/index.d.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 {
19
19
  var medeoPlugin: PluginAPI;
@@ -57,7 +57,7 @@ export declare const creation: {
57
57
  open: () => Promise<unknown>;
58
58
  };
59
59
  export declare const commands: {
60
- run: (command: string) => Promise<unknown>;
60
+ run: <T = unknown>(command: string, args?: JsonValue) => Promise<T>;
61
61
  };
62
62
  export interface Disposable {
63
63
  dispose(): unknown;
@@ -68,7 +68,7 @@ export interface PluginContext {
68
68
  subscriptions: Disposable[];
69
69
  /** Register a handler for each command the manifest declares; all must be registered when `activate` returns. */
70
70
  commands: {
71
- register(id: string, handler: () => unknown | Promise<unknown>): Disposable;
71
+ register(id: string, handler: (args?: JsonValue) => unknown | Promise<unknown>): Disposable;
72
72
  };
73
73
  /** `runtime: "node"` only: the installed, verified package directory (read-only). */
74
74
  extensionPath?: string;
@@ -435,6 +435,7 @@ export interface NetworkRequest {
435
435
  body?: string;
436
436
  body_base64?: string;
437
437
  timeout_ms?: number;
438
+ stream?: boolean;
438
439
  }
439
440
  export interface NetworkJob {
440
441
  id: string;
@@ -443,6 +444,7 @@ export interface NetworkJob {
443
444
  http_status?: number | null;
444
445
  headers?: [string, string][];
445
446
  error?: string | null;
447
+ stream?: boolean;
446
448
  }
447
449
  export interface NetworkChunk {
448
450
  data_base64: string;
@@ -454,6 +456,12 @@ export interface NetworkOrigin {
454
456
  declared: boolean;
455
457
  allowed: boolean;
456
458
  }
459
+ export interface NetworkServerEvent {
460
+ data: string;
461
+ event: string;
462
+ id: string;
463
+ retry?: number;
464
+ }
457
465
  export declare const network: {
458
466
  origins: () => Promise<NetworkOrigin[]>;
459
467
  requestOrigin: (origin: string, reason: string) => Promise<{
@@ -470,6 +478,14 @@ export declare const network: {
470
478
  }>;
471
479
  /** Resolves once the response has fully arrived; the job stays readable until released. */
472
480
  settled(id: string): Promise<NetworkJob>;
481
+ /** Single consumer for a stream:true job. Break/throw/abort cancels and releases it. */
482
+ bytes(id: string, signal?: AbortSignal): AsyncGenerator<Uint8Array>;
483
+ /** Incremental UTF-8 decoder, including code points split across HTTP chunks. */
484
+ textStream(id: string, signal?: AbortSignal): AsyncGenerator<string>;
485
+ /** SSE framing only: no automatic reconnect/retry or Last-Event-ID requests. */
486
+ sse(id: string, signal?: AbortSignal): AsyncGenerator<NetworkServerEvent>;
487
+ /** Explicitly dispose a stream that was never iterated (or has another owner). */
488
+ dispose(id: string): Promise<void>;
473
489
  text(id: string): Promise<string>;
474
490
  };
475
491
  /** Workspace operations use current directory references and asynchronous jobs. */
package/dist/index.js CHANGED
@@ -39,7 +39,7 @@ export const storage = {
39
39
  set: (key, value) => globalThis.medeoPlugin.invoke('storage.set', { key, value }),
40
40
  };
41
41
  export const creation = { open: () => globalThis.medeoPlugin.invoke('creation.open') };
42
- export const commands = { run: (command) => globalThis.medeoPlugin.run(command) };
42
+ export const commands = { run: (command, args) => globalThis.medeoPlugin.run(command, args) };
43
43
  export function definePlugin(definition) {
44
44
  return { deactivate: () => undefined, ...definition };
45
45
  }
@@ -168,6 +168,121 @@ export const network = {
168
168
  await change.promise;
169
169
  change.off();
170
170
  } },
171
+ /** Single consumer for a stream:true job. Break/throw/abort cancels and releases it. */
172
+ async *bytes(id, signal) {
173
+ let offset = 0;
174
+ let abortCleanup;
175
+ const abort = () => { abortCleanup = network.dispose(id); void abortCleanup.catch(() => { }); };
176
+ signal?.addEventListener('abort', abort, { once: true });
177
+ try {
178
+ for (;;) {
179
+ let wake;
180
+ const changed = new Promise(resolve => { wake = resolve; });
181
+ const off = events.on(event => { if (event.type === 'operation' && event.id === id)
182
+ wake(); });
183
+ signal?.addEventListener('abort', wake, { once: true });
184
+ try {
185
+ if (signal?.aborted)
186
+ throw new Error('network_cancelled');
187
+ const state = await network.state(id);
188
+ if (state.status === 'failed' || state.status === 'cancelled')
189
+ throw new Error(state.error ?? state.status);
190
+ const part = await network.read(id, offset);
191
+ offset = part.next_offset;
192
+ if (signal?.aborted)
193
+ throw new Error('network_cancelled');
194
+ if (part.data_base64)
195
+ yield Uint8Array.from(atob(part.data_base64), c => c.charCodeAt(0));
196
+ if (part.eof)
197
+ return;
198
+ if (!part.data_base64)
199
+ await changed;
200
+ }
201
+ finally {
202
+ off();
203
+ signal?.removeEventListener('abort', wake);
204
+ }
205
+ }
206
+ }
207
+ finally {
208
+ signal?.removeEventListener('abort', abort);
209
+ await (abortCleanup ?? network.dispose(id));
210
+ }
211
+ },
212
+ /** Incremental UTF-8 decoder, including code points split across HTTP chunks. */
213
+ async *textStream(id, signal) {
214
+ const decoder = new TextDecoder();
215
+ for await (const bytes of network.bytes(id, signal)) {
216
+ const text = decoder.decode(bytes, { stream: true });
217
+ if (text)
218
+ yield text;
219
+ }
220
+ const tail = decoder.decode();
221
+ if (tail)
222
+ yield tail;
223
+ },
224
+ /** SSE framing only: no automatic reconnect/retry or Last-Event-ID requests. */
225
+ async *sse(id, signal) {
226
+ let pending = '', data = [], event = '', lastId = '', retry, size = 0, skipLf = false;
227
+ for await (const text of network.textStream(id, signal)) {
228
+ pending += skipLf && text.startsWith('\n') ? text.slice(1) : text;
229
+ skipLf = false;
230
+ for (;;) {
231
+ const at = pending.search(/[\r\n]/);
232
+ if (at < 0)
233
+ break;
234
+ skipLf = pending[at] === '\r' && at === pending.length - 1;
235
+ const line = pending.slice(0, at);
236
+ pending = pending.slice(at + (pending[at] === '\r' && pending[at + 1] === '\n' ? 2 : 1));
237
+ size += line.length;
238
+ if (size > 262144)
239
+ throw new Error('network_sse_event_too_large');
240
+ if (!line) {
241
+ if (data.length)
242
+ yield { data: data.join('\n'), event: event || 'message', id: lastId, ...(retry === undefined ? {} : { retry }) };
243
+ data = [];
244
+ event = '';
245
+ size = 0;
246
+ continue;
247
+ }
248
+ if (line.startsWith(':'))
249
+ continue;
250
+ const colon = line.indexOf(':');
251
+ const field = colon < 0 ? line : line.slice(0, colon);
252
+ let value = colon < 0 ? '' : line.slice(colon + 1);
253
+ if (value.startsWith(' '))
254
+ value = value.slice(1);
255
+ if (field === 'data')
256
+ data.push(value);
257
+ else if (field === 'event')
258
+ event = value;
259
+ else if (field === 'id' && !value.includes('\0'))
260
+ lastId = value;
261
+ else if (field === 'retry' && /^\d+$/.test(value) && Number.isSafeInteger(Number(value)))
262
+ retry = Number(value);
263
+ }
264
+ if (size + pending.length > 262144)
265
+ throw new Error('network_sse_event_too_large');
266
+ }
267
+ // SSE requires a blank line to dispatch; incomplete final events are discarded.
268
+ },
269
+ /** Explicitly dispose a stream that was never iterated (or has another owner). */
270
+ async dispose(id) {
271
+ const ignoreMissing = (error) => { if (!/^(operation_missing|network_job_missing|permission_denied)$/.test(error instanceof Error ? error.message : String(error)))
272
+ throw error; };
273
+ try {
274
+ await network.cancel(id);
275
+ }
276
+ catch (error) {
277
+ ignoreMissing(error);
278
+ }
279
+ try {
280
+ await network.release(id);
281
+ }
282
+ catch (error) {
283
+ ignoreMissing(error);
284
+ }
285
+ },
171
286
  async text(id) {
172
287
  const decoder = new TextDecoder();
173
288
  let offset = 0, text = '';
@@ -0,0 +1,46 @@
1
+ /** React is only imported by the optional @medeo-plugin/sdk/ui/react entry. */
2
+ import { type ReactNode } from 'react';
3
+ import { type CardOptions, type PillOptions, type ToggleOptions, type SegmentedOptions, type ListOptions } from './ui.js';
4
+ export { uiStyles, installStyles } from './ui/styles.js';
5
+ export type { CardOptions, PillOptions, ToggleOptions, SegmentedOptions, ListOptions, Segment } from './ui.js';
6
+ /** Call once near your root. SSR-safe; style installation is idempotent. */
7
+ export declare function useUIStyles(): void;
8
+ export declare function Card({ title, description, testid, children }: CardOptions & {
9
+ children?: ReactNode;
10
+ }): import("react").DetailedReactHTMLElement<{
11
+ className: string;
12
+ 'data-testid': string;
13
+ 'aria-label': string;
14
+ }, HTMLElement>;
15
+ export declare function Pill({ label, tone, testid }: PillOptions): import("react").DetailedReactHTMLElement<{
16
+ className: string;
17
+ 'data-testid': string;
18
+ 'data-tone': "error" | "neutral" | "info";
19
+ }, HTMLElement>;
20
+ /** Controlled: update checked in onChange. Native input supplies name and Space handling. */
21
+ export declare function Toggle({ label, checked, disabled, onChange, testid }: ToggleOptions): import("react").DetailedReactHTMLElement<{
22
+ className: string;
23
+ 'data-testid': string;
24
+ }, HTMLElement>;
25
+ /** Controlled radio group with a single tab stop and arrow/Home/End navigation. */
26
+ export declare function Segmented<T extends string>({ label, value, options, disabled, onChange, testid }: SegmentedOptions<T>): import("react").DetailedReactHTMLElement<{
27
+ className: string;
28
+ role: "radiogroup";
29
+ 'aria-label': string;
30
+ 'data-testid': string;
31
+ }, HTMLElement>;
32
+ export declare function List({ label, testid, items }: ListOptions & {
33
+ items: readonly {
34
+ key: string;
35
+ content: ReactNode;
36
+ }[];
37
+ }): import("react").DetailedReactHTMLElement<{
38
+ className: string;
39
+ 'data-testid': string;
40
+ 'aria-label': string;
41
+ }, HTMLElement>;
42
+ export declare function EmptyState({ title, description, testid }: CardOptions): import("react").DetailedReactHTMLElement<{
43
+ className: string;
44
+ 'data-testid': string;
45
+ 'aria-label': string;
46
+ }, HTMLElement>;
package/dist/react.js ADDED
@@ -0,0 +1,44 @@
1
+ /** React is only imported by the optional @medeo-plugin/sdk/ui/react entry. */
2
+ import { createElement as h, useEffect } from 'react';
3
+ import { segmentTarget, segmentTabIndex } from './ui.js';
4
+ import { installStyles } from './ui/styles.js';
5
+ export { uiStyles, installStyles } from './ui/styles.js';
6
+ /** Call once near your root. SSR-safe; style installation is idempotent. */
7
+ export function useUIStyles() { useEffect(() => { installStyles(); }, []); }
8
+ export function Card({ title, description, testid, children }) {
9
+ return h('section', { className: 'medeo-ui medeo-card', 'data-testid': testid, 'aria-label': title }, h('h2', { 'data-testid': `${testid}.title` }, title), description !== undefined && h('p', { className: 'medeo-description' }, description), children);
10
+ }
11
+ export function Pill({ label, tone = 'info', testid }) {
12
+ return h('span', { className: 'medeo-ui medeo-pill', 'data-testid': testid, 'data-tone': tone }, label);
13
+ }
14
+ /** Controlled: update checked in onChange. Native input supplies name and Space handling. */
15
+ export function Toggle({ label, checked, disabled, onChange, testid }) {
16
+ return h('label', { className: 'medeo-ui medeo-toggle', 'data-testid': `${testid}.root` }, h('input', { type: 'checkbox', role: 'switch', checked, disabled, 'data-testid': testid, onChange: (event) => onChange(event.currentTarget.checked) }), h('span', null, label));
17
+ }
18
+ /** Controlled radio group with a single tab stop and arrow/Home/End navigation. */
19
+ export function Segmented({ label, value, options, disabled, onChange, testid }) {
20
+ return h('div', { className: 'medeo-ui medeo-segmented', role: 'radiogroup', 'aria-label': label, 'data-testid': testid }, options.map((option, index) => h('button', {
21
+ key: option.value, type: 'button', role: 'radio', 'aria-checked': option.value === value,
22
+ 'data-testid': `${testid}.${index}`, disabled: disabled || option.disabled,
23
+ tabIndex: disabled ? -1 : segmentTabIndex(options, value, index),
24
+ onClick: () => { if (!disabled && !option.disabled)
25
+ onChange(option.value); },
26
+ onKeyDown: (event) => {
27
+ if (disabled)
28
+ return;
29
+ const group = event.currentTarget.parentElement;
30
+ const target = segmentTarget(options, index, event.key, getComputedStyle(group).direction === 'rtl');
31
+ if (target === undefined)
32
+ return;
33
+ event.preventDefault();
34
+ group.children[target].focus();
35
+ onChange(options[target].value);
36
+ },
37
+ }, option.label)));
38
+ }
39
+ export function List({ label, testid, items }) {
40
+ return h('ul', { className: 'medeo-ui medeo-list', 'data-testid': testid, 'aria-label': label }, items.map(item => h('li', { key: item.key, 'data-testid': `${testid}.${item.key}` }, item.content)));
41
+ }
42
+ export function EmptyState({ title, description, testid }) {
43
+ return h('section', { className: 'medeo-ui medeo-empty', 'data-testid': testid, 'aria-label': title }, h('h2', null, title), description !== undefined && h('p', { className: 'medeo-description' }, description));
44
+ }
package/dist/rpc.d.ts ADDED
@@ -0,0 +1,28 @@
1
+ import { type Disposable } from './index.ts';
2
+ /** A transport is injectable for offline tests; the default is this plugin's own channel. */
3
+ export interface RpcTransport {
4
+ post(message: unknown): Promise<void>;
5
+ onMessage(listener: (message: unknown) => void): () => void;
6
+ }
7
+ export interface RpcOptions {
8
+ transport?: RpcTransport;
9
+ timeoutMs?: number;
10
+ signal?: AbortSignal;
11
+ }
12
+ type Handler = (...args: any[]) => unknown;
13
+ export type WorkerApiClient<T> = {
14
+ [K in keyof T]: T[K] extends (...args: infer A) => infer R ? (...args: A) => Promise<Awaited<R>> : never;
15
+ } & {
16
+ $dispose(): void;
17
+ };
18
+ /** Register in activate, then push the returned disposable into context.subscriptions.
19
+ * Requests only reach this plugin's worker; this is not a cross-plugin service API.
20
+ */
21
+ export declare function defineWorkerApi<T extends {
22
+ [K in keyof T]: Handler;
23
+ }>(handlers: T, options?: Pick<RpcOptions, 'transport'>): Disposable;
24
+ /** Create once per window, dispose on unmount. Timeout/disposal cancels waiting,
25
+ * not a worker side effect that already started. All arguments/results are JSON.
26
+ */
27
+ export declare function useWorkerApi<T>(options?: RpcOptions): WorkerApiClient<T>;
28
+ export {};
package/dist/rpc.js ADDED
@@ -0,0 +1,172 @@
1
+ import { channel } from "./index.js";
2
+ const PROTOCOL = 'medeo.rpc.v1';
3
+ const MAX_PENDING = 64;
4
+ const MAX_BYTES = 60 * 1024;
5
+ function packet(value) {
6
+ if (!value || typeof value !== 'object')
7
+ return false;
8
+ const p = value;
9
+ 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;
10
+ }
11
+ /** Reject values JSON would silently drop or change, rather than corrupting an RPC argument. */
12
+ function checked(value) {
13
+ const seen = new Set();
14
+ function visit(v) {
15
+ if (v === null || typeof v === 'string' || typeof v === 'boolean')
16
+ return;
17
+ if (typeof v === 'number' && Number.isFinite(v))
18
+ return;
19
+ if (!v || typeof v !== 'object' || (!Array.isArray(v) && Object.getPrototypeOf(v) !== Object.prototype && Object.getPrototypeOf(v) !== null) || seen.has(v))
20
+ throw new Error('rpc_invalid_json');
21
+ seen.add(v);
22
+ const array = Array.isArray(v);
23
+ if (array && Object.keys(v).length !== v.length)
24
+ throw new Error('rpc_invalid_json');
25
+ for (const key of Reflect.ownKeys(v)) {
26
+ if (array && key === 'length')
27
+ continue;
28
+ const descriptor = Object.getOwnPropertyDescriptor(v, key);
29
+ if (typeof key !== 'string' || !descriptor.enumerable || !('value' in descriptor) ||
30
+ (array && (!/^(0|[1-9]\d*)$/.test(key) || Number(key) >= v.length)))
31
+ throw new Error('rpc_invalid_json');
32
+ visit(descriptor.value);
33
+ }
34
+ seen.delete(v);
35
+ }
36
+ visit(value);
37
+ const json = JSON.stringify(value);
38
+ if (new TextEncoder().encode(json).length > MAX_BYTES)
39
+ throw new Error('rpc_message_too_large');
40
+ return JSON.parse(json);
41
+ }
42
+ /** Register in activate, then push the returned disposable into context.subscriptions.
43
+ * Requests only reach this plugin's worker; this is not a cross-plugin service API.
44
+ */
45
+ export function defineWorkerApi(handlers, options = {}) {
46
+ for (const name of ['then', '$dispose']) {
47
+ if (Object.hasOwn(handlers, name))
48
+ throw new Error(`rpc_reserved_method: ${name}`);
49
+ }
50
+ const transport = options.transport ?? channel;
51
+ const active = new Set();
52
+ let stopped = false;
53
+ const respond = (p) => {
54
+ if (stopped)
55
+ return;
56
+ const message = checked(p);
57
+ void Promise.resolve().then(() => { if (!stopped)
58
+ return transport.post(message); }).catch(() => { });
59
+ };
60
+ const off = transport.onMessage(message => {
61
+ if (stopped || !packet(message) || message.kind !== 'request')
62
+ return;
63
+ const key = JSON.stringify([message.client, message.id]);
64
+ if (active.has(key))
65
+ return;
66
+ const reply = { protocol: PROTOCOL, client: message.client, id: message.id };
67
+ if (active.size >= MAX_PENDING) {
68
+ respond({ ...reply, kind: 'error', error: 'rpc_busy' });
69
+ return;
70
+ }
71
+ if (typeof message.method !== 'string' || !Object.hasOwn(handlers, message.method) || typeof handlers[message.method] !== 'function' || !Array.isArray(message.args)) {
72
+ respond({ ...reply, kind: 'error', error: 'rpc_method_not_found' });
73
+ return;
74
+ }
75
+ active.add(key);
76
+ void Promise.resolve().then(() => {
77
+ if (stopped)
78
+ return;
79
+ const request = checked(message);
80
+ return handlers[request.method](...request.args);
81
+ }).then(value => {
82
+ respond({ ...reply, kind: 'result', value: checked(value === undefined ? null : value) });
83
+ }).catch(error => {
84
+ let detail = 'rpc_failed';
85
+ try {
86
+ detail = String(error instanceof Error ? error.message : error).slice(0, 1000);
87
+ }
88
+ catch { }
89
+ respond({ ...reply, kind: 'error', error: detail });
90
+ }).finally(() => active.delete(key));
91
+ });
92
+ return { dispose() { stopped = true; off(); active.clear(); } };
93
+ }
94
+ /** Create once per window, dispose on unmount. Timeout/disposal cancels waiting,
95
+ * not a worker side effect that already started. All arguments/results are JSON.
96
+ */
97
+ export function useWorkerApi(options = {}) {
98
+ const transport = options.transport ?? channel;
99
+ const timeoutMs = options.timeoutMs ?? 30_000;
100
+ if (!Number.isFinite(timeoutMs) || timeoutMs <= 0 || timeoutMs > 300_000)
101
+ throw new Error('rpc_invalid_timeout');
102
+ const client = globalThis.crypto.randomUUID();
103
+ let sequence = 0, stopped = false;
104
+ const pending = new Map();
105
+ const settle = (id, value, error) => {
106
+ const request = pending.get(id);
107
+ if (!request)
108
+ return;
109
+ pending.delete(id);
110
+ clearTimeout(request.timer);
111
+ if (error !== undefined)
112
+ request.reject(new Error(error));
113
+ else
114
+ request.resolve(value);
115
+ };
116
+ const off = transport.onMessage(message => {
117
+ if (!packet(message) || message.client !== client || message.kind === 'request')
118
+ return;
119
+ try {
120
+ const response = checked(message);
121
+ if (response.kind === 'error')
122
+ settle(response.id, null, typeof response.error === 'string' ? response.error : 'rpc_failed');
123
+ else
124
+ settle(response.id, checked(response.value));
125
+ }
126
+ catch (error) {
127
+ settle(message.id, null, error instanceof Error ? error.message : 'rpc_invalid_json');
128
+ }
129
+ });
130
+ const dispose = () => {
131
+ if (stopped)
132
+ return;
133
+ stopped = true;
134
+ off();
135
+ options.signal?.removeEventListener('abort', dispose);
136
+ for (const id of pending.keys())
137
+ settle(id, null, 'rpc_disposed');
138
+ };
139
+ options.signal?.addEventListener('abort', dispose, { once: true });
140
+ if (options.signal?.aborted)
141
+ dispose();
142
+ return new Proxy(Object.create(null), {
143
+ get(_target, method) {
144
+ if (method === '$dispose')
145
+ return dispose;
146
+ if (method === 'then' || typeof method !== 'string')
147
+ return undefined;
148
+ return (...args) => {
149
+ if (stopped)
150
+ return Promise.reject(new Error('rpc_disposed'));
151
+ if (pending.size >= MAX_PENDING)
152
+ return Promise.reject(new Error('rpc_busy'));
153
+ const id = String(++sequence);
154
+ let message;
155
+ try {
156
+ message = checked({ protocol: PROTOCOL, kind: 'request', client, id, method, args });
157
+ }
158
+ catch (error) {
159
+ return Promise.reject(error);
160
+ }
161
+ return new Promise((resolve, reject) => {
162
+ const timer = setTimeout(() => settle(id, null, 'rpc_timeout'), timeoutMs);
163
+ pending.set(id, { resolve, reject, timer });
164
+ void Promise.resolve().then(() => {
165
+ if (!stopped && pending.has(id))
166
+ return transport.post(message);
167
+ }).catch(error => settle(id, null, error instanceof Error ? error.message : 'rpc_send_failed'));
168
+ });
169
+ };
170
+ },
171
+ });
172
+ }
@@ -0,0 +1,28 @@
1
+ import type { PluginAPI, PluginContext, PlatformEvent, PluginLocaleState } from './index.ts';
2
+ export type FakeHandler = (args: Record<string, unknown>) => unknown | Promise<unknown>;
3
+ type CommandHandler = Parameters<PluginContext['commands']['register']>[1];
4
+ export interface FakeHostOptions {
5
+ storage?: Record<string, unknown>;
6
+ /** Declared configuration keys and their initial/default values. */
7
+ config?: Record<string, unknown>;
8
+ handlers?: Record<string, FakeHandler>;
9
+ commands?: Record<string, CommandHandler>;
10
+ }
11
+ /** Offline bridge for real SDK calls. No OS, permissions, persistence or manifest
12
+ * validation is simulated. Channel messages loop back to this host's listeners.
13
+ * Install only within a serial test scope (globals cannot isolate concurrent tests).
14
+ * Every unimplemented capability fails unless supplied in `handlers`.
15
+ */
16
+ export declare function createFakeHost(options?: FakeHostOptions): {
17
+ api: PluginAPI;
18
+ context: PluginContext;
19
+ /** Explicit installation: importing this module never changes the global. */
20
+ install(): () => void;
21
+ emitEvent: (event: PlatformEvent) => void;
22
+ emitMessage: (message: unknown) => void;
23
+ emitLocaleChanged: (state: PluginLocaleState) => void;
24
+ dispose(): Promise<void>;
25
+ };
26
+ /** Installs a bridge for a callback and restores it even when the callback throws. */
27
+ export declare function withFakeHost<T>(options: FakeHostOptions, run: (host: ReturnType<typeof createFakeHost>) => T | Promise<T>): Promise<T>;
28
+ export {};