@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.
@@ -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}`;
@@ -0,0 +1,153 @@
1
+ import type { ShenoraBridge } from './bridge.js';
2
+ import { BaseModuleService } from './moduleService.js';
3
+ /** One dialog filter row (e.g. `{ name: 'Images', extensions: ['png', 'jpg'] }`). */
4
+ export interface FileDialogFilter {
5
+ name: string;
6
+ /** Extensions WITHOUT the dot or wildcard — `'png'`, not `'*.png'`. */
7
+ extensions: string[];
8
+ }
9
+ /**
10
+ * What EVERY dialog call takes. The per-dialog shapes below add what only that dialog can honour —
11
+ * which is the point: a save-only field on a folder pick will not compile.
12
+ */
13
+ export interface FileDialogOptions {
14
+ /** Dialog title. Omit for a neutral per-dialog default. */
15
+ title?: string;
16
+ /** Start location when nothing is remembered. */
17
+ defaultPath?: string;
18
+ /**
19
+ * Key under which the host remembers the last-used directory and restores it next time.
20
+ * Remembered PER KEY, so "import" and "export" stay separate. Omit for no memory.
21
+ */
22
+ rememberPathKey?: string;
23
+ }
24
+ /** Inputs for {@link FileDialogs.openFile}. */
25
+ export interface OpenFileOptions extends FileDialogOptions {
26
+ /** Filter rows; omit/empty = "All Files". */
27
+ filters?: FileDialogFilter[];
28
+ /** Initial file name shown in the dialog. */
29
+ fileName?: string;
30
+ /** Require the picked file to exist. Default true. Desktop hint — a mobile picker ignores it. */
31
+ checkFileExists?: boolean;
32
+ /** Require the path to exist. Default true. Desktop hint. */
33
+ checkPathExists?: boolean;
34
+ /** Validate the host's file-name rules. Default true. Desktop hint. */
35
+ validateNames?: boolean;
36
+ }
37
+ /** Inputs for {@link FileDialogs.openFolder}. */
38
+ export interface OpenFolderOptions extends FileDialogOptions {
39
+ /** Also allow picking a FILE. The host swaps in a file dialog with relaxed validation. */
40
+ allowFileSelection?: boolean;
41
+ /** Filter rows for the FILE half — ⚠ ignored unless {@link allowFileSelection} is set. */
42
+ filters?: FileDialogFilter[];
43
+ }
44
+ /** Inputs for {@link FileDialogs.saveFile} and {@link FileDialogs.saveText}. */
45
+ export interface SaveFileOptions extends FileDialogOptions {
46
+ /** Filter rows; omit/empty = "All Files". */
47
+ filters?: FileDialogFilter[];
48
+ /** The name being suggested. */
49
+ fileName?: string;
50
+ /** Extension appended when the user omits one (no dot). */
51
+ defaultExtension?: string;
52
+ /** Prompt before overwriting. Default true. Desktop hint. */
53
+ overwritePrompt?: boolean;
54
+ /** Require the path to exist. Default true. Desktop hint. */
55
+ checkPathExists?: boolean;
56
+ }
57
+ /**
58
+ * A dialog outcome. ⚠ **THREE outcomes, not two** — and the third is the one that surprises people:
59
+ * cancelled (`success: false`); succeeded with an addressable location (`filePath` set); and
60
+ * **succeeded with NO location**, which is what {@link FileDialogs.saveText} returns on a phone,
61
+ * because the bytes went to a revocable grant rather than somewhere the app may reopen.
62
+ *
63
+ * So `success && filePath === undefined` is legitimate, and `result.filePath!` after checking
64
+ * `success` is a null-reference waiting for a mobile shell.
65
+ */
66
+ export interface FileDialogResult {
67
+ success: boolean;
68
+ filePath?: string;
69
+ }
70
+ interface FileDialogRequests {
71
+ OPEN_FILE: {
72
+ options?: OpenFileOptions;
73
+ };
74
+ OPEN_FOLDER: {
75
+ options?: OpenFolderOptions;
76
+ };
77
+ SAVE_FILE: {
78
+ options?: SaveFileOptions;
79
+ };
80
+ SAVE_TEXT: {
81
+ text: string;
82
+ options?: SaveFileOptions;
83
+ };
84
+ }
85
+ /**
86
+ * Typed client for the host's `SHENORA.DIALOGS` module (`FileDialogModule`).
87
+ *
88
+ * ⚠ **Two of these are DESKTOP capabilities.** `openFolder` and `saveFile` have no expression on a
89
+ * phone and reject with `IpcErrorCodes.capabilityNotSupported` there. Do not catch that — ask first,
90
+ * via {@link useFileDialogs}, and do not render the control at all. `openFile` and `saveText` work
91
+ * everywhere.
92
+ */
93
+ export declare class FileDialogs extends BaseModuleService<FileDialogRequests> {
94
+ constructor(bridge?: ShenoraBridge);
95
+ /** Pick an existing file. Available on every shell. */
96
+ openFile(options?: OpenFileOptions): Promise<FileDialogResult>;
97
+ /**
98
+ * Pick a folder. ⚠ DESKTOP only — gate on {@link FileDialogsHandle.canPickFolder}.
99
+ *
100
+ * On mobile "open folder" means the camera roll, the app's own space, or a scoped grant: the same
101
+ * word with a different guarantee, which is why there is no portable version of it.
102
+ */
103
+ openFolder(options?: OpenFolderOptions): Promise<FileDialogResult>;
104
+ /**
105
+ * Pick a save destination and get the PATH back. ⚠ DESKTOP only — gate on
106
+ * {@link FileDialogsHandle.canPickSavePath}. "Give me somewhere to write later" has no mobile
107
+ * expression; use {@link saveText} in portable code, which also works on the desktop.
108
+ */
109
+ saveFile(options?: SaveFileOptions): Promise<FileDialogResult>;
110
+ /**
111
+ * Pick a destination AND write `text` to it, in one call — the PORTABLE save, working everywhere
112
+ * because the HOST does the writing.
113
+ *
114
+ * ⚠ For text a page legitimately holds: an export, a report, a config. The content crosses the IPC
115
+ * envelope as JSON, so anything large or binary should be produced host-side and saved through the
116
+ * host's own `IFileDialogs.SaveAsync`, where it never enters a message.
117
+ */
118
+ saveText(text: string, options?: SaveFileOptions): Promise<FileDialogResult>;
119
+ }
120
+ /** What {@link useFileDialogs} returns: the client, plus what this shell will actually honour. */
121
+ export interface FileDialogsHandle {
122
+ /** The typed client. Stable across renders. */
123
+ dialogs: FileDialogs;
124
+ /** This shell can pick an existing file. */
125
+ canPickFile: boolean;
126
+ /** This shell can pick a FOLDER — desktop only (see {@link FileDialogs.openFolder}). */
127
+ canPickFolder: boolean;
128
+ /** This shell can return a save PATH — desktop only (see {@link FileDialogs.saveFile}). */
129
+ canPickSavePath: boolean;
130
+ }
131
+ /**
132
+ * The dialogs client together with what the CURRENT shell can honour — read from the ready
133
+ * handshake, not sniffed from the platform (D36).
134
+ *
135
+ * ```tsx
136
+ * const { dialogs, canPickFolder } = useFileDialogs();
137
+ * return (
138
+ * <>
139
+ * <button onClick={() => dialogs.openFile()}>Choose a file</button>
140
+ * {canPickFolder && <button onClick={() => dialogs.openFolder()}>Choose a folder</button>}
141
+ * </>
142
+ * );
143
+ * ```
144
+ *
145
+ * ⚠ **Use these to decide what to RENDER, not what to catch.** A refused call rejects with
146
+ * `IpcErrorCodes.capabilityNotSupported`, which is the honest answer to a question that should not
147
+ * have been asked — the button should not have been there.
148
+ *
149
+ * ⚠ Every flag is `false` until the handshake has landed, so await `bridge.notifyReady()` before
150
+ * rendering this tree — see {@link useShellInfo} for why the read is synchronous.
151
+ */
152
+ export declare function useFileDialogs(dialogs?: FileDialogs): FileDialogsHandle;
153
+ export {};
@@ -0,0 +1,90 @@
1
+ /**
2
+ * The page's half of the host's `SHENORA.DIALOGS` module — native file/folder/save dialogs, and the
3
+ * capability gating that lets ONE bundle ship to every shell.
4
+ *
5
+ * Mirrors `Shenora.Core.Ipc.FileDialogModule`. The wire names here are pinned against the host's own
6
+ * constants by `WireMirrorTests`, not by care.
7
+ */
8
+ import { useMemo } from 'react';
9
+ import { useShellInfo } from './hooks.js';
10
+ import { BaseModuleService } from './moduleService.js';
11
+ import { ShellCapabilities } from './types.js';
12
+ /**
13
+ * Typed client for the host's `SHENORA.DIALOGS` module (`FileDialogModule`).
14
+ *
15
+ * ⚠ **Two of these are DESKTOP capabilities.** `openFolder` and `saveFile` have no expression on a
16
+ * phone and reject with `IpcErrorCodes.capabilityNotSupported` there. Do not catch that — ask first,
17
+ * via {@link useFileDialogs}, and do not render the control at all. `openFile` and `saveText` work
18
+ * everywhere.
19
+ */
20
+ export class FileDialogs extends BaseModuleService {
21
+ constructor(bridge) {
22
+ super('SHENORA.DIALOGS', bridge);
23
+ }
24
+ /** Pick an existing file. Available on every shell. */
25
+ openFile(options) {
26
+ return this.send('OPEN_FILE', { payload: { options } });
27
+ }
28
+ /**
29
+ * Pick a folder. ⚠ DESKTOP only — gate on {@link FileDialogsHandle.canPickFolder}.
30
+ *
31
+ * On mobile "open folder" means the camera roll, the app's own space, or a scoped grant: the same
32
+ * word with a different guarantee, which is why there is no portable version of it.
33
+ */
34
+ openFolder(options) {
35
+ return this.send('OPEN_FOLDER', { payload: { options } });
36
+ }
37
+ /**
38
+ * Pick a save destination and get the PATH back. ⚠ DESKTOP only — gate on
39
+ * {@link FileDialogsHandle.canPickSavePath}. "Give me somewhere to write later" has no mobile
40
+ * expression; use {@link saveText} in portable code, which also works on the desktop.
41
+ */
42
+ saveFile(options) {
43
+ return this.send('SAVE_FILE', { payload: { options } });
44
+ }
45
+ /**
46
+ * Pick a destination AND write `text` to it, in one call — the PORTABLE save, working everywhere
47
+ * because the HOST does the writing.
48
+ *
49
+ * ⚠ For text a page legitimately holds: an export, a report, a config. The content crosses the IPC
50
+ * envelope as JSON, so anything large or binary should be produced host-side and saved through the
51
+ * host's own `IFileDialogs.SaveAsync`, where it never enters a message.
52
+ */
53
+ saveText(text, options) {
54
+ return this.send('SAVE_TEXT', { payload: { text, options } });
55
+ }
56
+ }
57
+ /**
58
+ * The dialogs client together with what the CURRENT shell can honour — read from the ready
59
+ * handshake, not sniffed from the platform (D36).
60
+ *
61
+ * ```tsx
62
+ * const { dialogs, canPickFolder } = useFileDialogs();
63
+ * return (
64
+ * <>
65
+ * <button onClick={() => dialogs.openFile()}>Choose a file</button>
66
+ * {canPickFolder && <button onClick={() => dialogs.openFolder()}>Choose a folder</button>}
67
+ * </>
68
+ * );
69
+ * ```
70
+ *
71
+ * ⚠ **Use these to decide what to RENDER, not what to catch.** A refused call rejects with
72
+ * `IpcErrorCodes.capabilityNotSupported`, which is the honest answer to a question that should not
73
+ * have been asked — the button should not have been there.
74
+ *
75
+ * ⚠ Every flag is `false` until the handshake has landed, so await `bridge.notifyReady()` before
76
+ * rendering this tree — see {@link useShellInfo} for why the read is synchronous.
77
+ */
78
+ export function useFileDialogs(dialogs) {
79
+ const shell = useShellInfo();
80
+ // The service is stateless and holds only its module name, but a fresh instance per render would
81
+ // still make it useless as an effect dependency — the same reason useWindowMaximized caches one.
82
+ const client = useMemo(() => dialogs ?? new FileDialogs(), [dialogs]);
83
+ const capabilities = shell?.capabilities;
84
+ return useMemo(() => ({
85
+ dialogs: client,
86
+ canPickFile: capabilities?.includes(ShellCapabilities.filePicker) ?? false,
87
+ canPickFolder: capabilities?.includes(ShellCapabilities.folderPicker) ?? false,
88
+ canPickSavePath: capabilities?.includes(ShellCapabilities.savePicker) ?? false,
89
+ }), [client, capabilities]);
90
+ }
package/dist/hooks.d.ts CHANGED
@@ -1,11 +1,41 @@
1
1
  import { type ShenoraBridge } from './bridge.js';
2
2
  import { type ShenoraEventBus } from './eventBus.js';
3
- import type { EventMessage } from './types.js';
4
- /** Host access for components: the (default) bridge and whether a host transport exists. */
3
+ import type { EventMessage, ShellInfo } from './types.js';
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;
8
16
  };
17
+ /**
18
+ * What the host IS and what it can do — the way one bundle renders correctly on every shell.
19
+ * Branch on {@link ShellInfo.capabilities}, never on {@link ShellInfo.name}.
20
+ *
21
+ * ```tsx
22
+ * const shell = useShellInfo();
23
+ * return <>{shell?.capabilities.includes(ShellCapabilities.windowChrome) && <TitleBar />}</>;
24
+ * ```
25
+ *
26
+ * `undefined` means no host said anything — a plain browser tab, a host predating the handshake, or a
27
+ * handshake that has not finished. **Treat absent as "assume nothing", never as "assume desktop".**
28
+ *
29
+ * ⚠ **Await `bridge.notifyReady()` before rendering the tree that depends on this.** The value is read
30
+ * SYNCHRONOUSLY from the bridge's cache, deliberately — a capability learned after layout is a visible
31
+ * flash — which means this hook does not re-render when a handshake lands later. A component mounted
32
+ * mid-handshake sees `undefined` and keeps seeing it, which is the documented "assume nothing" tree
33
+ * rather than a wrong one, but it is not the tree you wanted.
34
+ *
35
+ * ⚠ This hook was referenced by two doc examples in this package for several releases before it
36
+ * existed — `bridge.shell` was the only way to read it. If you wrote that workaround, this replaces it.
37
+ */
38
+ export declare function useShellInfo(bridge?: ShenoraBridge): ShellInfo | undefined;
9
39
  /**
10
40
  * Subscribe to one (module, type) event for the component's lifetime, ported from the primary
11
41
  * desktop sibling. The handler receives the unwrapped payload (plus the full event). DEVIATION
package/dist/hooks.js CHANGED
@@ -1,10 +1,43 @@
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]);
17
+ }
18
+ /**
19
+ * What the host IS and what it can do — the way one bundle renders correctly on every shell.
20
+ * Branch on {@link ShellInfo.capabilities}, never on {@link ShellInfo.name}.
21
+ *
22
+ * ```tsx
23
+ * const shell = useShellInfo();
24
+ * return <>{shell?.capabilities.includes(ShellCapabilities.windowChrome) && <TitleBar />}</>;
25
+ * ```
26
+ *
27
+ * `undefined` means no host said anything — a plain browser tab, a host predating the handshake, or a
28
+ * handshake that has not finished. **Treat absent as "assume nothing", never as "assume desktop".**
29
+ *
30
+ * ⚠ **Await `bridge.notifyReady()` before rendering the tree that depends on this.** The value is read
31
+ * SYNCHRONOUSLY from the bridge's cache, deliberately — a capability learned after layout is a visible
32
+ * flash — which means this hook does not re-render when a handshake lands later. A component mounted
33
+ * mid-handshake sees `undefined` and keeps seeing it, which is the documented "assume nothing" tree
34
+ * rather than a wrong one, but it is not the tree you wanted.
35
+ *
36
+ * ⚠ This hook was referenced by two doc examples in this package for several releases before it
37
+ * existed — `bridge.shell` was the only way to read it. If you wrote that workaround, this replaces it.
38
+ */
39
+ export function useShellInfo(bridge) {
40
+ return (bridge ?? getBridge()).shell;
8
41
  }
9
42
  /**
10
43
  * Subscribe to one (module, type) event for the component's lifetime, ported from the primary