@shenora/react 0.9.0 → 0.10.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,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 `FILE_DIALOGS` module (`FileDialogFacade`).
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 `FILE_DIALOGS` module — native file/folder/save dialogs, and the
3
+ * capability gating that lets ONE bundle ship to every shell.
4
+ *
5
+ * Mirrors `Shenora.Ipc.FileDialogFacade`. 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 `FILE_DIALOGS` module (`FileDialogFacade`).
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('FILE_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,33 @@
1
1
  import { type ShenoraBridge } from './bridge.js';
2
2
  import { type ShenoraEventBus } from './eventBus.js';
3
- import type { EventMessage } from './types.js';
3
+ import type { EventMessage, ShellInfo } from './types.js';
4
4
  /** Host access for components: the (default) bridge and whether a host transport exists. */
5
5
  export declare function useShenora(): {
6
6
  isAvailable: boolean;
7
7
  bridge: ShenoraBridge;
8
8
  };
9
+ /**
10
+ * What the host IS and what it can do — the way one bundle renders correctly on every shell.
11
+ * Branch on {@link ShellInfo.capabilities}, never on {@link ShellInfo.name}.
12
+ *
13
+ * ```tsx
14
+ * const shell = useShellInfo();
15
+ * return <>{shell?.capabilities.includes(ShellCapabilities.windowChrome) && <TitleBar />}</>;
16
+ * ```
17
+ *
18
+ * `undefined` means no host said anything — a plain browser tab, a host predating the handshake, or a
19
+ * handshake that has not finished. **Treat absent as "assume nothing", never as "assume desktop".**
20
+ *
21
+ * ⚠ **Await `bridge.notifyReady()` before rendering the tree that depends on this.** The value is read
22
+ * SYNCHRONOUSLY from the bridge's cache, deliberately — a capability learned after layout is a visible
23
+ * flash — which means this hook does not re-render when a handshake lands later. A component mounted
24
+ * mid-handshake sees `undefined` and keeps seeing it, which is the documented "assume nothing" tree
25
+ * rather than a wrong one, but it is not the tree you wanted.
26
+ *
27
+ * ⚠ This hook was referenced by two doc examples in this package for several releases before it
28
+ * existed — `bridge.shell` was the only way to read it. If you wrote that workaround, this replaces it.
29
+ */
30
+ export declare function useShellInfo(bridge?: ShenoraBridge): ShellInfo | undefined;
9
31
  /**
10
32
  * Subscribe to one (module, type) event for the component's lifetime, ported from the primary
11
33
  * desktop sibling. The handler receives the unwrapped payload (plus the full event). DEVIATION
package/dist/hooks.js CHANGED
@@ -6,6 +6,30 @@ export function useShenora() {
6
6
  const bridge = getBridge();
7
7
  return { isAvailable: bridge.isAvailable, bridge };
8
8
  }
9
+ /**
10
+ * What the host IS and what it can do — the way one bundle renders correctly on every shell.
11
+ * Branch on {@link ShellInfo.capabilities}, never on {@link ShellInfo.name}.
12
+ *
13
+ * ```tsx
14
+ * const shell = useShellInfo();
15
+ * return <>{shell?.capabilities.includes(ShellCapabilities.windowChrome) && <TitleBar />}</>;
16
+ * ```
17
+ *
18
+ * `undefined` means no host said anything — a plain browser tab, a host predating the handshake, or a
19
+ * handshake that has not finished. **Treat absent as "assume nothing", never as "assume desktop".**
20
+ *
21
+ * ⚠ **Await `bridge.notifyReady()` before rendering the tree that depends on this.** The value is read
22
+ * SYNCHRONOUSLY from the bridge's cache, deliberately — a capability learned after layout is a visible
23
+ * flash — which means this hook does not re-render when a handshake lands later. A component mounted
24
+ * mid-handshake sees `undefined` and keeps seeing it, which is the documented "assume nothing" tree
25
+ * rather than a wrong one, but it is not the tree you wanted.
26
+ *
27
+ * ⚠ This hook was referenced by two doc examples in this package for several releases before it
28
+ * existed — `bridge.shell` was the only way to read it. If you wrote that workaround, this replaces it.
29
+ */
30
+ export function useShellInfo(bridge) {
31
+ return (bridge ?? getBridge()).shell;
32
+ }
9
33
  /**
10
34
  * Subscribe to one (module, type) event for the component's lifetime, ported from the primary
11
35
  * desktop sibling. The handler receives the unwrapped payload (plus the full event). DEVIATION
package/dist/index.d.ts CHANGED
@@ -1,13 +1,14 @@
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
2
  export { OperationError } from './errors.js';
3
3
  export { isShenoraAvailable, createHostTransport, createWebView2Transport, createHybridWebViewTransport, type ShenoraTransport, } from './transport.js';
4
- export { ShenoraEventBus, eventBus } from './eventBus.js';
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
8
  export { OperationStatuses, OperationEventTypes, OperationModuleName, createOperationsStore, useShenoraOperations, type OperationStatus, type OperationLabel, type OperationProgress, type OperationInfo, type OperationsState, type OperationsActions, type OperationsStoreOptions, } from './operations.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
- export { useShenora, useShenoraEvent, useShenoraQuery, type ShenoraQueryResult } from './hooks.js';
11
+ export { useShenora, useShenoraEvent, useShenoraQuery, useShellInfo, type ShenoraQueryResult, } from './hooks.js';
12
+ export { FileDialogs, useFileDialogs, type FileDialogFilter, type FileDialogOptions, type OpenFileOptions, type OpenFolderOptions, type SaveFileOptions, type FileDialogResult, type FileDialogsHandle, } from './fileDialogs.js';
12
13
  export { installDevInterceptor, type DevInterceptorOptions, type DevIpcEntry, type DevEventEntry, } from './devInterceptor.js';
13
14
  export { mediaUrl, encodeMediaPayload, decodeMediaPayload } from './media.js';
package/dist/index.js CHANGED
@@ -5,7 +5,7 @@
5
5
  export { IpcCategories, IpcErrorCodes, HANDSHAKE_MODULE, HANDSHAKE_TYPE, ShellCapabilities, } from './types.js';
6
6
  export { OperationError } from './errors.js';
7
7
  export { isShenoraAvailable, createHostTransport, createWebView2Transport, createHybridWebViewTransport, } from './transport.js';
8
- export { ShenoraEventBus, eventBus } from './eventBus.js';
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';
@@ -17,7 +17,9 @@ export { OperationStatuses,
17
17
  OperationEventTypes, OperationModuleName, createOperationsStore, useShenoraOperations, } from './operations.js';
18
18
  export { WindowCommands, useWindowMaximized, } from './windowCommands.js';
19
19
  export { useDropZone, DROP_ZONE_MODULE, } from './useDropZone.js';
20
- export { useShenora, useShenoraEvent, useShenoraQuery } from './hooks.js';
20
+ export { useShenora, useShenoraEvent, useShenoraQuery, useShellInfo, } from './hooks.js';
21
+ // Native dialogs, capability-gated. The client half of the host's FILE_DIALOGS module.
22
+ export { FileDialogs, useFileDialogs, } from './fileDialogs.js';
21
23
  export { installDevInterceptor, } from './devInterceptor.js';
22
24
  // Addressing local content the page cannot reach itself. A pure function, not a hook — building the URL
23
25
  // needs no React, and a `useMediaSource` can follow if an adopter wants load/error state.
package/dist/types.d.ts CHANGED
@@ -37,6 +37,19 @@ export declare const IpcErrorCodes: {
37
37
  * one failure a UI should stay silent about. Previously indistinguishable from `UNKNOWN_ERROR`.
38
38
  */
39
39
  readonly operationCancelled: "OPERATION_CANCELLED";
40
+ /**
41
+ * The shell has NO EXPRESSION of what was asked for — not a fault, and not something a retry fixes.
42
+ * Parameters: `capability` (a {@link ShellCapabilities} value).
43
+ *
44
+ * Treat it like `operationCancelled`: do not show a fault. The right response is to hide the control,
45
+ * because the capability is absent by design on that platform (a folder picker on a phone, for
46
+ * instance) rather than broken.
47
+ *
48
+ * ⚠ A page should not normally NEED this. The ready handshake advertises `ShellInfo.capabilities`
49
+ * precisely so one bundle can decide BEFORE it asks — `useFileDialogs().canPickFolder` is the
50
+ * intended path. This is the honest answer when a page asks anyway.
51
+ */
52
+ readonly capabilityNotSupported: "CAPABILITY_NOT_SUPPORTED";
40
53
  /** Client-only: the request timed out waiting for a response. */
41
54
  readonly timeout: "TIMEOUT";
42
55
  /** Client-only: no transport is available and no fallback was configured. */
package/dist/types.js CHANGED
@@ -37,6 +37,19 @@ export const IpcErrorCodes = {
37
37
  * one failure a UI should stay silent about. Previously indistinguishable from `UNKNOWN_ERROR`.
38
38
  */
39
39
  operationCancelled: 'OPERATION_CANCELLED',
40
+ /**
41
+ * The shell has NO EXPRESSION of what was asked for — not a fault, and not something a retry fixes.
42
+ * Parameters: `capability` (a {@link ShellCapabilities} value).
43
+ *
44
+ * Treat it like `operationCancelled`: do not show a fault. The right response is to hide the control,
45
+ * because the capability is absent by design on that platform (a folder picker on a phone, for
46
+ * instance) rather than broken.
47
+ *
48
+ * ⚠ A page should not normally NEED this. The ready handshake advertises `ShellInfo.capabilities`
49
+ * precisely so one bundle can decide BEFORE it asks — `useFileDialogs().canPickFolder` is the
50
+ * intended path. This is the honest answer when a page asks anyway.
51
+ */
52
+ capabilityNotSupported: 'CAPABILITY_NOT_SUPPORTED',
40
53
  /** Client-only: the request timed out waiting for a response. */
41
54
  timeout: 'TIMEOUT',
42
55
  /** Client-only: no transport is available and no fallback was configured. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@shenora/react",
3
- "version": "0.9.0",
3
+ "version": "0.10.0",
4
4
  "description": "React client for Shenora desktop hosts: correlated invoke/send/subscribe over the WebView2 bridge, typed module services, hooks, and a browser fallback for pure-UI development.",
5
5
  "license": "MIT",
6
6
  "author": "Jiarong Gu",