@shenora/react 0.11.0 → 0.13.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/dist/bridge.d.ts +28 -47
- package/dist/bridge.js +37 -70
- package/dist/clipboard.d.ts +5 -10
- package/dist/clipboard.js +11 -18
- package/dist/devInterceptor.d.ts +8 -12
- package/dist/devInterceptor.js +10 -15
- package/dist/errors.d.ts +4 -5
- package/dist/errors.js +4 -5
- package/dist/eventBus.d.ts +13 -28
- package/dist/eventBus.js +19 -40
- package/dist/fileDialogs.d.ts +8 -11
- package/dist/fileDialogs.js +9 -13
- package/dist/hooks.d.ts +15 -24
- package/dist/hooks.js +19 -31
- package/dist/index.d.ts +2 -2
- package/dist/index.js +6 -14
- package/dist/internal.d.ts +2 -8
- package/dist/internal.js +2 -8
- package/dist/media.d.ts +14 -22
- package/dist/media.js +14 -22
- package/dist/mediaPlayer.d.ts +39 -22
- package/dist/mediaPlayer.js +54 -44
- package/dist/moduleService.d.ts +11 -22
- package/dist/moduleService.js +11 -22
- package/dist/requests.d.ts +34 -65
- package/dist/requests.js +21 -54
- package/dist/segmentBinder.d.ts +13 -26
- package/dist/segmentBinder.js +65 -42
- package/dist/segmentStream.d.ts +43 -54
- package/dist/segmentStream.js +102 -70
- package/dist/store.d.ts +15 -26
- package/dist/store.js +63 -59
- package/dist/transport.d.ts +9 -18
- package/dist/transport.js +9 -18
- package/dist/types.d.ts +23 -57
- package/dist/types.js +19 -40
- package/dist/useDropZone.d.ts +13 -25
- package/dist/useDropZone.js +24 -41
- package/dist/windowCommands.d.ts +14 -18
- package/dist/windowCommands.js +16 -23
- package/package.json +1 -1
package/dist/store.js
CHANGED
|
@@ -1,55 +1,74 @@
|
|
|
1
1
|
import { useCallback, useDebugValue, useRef, useSyncExternalStore } from 'react';
|
|
2
2
|
import { getBridge } from './bridge.js';
|
|
3
3
|
import { eventBus as defaultEventBus } from './eventBus.js';
|
|
4
|
-
/** Inputs for {@link createShenoraStore}. */
|
|
5
4
|
/**
|
|
6
5
|
* Is the fresh selector result the same VALUE as the previous one, for re-render purposes?
|
|
7
6
|
*
|
|
8
7
|
* `Object.is` plus ONE level of own-key comparison. The shallow step is what lets an inline selector
|
|
9
8
|
* that derives a new object work — `s => ({ count: s.lines.length })` builds a different object every
|
|
10
|
-
* call, and without
|
|
11
|
-
*
|
|
12
|
-
* ⚠ Deliberately one level. Deep equality would make an unchanged NESTED value hide a changed outer
|
|
13
|
-
* one only by cost, and a store whose selectors need deep comparison is selecting too much.
|
|
9
|
+
* call, and without it every call would look like a change and loop.
|
|
14
10
|
*/
|
|
15
11
|
function equivalent(a, b) {
|
|
16
12
|
if (Object.is(a, b))
|
|
17
13
|
return true;
|
|
18
14
|
if (typeof a !== 'object' || typeof b !== 'object' || a === null || b === null)
|
|
19
15
|
return false;
|
|
20
|
-
//
|
|
21
|
-
|
|
16
|
+
// Two different classes are never the same value, and this also separates an array from a bag.
|
|
17
|
+
const proto = Object.getPrototypeOf(a);
|
|
18
|
+
if (proto !== Object.getPrototypeOf(b))
|
|
22
19
|
return false;
|
|
20
|
+
// 🔴 A Date, a Set and a Map keep their contents in INTERNAL SLOTS, so `Object.keys` is `[]` for every
|
|
21
|
+
// one of them — the key compare below then reports two of different contents EQUAL, which pinned such
|
|
22
|
+
// a selector to its first value for the component's life. Silent: the early return also skips the cache
|
|
23
|
+
// refresh, so it never self-corrects. These three answer for themselves.
|
|
24
|
+
if (a instanceof Date)
|
|
25
|
+
return Object.is(a.getTime(), b.getTime());
|
|
26
|
+
if (a instanceof Set) {
|
|
27
|
+
const setB = b;
|
|
28
|
+
if (a.size !== setB.size)
|
|
29
|
+
return false;
|
|
30
|
+
for (const value of a)
|
|
31
|
+
if (!setB.has(value))
|
|
32
|
+
return false;
|
|
33
|
+
return true;
|
|
34
|
+
}
|
|
35
|
+
if (a instanceof Map) {
|
|
36
|
+
const mapB = b;
|
|
37
|
+
if (a.size !== mapB.size)
|
|
38
|
+
return false;
|
|
39
|
+
for (const [key, value] of a) {
|
|
40
|
+
if (!mapB.has(key) || !Object.is(mapB.get(key), value))
|
|
41
|
+
return false;
|
|
42
|
+
}
|
|
43
|
+
return true;
|
|
44
|
+
}
|
|
23
45
|
const aKeys = Object.keys(a);
|
|
24
|
-
|
|
25
|
-
|
|
46
|
+
if (aKeys.length !== Object.keys(b).length)
|
|
47
|
+
return false;
|
|
48
|
+
// ⚠ ZERO own keys on anything else exotic — a RegExp, a Promise, a class whose state is all private —
|
|
49
|
+
// means this compare learned NOTHING, so it must not answer "equal". A plain bag or an array genuinely
|
|
50
|
+
// IS empty. Class instances carrying own fields still compare shallowly, which is why the gate is on
|
|
51
|
+
// emptiness rather than on being a plain object: rejecting those would trade a stale render for
|
|
52
|
+
// React's "getSnapshot should be cached" loop.
|
|
53
|
+
if (aKeys.length === 0 && !Array.isArray(a) && proto !== Object.prototype && proto !== null)
|
|
26
54
|
return false;
|
|
27
55
|
return aKeys.every((k) => Object.prototype.hasOwnProperty.call(b, k)
|
|
28
56
|
&& Object.is(a[k], b[k]));
|
|
29
57
|
}
|
|
30
58
|
/**
|
|
31
|
-
* A store fed by one module's host event stream, shared by every component that reads it
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
* existed here — see `.claude/knowledge/generic-library.md`'s worked example for that survey. It exists
|
|
35
|
-
* because status- and progress-driven UI is inherently MANY-WATCHERS: a full panel and a compact
|
|
36
|
-
* progress strip want the same live state, and without a shared store each re-implements the wiring,
|
|
37
|
-
* each opens its own subscription, and each starts empty.
|
|
59
|
+
* A store fed by one module's host event stream, shared by every component that reads it — for the
|
|
60
|
+
* status- and progress-driven UI that is inherently many-watchers, where a full panel and a compact
|
|
61
|
+
* progress strip want the same live state.
|
|
38
62
|
*
|
|
39
63
|
* What it guarantees:
|
|
40
64
|
* - **One subscription per event type, however many components read it.** Mounting N components does
|
|
41
65
|
* not open N subscriptions; unmounting the last one tears them down.
|
|
42
66
|
* - **A late mounter sees current state**, via {@link ShenoraStoreOptions.snapshot} on the first
|
|
43
|
-
* subscription.
|
|
44
|
-
* - **No state library.** Built on React's `useSyncExternalStore`,
|
|
45
|
-
*
|
|
46
|
-
*
|
|
47
|
-
*
|
|
48
|
-
* - **Reducers are PURE and isolated**: they take state + payload and return state, so a store is
|
|
49
|
-
* testable with no bridge, and a throwing reducer is reported rather than corrupting shared state.
|
|
50
|
-
*
|
|
51
|
-
* Headless (D13/D21): the kit ships the MECHANISM. What an operation is — its phases, its progress
|
|
52
|
-
* shape, whether it queues — stays in the app; there is deliberately no job/queue/progress type here.
|
|
67
|
+
* subscription.
|
|
68
|
+
* - **No state library.** Built on React's `useSyncExternalStore`, so it is tearing-free under
|
|
69
|
+
* concurrent rendering and imposes no store dependency on the app.
|
|
70
|
+
* - **Reducers are PURE and isolated**: state + payload in, state out, so a store is testable with no
|
|
71
|
+
* bridge and a throwing reducer is reported rather than corrupting shared state.
|
|
53
72
|
*
|
|
54
73
|
* @example
|
|
55
74
|
* const useDeploy = createShenoraStore('DEPLOY', {
|
|
@@ -73,8 +92,8 @@ export function createShenoraStore(module, options) {
|
|
|
73
92
|
let state = initial;
|
|
74
93
|
let snapshotLoaded = false;
|
|
75
94
|
// Bumped on detach. A snapshot response from an earlier subscriber epoch resolving late must be
|
|
76
|
-
// DROPPED
|
|
77
|
-
//
|
|
95
|
+
// DROPPED — the current epoch has its own fresher request, and applying the old body over the new
|
|
96
|
+
// one is a lost update wearing a success path.
|
|
78
97
|
let snapshotEpoch = 0;
|
|
79
98
|
const listeners = new Set();
|
|
80
99
|
let unsubscribes = [];
|
|
@@ -95,16 +114,15 @@ export function createShenoraStore(module, options) {
|
|
|
95
114
|
setState(reduce(state, event.payload, event));
|
|
96
115
|
}
|
|
97
116
|
catch (error) {
|
|
98
|
-
// A throwing reducer must not corrupt shared state or break the other subscribers
|
|
99
|
-
// guarded-callback rule the host applies to app code (Shenora.AppCallback).
|
|
117
|
+
// A throwing reducer must not corrupt shared state or break the other subscribers.
|
|
100
118
|
report(error, { module, type });
|
|
101
119
|
}
|
|
102
120
|
};
|
|
103
121
|
const loadSnapshot = () => {
|
|
104
122
|
if (!snapshot || snapshotLoaded)
|
|
105
123
|
return;
|
|
106
|
-
snapshotLoaded = true; // set BEFORE awaiting: two components mounting in the same tick
|
|
107
|
-
//
|
|
124
|
+
snapshotLoaded = true; // set BEFORE awaiting: two components mounting in the same tick (React
|
|
125
|
+
// StrictMode double-invokes effects) must not both fire the request.
|
|
108
126
|
const epoch = snapshotEpoch;
|
|
109
127
|
bridge()
|
|
110
128
|
.invoke(module, snapshot.type, { payload: snapshot.payload, scope })
|
|
@@ -120,8 +138,8 @@ export function createShenoraStore(module, options) {
|
|
|
120
138
|
}, (error) => {
|
|
121
139
|
if (epoch !== snapshotEpoch)
|
|
122
140
|
return;
|
|
123
|
-
// Allow a later retry: a snapshot that failed because the host was not ready yet
|
|
124
|
-
// leave the store permanently empty
|
|
141
|
+
// Allow a later retry: a snapshot that failed because the host was not ready yet must not
|
|
142
|
+
// leave the store permanently empty.
|
|
125
143
|
snapshotLoaded = false;
|
|
126
144
|
report(error, { module, type: snapshot.type });
|
|
127
145
|
});
|
|
@@ -134,17 +152,15 @@ export function createShenoraStore(module, options) {
|
|
|
134
152
|
for (const off of unsubscribes)
|
|
135
153
|
off();
|
|
136
154
|
unsubscribes = [];
|
|
137
|
-
// The next mount must RE-LOAD: with no bus subscription live, everything the host emits from
|
|
138
|
-
//
|
|
139
|
-
//
|
|
140
|
-
// and the epoch bump orphans any request still in flight, so its late answer cannot clobber the
|
|
141
|
-
// next epoch's fresher one.
|
|
155
|
+
// The next mount must RE-LOAD: with no bus subscription live, everything the host emits from here
|
|
156
|
+
// on is missed, so the snapshot the flag was guarding is stale the moment this returns. The epoch
|
|
157
|
+
// bump orphans any request still in flight, so its late answer cannot clobber the next one.
|
|
142
158
|
snapshotLoaded = false;
|
|
143
159
|
snapshotEpoch++;
|
|
144
160
|
};
|
|
145
161
|
const subscribe = (listener) => {
|
|
146
|
-
// ONE subscription per event type for the whole store
|
|
147
|
-
//
|
|
162
|
+
// ONE subscription per event type for the whole store: the first listener attaches, the last one
|
|
163
|
+
// to leave detaches.
|
|
148
164
|
if (listeners.size === 0)
|
|
149
165
|
attach();
|
|
150
166
|
listeners.add(listener);
|
|
@@ -164,26 +180,14 @@ export function createShenoraStore(module, options) {
|
|
|
164
180
|
/**
|
|
165
181
|
* Subscribe to the store, optionally through a selector.
|
|
166
182
|
*
|
|
167
|
-
* 🔴 **The selector is RE-RUN every time and the previous RESULT is reused when equivalent
|
|
168
|
-
*
|
|
169
|
-
*
|
|
170
|
-
*
|
|
171
|
-
*
|
|
172
|
-
*
|
|
173
|
-
* ⚠ <b>Two requirements pull against each other and both are met by comparing RESULTS rather than
|
|
174
|
-
* inputs.</b> `useSyncExternalStore` re-renders whenever `getSnapshot` returns something new by
|
|
175
|
-
* `Object.is`, so:
|
|
176
|
-
* <list type="bullet">
|
|
177
|
-
* <item>keying the cache on STATE alone gives stale values when the selector changes — the bug above;</item>
|
|
178
|
-
* <item>keying it on the SELECTOR too loops forever for an inline selector that derives a new object
|
|
179
|
-
* (`s => ({ count: s.lines.length })`) — a fresh identity each render, a fresh object each call.
|
|
180
|
-
* zustand v5 has exactly that behaviour and answers it with an opt-in `useShallow`.</item>
|
|
181
|
-
* </list>
|
|
182
|
-
* Comparing the RESULT shallowly gives both: a derived object that is field-for-field the same reuses
|
|
183
|
-
* the previous reference and does not re-render, while a genuinely different row does.
|
|
183
|
+
* 🔴 **The selector is RE-RUN every time and the previous RESULT is reused when equivalent** — never
|
|
184
|
+
* cached against the state or the selector, both of which fail silently. Keying on STATE alone
|
|
185
|
+
* returns the PREVIOUS selector's value when the closure changes (a list row doing
|
|
186
|
+
* `s => s.byId[id]` whose `id` prop changes renders the previous row's data); keying on the SELECTOR
|
|
187
|
+
* too loops forever for an inline selector that derives a new object (`s => ({ n: s.lines.length })`).
|
|
184
188
|
*/
|
|
185
189
|
function useStore(selector) {
|
|
186
|
-
// The last result handed to React
|
|
190
|
+
// The last result handed to React, reused whenever the fresh one is equivalent — what keeps
|
|
187
191
|
// getSnapshot stable without pinning it to an input that can go stale.
|
|
188
192
|
const previous = useRef(null);
|
|
189
193
|
const getSelected = useCallback(() => {
|
package/dist/transport.d.ts
CHANGED
|
@@ -10,14 +10,9 @@ export interface ShenoraTransport {
|
|
|
10
10
|
subscribe(listener: (message: string) => void): () => void;
|
|
11
11
|
}
|
|
12
12
|
/**
|
|
13
|
-
* True when running inside ANY Shenora host — a WebView2 desktop shell or a MAUI
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
* It used to test WebView2 alone, which answered FALSE on the MAUI shell: an app would have
|
|
18
|
-
* concluded it was in a plain browser tab while a perfectly good host sat on the other side of the
|
|
19
|
-
* channel. Widened when the second shell arrived — the question this function is asked is "is there
|
|
20
|
-
* a host", never "is it WebView2".
|
|
13
|
+
* True when running inside ANY Shenora host — a WebView2 desktop shell or a MAUI `HybridWebView` — i.e.
|
|
14
|
+
* when a transport to the host exists. In a plain browser this is false and callers should fall back to
|
|
15
|
+
* browser-only behavior. The question is "is there a host", never "is it WebView2".
|
|
21
16
|
*/
|
|
22
17
|
export declare function isShenoraAvailable(): boolean;
|
|
23
18
|
/** The WebView2 postMessage transport, or null outside a WebView2 host. */
|
|
@@ -25,20 +20,16 @@ export declare function createWebView2Transport(): ShenoraTransport | null;
|
|
|
25
20
|
/**
|
|
26
21
|
* The MAUI `HybridWebView` transport, or null outside a MAUI host.
|
|
27
22
|
*
|
|
28
|
-
* Asymmetric by the platform's design
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
* changed, which is the whole point of the transport seam (D16).
|
|
23
|
+
* Asymmetric by the platform's design: sending goes through `window.HybridWebView.SendRawMessage`,
|
|
24
|
+
* while receiving is a `HybridWebViewMessageReceived` CustomEvent dispatched on `window`. Both
|
|
25
|
+
* directions carry the same JSON envelopes the desktop shell speaks; the host side is
|
|
26
|
+
* `Shenora.Maui.MauiIpcBridge`.
|
|
33
27
|
*/
|
|
34
28
|
export declare function createHybridWebViewTransport(): ShenoraTransport | null;
|
|
35
29
|
/**
|
|
36
30
|
* The transport for whichever Shenora host this page is running in, or null in a plain browser.
|
|
37
31
|
* This is what the bridge uses by default, so an app that simply calls `invoke`/`post` works on the
|
|
38
|
-
* desktop shell and the MAUI shell without knowing which one it is.
|
|
39
|
-
*
|
|
40
|
-
* WebView2 is probed FIRST for no deeper reason than that it is the older shell and a page can only
|
|
41
|
-
* ever be in one of them; if both objects were somehow present, preferring the one the desktop host
|
|
42
|
-
* injects keeps existing behaviour byte-identical.
|
|
32
|
+
* desktop shell and the MAUI shell without knowing which one it is. A page is only ever in one of
|
|
33
|
+
* them; WebView2 wins if both objects are somehow present.
|
|
43
34
|
*/
|
|
44
35
|
export declare function createHostTransport(): ShenoraTransport | null;
|
package/dist/transport.js
CHANGED
|
@@ -1,13 +1,8 @@
|
|
|
1
1
|
const webViewWindow = () => typeof window === 'undefined' ? undefined : window;
|
|
2
2
|
/**
|
|
3
|
-
* True when running inside ANY Shenora host — a WebView2 desktop shell or a MAUI
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
* It used to test WebView2 alone, which answered FALSE on the MAUI shell: an app would have
|
|
8
|
-
* concluded it was in a plain browser tab while a perfectly good host sat on the other side of the
|
|
9
|
-
* channel. Widened when the second shell arrived — the question this function is asked is "is there
|
|
10
|
-
* a host", never "is it WebView2".
|
|
3
|
+
* True when running inside ANY Shenora host — a WebView2 desktop shell or a MAUI `HybridWebView` — i.e.
|
|
4
|
+
* when a transport to the host exists. In a plain browser this is false and callers should fall back to
|
|
5
|
+
* browser-only behavior. The question is "is there a host", never "is it WebView2".
|
|
11
6
|
*/
|
|
12
7
|
export function isShenoraAvailable() {
|
|
13
8
|
const host = webViewWindow();
|
|
@@ -34,11 +29,10 @@ export function createWebView2Transport() {
|
|
|
34
29
|
/**
|
|
35
30
|
* The MAUI `HybridWebView` transport, or null outside a MAUI host.
|
|
36
31
|
*
|
|
37
|
-
* Asymmetric by the platform's design
|
|
38
|
-
*
|
|
39
|
-
*
|
|
40
|
-
*
|
|
41
|
-
* changed, which is the whole point of the transport seam (D16).
|
|
32
|
+
* Asymmetric by the platform's design: sending goes through `window.HybridWebView.SendRawMessage`,
|
|
33
|
+
* while receiving is a `HybridWebViewMessageReceived` CustomEvent dispatched on `window`. Both
|
|
34
|
+
* directions carry the same JSON envelopes the desktop shell speaks; the host side is
|
|
35
|
+
* `Shenora.Maui.MauiIpcBridge`.
|
|
42
36
|
*/
|
|
43
37
|
export function createHybridWebViewTransport() {
|
|
44
38
|
const host = webViewWindow();
|
|
@@ -64,11 +58,8 @@ export function createHybridWebViewTransport() {
|
|
|
64
58
|
/**
|
|
65
59
|
* The transport for whichever Shenora host this page is running in, or null in a plain browser.
|
|
66
60
|
* This is what the bridge uses by default, so an app that simply calls `invoke`/`post` works on the
|
|
67
|
-
* desktop shell and the MAUI shell without knowing which one it is.
|
|
68
|
-
*
|
|
69
|
-
* WebView2 is probed FIRST for no deeper reason than that it is the older shell and a page can only
|
|
70
|
-
* ever be in one of them; if both objects were somehow present, preferring the one the desktop host
|
|
71
|
-
* injects keeps existing behaviour byte-identical.
|
|
61
|
+
* desktop shell and the MAUI shell without knowing which one it is. A page is only ever in one of
|
|
62
|
+
* them; WebView2 wins if both objects are somehow present.
|
|
72
63
|
*/
|
|
73
64
|
export function createHostTransport() {
|
|
74
65
|
return createWebView2Transport() ?? createHybridWebViewTransport();
|
package/dist/types.d.ts
CHANGED
|
@@ -22,48 +22,33 @@ export declare const HANDSHAKE_TYPE = "READY";
|
|
|
22
22
|
export declare const IpcErrorCodes: {
|
|
23
23
|
readonly unknownError: "UNKNOWN_ERROR";
|
|
24
24
|
/**
|
|
25
|
-
* **No MODULE claimed the request** — nothing
|
|
26
|
-
* `module`, `type`.
|
|
25
|
+
* **No MODULE claimed the request** — nothing host-side answers that name, i.e. the module was never
|
|
26
|
+
* registered. Parameters: `module`, `type`.
|
|
27
27
|
*
|
|
28
|
-
* ⚠ Distinct from {@link noRoute}, and
|
|
29
|
-
* was never registered host-side, while `noRoute` means it WAS and does not know that type. Opposite
|
|
30
|
-
* fixes — wire the module up, versus correct a route name — so collapsing both into `NO_HANDLER` with
|
|
31
|
-
* identical parameters leaves a dead page undiagnosable from the wire.
|
|
28
|
+
* ⚠ Distinct from {@link noRoute}, and opposite fixes: wire the module up, versus correct a route name.
|
|
32
29
|
*/
|
|
33
30
|
readonly noHandler: "NO_HANDLER";
|
|
34
31
|
/**
|
|
35
|
-
* **The module answered but has no route of that type
|
|
36
|
-
*
|
|
37
|
-
* Seeing this is proof the module IS registered and mapped, which is exactly what {@link noHandler}
|
|
38
|
-
* cannot tell you — so this is a route-name problem, not a composition problem.
|
|
32
|
+
* **The module answered but has no route of that type** — so it IS registered, and this is a
|
|
33
|
+
* route-name problem. Parameters: `module`, `type`.
|
|
39
34
|
*/
|
|
40
35
|
readonly noRoute: "NO_ROUTE";
|
|
41
|
-
/**
|
|
42
|
-
* A scope-routed module was called without a `scope`. Parameters: `module`.
|
|
43
|
-
*
|
|
44
|
-
* This was MISSING here while the host emitted it (P5.5 H6), so a scoped app could not match it by
|
|
45
|
-
* constant and had to hard-code the string — against documentation claiming the two sides mirror
|
|
46
|
-
* name-for-name. The mirror is now enforced by a test rather than by care.
|
|
47
|
-
*/
|
|
36
|
+
/** A scope-routed module was called without a `scope`. Parameters: `module`. */
|
|
48
37
|
readonly scopeRequired: "SCOPE_REQUIRED";
|
|
49
38
|
readonly missingPayloadValue: "MISSING_PAYLOAD_VALUE";
|
|
50
39
|
readonly invalidPayloadValue: "INVALID_PAYLOAD_VALUE";
|
|
51
40
|
/**
|
|
52
41
|
* The operation was cancelled — a NORMAL outcome, not a fault. Treat it as "show nothing": it is the
|
|
53
|
-
* one failure a UI should stay silent about.
|
|
42
|
+
* one failure a UI should stay silent about.
|
|
54
43
|
*/
|
|
55
44
|
readonly operationCancelled: "OPERATION_CANCELLED";
|
|
56
45
|
/**
|
|
57
46
|
* The shell has NO EXPRESSION of what was asked for — not a fault, and not something a retry fixes.
|
|
58
|
-
* Parameters: `capability` (a {@link ShellCapabilities} value).
|
|
59
|
-
*
|
|
60
|
-
* Treat it like `operationCancelled`: do not show a fault. The right response is to hide the control,
|
|
61
|
-
* because the capability is absent by design on that platform (a folder picker on a phone, for
|
|
62
|
-
* instance) rather than broken.
|
|
47
|
+
* Parameters: `capability` (a {@link ShellCapabilities} value). Hide the control rather than showing
|
|
48
|
+
* an error: the capability is absent by design on that platform, not broken.
|
|
63
49
|
*
|
|
64
|
-
* ⚠ A page should not normally NEED this. The ready handshake advertises `ShellInfo.capabilities`
|
|
65
|
-
*
|
|
66
|
-
* intended path. This is the honest answer when a page asks anyway.
|
|
50
|
+
* ⚠ A page should not normally NEED this. The ready handshake advertises `ShellInfo.capabilities` so
|
|
51
|
+
* one bundle can decide BEFORE it asks — `useFileDialogs().canPickFolder` is the intended path.
|
|
67
52
|
*/
|
|
68
53
|
readonly capabilityNotSupported: "CAPABILITY_NOT_SUPPORTED";
|
|
69
54
|
/** Client-only: the request timed out waiting for a response. */
|
|
@@ -73,8 +58,7 @@ export declare const IpcErrorCodes: {
|
|
|
73
58
|
};
|
|
74
59
|
/**
|
|
75
60
|
* The codes that exist ONLY on the client — they never arrive from the host, but reject through the same
|
|
76
|
-
* structured shape
|
|
77
|
-
* exclude them by intent rather than by a hard-coded list on the other side.
|
|
61
|
+
* structured shape. Named here so the cross-language mirror check excludes them by intent.
|
|
78
62
|
*/
|
|
79
63
|
export declare const ClientOnlyIpcErrorCodes: readonly string[];
|
|
80
64
|
/** The request envelope a client sends to the host. */
|
|
@@ -101,20 +85,8 @@ export interface IpcError {
|
|
|
101
85
|
}
|
|
102
86
|
/**
|
|
103
87
|
* What the host is and what it can do — the handshake's response data (mirror of the host's
|
|
104
|
-
* `ShellInfo`).
|
|
105
|
-
*
|
|
106
|
-
* This is what lets ONE page ship to every shell. Render on the data rather than sniffing the
|
|
107
|
-
* platform:
|
|
108
|
-
*
|
|
109
|
-
* ```tsx
|
|
110
|
-
* const shell = useShellInfo();
|
|
111
|
-
* return <>{shell?.capabilities.includes(ShellCapabilities.windowChrome) && <TitleBar />}</>;
|
|
112
|
-
* ```
|
|
113
|
-
*
|
|
114
|
-
* A desktop shell that draws its own chrome advertises `windowChrome` and `dropZones`; a mobile one
|
|
115
|
-
* has neither, and the same bundle renders correctly on both. Undefined means no host said
|
|
116
|
-
* anything — a plain browser tab, or a host predating this — so treat absent as "assume nothing",
|
|
117
|
-
* never as "assume desktop".
|
|
88
|
+
* `ShellInfo`), and what lets ONE page ship to every shell. Read it with `useShellInfo`, whose docs
|
|
89
|
+
* carry the rule for an absent one.
|
|
118
90
|
*/
|
|
119
91
|
export interface ShellInfo {
|
|
120
92
|
/** Short host identifier, for diagnostics (`"winforms"`, `"maui"`). Never branch on this — branch on the capabilities. */
|
|
@@ -136,24 +108,19 @@ export declare const ShellCapabilities: {
|
|
|
136
108
|
readonly tray: "tray";
|
|
137
109
|
/**
|
|
138
110
|
* The host can put a FILE LIST on the clipboard, so the user can paste into Explorer, Finder or a
|
|
139
|
-
* file manager. No web API expresses this,
|
|
140
|
-
* of the clipboard worth branching on.
|
|
111
|
+
* file manager. No web API expresses this, so it is the one part of the clipboard worth branching on.
|
|
141
112
|
*
|
|
142
113
|
* ⚠ It says nothing about the rest: text and bytes work everywhere, and the gesture-driven half is
|
|
143
|
-
* `navigator.clipboard`'s job
|
|
114
|
+
* `navigator.clipboard`'s job.
|
|
144
115
|
*/
|
|
145
116
|
readonly clipboardFiles: "clipboardFiles";
|
|
146
117
|
/**
|
|
147
|
-
* The host can serve LOCAL FILES to this page — media, images, documents, exports — through its
|
|
148
|
-
* interceptor. Pair it with
|
|
149
|
-
*
|
|
150
|
-
* A page cannot reach a local file itself on any shell (`file://` is blocked from a virtual-host origin,
|
|
151
|
-
* and would be the wrong answer anyway), so branch on this and fall back rather than rendering a player
|
|
152
|
-
* that can never load.
|
|
118
|
+
* The host can serve LOCAL FILES to this page — media, images, documents, exports — through its
|
|
119
|
+
* resource interceptor. Pair it with `mediaUrl`.
|
|
153
120
|
*
|
|
154
|
-
* ⚠
|
|
155
|
-
*
|
|
156
|
-
*
|
|
121
|
+
* ⚠ A page cannot reach a local file itself on any shell, so branch on this and fall back rather than
|
|
122
|
+
* rendering a player that can never load. It says the host CAN serve, not what: routes, payload shape
|
|
123
|
+
* and allowed roots are the app's, and it says nothing about the URL SCHEME.
|
|
157
124
|
*/
|
|
158
125
|
readonly localFiles: "localFiles";
|
|
159
126
|
};
|
|
@@ -184,9 +151,8 @@ export interface IpcNotificationBatch {
|
|
|
184
151
|
timestamp: string;
|
|
185
152
|
}
|
|
186
153
|
/**
|
|
187
|
-
* A client-side event on the event bus — an unbundled {@link IpcNotification}
|
|
188
|
-
*
|
|
189
|
-
* cross the wire).
|
|
154
|
+
* A client-side event on the event bus — an unbundled {@link IpcNotification}, or a locally emitted
|
|
155
|
+
* event. The host-side `EventMessage` additionally carries id/timestamp, which don't cross the wire.
|
|
190
156
|
*/
|
|
191
157
|
export interface EventMessage<TPayload = unknown> {
|
|
192
158
|
module: string;
|
package/dist/types.js
CHANGED
|
@@ -22,48 +22,33 @@ export const HANDSHAKE_TYPE = 'READY';
|
|
|
22
22
|
export const IpcErrorCodes = {
|
|
23
23
|
unknownError: 'UNKNOWN_ERROR',
|
|
24
24
|
/**
|
|
25
|
-
* **No MODULE claimed the request** — nothing
|
|
26
|
-
* `module`, `type`.
|
|
25
|
+
* **No MODULE claimed the request** — nothing host-side answers that name, i.e. the module was never
|
|
26
|
+
* registered. Parameters: `module`, `type`.
|
|
27
27
|
*
|
|
28
|
-
* ⚠ Distinct from {@link noRoute}, and
|
|
29
|
-
* was never registered host-side, while `noRoute` means it WAS and does not know that type. Opposite
|
|
30
|
-
* fixes — wire the module up, versus correct a route name — so collapsing both into `NO_HANDLER` with
|
|
31
|
-
* identical parameters leaves a dead page undiagnosable from the wire.
|
|
28
|
+
* ⚠ Distinct from {@link noRoute}, and opposite fixes: wire the module up, versus correct a route name.
|
|
32
29
|
*/
|
|
33
30
|
noHandler: 'NO_HANDLER',
|
|
34
31
|
/**
|
|
35
|
-
* **The module answered but has no route of that type
|
|
36
|
-
*
|
|
37
|
-
* Seeing this is proof the module IS registered and mapped, which is exactly what {@link noHandler}
|
|
38
|
-
* cannot tell you — so this is a route-name problem, not a composition problem.
|
|
32
|
+
* **The module answered but has no route of that type** — so it IS registered, and this is a
|
|
33
|
+
* route-name problem. Parameters: `module`, `type`.
|
|
39
34
|
*/
|
|
40
35
|
noRoute: 'NO_ROUTE',
|
|
41
|
-
/**
|
|
42
|
-
* A scope-routed module was called without a `scope`. Parameters: `module`.
|
|
43
|
-
*
|
|
44
|
-
* This was MISSING here while the host emitted it (P5.5 H6), so a scoped app could not match it by
|
|
45
|
-
* constant and had to hard-code the string — against documentation claiming the two sides mirror
|
|
46
|
-
* name-for-name. The mirror is now enforced by a test rather than by care.
|
|
47
|
-
*/
|
|
36
|
+
/** A scope-routed module was called without a `scope`. Parameters: `module`. */
|
|
48
37
|
scopeRequired: 'SCOPE_REQUIRED',
|
|
49
38
|
missingPayloadValue: 'MISSING_PAYLOAD_VALUE',
|
|
50
39
|
invalidPayloadValue: 'INVALID_PAYLOAD_VALUE',
|
|
51
40
|
/**
|
|
52
41
|
* The operation was cancelled — a NORMAL outcome, not a fault. Treat it as "show nothing": it is the
|
|
53
|
-
* one failure a UI should stay silent about.
|
|
42
|
+
* one failure a UI should stay silent about.
|
|
54
43
|
*/
|
|
55
44
|
operationCancelled: 'OPERATION_CANCELLED',
|
|
56
45
|
/**
|
|
57
46
|
* The shell has NO EXPRESSION of what was asked for — not a fault, and not something a retry fixes.
|
|
58
|
-
* Parameters: `capability` (a {@link ShellCapabilities} value).
|
|
59
|
-
*
|
|
60
|
-
* Treat it like `operationCancelled`: do not show a fault. The right response is to hide the control,
|
|
61
|
-
* because the capability is absent by design on that platform (a folder picker on a phone, for
|
|
62
|
-
* instance) rather than broken.
|
|
47
|
+
* Parameters: `capability` (a {@link ShellCapabilities} value). Hide the control rather than showing
|
|
48
|
+
* an error: the capability is absent by design on that platform, not broken.
|
|
63
49
|
*
|
|
64
|
-
* ⚠ A page should not normally NEED this. The ready handshake advertises `ShellInfo.capabilities`
|
|
65
|
-
*
|
|
66
|
-
* intended path. This is the honest answer when a page asks anyway.
|
|
50
|
+
* ⚠ A page should not normally NEED this. The ready handshake advertises `ShellInfo.capabilities` so
|
|
51
|
+
* one bundle can decide BEFORE it asks — `useFileDialogs().canPickFolder` is the intended path.
|
|
67
52
|
*/
|
|
68
53
|
capabilityNotSupported: 'CAPABILITY_NOT_SUPPORTED',
|
|
69
54
|
/** Client-only: the request timed out waiting for a response. */
|
|
@@ -73,8 +58,7 @@ export const IpcErrorCodes = {
|
|
|
73
58
|
};
|
|
74
59
|
/**
|
|
75
60
|
* The codes that exist ONLY on the client — they never arrive from the host, but reject through the same
|
|
76
|
-
* structured shape
|
|
77
|
-
* exclude them by intent rather than by a hard-coded list on the other side.
|
|
61
|
+
* structured shape. Named here so the cross-language mirror check excludes them by intent.
|
|
78
62
|
*/
|
|
79
63
|
export const ClientOnlyIpcErrorCodes = [
|
|
80
64
|
IpcErrorCodes.timeout,
|
|
@@ -94,24 +78,19 @@ export const ShellCapabilities = {
|
|
|
94
78
|
tray: 'tray',
|
|
95
79
|
/**
|
|
96
80
|
* The host can put a FILE LIST on the clipboard, so the user can paste into Explorer, Finder or a
|
|
97
|
-
* file manager. No web API expresses this,
|
|
98
|
-
* of the clipboard worth branching on.
|
|
81
|
+
* file manager. No web API expresses this, so it is the one part of the clipboard worth branching on.
|
|
99
82
|
*
|
|
100
83
|
* ⚠ It says nothing about the rest: text and bytes work everywhere, and the gesture-driven half is
|
|
101
|
-
* `navigator.clipboard`'s job
|
|
84
|
+
* `navigator.clipboard`'s job.
|
|
102
85
|
*/
|
|
103
86
|
clipboardFiles: 'clipboardFiles',
|
|
104
87
|
/**
|
|
105
|
-
* The host can serve LOCAL FILES to this page — media, images, documents, exports — through its
|
|
106
|
-
* interceptor. Pair it with
|
|
107
|
-
*
|
|
108
|
-
* A page cannot reach a local file itself on any shell (`file://` is blocked from a virtual-host origin,
|
|
109
|
-
* and would be the wrong answer anyway), so branch on this and fall back rather than rendering a player
|
|
110
|
-
* that can never load.
|
|
88
|
+
* The host can serve LOCAL FILES to this page — media, images, documents, exports — through its
|
|
89
|
+
* resource interceptor. Pair it with `mediaUrl`.
|
|
111
90
|
*
|
|
112
|
-
* ⚠
|
|
113
|
-
*
|
|
114
|
-
*
|
|
91
|
+
* ⚠ A page cannot reach a local file itself on any shell, so branch on this and fall back rather than
|
|
92
|
+
* rendering a player that can never load. It says the host CAN serve, not what: routes, payload shape
|
|
93
|
+
* and allowed roots are the app's, and it says nothing about the URL SCHEME.
|
|
115
94
|
*/
|
|
116
95
|
localFiles: 'localFiles',
|
|
117
96
|
};
|
package/dist/useDropZone.d.ts
CHANGED
|
@@ -18,9 +18,7 @@ export interface DropZoneFileDrop {
|
|
|
18
18
|
* Inputs for {@link useDropZone}.
|
|
19
19
|
*
|
|
20
20
|
* No ordering constraint against `notifyReady()`: the host clears zones when a new DOCUMENT starts
|
|
21
|
-
* loading, not on the handshake, so this hook's `REGISTER` cannot be wiped by a reset
|
|
22
|
-
* after it. (It could, until the reset moved off the handshake — React runs CHILD effects before
|
|
23
|
-
* PARENT effects, which made losing the registration the default outcome rather than bad luck.)
|
|
21
|
+
* loading, not on the handshake, so this hook's `REGISTER` cannot be wiped by a later reset.
|
|
24
22
|
*/
|
|
25
23
|
export interface UseDropZoneOptions {
|
|
26
24
|
/** The element the native overlay tracks. */
|
|
@@ -33,11 +31,10 @@ export interface UseDropZoneOptions {
|
|
|
33
31
|
* does not re-register the zone, which is what "stable" means here. */
|
|
34
32
|
zoneId?: string;
|
|
35
33
|
/**
|
|
36
|
-
* Class toggled on the element while a file drag hovers the zone. UNSTYLED —
|
|
37
|
-
*
|
|
38
|
-
* ⚠ Read on the FIRST render only, like {@link UseDropZoneOptions.zoneId}:
|
|
39
|
-
*
|
|
40
|
-
* class and remove another, leaving the first stuck on the element. Switch it by remounting.
|
|
34
|
+
* Class toggled on the element while a file drag hovers the zone. UNSTYLED — the library ships no
|
|
35
|
+
* CSS; style it in the app. Default `"shenora-drop-hover"`.
|
|
36
|
+
* ⚠ Read on the FIRST render only, like {@link UseDropZoneOptions.zoneId}: a mid-hover change would
|
|
37
|
+
* add one class and remove another, leaving the first stuck on the element. Switch it by remounting.
|
|
41
38
|
*/
|
|
42
39
|
dropClassName?: string;
|
|
43
40
|
/** The bridge to speak over. Default: the shared default bridge. */
|
|
@@ -48,29 +45,20 @@ export interface UseDropZoneOptions {
|
|
|
48
45
|
* Where a failed REGISTER / UPDATE / SHOW / UNREGISTER is reported. Default: `console.error`.
|
|
49
46
|
*
|
|
50
47
|
* ⚠ Worth routing somewhere real, because a failure here is INVISIBLE in the UI: the page renders
|
|
51
|
-
* exactly as it should and files simply do not drop.
|
|
52
|
-
* that could only ever reach the console — `bridge.ts`'s `onPostError`, `store.ts`'s `onError` and
|
|
53
|
-
* `segmentBinder.ts`'s `onDiagnostic` all take an app sink.
|
|
48
|
+
* exactly as it should and files simply do not drop.
|
|
54
49
|
*/
|
|
55
50
|
onError?: (error: unknown, route: string) => void;
|
|
56
51
|
}
|
|
57
52
|
/**
|
|
58
53
|
* **The file-drop API for a Shenora page. Do not use the DOM's own drop event for files —
|
|
59
|
-
* it is not an alternative here, it is the thing this exists to replace.**
|
|
54
|
+
* it is not an alternative here, it is the thing this exists to replace.** A page-side `onDrop` gets a
|
|
55
|
+
* `File` whose only accessor is its CONTENT, so every dropped file is copied into the renderer and
|
|
56
|
+
* across the IPC boundary before the app knows whether it wants any of them. This gives you `string[]`
|
|
57
|
+
* OS paths instead — open lazily, stream, hash incrementally, move or link without copying.
|
|
60
58
|
*
|
|
61
|
-
*
|
|
62
|
-
*
|
|
63
|
-
*
|
|
64
|
-
* at drop time, before the app knows whether it wants any of them. Drop 200 files to filter by
|
|
65
|
-
* extension and you pay for all 200; drop a multi-GB asset and you pay that, to reach a file the
|
|
66
|
-
* host could have opened off the same disk. This hook gives you `string[]` paths instead — open
|
|
67
|
-
* lazily, stream, hash incrementally, move or link without copying, watch for changes.
|
|
68
|
-
*
|
|
69
|
-
* Sync a native drop-zone overlay to a page element, ported from the primary desktop sibling
|
|
70
|
-
* (its fix-history comments kept below): the host positions a transparent WinForms overlay
|
|
71
|
-
* over the element to capture REAL OS file paths — including drags started while the app is in
|
|
72
|
-
* the background. Bounds re-sync (debounced) on resize/scroll/intersection changes; the host
|
|
73
|
-
* converts the CSS rect to physical pixels per-monitor.
|
|
59
|
+
* The host positions a transparent native overlay over the element to capture those paths, including
|
|
60
|
+
* for drags started while the app is in the background. Bounds re-sync (debounced) on
|
|
61
|
+
* resize/scroll/intersection changes; the host converts the CSS rect to physical pixels per-monitor.
|
|
74
62
|
*
|
|
75
63
|
* How the visibility dance works: mouse leaves the element → SHOW (overlay up, ready to catch a
|
|
76
64
|
* drag); mouse enters → the host hides the overlay (hover effects keep working); an inactive
|