@shenora/react 0.10.0 → 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.
@@ -0,0 +1,126 @@
1
+ /**
2
+ * The page's half of the host's `SHENORA.CLIPBOARD` module — the parts of the native clipboard the web
3
+ * platform withholds.
4
+ *
5
+ * 🔴 **Reach for `navigator.clipboard` first.** The page runs in a real browser: a gesture-driven "copy
6
+ * this text" or "copy this image" already works, needs no host round trip, and is what the platform
7
+ * expects. Two things it cannot do are why this module exists —
8
+ *
9
+ * 1. **Files.** No web API puts a file list on the clipboard, so the user cannot paste into Explorer,
10
+ * Finder or a file manager. There is no polyfill.
11
+ * 2. **Access with no user gesture or focus.** `navigator.clipboard.read()` needs transient activation,
12
+ * document focus and a permission. A host has none of those constraints.
13
+ *
14
+ * ⚠ **And the choice is per-COPY, not per-format**, because a clipboard set is atomic: one item, last
15
+ * writer wins outright. An item that includes files must be written entirely through
16
+ * {@link ClipboardAccess.write}
17
+ * — writing its text half with `navigator.clipboard` and the files here leaves only the files, silently.
18
+ *
19
+ * Mirrors `Shenora.Modules.Clipboard.ClipboardModule`. The wire names are pinned against the host's own
20
+ * constants by `WireMirrorTests`, not by care.
21
+ */
22
+ import { useMemo } from 'react';
23
+ import { useShellInfo } from './hooks.js';
24
+ import { BaseModuleService } from './moduleService.js';
25
+ import { ShellCapabilities } from './types.js';
26
+ /** Media type for PNG bytes — the interchange image format every platform and browser reads. */
27
+ export const PNG_IMAGE = 'image/png';
28
+ /** Media type for UTF-8 HTML, for a paste that keeps its formatting. */
29
+ export const HTML = 'text/html';
30
+ /**
31
+ * Base64 without a `Buffer` and without `btoa(String.fromCharCode(...bytes))`.
32
+ *
33
+ * ⚠ The spread form is the idiom everyone writes and it throws `RangeError` on a large image — the
34
+ * argument list overflows the call stack somewhere north of ~100 kB, which is a small screenshot. A
35
+ * chunked loop has no such ceiling.
36
+ */
37
+ function toBase64(bytes) {
38
+ let binary = '';
39
+ const chunk = 0x8000;
40
+ for (let i = 0; i < bytes.length; i += chunk) {
41
+ binary += String.fromCharCode(...bytes.subarray(i, i + chunk));
42
+ }
43
+ return btoa(binary);
44
+ }
45
+ function fromBase64(value) {
46
+ const binary = atob(value);
47
+ const bytes = new Uint8Array(binary.length);
48
+ for (let i = 0; i < binary.length; i += 1)
49
+ bytes[i] = binary.charCodeAt(i);
50
+ return bytes;
51
+ }
52
+ /**
53
+ * Typed client for the host's `SHENORA.CLIPBOARD` module (`ClipboardModule`).
54
+ *
55
+ * ⚠ **`files` is a DESKTOP capability** and rejects with `IpcErrorCodes.capabilityNotSupported` on a
56
+ * phone. Do not catch that — ask first, via {@link useClipboard}, and do not render the control.
57
+ */
58
+ export class ClipboardAccess extends BaseModuleService {
59
+ constructor(bridge) {
60
+ super('SHENORA.CLIPBOARD', bridge);
61
+ }
62
+ /**
63
+ * Everything the clipboard is offering — **no user gesture, no permission prompt, no focus
64
+ * requirement**, which is the half `navigator.clipboard.read()` cannot give you.
65
+ */
66
+ async read() {
67
+ const wire = await this.send('READ');
68
+ const formats = {};
69
+ for (const [mediaType, value] of Object.entries(wire.formats ?? {})) {
70
+ formats[mediaType] = fromBase64(value);
71
+ }
72
+ return { text: wire.text, files: wire.files, formats };
73
+ }
74
+ /**
75
+ * Replace the clipboard with one item, every representation at once.
76
+ *
77
+ * ⚠ Bytes cross the IPC envelope as base64, so a large picture is a large message. A page copying
78
+ * something big should hand the host a path and let it read the file instead.
79
+ */
80
+ write(content) {
81
+ const formats = {};
82
+ for (const [mediaType, bytes] of Object.entries(content.formats ?? {})) {
83
+ formats[mediaType] = toBase64(bytes);
84
+ }
85
+ return this.send('WRITE', {
86
+ payload: { content: { text: content.text, files: content.files, formats } },
87
+ });
88
+ }
89
+ /** Leave the clipboard holding nothing. */
90
+ clear() {
91
+ return this.send('CLEAR');
92
+ }
93
+ }
94
+ /**
95
+ * The clipboard client together with what the CURRENT shell can honour — read from the ready
96
+ * handshake, not sniffed from the platform (D36).
97
+ *
98
+ * ```tsx
99
+ * const { clipboard, canCopyFiles } = useClipboard();
100
+ * return (
101
+ * <>
102
+ * <button onClick={() => navigator.clipboard.writeText(name)}>Copy name</button>
103
+ * {canCopyFiles && (
104
+ * <button onClick={() => clipboard.write({ text: name, files: [path] })}>Copy file</button>
105
+ * )}
106
+ * </>
107
+ * );
108
+ * ```
109
+ *
110
+ * ⚠ Note the first button: plain text on a click is the browser's job and stays there. This hook is for
111
+ * the file copy beside it.
112
+ *
113
+ * ⚠ `canCopyFiles` is `false` until the handshake has landed, so await `bridge.notifyReady()` before
114
+ * rendering this tree — see {@link useShellInfo} for why the read is synchronous.
115
+ */
116
+ export function useClipboard(clipboard) {
117
+ const shell = useShellInfo();
118
+ // The service is stateless and holds only its module name, but a fresh instance per render would
119
+ // still make it useless as an effect dependency — the same reason useFileDialogs caches one.
120
+ const client = useMemo(() => clipboard ?? new ClipboardAccess(), [clipboard]);
121
+ const capabilities = shell?.capabilities;
122
+ return useMemo(() => ({
123
+ clipboard: client,
124
+ canCopyFiles: capabilities?.includes(ShellCapabilities.clipboardFiles) ?? false,
125
+ }), [client, capabilities]);
126
+ }
@@ -44,7 +44,14 @@ export interface DevInterceptorOptions {
44
44
  bus?: ShenoraEventBus;
45
45
  }
46
46
  /**
47
- * Install the interceptor (idempotent across HMR / StrictMode double-invoke — keyed on the
48
- * window global).
47
+ * Install the interceptor. Idempotent across HMR and StrictMode's double-invoke.
48
+ *
49
+ * 🔴 **Idempotency is keyed on WHICH bridge and bus were wrapped, not on "something is installed".**
50
+ * The wrapping mutates a specific `ShenoraBridge` INSTANCE, but the guard used to ask only whether the
51
+ * window global existed — so `configureBridge()`, which disposes the default bridge and builds a new
52
+ * one, left the interceptor pointing at the dead instance and a second install returning early without
53
+ * wrapping the live one. The tool then looked installed and recorded NOTHING, while
54
+ * `window.__shenora.call` drove a disposed bridge. A dev tool that fails silently is worse than one that
55
+ * is absent, because its silence reads as "no traffic".
49
56
  */
50
57
  export declare function installDevInterceptor(options?: DevInterceptorOptions): void;
@@ -15,19 +15,30 @@
15
15
  import { getBridge } from './bridge.js';
16
16
  import { eventBus as defaultEventBus } from './eventBus.js';
17
17
  /**
18
- * Install the interceptor (idempotent across HMR / StrictMode double-invoke — keyed on the
19
- * window global).
18
+ * Install the interceptor. Idempotent across HMR and StrictMode's double-invoke.
19
+ *
20
+ * 🔴 **Idempotency is keyed on WHICH bridge and bus were wrapped, not on "something is installed".**
21
+ * The wrapping mutates a specific `ShenoraBridge` INSTANCE, but the guard used to ask only whether the
22
+ * window global existed — so `configureBridge()`, which disposes the default bridge and builds a new
23
+ * one, left the interceptor pointing at the dead instance and a second install returning early without
24
+ * wrapping the live one. The tool then looked installed and recorded NOTHING, while
25
+ * `window.__shenora.call` drove a disposed bridge. A dev tool that fails silently is worse than one that
26
+ * is absent, because its silence reads as "no traffic".
20
27
  */
21
28
  export function installDevInterceptor(options = {}) {
22
29
  if (typeof window === 'undefined')
23
30
  return;
24
31
  const globalName = options.globalName ?? '__shenora';
25
32
  const w = window;
26
- if (w[globalName])
27
- return;
28
33
  const ringSize = options.ringSize ?? 300;
34
+ // Resolved BEFORE the guard, because the guard's question is now about these two objects.
29
35
  const bridge = options.bridge ?? getBridge();
30
36
  const bus = options.bus ?? defaultEventBus;
37
+ // Same pair = the HMR/StrictMode case this exists for; wrapping twice would double every log line and
38
+ // stack a second recorder on the first.
39
+ const installed = w[globalName];
40
+ if (installed && installed.bridge === bridge && installed.eventBus === bus)
41
+ return;
31
42
  const ipc = [];
32
43
  const events = [];
33
44
  const push = (buffer, entry) => {
package/dist/errors.d.ts CHANGED
@@ -1,12 +1,12 @@
1
1
  import type { IpcError } from './types.js';
2
2
  /**
3
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
4
+ * `ShenoraException` (the `Error` suffix is the TS idiom; the C# type keeps the platform's
5
5
  * `Exception` suffix). Carries the structured code + parameters so callers translate
6
6
  * `errors.{code}` instead of matching message strings. Client-side failures (timeout, missing
7
7
  * transport) reject through this same shape with the client-reserved codes.
8
8
  */
9
- export declare class OperationError extends Error {
9
+ export declare class ShenoraError extends Error {
10
10
  /** Error code / i18n key (e.g. `"IMPORT_FAILED"`, `"TIMEOUT"`). */
11
11
  readonly code: string;
12
12
  /** Values the client interpolates into the translated message. */
package/dist/errors.js CHANGED
@@ -1,14 +1,14 @@
1
1
  /**
2
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
3
+ * `ShenoraException` (the `Error` suffix is the TS idiom; the C# type keeps the platform's
4
4
  * `Exception` suffix). Carries the structured code + parameters so callers translate
5
5
  * `errors.{code}` instead of matching message strings. Client-side failures (timeout, missing
6
6
  * transport) reject through this same shape with the client-reserved codes.
7
7
  */
8
- export class OperationError extends Error {
8
+ export class ShenoraError extends Error {
9
9
  constructor(error) {
10
10
  super(error.message ?? error.code);
11
- this.name = 'OperationError';
11
+ this.name = 'ShenoraError';
12
12
  this.code = error.code;
13
13
  this.parameters = error.parameters;
14
14
  }
@@ -16,7 +16,7 @@ export interface SubscribeOptions {
16
16
  * Event enums/maps are app schema, so apps layer their own typed wrappers on top (headless per D13).
17
17
  * A throwing handler is isolated: it never breaks the other subscribers or the emitter.
18
18
  *
19
- * Three subscription breadths, mirroring the host's `Shenora.Core.IEventBus`: an exact
19
+ * Three subscription breadths, mirroring the host's `Shenora.IEventBus`: an exact
20
20
  * `(module, type)` pair, a whole {@link subscribeToModule | module}, or
21
21
  * {@link subscribeToAll | everything}. The broad two were added in P6.4 — the host had shipped them
22
22
  * from the start (`WebViewIpcBridge` itself consumes `SubscribeToAll`), so the client was the
@@ -63,10 +63,20 @@ export declare class ShenoraEventBus {
63
63
  /** Remove every subscription (tests). */
64
64
  clear(): void;
65
65
  /**
66
- * Subscription count (diagnostics). With no arguments: every subscription of any breadth. With a
67
- * `(module, type)`: every subscription that WOULD receive that pair — exact, whole-module and
68
- * catch-all — because "how many listeners does this event have?" is the question a diagnostic is
69
- * actually asking. Scope is not applied; a count is not a delivery.
66
+ * Subscription count (diagnostics). Every form answers "how many subscriptions WOULD receive this?",
67
+ * which is the question a diagnostic is actually asking — so each includes the broader breadths that
68
+ * would also fire. Scope is not applied; a count is not a delivery.
69
+ *
70
+ * - no arguments — every subscription of any breadth.
71
+ * - `(module)` — everything that would receive ANY event of that module: its exact-type
72
+ * subscriptions, its whole-module ones, and the catch-alls.
73
+ * - `(module, type)` — everything that would receive that one pair.
74
+ *
75
+ * 🔴 **The one-argument form used to silently answer the GLOBAL total.** The guard read
76
+ * `if (module && type)`, so passing a module alone fell through to the count-everything branch — a
77
+ * plausible number, for a different question, with nothing to indicate the substitution. The fix is
78
+ * at RUNTIME rather than in an overload signature because this package ships JavaScript: a
79
+ * type-level restriction would leave a JS consumer receiving exactly the same wrong number.
70
80
  */
71
81
  getSubscriptionCount(module?: string, type?: string): number;
72
82
  }
package/dist/eventBus.js CHANGED
@@ -4,7 +4,7 @@
4
4
  * Event enums/maps are app schema, so apps layer their own typed wrappers on top (headless per D13).
5
5
  * A throwing handler is isolated: it never breaks the other subscribers or the emitter.
6
6
  *
7
- * Three subscription breadths, mirroring the host's `Shenora.Core.IEventBus`: an exact
7
+ * Three subscription breadths, mirroring the host's `Shenora.IEventBus`: an exact
8
8
  * `(module, type)` pair, a whole {@link subscribeToModule | module}, or
9
9
  * {@link subscribeToAll | everything}. The broad two were added in P6.4 — the host had shipped them
10
10
  * from the start (`WebViewIpcBridge` itself consumes `SubscribeToAll`), so the client was the
@@ -86,17 +86,37 @@ export class ShenoraEventBus {
86
86
  this.all.clear();
87
87
  }
88
88
  /**
89
- * Subscription count (diagnostics). With no arguments: every subscription of any breadth. With a
90
- * `(module, type)`: every subscription that WOULD receive that pair — exact, whole-module and
91
- * catch-all — because "how many listeners does this event have?" is the question a diagnostic is
92
- * actually asking. Scope is not applied; a count is not a delivery.
89
+ * Subscription count (diagnostics). Every form answers "how many subscriptions WOULD receive this?",
90
+ * which is the question a diagnostic is actually asking — so each includes the broader breadths that
91
+ * would also fire. Scope is not applied; a count is not a delivery.
92
+ *
93
+ * - no arguments — every subscription of any breadth.
94
+ * - `(module)` — everything that would receive ANY event of that module: its exact-type
95
+ * subscriptions, its whole-module ones, and the catch-alls.
96
+ * - `(module, type)` — everything that would receive that one pair.
97
+ *
98
+ * 🔴 **The one-argument form used to silently answer the GLOBAL total.** The guard read
99
+ * `if (module && type)`, so passing a module alone fell through to the count-everything branch — a
100
+ * plausible number, for a different question, with nothing to indicate the substitution. The fix is
101
+ * at RUNTIME rather than in an overload signature because this package ships JavaScript: a
102
+ * type-level restriction would leave a JS consumer receiving exactly the same wrong number.
93
103
  */
94
104
  getSubscriptionCount(module, type) {
95
- if (module && type) {
105
+ if (module !== undefined && type !== undefined) {
96
106
  return (this.exact.get(eventKey(module, type))?.size ?? 0)
97
107
  + (this.byModule.get(module)?.size ?? 0)
98
108
  + this.all.size;
99
109
  }
110
+ if (module !== undefined) {
111
+ let total = this.all.size + (this.byModule.get(module)?.size ?? 0);
112
+ // The exact map is keyed `module\0type`, so a module's own entries are its prefix — compared
113
+ // against the separator so that a module named `APP` cannot pick up `APPLE`'s subscriptions.
114
+ const prefix = `${module}\0`;
115
+ for (const [key, set] of this.exact)
116
+ if (key.startsWith(prefix))
117
+ total += set.size;
118
+ return total;
119
+ }
100
120
  let total = this.all.size;
101
121
  for (const set of this.exact.values())
102
122
  total += set.size;
@@ -135,10 +155,9 @@ function addTo(collection, key, subscription) {
135
155
  * the colliding form, so the two halves of one contract disagreed. `'\0'` cannot appear in a JS string
136
156
  * literal a developer types by accident, which is what makes it safe as a separator.
137
157
  *
138
- * The broad subscriptions deliberately do NOT reuse this with a `"*"` sentinel the way the host's
139
- * pattern matcher does: a module or type legitimately named `*` would then silently become a
140
- * catch-all. Separate collections cannot collide with an app string at all — same lesson as above,
141
- * applied before it could be earned a second time.
158
+ * The broad subscriptions use separate collections rather than a wildcard key. That is an
159
+ * implementation choice here, not a correction of the host: the host's pattern matcher takes `"*"` and
160
+ * that is fine, because a module or type named `*` is not a name any app has or wants.
142
161
  */
143
162
  function eventKey(module, type) {
144
163
  return `${module}\0${type}`;
@@ -83,7 +83,7 @@ interface FileDialogRequests {
83
83
  };
84
84
  }
85
85
  /**
86
- * Typed client for the host's `FILE_DIALOGS` module (`FileDialogFacade`).
86
+ * Typed client for the host's `SHENORA.DIALOGS` module (`FileDialogModule`).
87
87
  *
88
88
  * ⚠ **Two of these are DESKTOP capabilities.** `openFolder` and `saveFile` have no expression on a
89
89
  * phone and reject with `IpcErrorCodes.capabilityNotSupported` there. Do not catch that — ask first,
@@ -1,8 +1,8 @@
1
1
  /**
2
- * The page's half of the host's `FILE_DIALOGS` module — native file/folder/save dialogs, and the
2
+ * The page's half of the host's `SHENORA.DIALOGS` module — native file/folder/save dialogs, and the
3
3
  * capability gating that lets ONE bundle ship to every shell.
4
4
  *
5
- * Mirrors `Shenora.Ipc.FileDialogFacade`. The wire names here are pinned against the host's own
5
+ * Mirrors `Shenora.Core.Ipc.FileDialogModule`. The wire names here are pinned against the host's own
6
6
  * constants by `WireMirrorTests`, not by care.
7
7
  */
8
8
  import { useMemo } from 'react';
@@ -10,7 +10,7 @@ import { useShellInfo } from './hooks.js';
10
10
  import { BaseModuleService } from './moduleService.js';
11
11
  import { ShellCapabilities } from './types.js';
12
12
  /**
13
- * Typed client for the host's `FILE_DIALOGS` module (`FileDialogFacade`).
13
+ * Typed client for the host's `SHENORA.DIALOGS` module (`FileDialogModule`).
14
14
  *
15
15
  * ⚠ **Two of these are DESKTOP capabilities.** `openFolder` and `saveFile` have no expression on a
16
16
  * phone and reject with `IpcErrorCodes.capabilityNotSupported` there. Do not catch that — ask first,
@@ -19,7 +19,7 @@ import { ShellCapabilities } from './types.js';
19
19
  */
20
20
  export class FileDialogs extends BaseModuleService {
21
21
  constructor(bridge) {
22
- super('FILE_DIALOGS', bridge);
22
+ super('SHENORA.DIALOGS', bridge);
23
23
  }
24
24
  /** Pick an existing file. Available on every shell. */
25
25
  openFile(options) {
package/dist/hooks.d.ts CHANGED
@@ -1,7 +1,15 @@
1
1
  import { type ShenoraBridge } from './bridge.js';
2
2
  import { type ShenoraEventBus } from './eventBus.js';
3
3
  import type { EventMessage, ShellInfo } from './types.js';
4
- /** Host access for components: the (default) bridge and whether a host transport exists. */
4
+ /**
5
+ * Host access for components: the (default) bridge and whether a host transport exists.
6
+ *
7
+ * ⚠ The result is MEMOIZED, and that is not a micro-optimisation. A fresh object every render is a
8
+ * fresh dependency for every `useEffect`/`useMemo`/`useCallback` that lists it, so the natural
9
+ * `const shenora = useShenora(); useEffect(…, [shenora])` re-runs on EVERY render — a subscribe/
10
+ * unsubscribe cycle per frame in the worst case. The identity changes only when the bridge itself does,
11
+ * or when `isAvailable` flips as a host attaches, which are the two moments a consumer means to react to.
12
+ */
5
13
  export declare function useShenora(): {
6
14
  isAvailable: boolean;
7
15
  bridge: ShenoraBridge;
package/dist/hooks.js CHANGED
@@ -1,10 +1,19 @@
1
- import { useCallback, useEffect, useRef, useState } from 'react';
1
+ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
2
2
  import { getBridge } from './bridge.js';
3
3
  import { eventBus as defaultEventBus } from './eventBus.js';
4
- /** Host access for components: the (default) bridge and whether a host transport exists. */
4
+ /**
5
+ * Host access for components: the (default) bridge and whether a host transport exists.
6
+ *
7
+ * ⚠ The result is MEMOIZED, and that is not a micro-optimisation. A fresh object every render is a
8
+ * fresh dependency for every `useEffect`/`useMemo`/`useCallback` that lists it, so the natural
9
+ * `const shenora = useShenora(); useEffect(…, [shenora])` re-runs on EVERY render — a subscribe/
10
+ * unsubscribe cycle per frame in the worst case. The identity changes only when the bridge itself does,
11
+ * or when `isAvailable` flips as a host attaches, which are the two moments a consumer means to react to.
12
+ */
5
13
  export function useShenora() {
6
14
  const bridge = getBridge();
7
- return { isAvailable: bridge.isAvailable, bridge };
15
+ const isAvailable = bridge.isAvailable;
16
+ return useMemo(() => ({ isAvailable, bridge }), [isAvailable, bridge]);
8
17
  }
9
18
  /**
10
19
  * What the host IS and what it can do — the way one bundle renders correctly on every shell.
package/dist/index.d.ts CHANGED
@@ -1,14 +1,20 @@
1
1
  export { IpcCategories, IpcErrorCodes, HANDSHAKE_MODULE, HANDSHAKE_TYPE, type IpcRequest, type IpcResponse, type IpcError, type IpcNotification, type IpcNotificationBatch, type EventMessage, ShellCapabilities, type ShellInfo, } from './types.js';
2
- export { OperationError } from './errors.js';
2
+ export { ShenoraError } from './errors.js';
3
3
  export { isShenoraAvailable, createHostTransport, createWebView2Transport, createHybridWebViewTransport, type ShenoraTransport, } from './transport.js';
4
4
  export { ShenoraEventBus, eventBus, type SubscribeOptions, } from './eventBus.js';
5
5
  export { ShenoraBridge, getBridge, configureBridge, type ShenoraBridgeOptions, type InvokeOptions, type PostOptions, type PostFailure, } from './bridge.js';
6
6
  export { createShenoraStore, type ShenoraStore, type ShenoraStoreOptions, type ShenoraStoreIo, type ShenoraStoreSnapshot, } from './store.js';
7
7
  export { BaseModuleService } from './moduleService.js';
8
- export { OperationStatuses, OperationEventTypes, OperationModuleName, createOperationsStore, useShenoraOperations, type OperationStatus, type OperationLabel, type OperationProgress, type OperationInfo, type OperationsState, type OperationsActions, type OperationsStoreOptions, } from './operations.js';
8
+ export { IpcRequestStates, IpcRequestEventTypes, IpcRequestRoutes, IpcRequestsModuleName, createRequestsStore, useShenoraRequests, type IpcRequestState, type IpcLabel, type IpcProgress, type IpcRequestStatus, type RequestsState, type RequestsActions, type RequestsStoreOptions, } from './requests.js';
9
9
  export { WindowCommands, useWindowMaximized, type WindowResizeEdge, type CaptionButtonKind, type CaptionButtonRect, } from './windowCommands.js';
10
10
  export { useDropZone, DROP_ZONE_MODULE, type DropZoneFileDrop, type UseDropZoneOptions, } from './useDropZone.js';
11
11
  export { useShenora, useShenoraEvent, useShenoraQuery, useShellInfo, type ShenoraQueryResult, } from './hooks.js';
12
12
  export { FileDialogs, useFileDialogs, type FileDialogFilter, type FileDialogOptions, type OpenFileOptions, type OpenFolderOptions, type SaveFileOptions, type FileDialogResult, type FileDialogsHandle, } from './fileDialogs.js';
13
+ export { ClipboardAccess, useClipboard, PNG_IMAGE, HTML, type ClipboardContent, type ClipboardHandle, } from './clipboard.js';
13
14
  export { installDevInterceptor, type DevInterceptorOptions, type DevIpcEntry, type DevEventEntry, } from './devInterceptor.js';
14
15
  export { mediaUrl, encodeMediaPayload, decodeMediaPayload } from './media.js';
16
+ export { parseManifest, pickMediaSource, nextSegment, segmentMimeType, codecsFromInitSegment, } from './segmentStream.js';
17
+ export { bindSegmentStream, SegmentBinderError } from './segmentBinder.js';
18
+ export type { SegmentBinderOptions, SegmentBinding } from './segmentBinder.js';
19
+ export type { SegmentEntry, SegmentManifest, MediaSourceKind, MediaSourceGlobals, FetchState, FetchPolicy, } from './segmentStream.js';
20
+ export { useMediaPlayer, MEDIA_PLAYER_MODULE, MEDIA_PLAYER_REPORT, MediaPlayerCommands, type MediaPlayerReport, type MediaPlayerReportState, type UseMediaPlayerOptions, } from './mediaPlayer.js';
package/dist/index.js CHANGED
@@ -3,24 +3,36 @@
3
3
  // React hooks, and the dev interceptor for CDP-driven testing. Headless by design (D13): no UI
4
4
  // components, no design-system dependency — apps bring their own.
5
5
  export { IpcCategories, IpcErrorCodes, HANDSHAKE_MODULE, HANDSHAKE_TYPE, ShellCapabilities, } from './types.js';
6
- export { OperationError } from './errors.js';
6
+ export { ShenoraError } from './errors.js';
7
7
  export { isShenoraAvailable, createHostTransport, createWebView2Transport, createHybridWebViewTransport, } from './transport.js';
8
8
  export { ShenoraEventBus, eventBus, } from './eventBus.js';
9
9
  export { ShenoraBridge, getBridge, configureBridge, } from './bridge.js';
10
10
  export { createShenoraStore, } from './store.js';
11
11
  export { BaseModuleService } from './moduleService.js';
12
- export { OperationStatuses,
13
- // The event vocabulary + the default module name. `createOperationsStore` deliberately does NOT
12
+ export { IpcRequestStates,
13
+ // The event vocabulary + the default module name. `createRequestsStore` deliberately does NOT
14
14
  // subscribe to RESUME_REQUESTED / WAIT_REQUESTED — those target the OWNING module's own service,
15
15
  // not the generic store — so the app writing that handler needs both symbols, and until now had
16
16
  // neither: it had to hard-code the literals the wire-mirror tests exist to keep it from doing.
17
- OperationEventTypes, OperationModuleName, createOperationsStore, useShenoraOperations, } from './operations.js';
17
+ IpcRequestEventTypes,
18
+ // The ROUTE half of that same wire, and it was the one left behind: an app cancelling a request
19
+ // without `useShenoraRequests` had the module name and the event names but had to hard-code
20
+ // 'CANCEL'. Pinned by `WireMirrorTests.Request_route_names_match_the_hosts_module`.
21
+ IpcRequestRoutes, IpcRequestsModuleName, createRequestsStore, useShenoraRequests, } from './requests.js';
18
22
  export { WindowCommands, useWindowMaximized, } from './windowCommands.js';
19
23
  export { useDropZone, DROP_ZONE_MODULE, } from './useDropZone.js';
20
24
  export { useShenora, useShenoraEvent, useShenoraQuery, useShellInfo, } from './hooks.js';
21
- // Native dialogs, capability-gated. The client half of the host's FILE_DIALOGS module.
25
+ // Native dialogs, capability-gated. The client half of the host's SHENORA.DIALOGS module.
22
26
  export { FileDialogs, useFileDialogs, } from './fileDialogs.js';
27
+ // The native clipboard, for the two things navigator.clipboard cannot do: FILES, and access with no
28
+ // user gesture. The client half of the host's SHENORA.CLIPBOARD module.
29
+ export { ClipboardAccess, useClipboard, PNG_IMAGE, HTML, } from './clipboard.js';
23
30
  export { installDevInterceptor, } from './devInterceptor.js';
24
31
  // Addressing local content the page cannot reach itself. A pure function, not a hook — building the URL
25
32
  // needs no React, and a `useMediaSource` can follow if an adopter wants load/error state.
26
33
  export { mediaUrl, encodeMediaPayload, decodeMediaPayload } from './media.js';
34
+ export { parseManifest, pickMediaSource, nextSegment, segmentMimeType, codecsFromInitSegment, } from './segmentStream.js';
35
+ export { bindSegmentStream, SegmentBinderError } from './segmentBinder.js';
36
+ // The HOST-owned player (D58): .NET holds the lifecycle, the page's element is the display and the sound.
37
+ // One hook, and the page stops deciding anything about formats.
38
+ export { useMediaPlayer, MEDIA_PLAYER_MODULE, MEDIA_PLAYER_REPORT, MediaPlayerCommands, } from './mediaPlayer.js';
@@ -0,0 +1,86 @@
1
+ import { type RefObject } from 'react';
2
+ import { type ShenoraBridge } from './bridge.js';
3
+ import { type ShenoraEventBus } from './eventBus.js';
4
+ /**
5
+ * The module the player speaks on. Matches `MediaPlayerOptions.Access.Module` on the host (the
6
+ * containment/module boundary every media delivery path shares — `MediaAccessOptions`, D71), which
7
+ * defaults to the same string — change one and you must change the other.
8
+ *
9
+ * ⚠ The `SHENORA.` prefix is RESERVED for the kit's own modules (D64), beside the handshake's bare
10
+ * `SHENORA`. It exists so your app stays free to own a module called plainly `MEDIA`.
11
+ */
12
+ export declare const MEDIA_PLAYER_MODULE = "SHENORA.MEDIA";
13
+ /**
14
+ * Commands the host sends. **A wire contract**: these strings are duplicated in C# as
15
+ * `MediaPlayerEvents`, and the two halves agree by string or not at all.
16
+ */
17
+ export declare const MediaPlayerCommands: {
18
+ readonly load: "PLAYER_LOAD";
19
+ readonly play: "PLAYER_PLAY";
20
+ readonly pause: "PLAYER_PAUSE";
21
+ readonly seek: "PLAYER_SEEK";
22
+ readonly rate: "PLAYER_RATE";
23
+ readonly unload: "PLAYER_UNLOAD";
24
+ };
25
+ /** The one message the page sends back. The host turns it into `MediaPlayer.Report(...)`. */
26
+ export declare const MEDIA_PLAYER_REPORT = "PLAYER_REPORT";
27
+ /**
28
+ * What the element is doing, in the host's vocabulary (`MediaPlayerState`).
29
+ *
30
+ * ⚠ `opening` and `buffering` are distinct, matching the host: opening is "no position yet", buffering is
31
+ * "had one and it stopped moving". Collapsing them makes a UI extrapolate a position that is not advancing.
32
+ */
33
+ export type MediaPlayerReportState = 'Empty' | 'Opening' | 'Paused' | 'Playing' | 'Buffering' | 'Ended' | 'Failed';
34
+ /** One state report, sent on TRANSITIONS only. */
35
+ export interface MediaPlayerReport {
36
+ state: MediaPlayerReportState;
37
+ /** Seconds. */
38
+ position: number;
39
+ /** Seconds, or null for a live stream / not yet known. */
40
+ duration: number | null;
41
+ /** A short reason when `state` is `Failed`; never the platform's raw text. */
42
+ error?: string;
43
+ }
44
+ /** Inputs for {@link useMediaPlayer}. */
45
+ export interface UseMediaPlayerOptions {
46
+ /** Override the module. Must match the host's `MediaPlayerOptions.Access.Module`. */
47
+ module?: string;
48
+ /** Test seams. */
49
+ bridge?: ShenoraBridge;
50
+ eventBus?: ShenoraEventBus;
51
+ }
52
+ /**
53
+ * Bind a `<video>`/`<audio>` element to the HOST's player: the element becomes the display and the sound,
54
+ * and .NET owns the lifecycle (D58).
55
+ *
56
+ * ```tsx
57
+ * const ref = useRef<HTMLVideoElement>(null);
58
+ * useMediaPlayer(ref);
59
+ * return <video ref={ref} playsInline />;
60
+ * ```
61
+ *
62
+ * **That is the whole page-side integration.** No `src`, no play button wiring, no state machine — the app
63
+ * calls `IMediaPlayer.OpenAsync/PlayAsync` in C#, and this drives the element to match. The page keeps what
64
+ * it is good at (rendering) and gives up what it was never good at (deciding whether a file can be played
65
+ * at all, which needs a probe and a device capability query).
66
+ *
67
+ * ⚠ **The host half needs nothing from you.** The reports posted here are an ordinary IPC message
68
+ * (`PLAYER_REPORT` on {@link MEDIA_PLAYER_MODULE}) and the kit's own `MediaPlayerModule` answers it,
69
+ * registered by the media feature itself. If you wrote that route by hand against a build from before
70
+ * 2026-08-07, **delete it** — two facades on one module is a duplicate the host rejects.
71
+ *
72
+ * ⚠ **The element must exist when this effect first runs.** `ref.current` is read once, and a `useRef`
73
+ * object is stable, so an element rendered CONDITIONALLY (`{ready && <video ref={ref} />}`) mounts after
74
+ * the effect and never binds — silently. Render the element unconditionally and hide it with CSS, or key
75
+ * the component so the hook remounts with it.
76
+ *
77
+ * ⚠ **It reports on TRANSITIONS, never on `timeupdate`.** That event fires ~4×/second and forwarding it
78
+ * would cost battery and IPC to tell the host something it can extrapolate from a position and a rate. If
79
+ * you need a moving scrubber, read `element.currentTime` in your own render loop — locally, at the rate you
80
+ * actually redraw.
81
+ *
82
+ * ⚠ **Autoplay policies still apply.** A `PLAYER_PLAY` arriving before any user gesture can be refused by
83
+ * the browser, and the element reports `Failed`. That is the platform's rule, not the kit's, and the host
84
+ * hears about it rather than silently believing playback started.
85
+ */
86
+ export declare function useMediaPlayer(ref: RefObject<HTMLMediaElement | null>, options?: UseMediaPlayerOptions): void;