@shenora/react 0.9.1 → 0.11.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/README.md CHANGED
@@ -1,165 +1,152 @@
1
- # @shenora/react
2
-
3
- React client for [Shenora](https://github.com/JiarongGu/Shenora) desktop hosts (.NET + WinForms +
4
- WebView2). The typed bridge between a React frontend and the Shenora host: correlated `invoke`
5
- with timeouts and structured errors, the event hub host notifications stream into, typed module
6
- services, React hooks, and a pluggable transport with a browser fallback so the UI can be
7
- developed in a plain browser. Headless by design — no UI components, bring your own design
8
- system. Versioned in lockstep with the `Shenora.*` NuGet packages.
9
-
10
- ```ts
11
- import {
12
- getBridge, useShenoraEvent, useShenoraQuery, BaseModuleService, createShenoraStore,
13
- } from '@shenora/react';
14
-
15
- // Once at startup, after your listeners are attached: it starts notification delivery (anything the
16
- // host buffered arrives in the first batch). Drop zones need no particular ordering against it — the
17
- // host clears them when a new DOCUMENT loads, not on this handshake.
18
- await getBridge().notifyReady();
19
-
20
- // a typed service per backend module:
21
- interface NoteRequests { GET_ALL: void; ADD: { title: string } }
22
- class NoteService extends BaseModuleService<NoteRequests> {
23
- constructor() { super('NOTES'); }
24
- getAll() { return this.send<Note[]>('GET_ALL'); }
25
- add(title: string) { return this.send<Note>('ADD', { payload: { title } }); }
26
- }
27
-
28
- // in components:
29
- const { data, loading, refetch } = useShenoraQuery<Note[]>('NOTES', 'GET_ALL');
30
- useShenoraEvent<Note>('NOTES', 'ADDED', (note) => refetch());
31
- ```
32
-
33
- Failed calls reject with `OperationError` — a structured `code` (an i18n key: translate
34
- `errors.{code}`) plus interpolation `parameters`, never raw host exception text.
35
-
36
- ### Long-running work: post, then read a store
37
-
38
- `invoke` awaits a correlated reply and carries a timeout, and its handler's synchronous segment runs
39
- on the host's **UI thread** — so it is for calls that are quick and UI-thread-safe. Everything else
40
- posts and streams results back as events:
41
-
42
- ```ts
43
- const useDeploy = createShenoraStore('DEPLOY', {
44
- initial: { status: 'idle', lines: [] as string[] },
45
- // Loaded on the FIRST subscriber, so a component that mounts mid-run isn't empty:
46
- // events it missed cannot be replayed.
47
- snapshot: { type: 'GET_STATE', apply: (s, d) => ({ ...s, ...(d as object) }) },
48
- on: {
49
- PROGRESS: (s, p: { line: string }) => ({ ...s, lines: [...s.lines, p.line] }),
50
- ENDED: (s, p: { ok: boolean }) => ({ ...s, status: p.ok ? 'done' : 'failed' }),
51
- },
52
- actions: ({ post }) => ({ start: (cfg: unknown) => post('START', { payload: cfg }) }),
53
- });
54
-
55
- // any number of components share ONE subscription:
56
- const status = useDeploy((s) => s.status);
57
- useDeploy.actions.start({ env: 'prod' });
58
- ```
59
-
60
- Use the **store for shared or long-lived state** and **`useShenoraEvent` for a one-off reaction in a
61
- single component**. A failed `post` has no promise to reject, so it is reported through the bridge's
62
- `onPostError` rather than vanishing — it is bridge-wide, so wire it once at startup
63
- (`getBridge()` takes no options):
64
-
65
- ```ts
66
- configureBridge({ onPostError: (failure) => log.error(failure.module, failure.type, failure.error) });
67
- ```
68
-
69
- ### Tracked operations: a ready-made progress store
70
-
71
- For work the host tracks with `Shenora.Ipc`'s operation registry (an `IModuleContext.Run`/`Start`
72
- route on the C# side), `useShenoraOperations()` is a `createShenoraStore` instance built the same
73
- way as the example above — no per-feature event wiring needed:
74
-
75
- ```ts
76
- import { useShenoraOperations } from '@shenora/react';
77
-
78
- const running = useShenoraOperations((s) => s.running); // every in-flight operation
79
- const waiting = useShenoraOperations((s) => s.waiting); // stopped, awaiting a decision — "needs you", one bucket
80
- const importJob = useShenoraOperations((s) => s.byId[jobId]); // one, by id
81
-
82
- useShenoraOperations.actions.cancel(jobId); // only does anything if the op opted into Cancellable
83
- useShenoraOperations.actions.dismiss(jobId); // decline a waiting offer — refuses a running one
84
- useShenoraOperations.actions.wait(jobId); // ASK the host to wait running work — refuses anything not running
85
- useShenoraOperations.actions.clearFinished();
86
- ```
87
-
88
- `operation.progress` is an exported `OperationProgress` (`{ value: number; total?: number; unit?:
89
- string }`) — import the type rather than re-declaring the shape — the APP's own unit
90
- (bytes-of-a-known-total, items-of-a-known-total, an absolute count with no known total, or a genuine
91
- percent), never a kit-assumed percentage: `total` is the denominator when one is known and `undefined`
92
- when there isn't one, and `unit` is app-defined and uninterpreted, exactly like `kind`. The kit ships
93
- no percent helper — render a ratio only when you have a `total`:
94
-
95
- ```ts
96
- import type { OperationProgress } from '@shenora/react';
97
-
98
- function format(progress?: OperationProgress): string {
99
- if (!progress) return 'starting…';
100
- return progress.total
101
- ? `${Math.round((progress.value / progress.total) * 100)}%`
102
- : `${progress.value}${progress.unit ? ` ${progress.unit}` : ''}`; // no known total
103
- }
104
- ```
105
-
106
- That division is your own policy, not the kit's, which is why it lives here instead of in `src/`.
107
-
108
- For the two events the store deliberately does NOT subscribe to — `OPERATION_RESUME_REQUESTED` and
109
- `OPERATION_WAIT_REQUESTED`, which target the OWNING module's service rather than this generic store —
110
- the module name and the event vocabulary are exported too, so you match by symbol and stay inside the
111
- host↔client mirror the wire tests pin:
112
-
113
- ```ts
114
- import { OperationEventTypes, OperationModuleName, useShenoraEvent } from '@shenora/react';
115
-
116
- useShenoraEvent(OperationModuleName, OperationEventTypes.ResumeRequested, (payload) => …);
117
- ```
118
-
119
- It snapshots via `LIST` on first subscribe (so a progress strip that mounts mid-run isn't empty), then
120
- folds `OPERATION_UPDATED` by id — one subscription however many components read it. The client
121
- mirrors the host's three bands (design §5A.2): `running` (Active), `waiting` (Waiting), and `finished`
122
- (Terminal). All three are derived from `byId` on every read, never a second copy to keep in sync —
123
- `waiting` is a single-status filter, exactly like `running`. `OperationStatus` carries ONE waiting
124
- value reached ONE way — the host's `IOperation.Wait` on a live operation — so there is no sub-case to
125
- tell apart. Filtering by your own `module`/`kind` is a plain `Array.filter` over any of them. A
126
- `waiting` operation carries `waitReason` — an app-defined string, like `kind`, OPTIONAL on the host
127
- side — for your UI to branch on. The host never prunes a waiting entry on its own: it stays until
128
- someone resumes, dismisses, or finishes it. `resume`/`dismiss`/`wait` are all fire-and-forget client
129
- requests — the host's own `IOperation.Wait`/`Resume` (called by whoever owns the operation, hearing
130
- `OPERATION_WAIT_REQUESTED`/`OPERATION_RESUME_REQUESTED`) is what actually changes the state; asking
131
- is not acting. `clearFinished`/`resume`/`wait` do not touch local state themselves at all: the host's
132
- `OPERATION_REMOVED { operationIds }` is the ONE authoritative removal signal the store folds, deleting
133
- exactly the named ids — `MaxHistory` eviction and `clearFinished`
134
- publish it, so a long-lived store's mirror of bounded host history cannot drift from what the host
135
- actually did (this replaced two hand-written optimistic local prunes that a past release carried —
136
- one of which was this project's only Critical, a `resume` prune that dropped a still-waiting row).
137
- `dismiss` never needed one, since the host's `Dismiss` publishes an ordinary terminal snapshot over
138
- the wire, the same as a real cancel. Use `createOperationsStore({ module, scope })` instead of the
139
- default export if your host renamed `OperationRegistryOptions.ModuleName` or
140
- you need a scope-filtered instance (a secondary window, an auxiliary session) — `clearFinished`
141
- forwards that scope so clearing history in one window cannot wipe another's.
142
-
143
- ### Observing the whole stream
144
-
145
- `useShenoraEvent` and `createShenoraStore` listen for an exact `(module, type)`. When the vocabulary
146
- isn't knowable up front — plug-in-contributed events, a diagnostics tap, or an adoption shim keeping
147
- a legacy "every host message" handler alive while features migrate one at a time — subscribe broadly
148
- instead. Both mirror the host's `IEventBus` and return an unsubscribe:
149
-
150
- ```ts
151
- const off = eventBus.subscribeToAll((event) => log.debug(event.module, event.type, event.payload));
152
- eventBus.subscribeToModule('DEPLOY', (event) => audit(event)); // every type from one module
153
- ```
154
-
155
- Delivery is narrowest-first — exact pair, then module, then catch-all — so a broad observer never
156
- runs ahead of the feature code it is observing. Prefer `subscribe` when you know the pair: a
157
- catch-all wakes for every event on the bus.
158
-
159
- Pure-UI development in a plain browser: pass a `fallback` to `configureBridge` (gated behind
160
- `import.meta.env.DEV`) to answer requests with canned data. Other shells (WebSocket,
161
- mobile/Capacitor) implement the small `ShenoraTransport` seam and speak the same envelopes.
162
- For CDP-driven testing, `installDevInterceptor()` records IPC/event traffic into ring buffers
163
- and exposes `window.__shenora.call()/waitEvent()`.
164
-
165
- MIT © Jiarong Gu
1
+ # @shenora/react
2
+
3
+ React client for [Shenora](https://github.com/JiarongGu/Shenora) desktop hosts (.NET + WinForms +
4
+ WebView2). The typed bridge between a React frontend and the Shenora host: correlated `invoke`
5
+ with timeouts and structured errors, the event hub host notifications stream into, typed module
6
+ services, React hooks, and a pluggable transport with a browser fallback so the UI can be
7
+ developed in a plain browser. Headless by design — no UI components, bring your own design
8
+ system. Versioned in lockstep with the `Shenora.*` NuGet packages.
9
+
10
+ ```ts
11
+ import {
12
+ getBridge, useShenoraEvent, useShenoraQuery, BaseModuleService, createShenoraStore,
13
+ } from '@shenora/react';
14
+
15
+ // Once at startup, after your listeners are attached: it starts notification delivery (anything the
16
+ // host buffered arrives in the first batch). Drop zones need no particular ordering against it — the
17
+ // host clears them when a new DOCUMENT loads, not on this handshake.
18
+ await getBridge().notifyReady();
19
+
20
+ // a typed service per backend module:
21
+ interface NoteRequests { GET_ALL: void; ADD: { title: string } }
22
+ class NoteService extends BaseModuleService<NoteRequests> {
23
+ constructor() { super('NOTES'); }
24
+ getAll() { return this.send<Note[]>('GET_ALL'); }
25
+ add(title: string) { return this.send<Note>('ADD', { payload: { title } }); }
26
+ }
27
+
28
+ // in components:
29
+ const { data, loading, refetch } = useShenoraQuery<Note[]>('NOTES', 'GET_ALL');
30
+ useShenoraEvent<Note>('NOTES', 'ADDED', (note) => refetch());
31
+ ```
32
+
33
+ Failed calls reject with `ShenoraError` — a structured `code` (an i18n key: translate
34
+ `errors.{code}`) plus interpolation `parameters`, never raw host exception text.
35
+
36
+ ### Long-running work: post, then read a store
37
+
38
+ `invoke` awaits a correlated reply and carries a timeout, and its handler's synchronous segment runs
39
+ on the host's **UI thread** — so it is for calls that are quick and UI-thread-safe. Everything else
40
+ posts and streams results back as events:
41
+
42
+ ```ts
43
+ const useDeploy = createShenoraStore('DEPLOY', {
44
+ initial: { status: 'idle', lines: [] as string[] },
45
+ // Loaded on the FIRST subscriber, so a component that mounts mid-run isn't empty:
46
+ // events it missed cannot be replayed.
47
+ snapshot: { type: 'GET_STATE', apply: (s, d) => ({ ...s, ...(d as object) }) },
48
+ on: {
49
+ PROGRESS: (s, p: { line: string }) => ({ ...s, lines: [...s.lines, p.line] }),
50
+ ENDED: (s, p: { ok: boolean }) => ({ ...s, status: p.ok ? 'done' : 'failed' }),
51
+ },
52
+ actions: ({ post }) => ({ start: (cfg: unknown) => post('START', { payload: cfg }) }),
53
+ });
54
+
55
+ // any number of components share ONE subscription:
56
+ const status = useDeploy((s) => s.status);
57
+ useDeploy.actions.start({ env: 'prod' });
58
+ ```
59
+
60
+ Use the **store for shared or long-lived state** and **`useShenoraEvent` for a one-off reaction in a
61
+ single component**. A failed `post` has no promise to reject, so it is reported through the bridge's
62
+ `onPostError` rather than vanishing — it is bridge-wide, so wire it once at startup
63
+ (`getBridge()` takes no options):
64
+
65
+ ```ts
66
+ configureBridge({ onPostError: (failure) => log.error(failure.module, failure.type, failure.error) });
67
+ ```
68
+
69
+ ### Requests in flight: a ready-made progress store
70
+
71
+ Every request the host handles is tracked automatically — there is nothing to declare on either side.
72
+ `useShenoraRequests()` is a `createShenoraStore` instance built the same way as the example above:
73
+
74
+ ```ts
75
+ import { useShenoraRequests } from '@shenora/react';
76
+
77
+ const running = useShenoraRequests((s) => s.running); // every request still in flight
78
+ const finished = useShenoraRequests((s) => s.finished); // retained history, newest first
79
+ const importJob = useShenoraRequests((s) => s.byId[requestId]); // one, by id
80
+
81
+ useShenoraRequests.actions.cancel(requestId); // XMLHttpRequest.abort() — the id you sent with
82
+ useShenoraRequests.actions.clearFinished();
83
+ ```
84
+
85
+ 🔴 **The id is the one you already have.** `requestId` is the `id` of the request you sent — there is no
86
+ second identity to correlate. Cancelling it targets the token the route is running under.
87
+
88
+ ⚠ **Most requests never appear here, and that is the design.** The host stays SILENT for the first
89
+ 50 ms (`IpcRequestTrackerOptions.GracePeriod`): a request that finishes inside that window emits no
90
+ event at all — no running snapshot, no completion, nothing retained. So this store is a list of work
91
+ that is actually *taking a while*, not a log of every call your page made. Nobody wants a spinner for
92
+ five milliseconds of work, and the clock decides that at run time rather than a module author guessing
93
+ at authoring time.
94
+
95
+ `request.progress` is an exported `IpcProgress` (`{ value: number; total?: number; unit?: string }`) —
96
+ import the type rather than re-declaring the shape. It is the APP's own unit (bytes of a known total,
97
+ items of a known total, an absolute count with no known total, or a genuine percent), never a
98
+ kit-assumed percentage: `total` is the denominator when one is known and `undefined` when there is
99
+ none, and `unit` is app-defined and uninterpreted. The kit ships no percent helper — render a ratio
100
+ only when you have a `total`:
101
+
102
+ ```ts
103
+ import type { IpcProgress } from '@shenora/react';
104
+
105
+ function format(progress?: IpcProgress): string {
106
+ if (!progress) return 'starting…';
107
+ return progress.total
108
+ ? `${Math.round((progress.value / progress.total) * 100)}%`
109
+ : `${progress.value}${progress.unit ? ` ${progress.unit}` : ''}`; // no known total
110
+ }
111
+ ```
112
+
113
+ That division is your own policy, not the kit's, which is why it lives here instead of in `src/`.
114
+
115
+ The store snapshots via `LIST` on first subscribe (so a progress strip that mounts mid-run is not
116
+ empty), then folds `REQUEST_UPDATED` by id — one subscription however many components read it. Two
117
+ bands, both derived from `byId` on every read: `running` and `finished`. Filtering by your own
118
+ `module`/`type` is a plain `Array.filter` over either.
119
+
120
+ `clearFinished` does not touch local state itself: the host's `REQUEST_REMOVED { requestIds }` is the
121
+ one authoritative removal signal the store folds, deleting exactly the named ids. History eviction and
122
+ `clearFinished` both publish it, so a long-lived store's mirror of bounded host history cannot drift
123
+ from what the host actually did.
124
+
125
+ ⚠ **Work nobody requested does not belong here.** A scheduled job, a background sync, anything the host
126
+ starts on its own — those have no request behind them and no response to wait for, so they report on
127
+ their own event stream via `useShenoraEvent`. Squeezing them in here is what the previous design did,
128
+ and it is why it needed a "waiting" state nothing else could explain.
129
+
130
+ ### Observing the whole stream
131
+
132
+ `useShenoraEvent` and `createShenoraStore` listen for an exact `(module, type)`. When the vocabulary
133
+ isn't knowable up front — plug-in-contributed events, a diagnostics tap, or an adoption shim keeping
134
+ a legacy "every host message" handler alive while features migrate one at a time — subscribe broadly
135
+ instead. Both mirror the host's `IEventBus` and return an unsubscribe:
136
+
137
+ ```ts
138
+ const off = eventBus.subscribeToAll((event) => log.debug(event.module, event.type, event.payload));
139
+ eventBus.subscribeToModule('DEPLOY', (event) => audit(event)); // every type from one module
140
+ ```
141
+
142
+ Delivery is narrowest-first — exact pair, then module, then catch-all — so a broad observer never
143
+ runs ahead of the feature code it is observing. Prefer `subscribe` when you know the pair: a
144
+ catch-all wakes for every event on the bus.
145
+
146
+ Pure-UI development in a plain browser: pass a `fallback` to `configureBridge` (gated behind
147
+ `import.meta.env.DEV`) to answer requests with canned data. Other shells (WebSocket,
148
+ mobile/Capacitor) implement the small `ShenoraTransport` seam and speak the same envelopes.
149
+ For CDP-driven testing, `installDevInterceptor()` records IPC/event traffic into ring buffers
150
+ and exposes `window.__shenora.call()/waitEvent()`.
151
+
152
+ MIT © Jiarong Gu
package/dist/bridge.d.ts CHANGED
@@ -89,7 +89,7 @@ export declare class ShenoraBridge {
89
89
  get isAvailable(): boolean;
90
90
  /**
91
91
  * Send a request and await its typed response data. A failed response rejects with the
92
- * structured {@link OperationError} (code + parameters); no response within the timeout
92
+ * structured {@link ShenoraError} (code + parameters); no response within the timeout
93
93
  * rejects with code `TIMEOUT`; no transport and no fallback rejects with `NO_TRANSPORT`.
94
94
  */
95
95
  invoke<TData = unknown, TPayload = unknown>(module: string, type: string, options?: InvokeOptions<TPayload>): Promise<TData>;
@@ -109,7 +109,9 @@ export declare class ShenoraBridge {
109
109
  *
110
110
  * Failures are not silent. There is no promise to reject, so a failed response is reported through
111
111
  * `onPostError` (default `console.error`) rather than being dropped the way an unmatched response
112
- * otherwise is. Nothing is queued and no timer is set, so there is nothing to leak and no deadline.
112
+ * otherwise is. ⚠ Reporting it means the id IS remembered — see {@link ShenoraBridgeOptions.maxTrackedPosts},
113
+ * which caps the set drop-oldest so a host that never answers cannot grow it without bound. No TIMER is
114
+ * set, though, so there is no deadline and nothing to fire later.
113
115
  *
114
116
  * No transport (a plain browser tab) is a silent no-op, matching the fire-and-forget contract —
115
117
  * unlike `invoke`, there is no caller waiting to be told. **A DISPOSED bridge is the same silent
package/dist/bridge.js CHANGED
@@ -1,4 +1,4 @@
1
- import { OperationError } from './errors.js';
1
+ import { ShenoraError } from './errors.js';
2
2
  import { eventBus as defaultEventBus } from './eventBus.js';
3
3
  import { randomId } from './internal.js';
4
4
  import { createHostTransport } from './transport.js';
@@ -43,7 +43,7 @@ export class ShenoraBridge {
43
43
  }
44
44
  /**
45
45
  * Send a request and await its typed response data. A failed response rejects with the
46
- * structured {@link OperationError} (code + parameters); no response within the timeout
46
+ * structured {@link ShenoraError} (code + parameters); no response within the timeout
47
47
  * rejects with code `TIMEOUT`; no transport and no fallback rejects with `NO_TRANSPORT`.
48
48
  */
49
49
  invoke(module, type, options = {}) {
@@ -51,7 +51,7 @@ export class ShenoraBridge {
51
51
  // Fail fast: the transport subscription is gone, so a response could never correlate —
52
52
  // without this the call would burn the full timeout (stale references after
53
53
  // configureBridge replaced the default are the typical way here).
54
- return Promise.reject(new OperationError({
54
+ return Promise.reject(new ShenoraError({
55
55
  code: IpcErrorCodes.noTransport,
56
56
  message: `Bridge disposed — ${module}.${type} cannot be sent.`,
57
57
  }));
@@ -80,18 +80,25 @@ export class ShenoraBridge {
80
80
  // value is already settled.
81
81
  if (!isThenable(result))
82
82
  return Promise.resolve(result);
83
+ // ⚠ The loser's timer must be CLEARED, as every other path in this file does — the real invoke's
84
+ // timeout handler, its transport-throw catch, its response path and `dispose`. Leaving it holds a
85
+ // live timer per call for the full timeout
86
+ // (30 s by default), each holding its closure. Harmless to a caller, because `race` has already
87
+ // settled and has a rejection handler attached either way, but it is the same "one site out of
88
+ // N" shape the rest of this file is careful about, and it keeps timers pending in a test run.
89
+ let timer;
83
90
  return Promise.race([
84
91
  Promise.resolve(result),
85
92
  new Promise((_, reject) => {
86
- setTimeout(() => reject(new OperationError({
93
+ timer = setTimeout(() => reject(new ShenoraError({
87
94
  code: IpcErrorCodes.timeout,
88
95
  message: `${module}.${type} timed out after ${timeoutMs} ms (in the configured fallback).`,
89
96
  parameters: { module, type },
90
97
  })), timeoutMs);
91
98
  }),
92
- ]);
99
+ ]).finally(() => clearTimeout(timer));
93
100
  }
94
- return Promise.reject(new OperationError({
101
+ return Promise.reject(new ShenoraError({
95
102
  code: IpcErrorCodes.noTransport,
96
103
  message: `No transport for ${module}.${type} — not inside a Shenora host, and no fallback is configured.`,
97
104
  }));
@@ -99,7 +106,7 @@ export class ShenoraBridge {
99
106
  return new Promise((resolve, reject) => {
100
107
  const timer = setTimeout(() => {
101
108
  this.pending.delete(request.id);
102
- reject(new OperationError({
109
+ reject(new ShenoraError({
103
110
  code: IpcErrorCodes.timeout,
104
111
  message: `${module}.${type} timed out after ${timeoutMs} ms.`,
105
112
  parameters: { module, type },
@@ -136,7 +143,9 @@ export class ShenoraBridge {
136
143
  *
137
144
  * Failures are not silent. There is no promise to reject, so a failed response is reported through
138
145
  * `onPostError` (default `console.error`) rather than being dropped the way an unmatched response
139
- * otherwise is. Nothing is queued and no timer is set, so there is nothing to leak and no deadline.
146
+ * otherwise is. ⚠ Reporting it means the id IS remembered — see {@link ShenoraBridgeOptions.maxTrackedPosts},
147
+ * which caps the set drop-oldest so a host that never answers cannot grow it without bound. No TIMER is
148
+ * set, though, so there is no deadline and nothing to fire later.
140
149
  *
141
150
  * No transport (a plain browser tab) is a silent no-op, matching the fire-and-forget contract —
142
151
  * unlike `invoke`, there is no caller waiting to be told. **A DISPOSED bridge is the same silent
@@ -232,7 +241,7 @@ export class ShenoraBridge {
232
241
  this.unsubscribe?.();
233
242
  for (const [id, entry] of this.pending) {
234
243
  clearTimeout(entry.timer);
235
- entry.reject(new OperationError({ code: IpcErrorCodes.noTransport, message: 'Bridge disposed.' }));
244
+ entry.reject(new ShenoraError({ code: IpcErrorCodes.noTransport, message: 'Bridge disposed.' }));
236
245
  this.pending.delete(id);
237
246
  }
238
247
  // Unawaited ids are pure bookkeeping with nothing to settle — drop them so a disposed bridge
@@ -285,7 +294,7 @@ export class ShenoraBridge {
285
294
  entry.resolve(response.data);
286
295
  }
287
296
  else {
288
- entry.reject(new OperationError(response.error ?? { code: IpcErrorCodes.unknownError }));
297
+ entry.reject(new ShenoraError(response.error ?? { code: IpcErrorCodes.unknownError }));
289
298
  }
290
299
  return;
291
300
  }
@@ -0,0 +1,94 @@
1
+ import type { ShenoraBridge } from './bridge.js';
2
+ import { BaseModuleService } from './moduleService.js';
3
+ /** Media type for PNG bytes — the interchange image format every platform and browser reads. */
4
+ export declare const PNG_IMAGE = "image/png";
5
+ /** Media type for UTF-8 HTML, for a paste that keeps its formatting. */
6
+ export declare const HTML = "text/html";
7
+ /**
8
+ * One clipboard item and every representation it offers.
9
+ *
10
+ * `text` and `files` are named because every platform has a first-class API for them; everything else
11
+ * lives in `formats`, keyed by media type — {@link PNG_IMAGE}, {@link HTML}, or your own
12
+ * `application/…` type, which the host carries verbatim so a paste can round-trip it losslessly.
13
+ */
14
+ export interface ClipboardContent {
15
+ /** The plain-text representation. */
16
+ text?: string;
17
+ /**
18
+ * Absolute paths, for the copy a file manager can paste.
19
+ * ⚠ DESKTOP only — gate on {@link ClipboardHandle.canCopyFiles}.
20
+ */
21
+ files?: string[];
22
+ /** Every other representation, as raw bytes keyed by media type. */
23
+ formats?: Record<string, Uint8Array>;
24
+ }
25
+ /** What actually crosses the wire: the same shape with the byte payloads base64-encoded. */
26
+ interface ClipboardWire {
27
+ text?: string;
28
+ files?: string[];
29
+ formats?: Record<string, string>;
30
+ }
31
+ interface ClipboardRequests {
32
+ READ: undefined;
33
+ WRITE: {
34
+ content: ClipboardWire;
35
+ };
36
+ CLEAR: undefined;
37
+ }
38
+ /**
39
+ * Typed client for the host's `SHENORA.CLIPBOARD` module (`ClipboardModule`).
40
+ *
41
+ * ⚠ **`files` is a DESKTOP capability** and rejects with `IpcErrorCodes.capabilityNotSupported` on a
42
+ * phone. Do not catch that — ask first, via {@link useClipboard}, and do not render the control.
43
+ */
44
+ export declare class ClipboardAccess extends BaseModuleService<ClipboardRequests> {
45
+ constructor(bridge?: ShenoraBridge);
46
+ /**
47
+ * Everything the clipboard is offering — **no user gesture, no permission prompt, no focus
48
+ * requirement**, which is the half `navigator.clipboard.read()` cannot give you.
49
+ */
50
+ read(): Promise<ClipboardContent>;
51
+ /**
52
+ * Replace the clipboard with one item, every representation at once.
53
+ *
54
+ * ⚠ Bytes cross the IPC envelope as base64, so a large picture is a large message. A page copying
55
+ * something big should hand the host a path and let it read the file instead.
56
+ */
57
+ write(content: ClipboardContent): Promise<void>;
58
+ /** Leave the clipboard holding nothing. */
59
+ clear(): Promise<void>;
60
+ }
61
+ /** What {@link useClipboard} returns: the client, plus what this shell will actually honour. */
62
+ export interface ClipboardHandle {
63
+ /** The typed client. Stable across renders. */
64
+ clipboard: ClipboardAccess;
65
+ /**
66
+ * This shell can put a FILE LIST on the clipboard — desktop only. Decide what to RENDER with it; a
67
+ * refused call rejects, which is the honest answer to a question that should not have been asked.
68
+ */
69
+ canCopyFiles: boolean;
70
+ }
71
+ /**
72
+ * The clipboard client together with what the CURRENT shell can honour — read from the ready
73
+ * handshake, not sniffed from the platform (D36).
74
+ *
75
+ * ```tsx
76
+ * const { clipboard, canCopyFiles } = useClipboard();
77
+ * return (
78
+ * <>
79
+ * <button onClick={() => navigator.clipboard.writeText(name)}>Copy name</button>
80
+ * {canCopyFiles && (
81
+ * <button onClick={() => clipboard.write({ text: name, files: [path] })}>Copy file</button>
82
+ * )}
83
+ * </>
84
+ * );
85
+ * ```
86
+ *
87
+ * ⚠ Note the first button: plain text on a click is the browser's job and stays there. This hook is for
88
+ * the file copy beside it.
89
+ *
90
+ * ⚠ `canCopyFiles` is `false` until the handshake has landed, so await `bridge.notifyReady()` before
91
+ * rendering this tree — see {@link useShellInfo} for why the read is synchronous.
92
+ */
93
+ export declare function useClipboard(clipboard?: ClipboardAccess): ClipboardHandle;
94
+ export {};