@brftech/filex-app-ui 0.48.1

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,384 @@
1
+ /**
2
+ * @brftech/filex-app-ui — the SDK an app's own interface bundles.
3
+ *
4
+ * ```js
5
+ * import { connect } from '@brftech/filex-app-ui';
6
+ *
7
+ * const fx = await connect(); // the handshake with filex
8
+ * const file = await fx.open(); // the file the app was opened with
9
+ * editor.setValue(await file.text());
10
+ * editor.onChange(() => fx.dirty(true));
11
+ * fx.onSave(async () => editor.getValue()); // filex's Save, a draft's "Save to disk", Ctrl+S
12
+ * ```
13
+ *
14
+ * No dependency, a few KB. The interface runs in a sandboxed frame with no
15
+ * network and no storage of its own: every call below goes to filex over the
16
+ * MessagePort the handshake set up, and filex decides it (docs/APP-PLUGINS.md
17
+ * → "An app's own interface"). The protocol itself is `./protocol`.
18
+ */
19
+ import {
20
+ BRIDGE_VERSION,
21
+ HELLO,
22
+ LIMITS,
23
+ isPortMessage,
24
+ type BridgeError,
25
+ type ConfirmParams,
26
+ type ErrorCode,
27
+ type FileInfo,
28
+ type HostEvent,
29
+ type HostRequestMessage,
30
+ type Method,
31
+ type ReadResult,
32
+ type SaveAsResult,
33
+ type SaveResult,
34
+ type Session,
35
+ type Theme,
36
+ type ToastParams,
37
+ type DownloadResult,
38
+ } from './protocol';
39
+
40
+ export * from './protocol';
41
+
42
+ /** A call filex refused, with the reason as a code. */
43
+ export class FilexError extends Error {
44
+ readonly code: ErrorCode;
45
+ constructor(err: BridgeError) {
46
+ super(err.message || err.code);
47
+ this.name = 'FilexError';
48
+ this.code = err.code;
49
+ }
50
+ }
51
+
52
+ /** Content the app hands to a save. */
53
+ export type SaveData = string | Blob | ArrayBuffer | ArrayBufferView | ReadableStream<Uint8Array>;
54
+
55
+ /** One opened file, read the way the app needs it. */
56
+ export interface OpenedFile extends FileInfo {
57
+ text(): Promise<string>;
58
+ bytes(): Promise<ArrayBuffer>;
59
+ stream(): Promise<ReadableStream<Uint8Array>>;
60
+ /** Save new content over this file (a new version, or the draft it is). */
61
+ save(data: SaveData, mime?: string): Promise<SaveResult>;
62
+ }
63
+
64
+ export interface ConnectOptions {
65
+ /** How long to wait for filex (default 10 s). */
66
+ timeoutMs?: number;
67
+ /**
68
+ * Put filex's colours (`--fe-*`), `lang`, `dir` and `data-theme` on
69
+ * `<html>`, and keep them current (default true).
70
+ */
71
+ applyTheme?: boolean;
72
+ /** Ctrl/Cmd+S inside the interface runs the save handler (default true). */
73
+ saveShortcut?: boolean;
74
+ }
75
+
76
+ export interface FilexApp {
77
+ readonly session: Session;
78
+ /** The file at `index` (default the first) the interface was opened with. */
79
+ open(index?: number): Promise<OpenedFile>;
80
+ /** Save new content over an opened file. */
81
+ save(data: SaveData, opts?: { index?: number; mime?: string }): Promise<SaveResult>;
82
+ /** Save a NEW file; filex asks the person where (its own folder picker). */
83
+ saveAs(name: string, data: SaveData, mime?: string): Promise<SaveAsResult>;
84
+ /** Unsaved changes, or not: filex asks before the person leaves them. */
85
+ dirty(on: boolean): void;
86
+ title(text: string): void;
87
+ toast(text: string, tone?: ToastParams['tone']): void;
88
+ confirm(opts: ConfirmParams | string): Promise<boolean>;
89
+ /** Ask filex to close the interface (it asks first when there are changes). */
90
+ close(): void;
91
+ /** Put text on the clipboard (filex does it, the same way in every browser). */
92
+ copy(text: string): Promise<void>;
93
+ /**
94
+ * Hand the person a file for their own disk (the app needs `ui.download`
95
+ * in its manifest). Call it from a click or a key press: without one,
96
+ * filex asks the person first. `cancelled` when they say no.
97
+ */
98
+ download(name: string, data: SaveData, mime?: string): Promise<DownloadResult>;
99
+ /** Call the app's own module (`ui_call` export) with the opened files. */
100
+ call<T = unknown>(method: string, params?: unknown): Promise<T>;
101
+ /** Queue one of the app's actions on the opened files; answers the op. */
102
+ submit(action: string, params?: Record<string, unknown>): Promise<{ op: unknown }>;
103
+ /** This person's small store for this app (JSON values, 8 KiB each). */
104
+ state: {
105
+ get<T = unknown>(key: string): Promise<T | undefined>;
106
+ set(key: string, value: unknown): Promise<void>;
107
+ };
108
+ /** Listen to filex. Returns the unsubscribe. */
109
+ on(event: HostEvent, handler: (data: unknown) => void): () => void;
110
+ /**
111
+ * What to save when filex asks (its Save button, a draft's "Save to disk",
112
+ * Ctrl+S): return the document and the SDK saves it over the opened file;
113
+ * return nothing if the handler saved by itself.
114
+ */
115
+ onSave(handler: () => SaveData | void | Promise<SaveData | void>): () => void;
116
+ /** Raw call, for a method this SDK has no helper for. */
117
+ request<T = unknown>(method: Method, params?: unknown, transfer?: Transferable[]): Promise<T>;
118
+ }
119
+
120
+ let connecting: Promise<FilexApp> | null = null;
121
+
122
+ /**
123
+ * The handshake. Resolves once filex answered with its port and the session;
124
+ * rejects outside filex or when filex did not answer in time. Calling it again
125
+ * answers the same connection.
126
+ */
127
+ export function connect(opts: ConnectOptions = {}): Promise<FilexApp> {
128
+ if (!connecting) connecting = open(opts);
129
+ return connecting;
130
+ }
131
+
132
+ function open(opts: ConnectOptions): Promise<FilexApp> {
133
+ return new Promise<FilexApp>((resolve, reject) => {
134
+ if (typeof window === 'undefined' || window.parent === window) {
135
+ reject(new FilexError({ code: 'unavailable', message: 'not running inside filex' }));
136
+ return;
137
+ }
138
+ const parent = window.parent;
139
+ // Where filex runs, when the browser says so (Chrome, Safari). A frame
140
+ // drawn with referrerpolicy="no-referrer" has no document.referrer, and
141
+ // Firefox has no ancestorOrigins: then `source === parent` is the check,
142
+ // which is the one that matters (only the parent window IS the parent).
143
+ const expected = ancestorOrigin();
144
+ const timer = setTimeout(() => {
145
+ window.removeEventListener('message', onMessage);
146
+ reject(new FilexError({ code: 'unavailable', message: 'filex did not answer' }));
147
+ }, opts.timeoutMs ?? LIMITS.connectTimeoutMs);
148
+
149
+ function onMessage(ev: MessageEvent) {
150
+ if (ev.source !== parent || !isPortMessage(ev.data) || !ev.ports[0]) return;
151
+ if (expected && ev.origin !== expected) return;
152
+ window.removeEventListener('message', onMessage);
153
+ clearTimeout(timer);
154
+ const port = ev.ports[0];
155
+ const app = bridge(port, opts);
156
+ app
157
+ .request<Session>('session.get')
158
+ .then((session) => {
159
+ (app as { session: Session }).session = session;
160
+ if (opts.applyTheme !== false) {
161
+ applyLook(session.theme, session.locale, session.dir);
162
+ app.on('theme', (t) => applyLook(t as Theme));
163
+ app.on('locale', (l) => {
164
+ const v = l as { locale?: string; dir?: 'ltr' | 'rtl' };
165
+ applyLook(undefined, v.locale, v.dir);
166
+ });
167
+ }
168
+ resolve(app);
169
+ })
170
+ .catch(reject);
171
+ }
172
+ window.addEventListener('message', onMessage);
173
+ parent.postMessage({ type: HELLO, v: BRIDGE_VERSION }, '*');
174
+ });
175
+ }
176
+
177
+ function ancestorOrigin(): string {
178
+ try {
179
+ const list = (window.location as Location & { ancestorOrigins?: DOMStringList }).ancestorOrigins;
180
+ if (list && list.length > 0 && list[0] !== 'null') return list[0];
181
+ } catch {
182
+ /* no such API */
183
+ }
184
+ return '';
185
+ }
186
+
187
+ function bridge(port: MessagePort, opts: ConnectOptions): FilexApp {
188
+ let seq = 0;
189
+ const pending = new Map<number, { resolve: (v: unknown) => void; reject: (e: unknown) => void }>();
190
+ const listeners = new Map<HostEvent, Set<(data: unknown) => void>>();
191
+ let saveHandler: (() => SaveData | void | Promise<SaveData | void>) | null = null;
192
+
193
+ port.onmessage = (ev: MessageEvent) => {
194
+ const m = ev.data as Record<string, unknown> | null;
195
+ if (!m || typeof m !== 'object') return;
196
+ if (typeof m.id === 'number') {
197
+ const p = pending.get(m.id);
198
+ if (!p) return;
199
+ pending.delete(m.id);
200
+ if (m.error) p.reject(new FilexError(m.error as BridgeError));
201
+ else p.resolve(m.result);
202
+ return;
203
+ }
204
+ if (typeof m.hid === 'number') {
205
+ void answerHost(m as unknown as HostRequestMessage);
206
+ return;
207
+ }
208
+ if (typeof m.event === 'string') {
209
+ for (const h of listeners.get(m.event as HostEvent) ?? []) {
210
+ try {
211
+ h(m.data);
212
+ } catch (e) {
213
+ console.error('[filex-app-ui] listener failed', e);
214
+ }
215
+ }
216
+ }
217
+ };
218
+
219
+ async function answerHost(m: HostRequestMessage) {
220
+ if (m.request === 'save') {
221
+ if (!saveHandler) {
222
+ port.postMessage({ hid: m.hid, error: { code: 'unavailable', message: 'this app has no save handler' } });
223
+ return;
224
+ }
225
+ try {
226
+ const res = await runSave();
227
+ port.postMessage({ hid: m.hid, result: res ?? { saved: true } });
228
+ } catch (e) {
229
+ const err = e instanceof FilexError ? { code: e.code, message: e.message } : { code: 'failed', message: String((e as Error)?.message ?? e) };
230
+ port.postMessage({ hid: m.hid, error: err });
231
+ }
232
+ return;
233
+ }
234
+ port.postMessage({ hid: m.hid, error: { code: 'unknown_method' } });
235
+ }
236
+
237
+ async function runSave(): Promise<SaveResult | null> {
238
+ if (!saveHandler) return null;
239
+ const data = await saveHandler();
240
+ if (data === undefined || data === null) return null;
241
+ return app.save(data as SaveData);
242
+ }
243
+
244
+ function request<T>(method: Method, params?: unknown, transfer: Transferable[] = []): Promise<T> {
245
+ const id = ++seq;
246
+ return new Promise<T>((resolve, reject) => {
247
+ pending.set(id, { resolve: resolve as (v: unknown) => void, reject });
248
+ try {
249
+ port.postMessage({ id, method, params }, transfer);
250
+ } catch (e) {
251
+ pending.delete(id);
252
+ reject(e);
253
+ }
254
+ });
255
+ }
256
+
257
+ function notify(method: Method, params?: unknown) {
258
+ request(method, params).catch((e) => console.warn('[filex-app-ui]', method, e));
259
+ }
260
+
261
+ async function payload(data: SaveData): Promise<{ data: ReadableStream<Uint8Array> | ArrayBuffer | string; transfer: Transferable[] }> {
262
+ if (typeof data === 'string') return { data, transfer: [] };
263
+ if (data instanceof ArrayBuffer) return { data, transfer: [data] };
264
+ if (ArrayBuffer.isView(data)) {
265
+ const copy = data.buffer.slice(data.byteOffset, data.byteOffset + data.byteLength) as ArrayBuffer;
266
+ return { data: copy, transfer: [copy] };
267
+ }
268
+ if (typeof Blob !== 'undefined' && data instanceof Blob) {
269
+ const buf = await data.arrayBuffer();
270
+ return { data: buf, transfer: [buf] };
271
+ }
272
+ if (typeof ReadableStream !== 'undefined' && data instanceof ReadableStream) {
273
+ return { data, transfer: [data as unknown as Transferable] };
274
+ }
275
+ throw new FilexError({ code: 'invalid', message: 'save takes a string, a Blob, bytes or a ReadableStream' });
276
+ }
277
+
278
+ const app: FilexApp = {
279
+ session: undefined as unknown as Session,
280
+ async open(index = 0) {
281
+ const info = app.session?.files?.[index];
282
+ if (!info) throw new FilexError({ code: 'not_found', message: `no file at ${index}` });
283
+ const read = <K extends keyof ReadResult>(as: 'stream' | 'bytes' | 'text', key: K) =>
284
+ request<ReadResult>('file.read', { index, as }).then((r) => r[key] as NonNullable<ReadResult[K]>);
285
+ return {
286
+ ...info,
287
+ text: () => read('text', 'text'),
288
+ bytes: () => read('bytes', 'bytes'),
289
+ stream: () => read('stream', 'stream'),
290
+ save: (data: SaveData, mime?: string) => app.save(data, { index, mime }),
291
+ };
292
+ },
293
+ async save(data, o = {}) {
294
+ const p = await payload(data);
295
+ return request<SaveResult>('file.save', { index: o.index ?? 0, data: p.data, mime: o.mime }, p.transfer);
296
+ },
297
+ async saveAs(name, data, mime) {
298
+ const p = await payload(data);
299
+ return request<SaveAsResult>('file.saveAs', { name, data: p.data, mime }, p.transfer);
300
+ },
301
+ async download(name, data, mime) {
302
+ const p = await payload(data);
303
+ return request<DownloadResult>('ui.download', { name, data: p.data, mime }, p.transfer);
304
+ },
305
+ dirty(on) {
306
+ notify('ui.dirty', { dirty: !!on });
307
+ },
308
+ title(text) {
309
+ notify('ui.title', { text: String(text).slice(0, LIMITS.maxLineChars) });
310
+ },
311
+ toast(text, tone) {
312
+ notify('ui.toast', { text: String(text).slice(0, LIMITS.maxLineChars), tone });
313
+ },
314
+ confirm(o) {
315
+ const params = typeof o === 'string' ? { text: o } : o;
316
+ return request<boolean>('ui.confirm', params);
317
+ },
318
+ close() {
319
+ notify('ui.close');
320
+ },
321
+ copy(text) {
322
+ return request<void>('clipboard.write', { text: String(text) });
323
+ },
324
+ call<T>(method: string, params?: unknown) {
325
+ return request<T>('engine.call', { method, params });
326
+ },
327
+ submit(action, params) {
328
+ return request<{ op: unknown }>('job.submit', { action, params });
329
+ },
330
+ state: {
331
+ get<T>(key: string) {
332
+ return request<T | undefined>('state.get', { key });
333
+ },
334
+ set(key: string, value: unknown) {
335
+ return request<void>('state.set', { key, value });
336
+ },
337
+ },
338
+ on(event, handler) {
339
+ let set = listeners.get(event);
340
+ if (!set) listeners.set(event, (set = new Set()));
341
+ set.add(handler);
342
+ return () => set!.delete(handler);
343
+ },
344
+ onSave(handler) {
345
+ saveHandler = handler;
346
+ return () => {
347
+ if (saveHandler === handler) saveHandler = null;
348
+ };
349
+ },
350
+ request,
351
+ };
352
+
353
+ if (opts.saveShortcut !== false && typeof document !== 'undefined') {
354
+ // ⚠ The keystroke is handled HERE and never forwarded: a frame that could
355
+ // send key events to filex could press filex's own shortcuts. Ctrl+S runs
356
+ // the app's own save handler, which saves through the bridge like any call.
357
+ document.addEventListener(
358
+ 'keydown',
359
+ (e) => {
360
+ if ((e.ctrlKey || e.metaKey) && !e.altKey && !e.shiftKey && (e.key === 's' || e.key === 'S') && saveHandler) {
361
+ e.preventDefault();
362
+ runSave().catch((err) => app.toast(String((err as Error)?.message ?? err), 'error'));
363
+ }
364
+ },
365
+ true,
366
+ );
367
+ }
368
+ return app;
369
+ }
370
+
371
+ /** Paint filex's look onto the interface's own document. */
372
+ function applyLook(theme?: Theme, locale?: string, dir?: 'ltr' | 'rtl') {
373
+ if (typeof document === 'undefined') return;
374
+ const root = document.documentElement;
375
+ if (theme) {
376
+ root.dataset.theme = theme.mode;
377
+ root.style.colorScheme = theme.mode;
378
+ for (const [k, v] of Object.entries(theme.tokens ?? {})) {
379
+ if (/^--fe-[a-z0-9-]+$/.test(k) && typeof v === 'string') root.style.setProperty(k, v);
380
+ }
381
+ }
382
+ if (locale) root.lang = locale;
383
+ if (dir) root.dir = dir;
384
+ }
@@ -0,0 +1,320 @@
1
+ /**
2
+ * The bridge between filex and an app's own interface — the ONE definition.
3
+ *
4
+ * An app's interface (the `ui` block of its filex-app.json) runs in a frame
5
+ * filex draws with `sandbox="allow-scripts"`: an opaque origin, no cookies, no
6
+ * storage, no network (the server's CSP says `connect-src 'none'`). Everything
7
+ * it may do goes through this bridge, and the host decides every call.
8
+ *
9
+ * Both halves import this file — the SDK the app bundles (`src/index.ts`) and
10
+ * filex's own frame (`packages/core/src/components/plugin/AppFrame.vue`) — so a
11
+ * method or an event added here is added on both sides at once.
12
+ *
13
+ * The wire, in order (docs/APP-PLUGINS-API.md → "The interface bridge"):
14
+ *
15
+ * 1. The app posts `{type: 'filex:hello', v: 1}` to its parent window.
16
+ * 2. The host takes it ONLY when `event.source` is the frame it drew. The
17
+ * origin says nothing here: a sandboxed frame's origin is always "null",
18
+ * so any other sandboxed frame on the page would look the same (measured,
19
+ * 2026-09-27, Chrome, Firefox and WebKit).
20
+ * 3. The host answers with a MessagePort: `{type: 'filex:port', v: 1}` and
21
+ * the port transferred. Once per load of the frame; a second hello is
22
+ * ignored.
23
+ * 4. The app takes the port ONLY when `event.source` is its parent window.
24
+ * 5. From then on, only the port: requests `{id, method, params}`, answers
25
+ * `{id, result}` or `{id, error}`, events `{event, data}`, and the host's
26
+ * own requests `{hid, request, params}` answered `{hid, result|error}`.
27
+ */
28
+
29
+ /** The bridge generation both sides speak. */
30
+ export const BRIDGE_VERSION = 1;
31
+
32
+ /** The app's first message, to `window.parent`. */
33
+ export const HELLO = 'filex:hello';
34
+ /** The host's answer, carrying the port. */
35
+ export const PORT = 'filex:port';
36
+
37
+ /** What the app may ask the host. Anything else is `unknown_method`. */
38
+ export type Method =
39
+ | 'session.get'
40
+ | 'file.read'
41
+ | 'file.save'
42
+ | 'file.saveAs'
43
+ | 'ui.dirty'
44
+ | 'ui.title'
45
+ | 'ui.toast'
46
+ | 'ui.confirm'
47
+ | 'ui.close'
48
+ | 'clipboard.write'
49
+ | 'ui.download'
50
+ | 'engine.call'
51
+ | 'job.submit'
52
+ | 'state.get'
53
+ | 'state.set';
54
+
55
+ export const METHODS: readonly Method[] = [
56
+ 'session.get',
57
+ 'file.read',
58
+ 'file.save',
59
+ 'file.saveAs',
60
+ 'ui.dirty',
61
+ 'ui.title',
62
+ 'ui.toast',
63
+ 'ui.confirm',
64
+ 'ui.close',
65
+ 'clipboard.write',
66
+ 'ui.download',
67
+ 'engine.call',
68
+ 'job.submit',
69
+ 'state.get',
70
+ 'state.set',
71
+ ];
72
+
73
+ /** What the host tells the app, unasked. */
74
+ export type HostEvent =
75
+ /** The interface's colours changed (light/dark, palette). `data: Theme`. */
76
+ | 'theme'
77
+ /** The reader's language changed. `data: {locale, dir}`. */
78
+ | 'locale'
79
+ /** The open file changed underneath (another tab, another person). */
80
+ | 'file.changed'
81
+ /** The person closed the frame's surroundings; last chance to say so. */
82
+ | 'close.request'
83
+ /** An administrator installed a new version of this app: reload to use it. */
84
+ | 'app.updated';
85
+
86
+ /** What the host may ask the app (and wait for). */
87
+ export type HostRequest =
88
+ /** Save now (the host's Save button, a draft's "Save to disk"): the app
89
+ * hands its document to `file.save` and answers when it is written. */
90
+ 'save';
91
+
92
+ /** Why a call was refused. */
93
+ export type ErrorCode =
94
+ /** The app was not granted what the call needs (files:write for a save…). */
95
+ | 'not_granted'
96
+ /** No such file (an index past the opened files), no such key. */
97
+ | 'not_found'
98
+ /** The file cannot be written: a read-only storage, a view-only opening. */
99
+ | 'read_only'
100
+ /** The call's parameters are wrong. */
101
+ | 'invalid'
102
+ /** Over a limit (LIMITS). */
103
+ | 'too_large'
104
+ /** The server could not be reached or refused; `message` says what. */
105
+ | 'failed'
106
+ /** The person said no (a confirm, a save-as picker closed). */
107
+ | 'cancelled'
108
+ /** The host does not offer this here (no engine, no save handler). */
109
+ | 'unavailable'
110
+ | 'unknown_method';
111
+
112
+ export interface BridgeError {
113
+ code: ErrorCode;
114
+ message?: string;
115
+ }
116
+
117
+ /* ── messages ──────────────────────────────────────────────────────────── */
118
+
119
+ export interface HelloMessage {
120
+ type: typeof HELLO;
121
+ v: number;
122
+ }
123
+
124
+ export interface PortMessage {
125
+ type: typeof PORT;
126
+ v: number;
127
+ }
128
+
129
+ export interface RequestMessage {
130
+ id: number;
131
+ method: Method;
132
+ params?: unknown;
133
+ }
134
+
135
+ export interface ResponseMessage {
136
+ id: number;
137
+ result?: unknown;
138
+ error?: BridgeError;
139
+ }
140
+
141
+ export interface EventMessage {
142
+ event: HostEvent;
143
+ data?: unknown;
144
+ }
145
+
146
+ export interface HostRequestMessage {
147
+ hid: number;
148
+ request: HostRequest;
149
+ params?: unknown;
150
+ }
151
+
152
+ export interface HostResponseMessage {
153
+ hid: number;
154
+ result?: unknown;
155
+ error?: BridgeError;
156
+ }
157
+
158
+ /* ── shapes ────────────────────────────────────────────────────────────── */
159
+
160
+ /** The interface's look: `mode` and filex's own `--fe-*` custom properties. */
161
+ export interface Theme {
162
+ mode: 'light' | 'dark';
163
+ /** `--fe-bg`, `--fe-text`, `--fe-accent`… — the SDK sets them on `<html>`. */
164
+ tokens: Record<string, string>;
165
+ }
166
+
167
+ /** One file the interface was opened with. Never a storage path. */
168
+ export interface FileInfo {
169
+ index: number;
170
+ name: string;
171
+ /** Lower-case, no dot. */
172
+ ext: string;
173
+ size: number;
174
+ mime: string;
175
+ /** It cannot be saved over: read-only storage, view-only opening, or the
176
+ * app holds no `files:write`. */
177
+ readOnly: boolean;
178
+ }
179
+
180
+ /** What `session.get` answers — what the app knows about where it runs. */
181
+ export interface Session {
182
+ v: number;
183
+ app: { name: string; version: string };
184
+ view: { id: string; placement: 'modal' | 'page' | 'inspector' | 'home' | 'viewer' };
185
+ /** The reader's language tag (`tr`, `pt-br`) and its direction. */
186
+ locale: string;
187
+ dir: 'ltr' | 'rtl';
188
+ theme: Theme;
189
+ /** The person's display name; never their e-mail, never a token. */
190
+ user: { name: string };
191
+ files: FileInfo[];
192
+ /** The permissions this app was granted — so it can hide what it may not do. */
193
+ grants: string[];
194
+ /** The administrator's non-secret settings for this app (grant `settings`). */
195
+ settings?: Record<string, string>;
196
+ }
197
+
198
+ /** `file.read` params. */
199
+ export interface ReadParams {
200
+ index?: number;
201
+ /** `stream` (a transferred ReadableStream<Uint8Array>), `bytes` (an
202
+ * ArrayBuffer), or `text` (UTF-8, at most LIMITS.maxTextBytes). */
203
+ as?: 'stream' | 'bytes' | 'text';
204
+ }
205
+
206
+ export interface ReadResult {
207
+ name: string;
208
+ size: number;
209
+ mime: string;
210
+ stream?: ReadableStream<Uint8Array>;
211
+ bytes?: ArrayBuffer;
212
+ text?: string;
213
+ }
214
+
215
+ /** `file.save` params: the new content of the opened file. */
216
+ export interface SaveParams {
217
+ index?: number;
218
+ /** A transferred ReadableStream<Uint8Array>, an ArrayBuffer or a string. */
219
+ data: ReadableStream<Uint8Array> | ArrayBuffer | string;
220
+ mime?: string;
221
+ }
222
+
223
+ export interface SaveResult {
224
+ saved: true;
225
+ size: number;
226
+ }
227
+
228
+ /** `file.saveAs`: a NEW file, somewhere the person picks in filex's own dialog. */
229
+ /**
230
+ * `ui.download`: a file for the person's own disk. filex does it (a sandboxed
231
+ * frame cannot download), with the app's `ui:download` grant, on a gesture in
232
+ * the frame or the person's yes, never over LIMITS.maxDownloadBytes.
233
+ */
234
+ export interface DownloadParams {
235
+ /** The file name offered — a name, not a path. */
236
+ name: string;
237
+ /** A transferred ReadableStream<Uint8Array>, an ArrayBuffer or a string. */
238
+ data: ReadableStream<Uint8Array> | ArrayBuffer | string;
239
+ mime?: string;
240
+ }
241
+
242
+ export interface DownloadResult {
243
+ saved: true;
244
+ size: number;
245
+ }
246
+
247
+ export interface SaveAsParams {
248
+ name: string;
249
+ data: ReadableStream<Uint8Array> | ArrayBuffer | string;
250
+ mime?: string;
251
+ }
252
+
253
+ export interface SaveAsResult {
254
+ saved: true;
255
+ name: string;
256
+ size: number;
257
+ }
258
+
259
+ export interface ToastParams {
260
+ text: string;
261
+ tone?: 'info' | 'success' | 'warning' | 'error';
262
+ }
263
+
264
+ export interface ConfirmParams {
265
+ title?: string;
266
+ text: string;
267
+ confirm?: string;
268
+ cancel?: string;
269
+ danger?: boolean;
270
+ }
271
+
272
+ /** `engine.call`: the app's own module, `ui_call` export. */
273
+ export interface EngineCallParams {
274
+ method: string;
275
+ params?: unknown;
276
+ }
277
+
278
+ /** `job.submit`: queue one of the app's actions on the opened files. */
279
+ export interface JobSubmitParams {
280
+ action: string;
281
+ params?: Record<string, unknown>;
282
+ }
283
+
284
+ /* ── limits ────────────────────────────────────────────────────────────── */
285
+
286
+ export const LIMITS = {
287
+ /** `file.read({as: 'text'})` refuses a file larger than this; read a stream. */
288
+ maxTextBytes: 32 << 20,
289
+ /** One `state.set` value, as JSON. */
290
+ maxStateBytes: 8 << 10,
291
+ /** A `ui.toast` / `ui.title` text, in characters. */
292
+ maxLineChars: 300,
293
+ /** How long the SDK waits for the host's port. */
294
+ connectTimeoutMs: 10_000,
295
+ /** The largest file `ui.download` hands the person (streamed or not). */
296
+ maxDownloadBytes: 256 << 20,
297
+ } as const;
298
+
299
+ /* ── guards ────────────────────────────────────────────────────────────── */
300
+
301
+ export function isHello(d: unknown): d is HelloMessage {
302
+ return !!d && typeof d === 'object' && (d as HelloMessage).type === HELLO && typeof (d as HelloMessage).v === 'number';
303
+ }
304
+
305
+ export function isPortMessage(d: unknown): d is PortMessage {
306
+ return !!d && typeof d === 'object' && (d as PortMessage).type === PORT;
307
+ }
308
+
309
+ export function isRequest(d: unknown): d is RequestMessage {
310
+ return (
311
+ !!d &&
312
+ typeof d === 'object' &&
313
+ typeof (d as RequestMessage).id === 'number' &&
314
+ typeof (d as RequestMessage).method === 'string'
315
+ );
316
+ }
317
+
318
+ export function isKnownMethod(m: string): m is Method {
319
+ return (METHODS as readonly string[]).includes(m);
320
+ }