@shenora/react 0.9.1 → 0.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +152 -165
- package/dist/bridge.d.ts +4 -2
- package/dist/bridge.js +19 -10
- package/dist/clipboard.d.ts +94 -0
- package/dist/clipboard.js +126 -0
- package/dist/devInterceptor.d.ts +9 -2
- package/dist/devInterceptor.js +15 -4
- package/dist/errors.d.ts +2 -2
- package/dist/errors.js +3 -3
- package/dist/eventBus.d.ts +15 -5
- package/dist/eventBus.js +29 -10
- package/dist/fileDialogs.d.ts +153 -0
- package/dist/fileDialogs.js +90 -0
- package/dist/hooks.d.ts +32 -2
- package/dist/hooks.js +36 -3
- package/dist/index.d.ts +11 -4
- package/dist/index.js +20 -6
- package/dist/mediaPlayer.d.ts +86 -0
- package/dist/mediaPlayer.js +192 -0
- package/dist/moduleService.d.ts +9 -2
- package/dist/moduleService.js +9 -2
- package/dist/requests.d.ts +176 -0
- package/dist/requests.js +146 -0
- package/dist/segmentBinder.d.ts +87 -0
- package/dist/segmentBinder.js +256 -0
- package/dist/segmentStream.d.ts +136 -0
- package/dist/segmentStream.js +248 -0
- package/dist/store.d.ts +10 -7
- package/dist/store.js +74 -13
- package/dist/types.d.ts +39 -1
- package/dist/types.js +39 -1
- package/dist/useDropZone.d.ts +16 -3
- package/dist/useDropZone.js +28 -7
- package/dist/windowCommands.d.ts +1 -1
- package/dist/windowCommands.js +2 -2
- package/package.json +10 -3
- package/dist/operations.d.ts +0 -256
- package/dist/operations.js +0 -191
|
@@ -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
|
+
}
|
package/dist/devInterceptor.d.ts
CHANGED
|
@@ -44,7 +44,14 @@ export interface DevInterceptorOptions {
|
|
|
44
44
|
bus?: ShenoraEventBus;
|
|
45
45
|
}
|
|
46
46
|
/**
|
|
47
|
-
* Install the interceptor
|
|
48
|
-
*
|
|
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;
|
package/dist/devInterceptor.js
CHANGED
|
@@ -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
|
|
19
|
-
*
|
|
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
|
-
* `
|
|
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
|
|
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
|
-
* `
|
|
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
|
|
8
|
+
export class ShenoraError extends Error {
|
|
9
9
|
constructor(error) {
|
|
10
10
|
super(error.message ?? error.code);
|
|
11
|
-
this.name = '
|
|
11
|
+
this.name = 'ShenoraError';
|
|
12
12
|
this.code = error.code;
|
|
13
13
|
this.parameters = error.parameters;
|
|
14
14
|
}
|
package/dist/eventBus.d.ts
CHANGED
|
@@ -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.
|
|
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).
|
|
67
|
-
*
|
|
68
|
-
*
|
|
69
|
-
*
|
|
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.
|
|
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).
|
|
90
|
-
*
|
|
91
|
-
*
|
|
92
|
-
*
|
|
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
|
|
139
|
-
*
|
|
140
|
-
*
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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
|
-
|
|
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
|