@shenora/react 0.10.0 → 0.12.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.
Files changed (44) hide show
  1. package/README.md +152 -165
  2. package/dist/bridge.d.ts +29 -46
  3. package/dist/bridge.js +47 -71
  4. package/dist/clipboard.d.ts +89 -0
  5. package/dist/clipboard.js +119 -0
  6. package/dist/devInterceptor.d.ts +11 -8
  7. package/dist/devInterceptor.js +16 -10
  8. package/dist/errors.d.ts +5 -6
  9. package/dist/errors.js +6 -7
  10. package/dist/eventBus.d.ts +21 -26
  11. package/dist/eventBus.js +38 -40
  12. package/dist/fileDialogs.d.ts +9 -12
  13. package/dist/fileDialogs.js +12 -16
  14. package/dist/hooks.d.ts +19 -20
  15. package/dist/hooks.js +26 -29
  16. package/dist/index.d.ts +8 -2
  17. package/dist/index.js +14 -10
  18. package/dist/internal.d.ts +2 -8
  19. package/dist/internal.js +2 -8
  20. package/dist/media.d.ts +14 -22
  21. package/dist/media.js +14 -22
  22. package/dist/mediaPlayer.d.ts +103 -0
  23. package/dist/mediaPlayer.js +202 -0
  24. package/dist/moduleService.d.ts +17 -21
  25. package/dist/moduleService.js +17 -21
  26. package/dist/requests.d.ts +145 -0
  27. package/dist/requests.js +113 -0
  28. package/dist/segmentBinder.d.ts +74 -0
  29. package/dist/segmentBinder.js +239 -0
  30. package/dist/segmentStream.d.ts +125 -0
  31. package/dist/segmentStream.js +239 -0
  32. package/dist/store.d.ts +18 -26
  33. package/dist/store.js +69 -36
  34. package/dist/transport.d.ts +9 -18
  35. package/dist/transport.js +9 -18
  36. package/dist/types.d.ts +33 -42
  37. package/dist/types.js +29 -25
  38. package/dist/useDropZone.d.ts +23 -22
  39. package/dist/useDropZone.js +41 -37
  40. package/dist/windowCommands.d.ts +15 -19
  41. package/dist/windowCommands.js +18 -25
  42. package/package.json +10 -3
  43. package/dist/operations.d.ts +0 -256
  44. package/dist/operations.js +0 -191
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';
@@ -8,10 +8,10 @@ const newId = () => randomId();
8
8
  const isThenable = (value) => typeof value === 'object' && value !== null
9
9
  && typeof value.then === 'function';
10
10
  /**
11
- * The client side of the Shenora IPC contract, ported from the primary desktop sibling:
12
- * correlated request/response over a pluggable transport, category routing of host messages
13
- * (`ipc` → resolve the pending call, `notification` → unbundle the batch into the event bus),
14
- * per-request timeout, the ready handshake, and a browser fallback seam for pure-UI development.
11
+ * The client side of the Shenora IPC contract: correlated request/response over a pluggable
12
+ * transport, category routing of host messages (`ipc` → resolve the pending call, `notification` →
13
+ * unbundle the batch into the event bus), per-request timeout, the ready handshake, and a browser
14
+ * fallback seam for pure-UI development.
15
15
  *
16
16
  * Most apps use the lazy default instance via {@link getBridge}/{@link configureBridge}; create
17
17
  * instances directly for tests or multi-transport setups.
@@ -20,7 +20,7 @@ export class ShenoraBridge {
20
20
  constructor(options = {}) {
21
21
  this.pending = new Map();
22
22
  // Ids of one-way sends, kept ONLY so a failed response can be reported instead of vanishing.
23
- // Insertion-ordered and capped: a Map is used for its ordered keys, not for the values.
23
+ // A Map for its insertion-ordered keys, so the cap can evict the oldest.
24
24
  this.unawaited = new Map();
25
25
  this.disposed = false;
26
26
  this.transport = options.transport !== undefined ? options.transport : createHostTransport();
@@ -34,24 +34,21 @@ export class ShenoraBridge {
34
34
  }
35
35
  /**
36
36
  * True when this bridge can actually send: a transport to a host exists AND the bridge has not been
37
- * disposed. It used to ignore `disposed`, so a stale reference to a bridge that `configureBridge`
38
- * replaced still reported itself available while every `invoke` on it rejected with `NO_TRANSPORT` —
39
- * the exact case the disposed check in `invoke` exists for (P5.5 H2).
37
+ * disposed. The check to make on a reference that may have outlived a {@link configureBridge} swap.
40
38
  */
41
39
  get isAvailable() {
42
40
  return !this.disposed && this.transport !== null;
43
41
  }
44
42
  /**
45
43
  * 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
44
+ * structured {@link ShenoraError} (code + parameters); no response within the timeout
47
45
  * rejects with code `TIMEOUT`; no transport and no fallback rejects with `NO_TRANSPORT`.
48
46
  */
49
47
  invoke(module, type, options = {}) {
50
48
  if (this.disposed) {
51
- // Fail fast: the transport subscription is gone, so a response could never correlate —
52
- // without this the call would burn the full timeout (stale references after
53
- // configureBridge replaced the default are the typical way here).
54
- return Promise.reject(new OperationError({
49
+ // Fail fast: the transport subscription is gone, so a response could never correlate and the
50
+ // call would otherwise burn the full timeout.
51
+ return Promise.reject(new ShenoraError({
55
52
  code: IpcErrorCodes.noTransport,
56
53
  message: `Bridge disposed — ${module}.${type} cannot be sent.`,
57
54
  }));
@@ -74,24 +71,25 @@ export class ShenoraBridge {
74
71
  catch (error) {
75
72
  return Promise.reject(error);
76
73
  }
77
- // A fallback may be async (a scripted preview harness commonly is), and this path used to
78
- // bypass the timeout entirely — so a fallback that never settled hung the caller forever, with
79
- // none of the diagnostics the real path gives (P5.5 H2). Only a thenable needs racing; a plain
80
- // value is already settled.
74
+ // An async fallback is raced against the timeout too, or one that never settles hangs the
75
+ // caller forever. Only a thenable needs racing; a plain value has already settled.
81
76
  if (!isThenable(result))
82
77
  return Promise.resolve(result);
78
+ // ⚠ The loser's timer is cleared in `finally` — otherwise every call holds a live timer, and
79
+ // its closure, for the full timeout.
80
+ let timer;
83
81
  return Promise.race([
84
82
  Promise.resolve(result),
85
83
  new Promise((_, reject) => {
86
- setTimeout(() => reject(new OperationError({
84
+ timer = setTimeout(() => reject(new ShenoraError({
87
85
  code: IpcErrorCodes.timeout,
88
86
  message: `${module}.${type} timed out after ${timeoutMs} ms (in the configured fallback).`,
89
87
  parameters: { module, type },
90
88
  })), timeoutMs);
91
89
  }),
92
- ]);
90
+ ]).finally(() => clearTimeout(timer));
93
91
  }
94
- return Promise.reject(new OperationError({
92
+ return Promise.reject(new ShenoraError({
95
93
  code: IpcErrorCodes.noTransport,
96
94
  message: `No transport for ${module}.${type} — not inside a Shenora host, and no fallback is configured.`,
97
95
  }));
@@ -99,7 +97,7 @@ export class ShenoraBridge {
99
97
  return new Promise((resolve, reject) => {
100
98
  const timer = setTimeout(() => {
101
99
  this.pending.delete(request.id);
102
- reject(new OperationError({
100
+ reject(new ShenoraError({
103
101
  code: IpcErrorCodes.timeout,
104
102
  message: `${module}.${type} timed out after ${timeoutMs} ms.`,
105
103
  parameters: { module, type },
@@ -123,26 +121,17 @@ export class ShenoraBridge {
123
121
  /**
124
122
  * Send WITHOUT awaiting a reply, and return the request id.
125
123
  *
126
- * This is the default shape for a desktop shell, and {@link invoke} is the special case — see
127
- * `docs/DECISIONS.md` D23. Two reasons: a correlated call carries a deadline
128
- * (30 s by default) and real work does not; and request/response is UI-THREAD-COUPLED here by
129
- * design, because the dispatch pipeline preserves the caller's synchronization context so facades
130
- * can touch the window. Reserve `invoke` for calls that are quick AND safe on the UI thread — the
131
- * window commands are the model — and post everything else, streaming results back as notifications.
124
+ * This is the default shape for a desktop shell and {@link invoke} is the special case (D23):
125
+ * reserve `invoke` for calls that are quick AND safe on the host's UI thread, and post everything
126
+ * else, streaming results back as notifications.
132
127
  *
133
- * ⚠ Posting is only HALF of freeing the UI thread. The host still dispatches on the UI thread
134
- * whether or not the client awaits, so a handler that does heavy work synchronously stalls the
135
- * window either way. The other half is the host's: return from the route immediately and stream.
128
+ * A failed response is reported through `onPostError` (default `console.error`) rather than
129
+ * dropped. The id is remembered for that — see {@link ShenoraBridgeOptions.maxTrackedPosts}. No
130
+ * timer is set, so there is no deadline.
136
131
  *
137
- * Failures are not silent. There is no promise to reject, so a failed response is reported through
138
- * `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.
140
- *
141
- * No transport (a plain browser tab) is a silent no-op, matching the fire-and-forget contract —
142
- * unlike `invoke`, there is no caller waiting to be told. **A DISPOSED bridge is the same silent
143
- * no-op**, and that one can surprise: `invoke` on a disposed bridge rejects with `NO_TRANSPORT`
144
- * while `post` just returns the id, so a stale reference kept across a `configureBridge` swap looks
145
- * like it is still sending. `isAvailable` is the check.
132
+ * ⚠ No transport (a plain browser tab) is a silent no-op, and **so is a DISPOSED bridge** — where
133
+ * `invoke` would reject with `NO_TRANSPORT`, this just returns the id, so a stale reference kept
134
+ * across a {@link configureBridge} swap looks like it is still sending. `isAvailable` is the check.
146
135
  */
147
136
  post(module, type, options = {}) {
148
137
  const request = {
@@ -155,8 +144,7 @@ export class ShenoraBridge {
155
144
  };
156
145
  if (this.disposed || !this.transport)
157
146
  return request.id;
158
- // Remember it ONLY to report a failure. Drop-oldest at the cap so a host that never answers
159
- // cannot grow this without bound; an evicted id simply loses its error report.
147
+ // Remembered ONLY to report a failure, drop-oldest at the cap.
160
148
  if (this.unawaited.size >= this.maxTrackedPosts) {
161
149
  const oldest = this.unawaited.keys().next();
162
150
  if (!oldest.done)
@@ -187,14 +175,10 @@ export class ShenoraBridge {
187
175
  * also the host's cue to reset per-page state. No-transport is a silent no-op so browser dev
188
176
  * doesn't error.
189
177
  *
190
- * Ordering used to matter here and no longer does for drop zones: the host cleared the previous
191
- * page's overlays on this handshake, so a `REGISTER` sent before `READY` was wiped even though it
192
- * was acked. `DropZoneManager` now clears on DOCUMENT CHANGE instead, which cannot race the client.
193
- * The general point still stands for any per-page state YOUR host resets here — a reset keyed on
194
- * the handshake races anything the page sends before it, and in React that is structural rather
195
- * than a mistake, because CHILD effects run before PARENT effects.
178
+ * ⚠ Any per-page state YOUR host resets on this handshake races whatever the page sent before it,
179
+ * and in React that is structural rather than bad luck: CHILD effects run before PARENT effects.
196
180
  *
197
- * The returned promise REJECTS on a failed handshake (disposed bridge, timeout). Handle it —
181
+ * ⚠ The returned promise REJECTS on a failed handshake (disposed bridge, timeout). Handle it —
198
182
  * `void bridge.notifyReady()` turns that into an unhandled rejection, which in a WebView2 page is
199
183
  * a silent console error.
200
184
  */
@@ -202,14 +186,9 @@ export class ShenoraBridge {
202
186
  if (!this.transport)
203
187
  return undefined;
204
188
  // The host answers with what it IS and what it can do, so a page can render one tree on every
205
- // shell instead of sniffing the platform. A host that says nothing (no descriptor configured, or
206
- // one predating this) leaves it UNDEFINED — absent means "assume nothing", never "assume
207
- // desktop".
208
- //
209
- // undefined rather than null on purpose: JSON null means absent on this wire and the client
210
- // convention is undefined (see .claude/knowledge/ipc-contracts.md). Returning null broke two
211
- // existing tests that assert this resolves to undefined, which is the convention catching a
212
- // deviation exactly where it should.
189
+ // shell instead of sniffing the platform. A host that says nothing leaves it UNDEFINED (never
190
+ // null — JSON null means absent on this wire) — and absent means "assume nothing", never
191
+ // "assume desktop".
213
192
  const shell = await this.invoke(HANDSHAKE_MODULE, HANDSHAKE_TYPE, { payload });
214
193
  this.shellInfo = shell && typeof shell === 'object' && typeof shell.name === 'string'
215
194
  ? { name: shell.name, capabilities: Array.isArray(shell.capabilities) ? shell.capabilities : [] }
@@ -218,8 +197,8 @@ export class ShenoraBridge {
218
197
  }
219
198
  /**
220
199
  * What the host said it was during {@link notifyReady} — undefined before the handshake, or when
221
- * the host advertised nothing. Cached so components can read it synchronously while rendering,
222
- * which is the whole point: a capability learned after layout is a flash.
200
+ * the host advertised nothing. Cached so components can read it synchronously while rendering; a
201
+ * capability learned after layout is a visible flash.
223
202
  */
224
203
  get shell() {
225
204
  return this.shellInfo;
@@ -232,11 +211,10 @@ export class ShenoraBridge {
232
211
  this.unsubscribe?.();
233
212
  for (const [id, entry] of this.pending) {
234
213
  clearTimeout(entry.timer);
235
- entry.reject(new OperationError({ code: IpcErrorCodes.noTransport, message: 'Bridge disposed.' }));
214
+ entry.reject(new ShenoraError({ code: IpcErrorCodes.noTransport, message: 'Bridge disposed.' }));
236
215
  this.pending.delete(id);
237
216
  }
238
- // Unawaited ids are pure bookkeeping with nothing to settle — drop them so a disposed bridge
239
- // holds no references (this is the instance `configureBridge` replaces).
217
+ // Pure bookkeeping with nothing to settle — dropped so a disposed bridge holds no references.
240
218
  this.unawaited.clear();
241
219
  }
242
220
  onHostMessage(message) {
@@ -248,10 +226,9 @@ export class ShenoraBridge {
248
226
  console.error('[shenora] ignored unparseable host message:', error);
249
227
  return;
250
228
  }
251
- // A literal `null` is VALID JSON, so it survives the parse and then `parsed.category` threw a
252
- // TypeError — out of a transport listener, i.e. an uncaught page error with no caller to catch it
253
- // (P5.5 H2). Primitives (`"str"`, `123`, `true`) never threw because property access on them just
254
- // yields undefined; null and only null did. Anything that isn't an object simply isn't ours.
229
+ // ⚠ A literal `null` is VALID JSON and survives the parse, and property access on it throws —
230
+ // out of a transport listener, i.e. an uncaught page error with no caller to catch it. Anything
231
+ // that is not an object simply is not ours.
255
232
  if (parsed === null || typeof parsed !== 'object')
256
233
  return;
257
234
  const envelope = parsed;
@@ -261,10 +238,9 @@ export class ShenoraBridge {
261
238
  return;
262
239
  const entry = this.pending.get(response.id);
263
240
  if (!entry) {
264
- // No pending call. Either this answers a one-way `post` — in which case a FAILURE must be
265
- // surfaced, because there is no promise to reject and dropping it here is exactly how a
266
- // feature "just stops working" with nothing to grep for — or it is a late/foreign response,
267
- // which stays ignored.
241
+ // No pending call. Either this answers a one-way `post`, whose FAILURE must be surfaced
242
+ // because there is no promise to reject, or it is a late/foreign response, which stays
243
+ // ignored.
268
244
  const posted = this.unawaited.get(response.id);
269
245
  if (posted) {
270
246
  this.unawaited.delete(response.id);
@@ -285,7 +261,7 @@ export class ShenoraBridge {
285
261
  entry.resolve(response.data);
286
262
  }
287
263
  else {
288
- entry.reject(new OperationError(response.error ?? { code: IpcErrorCodes.unknownError }));
264
+ entry.reject(new ShenoraError(response.error ?? { code: IpcErrorCodes.unknownError }));
289
265
  }
290
266
  return;
291
267
  }
@@ -0,0 +1,89 @@
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. `text` and `files` are named because every
9
+ * platform has a first-class API for them; everything else lives in `formats`, keyed by media type —
10
+ * {@link PNG_IMAGE}, {@link HTML}, or your own `application/…` type, which the host carries verbatim.
11
+ */
12
+ export interface ClipboardContent {
13
+ /** The plain-text representation. */
14
+ text?: string;
15
+ /**
16
+ * Absolute paths, for the copy a file manager can paste.
17
+ * ⚠ DESKTOP only — gate on {@link ClipboardHandle.canCopyFiles}.
18
+ */
19
+ files?: string[];
20
+ /** Every other representation, as raw bytes keyed by media type. */
21
+ formats?: Record<string, Uint8Array>;
22
+ }
23
+ /** What actually crosses the wire: the same shape with the byte payloads base64-encoded. */
24
+ interface ClipboardWire {
25
+ text?: string;
26
+ files?: string[];
27
+ formats?: Record<string, string>;
28
+ }
29
+ interface ClipboardRequests {
30
+ READ: undefined;
31
+ WRITE: {
32
+ content: ClipboardWire;
33
+ };
34
+ CLEAR: undefined;
35
+ }
36
+ /**
37
+ * Typed client for the host's `SHENORA.CLIPBOARD` module (`ClipboardModule`).
38
+ *
39
+ * ⚠ **`files` is a DESKTOP capability** and rejects with `IpcErrorCodes.capabilityNotSupported` on a
40
+ * phone. Do not catch that — ask first, via {@link useClipboard}, and do not render the control.
41
+ */
42
+ export declare class ClipboardAccess extends BaseModuleService<ClipboardRequests> {
43
+ constructor(bridge?: ShenoraBridge);
44
+ /**
45
+ * Everything the clipboard is offering — **no user gesture, no permission prompt, no focus
46
+ * requirement**, unlike `navigator.clipboard.read()`.
47
+ */
48
+ read(): Promise<ClipboardContent>;
49
+ /**
50
+ * Replace the clipboard with one item, every representation at once.
51
+ *
52
+ * ⚠ Bytes cross the IPC envelope as base64, so a large picture is a large message. A page copying
53
+ * something big should hand the host a path and let it read the file instead.
54
+ */
55
+ write(content: ClipboardContent): Promise<void>;
56
+ /** Leave the clipboard holding nothing. */
57
+ clear(): Promise<void>;
58
+ }
59
+ /** What {@link useClipboard} returns: the client, plus what this shell will actually honour. */
60
+ export interface ClipboardHandle {
61
+ /** The typed client. Stable across renders. */
62
+ clipboard: ClipboardAccess;
63
+ /** This shell can put a FILE LIST on the clipboard — desktop only. Use it to decide what to RENDER. */
64
+ canCopyFiles: boolean;
65
+ }
66
+ /**
67
+ * The clipboard client together with what the CURRENT shell can honour — read from the ready
68
+ * handshake, not sniffed from the platform (D36).
69
+ *
70
+ * ```tsx
71
+ * const { clipboard, canCopyFiles } = useClipboard();
72
+ * return (
73
+ * <>
74
+ * <button onClick={() => navigator.clipboard.writeText(name)}>Copy name</button>
75
+ * {canCopyFiles && (
76
+ * <button onClick={() => clipboard.write({ text: name, files: [path] })}>Copy file</button>
77
+ * )}
78
+ * </>
79
+ * );
80
+ * ```
81
+ *
82
+ * ⚠ Note the first button: plain text on a click is the browser's job and stays there. This hook is for
83
+ * the file copy beside it.
84
+ *
85
+ * ⚠ `canCopyFiles` is `false` until the handshake has landed, so await `bridge.notifyReady()` before
86
+ * rendering this tree — see {@link useShellInfo} for why the read is synchronous.
87
+ */
88
+ export declare function useClipboard(clipboard?: ClipboardAccess): ClipboardHandle;
89
+ export {};
@@ -0,0 +1,119 @@
1
+ /**
2
+ * The page's half of the host's `SHENORA.CLIPBOARD` module — the parts of the native clipboard the web
3
+ * platform withholds. Mirrors `Shenora.Modules.Clipboard.ClipboardModule`, pinned by `WireMirrorTests`.
4
+ *
5
+ * 🔴 **Reach for `navigator.clipboard` first.** A gesture-driven "copy this text" or "copy this image"
6
+ * already works and needs no host round trip. Two things it cannot do are why this module exists —
7
+ *
8
+ * 1. **Files.** No web API puts a file list on the clipboard, so the user cannot paste into Explorer,
9
+ * Finder or a file manager. There is no polyfill.
10
+ * 2. **Access with no user gesture or focus.** `navigator.clipboard.read()` needs transient activation,
11
+ * document focus and a permission. A host has none of those constraints.
12
+ *
13
+ * ⚠ **The choice is per-COPY, not per-format**: a clipboard set is atomic, one item, last writer wins.
14
+ * An item that includes files must be written entirely through {@link ClipboardAccess.write} — writing
15
+ * its text half with `navigator.clipboard` and the files here leaves only the files, silently.
16
+ */
17
+ import { useMemo } from 'react';
18
+ import { useShellInfo } from './hooks.js';
19
+ import { BaseModuleService } from './moduleService.js';
20
+ import { ShellCapabilities } from './types.js';
21
+ /** Media type for PNG bytes — the interchange image format every platform and browser reads. */
22
+ export const PNG_IMAGE = 'image/png';
23
+ /** Media type for UTF-8 HTML, for a paste that keeps its formatting. */
24
+ export const HTML = 'text/html';
25
+ /**
26
+ * Base64 without a `Buffer`.
27
+ *
28
+ * ⚠ Never `btoa(String.fromCharCode(...bytes))`: the spread overflows the argument list and throws
29
+ * `RangeError` on anything past roughly a small screenshot. A chunked loop has no such ceiling.
30
+ */
31
+ function toBase64(bytes) {
32
+ let binary = '';
33
+ const chunk = 0x8000;
34
+ for (let i = 0; i < bytes.length; i += chunk) {
35
+ binary += String.fromCharCode(...bytes.subarray(i, i + chunk));
36
+ }
37
+ return btoa(binary);
38
+ }
39
+ function fromBase64(value) {
40
+ const binary = atob(value);
41
+ const bytes = new Uint8Array(binary.length);
42
+ for (let i = 0; i < binary.length; i += 1)
43
+ bytes[i] = binary.charCodeAt(i);
44
+ return bytes;
45
+ }
46
+ /**
47
+ * Typed client for the host's `SHENORA.CLIPBOARD` module (`ClipboardModule`).
48
+ *
49
+ * ⚠ **`files` is a DESKTOP capability** and rejects with `IpcErrorCodes.capabilityNotSupported` on a
50
+ * phone. Do not catch that — ask first, via {@link useClipboard}, and do not render the control.
51
+ */
52
+ export class ClipboardAccess extends BaseModuleService {
53
+ constructor(bridge) {
54
+ super('SHENORA.CLIPBOARD', bridge);
55
+ }
56
+ /**
57
+ * Everything the clipboard is offering — **no user gesture, no permission prompt, no focus
58
+ * requirement**, unlike `navigator.clipboard.read()`.
59
+ */
60
+ async read() {
61
+ const wire = await this.send('READ');
62
+ const formats = {};
63
+ for (const [mediaType, value] of Object.entries(wire.formats ?? {})) {
64
+ formats[mediaType] = fromBase64(value);
65
+ }
66
+ return { text: wire.text, files: wire.files, formats };
67
+ }
68
+ /**
69
+ * Replace the clipboard with one item, every representation at once.
70
+ *
71
+ * ⚠ Bytes cross the IPC envelope as base64, so a large picture is a large message. A page copying
72
+ * something big should hand the host a path and let it read the file instead.
73
+ */
74
+ write(content) {
75
+ const formats = {};
76
+ for (const [mediaType, bytes] of Object.entries(content.formats ?? {})) {
77
+ formats[mediaType] = toBase64(bytes);
78
+ }
79
+ return this.send('WRITE', {
80
+ payload: { content: { text: content.text, files: content.files, formats } },
81
+ });
82
+ }
83
+ /** Leave the clipboard holding nothing. */
84
+ clear() {
85
+ return this.send('CLEAR');
86
+ }
87
+ }
88
+ /**
89
+ * The clipboard client together with what the CURRENT shell can honour — read from the ready
90
+ * handshake, not sniffed from the platform (D36).
91
+ *
92
+ * ```tsx
93
+ * const { clipboard, canCopyFiles } = useClipboard();
94
+ * return (
95
+ * <>
96
+ * <button onClick={() => navigator.clipboard.writeText(name)}>Copy name</button>
97
+ * {canCopyFiles && (
98
+ * <button onClick={() => clipboard.write({ text: name, files: [path] })}>Copy file</button>
99
+ * )}
100
+ * </>
101
+ * );
102
+ * ```
103
+ *
104
+ * ⚠ Note the first button: plain text on a click is the browser's job and stays there. This hook is for
105
+ * the file copy beside it.
106
+ *
107
+ * ⚠ `canCopyFiles` is `false` until the handshake has landed, so await `bridge.notifyReady()` before
108
+ * rendering this tree — see {@link useShellInfo} for why the read is synchronous.
109
+ */
110
+ export function useClipboard(clipboard) {
111
+ const shell = useShellInfo();
112
+ // Cached so it is usable as an effect dependency, not because the service is expensive.
113
+ const client = useMemo(() => clipboard ?? new ClipboardAccess(), [clipboard]);
114
+ const capabilities = shell?.capabilities;
115
+ return useMemo(() => ({
116
+ clipboard: client,
117
+ canCopyFiles: capabilities?.includes(ShellCapabilities.clipboardFiles) ?? false,
118
+ }), [client, capabilities]);
119
+ }
@@ -1,11 +1,10 @@
1
1
  /**
2
- * Dev-only IPC + event-hub interceptor, ported from the primary desktop sibling (NEVER ship it
3
- * in prod — gate the single call site with `import.meta.env.DEV`).
2
+ * Dev-only IPC + event-hub interceptor. ⚠ NEVER ship it in prod — gate the single call site with
3
+ * `import.meta.env.DEV`.
4
4
  *
5
- * Why: during desktop-app testing the agent drives the UI over CDP, but native dialogs and
6
- * event-driven flows can't be exercised by clicking. This wraps the bridge's `invoke` (the IPC
7
- * seam) and the event bus's `emit` (the event hub) to (1) record + console.debug every
8
- * request/response/event into ring buffers, and (2) expose a window global so a CDP eval can
5
+ * For driving a page over CDP, where native dialogs and event-driven flows cannot be exercised by
6
+ * clicking. It wraps the bridge's `invoke` and the event bus's `emit` to (1) record + console.debug
7
+ * every request/response/event into ring buffers, and (2) expose a window global so a CDP eval can
9
8
  * invoke ANY IPC directly and await events:
10
9
  *
11
10
  * window.__shenora.call('NOTES', 'ADD', { title: 'x' }) // drive an IPC, bypass the UI
@@ -44,7 +43,11 @@ export interface DevInterceptorOptions {
44
43
  bus?: ShenoraEventBus;
45
44
  }
46
45
  /**
47
- * Install the interceptor (idempotent across HMR / StrictMode double-invoke — keyed on the
48
- * window global).
46
+ * Install the interceptor. Idempotent across HMR and StrictMode's double-invoke.
47
+ *
48
+ * 🔴 **Idempotency is keyed on WHICH bridge and bus were wrapped, not on "something is installed".**
49
+ * The wrapping mutates a specific `ShenoraBridge` INSTANCE, so a guard that only asks whether the
50
+ * window global exists leaves the interceptor pointing at the bridge `configureBridge()` disposed:
51
+ * installed-looking, recording NOTHING, and its silence reads as "no traffic".
49
52
  */
50
53
  export declare function installDevInterceptor(options?: DevInterceptorOptions): void;
@@ -1,11 +1,10 @@
1
1
  /**
2
- * Dev-only IPC + event-hub interceptor, ported from the primary desktop sibling (NEVER ship it
3
- * in prod — gate the single call site with `import.meta.env.DEV`).
2
+ * Dev-only IPC + event-hub interceptor. ⚠ NEVER ship it in prod — gate the single call site with
3
+ * `import.meta.env.DEV`.
4
4
  *
5
- * Why: during desktop-app testing the agent drives the UI over CDP, but native dialogs and
6
- * event-driven flows can't be exercised by clicking. This wraps the bridge's `invoke` (the IPC
7
- * seam) and the event bus's `emit` (the event hub) to (1) record + console.debug every
8
- * request/response/event into ring buffers, and (2) expose a window global so a CDP eval can
5
+ * For driving a page over CDP, where native dialogs and event-driven flows cannot be exercised by
6
+ * clicking. It wraps the bridge's `invoke` and the event bus's `emit` to (1) record + console.debug
7
+ * every request/response/event into ring buffers, and (2) expose a window global so a CDP eval can
9
8
  * invoke ANY IPC directly and await events:
10
9
  *
11
10
  * window.__shenora.call('NOTES', 'ADD', { title: 'x' }) // drive an IPC, bypass the UI
@@ -15,19 +14,26 @@
15
14
  import { getBridge } from './bridge.js';
16
15
  import { eventBus as defaultEventBus } from './eventBus.js';
17
16
  /**
18
- * Install the interceptor (idempotent across HMR / StrictMode double-invoke — keyed on the
19
- * window global).
17
+ * Install the interceptor. Idempotent across HMR and StrictMode's double-invoke.
18
+ *
19
+ * 🔴 **Idempotency is keyed on WHICH bridge and bus were wrapped, not on "something is installed".**
20
+ * The wrapping mutates a specific `ShenoraBridge` INSTANCE, so a guard that only asks whether the
21
+ * window global exists leaves the interceptor pointing at the bridge `configureBridge()` disposed:
22
+ * installed-looking, recording NOTHING, and its silence reads as "no traffic".
20
23
  */
21
24
  export function installDevInterceptor(options = {}) {
22
25
  if (typeof window === 'undefined')
23
26
  return;
24
27
  const globalName = options.globalName ?? '__shenora';
25
28
  const w = window;
26
- if (w[globalName])
27
- return;
28
29
  const ringSize = options.ringSize ?? 300;
30
+ // Resolved BEFORE the guard, because the guard's question is about these two objects.
29
31
  const bridge = options.bridge ?? getBridge();
30
32
  const bus = options.bus ?? defaultEventBus;
33
+ // Same pair = the HMR/StrictMode case; wrapping twice doubles every log line.
34
+ const installed = w[globalName];
35
+ if (installed && installed.bridge === bridge && installed.eventBus === bus)
36
+ return;
31
37
  const ipc = [];
32
38
  const events = [];
33
39
  const push = (buffer, entry) => {
package/dist/errors.d.ts CHANGED
@@ -1,12 +1,11 @@
1
1
  import type { IpcError } from './types.js';
2
2
  /**
3
- * The rejection type for every failed bridge call — the client mirror of the host's
4
- * `OperationException` (the `Error` suffix is the TS idiom; the C# type keeps the platform's
5
- * `Exception` suffix). Carries the structured code + parameters so callers translate
6
- * `errors.{code}` instead of matching message strings. Client-side failures (timeout, missing
7
- * transport) reject through this same shape with the client-reserved codes.
3
+ * The rejection type for every failed bridge call — the client mirror of the host's `ShenoraException`.
4
+ * Carries the structured code + parameters so callers translate `errors.{code}` instead of matching
5
+ * message strings. Client-side failures (timeout, missing transport) reject through this same shape
6
+ * with the client-reserved codes.
8
7
  */
9
- export declare class OperationError extends Error {
8
+ export declare class ShenoraError extends Error {
10
9
  /** Error code / i18n key (e.g. `"IMPORT_FAILED"`, `"TIMEOUT"`). */
11
10
  readonly code: string;
12
11
  /** Values the client interpolates into the translated message. */
package/dist/errors.js CHANGED
@@ -1,14 +1,13 @@
1
1
  /**
2
- * The rejection type for every failed bridge call — the client mirror of the host's
3
- * `OperationException` (the `Error` suffix is the TS idiom; the C# type keeps the platform's
4
- * `Exception` suffix). Carries the structured code + parameters so callers translate
5
- * `errors.{code}` instead of matching message strings. Client-side failures (timeout, missing
6
- * transport) reject through this same shape with the client-reserved codes.
2
+ * The rejection type for every failed bridge call — the client mirror of the host's `ShenoraException`.
3
+ * Carries the structured code + parameters so callers translate `errors.{code}` instead of matching
4
+ * message strings. Client-side failures (timeout, missing transport) reject through this same shape
5
+ * with the client-reserved codes.
7
6
  */
8
- export class OperationError extends Error {
7
+ export class ShenoraError extends Error {
9
8
  constructor(error) {
10
9
  super(error.message ?? error.code);
11
- this.name = 'OperationError';
10
+ this.name = 'ShenoraError';
12
11
  this.code = error.code;
13
12
  this.parameters = error.parameters;
14
13
  }