@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
package/README.md
CHANGED
|
@@ -1,165 +1,152 @@
|
|
|
1
|
-
# @shenora/react
|
|
2
|
-
|
|
3
|
-
React client for [Shenora](https://github.com/JiarongGu/Shenora) desktop hosts (.NET + WinForms +
|
|
4
|
-
WebView2). The typed bridge between a React frontend and the Shenora host: correlated `invoke`
|
|
5
|
-
with timeouts and structured errors, the event hub host notifications stream into, typed module
|
|
6
|
-
services, React hooks, and a pluggable transport with a browser fallback so the UI can be
|
|
7
|
-
developed in a plain browser. Headless by design — no UI components, bring your own design
|
|
8
|
-
system. Versioned in lockstep with the `Shenora.*` NuGet packages.
|
|
9
|
-
|
|
10
|
-
```ts
|
|
11
|
-
import {
|
|
12
|
-
getBridge, useShenoraEvent, useShenoraQuery, BaseModuleService, createShenoraStore,
|
|
13
|
-
} from '@shenora/react';
|
|
14
|
-
|
|
15
|
-
// Once at startup, after your listeners are attached: it starts notification delivery (anything the
|
|
16
|
-
// host buffered arrives in the first batch). Drop zones need no particular ordering against it — the
|
|
17
|
-
// host clears them when a new DOCUMENT loads, not on this handshake.
|
|
18
|
-
await getBridge().notifyReady();
|
|
19
|
-
|
|
20
|
-
// a typed service per backend module:
|
|
21
|
-
interface NoteRequests { GET_ALL: void; ADD: { title: string } }
|
|
22
|
-
class NoteService extends BaseModuleService<NoteRequests> {
|
|
23
|
-
constructor() { super('NOTES'); }
|
|
24
|
-
getAll() { return this.send<Note[]>('GET_ALL'); }
|
|
25
|
-
add(title: string) { return this.send<Note>('ADD', { payload: { title } }); }
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
// in components:
|
|
29
|
-
const { data, loading, refetch } = useShenoraQuery<Note[]>('NOTES', 'GET_ALL');
|
|
30
|
-
useShenoraEvent<Note>('NOTES', 'ADDED', (note) => refetch());
|
|
31
|
-
```
|
|
32
|
-
|
|
33
|
-
Failed calls reject with `
|
|
34
|
-
`errors.{code}`) plus interpolation `parameters`, never raw host exception text.
|
|
35
|
-
|
|
36
|
-
### Long-running work: post, then read a store
|
|
37
|
-
|
|
38
|
-
`invoke` awaits a correlated reply and carries a timeout, and its handler's synchronous segment runs
|
|
39
|
-
on the host's **UI thread** — so it is for calls that are quick and UI-thread-safe. Everything else
|
|
40
|
-
posts and streams results back as events:
|
|
41
|
-
|
|
42
|
-
```ts
|
|
43
|
-
const useDeploy = createShenoraStore('DEPLOY', {
|
|
44
|
-
initial: { status: 'idle', lines: [] as string[] },
|
|
45
|
-
// Loaded on the FIRST subscriber, so a component that mounts mid-run isn't empty:
|
|
46
|
-
// events it missed cannot be replayed.
|
|
47
|
-
snapshot: { type: 'GET_STATE', apply: (s, d) => ({ ...s, ...(d as object) }) },
|
|
48
|
-
on: {
|
|
49
|
-
PROGRESS: (s, p: { line: string }) => ({ ...s, lines: [...s.lines, p.line] }),
|
|
50
|
-
ENDED: (s, p: { ok: boolean }) => ({ ...s, status: p.ok ? 'done' : 'failed' }),
|
|
51
|
-
},
|
|
52
|
-
actions: ({ post }) => ({ start: (cfg: unknown) => post('START', { payload: cfg }) }),
|
|
53
|
-
});
|
|
54
|
-
|
|
55
|
-
// any number of components share ONE subscription:
|
|
56
|
-
const status = useDeploy((s) => s.status);
|
|
57
|
-
useDeploy.actions.start({ env: 'prod' });
|
|
58
|
-
```
|
|
59
|
-
|
|
60
|
-
Use the **store for shared or long-lived state** and **`useShenoraEvent` for a one-off reaction in a
|
|
61
|
-
single component**. A failed `post` has no promise to reject, so it is reported through the bridge's
|
|
62
|
-
`onPostError` rather than vanishing — it is bridge-wide, so wire it once at startup
|
|
63
|
-
(`getBridge()` takes no options):
|
|
64
|
-
|
|
65
|
-
```ts
|
|
66
|
-
configureBridge({ onPostError: (failure) => log.error(failure.module, failure.type, failure.error) });
|
|
67
|
-
```
|
|
68
|
-
|
|
69
|
-
###
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
const
|
|
79
|
-
const
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
import type
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
`
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
```
|
|
154
|
-
|
|
155
|
-
Delivery is narrowest-first — exact pair, then module, then catch-all — so a broad observer never
|
|
156
|
-
runs ahead of the feature code it is observing. Prefer `subscribe` when you know the pair: a
|
|
157
|
-
catch-all wakes for every event on the bus.
|
|
158
|
-
|
|
159
|
-
Pure-UI development in a plain browser: pass a `fallback` to `configureBridge` (gated behind
|
|
160
|
-
`import.meta.env.DEV`) to answer requests with canned data. Other shells (WebSocket,
|
|
161
|
-
mobile/Capacitor) implement the small `ShenoraTransport` seam and speak the same envelopes.
|
|
162
|
-
For CDP-driven testing, `installDevInterceptor()` records IPC/event traffic into ring buffers
|
|
163
|
-
and exposes `window.__shenora.call()/waitEvent()`.
|
|
164
|
-
|
|
165
|
-
MIT © Jiarong Gu
|
|
1
|
+
# @shenora/react
|
|
2
|
+
|
|
3
|
+
React client for [Shenora](https://github.com/JiarongGu/Shenora) desktop hosts (.NET + WinForms +
|
|
4
|
+
WebView2). The typed bridge between a React frontend and the Shenora host: correlated `invoke`
|
|
5
|
+
with timeouts and structured errors, the event hub host notifications stream into, typed module
|
|
6
|
+
services, React hooks, and a pluggable transport with a browser fallback so the UI can be
|
|
7
|
+
developed in a plain browser. Headless by design — no UI components, bring your own design
|
|
8
|
+
system. Versioned in lockstep with the `Shenora.*` NuGet packages.
|
|
9
|
+
|
|
10
|
+
```ts
|
|
11
|
+
import {
|
|
12
|
+
getBridge, useShenoraEvent, useShenoraQuery, BaseModuleService, createShenoraStore,
|
|
13
|
+
} from '@shenora/react';
|
|
14
|
+
|
|
15
|
+
// Once at startup, after your listeners are attached: it starts notification delivery (anything the
|
|
16
|
+
// host buffered arrives in the first batch). Drop zones need no particular ordering against it — the
|
|
17
|
+
// host clears them when a new DOCUMENT loads, not on this handshake.
|
|
18
|
+
await getBridge().notifyReady();
|
|
19
|
+
|
|
20
|
+
// a typed service per backend module:
|
|
21
|
+
interface NoteRequests { GET_ALL: void; ADD: { title: string } }
|
|
22
|
+
class NoteService extends BaseModuleService<NoteRequests> {
|
|
23
|
+
constructor() { super('NOTES'); }
|
|
24
|
+
getAll() { return this.send<Note[]>('GET_ALL'); }
|
|
25
|
+
add(title: string) { return this.send<Note>('ADD', { payload: { title } }); }
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// in components:
|
|
29
|
+
const { data, loading, refetch } = useShenoraQuery<Note[]>('NOTES', 'GET_ALL');
|
|
30
|
+
useShenoraEvent<Note>('NOTES', 'ADDED', (note) => refetch());
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
Failed calls reject with `ShenoraError` — a structured `code` (an i18n key: translate
|
|
34
|
+
`errors.{code}`) plus interpolation `parameters`, never raw host exception text.
|
|
35
|
+
|
|
36
|
+
### Long-running work: post, then read a store
|
|
37
|
+
|
|
38
|
+
`invoke` awaits a correlated reply and carries a timeout, and its handler's synchronous segment runs
|
|
39
|
+
on the host's **UI thread** — so it is for calls that are quick and UI-thread-safe. Everything else
|
|
40
|
+
posts and streams results back as events:
|
|
41
|
+
|
|
42
|
+
```ts
|
|
43
|
+
const useDeploy = createShenoraStore('DEPLOY', {
|
|
44
|
+
initial: { status: 'idle', lines: [] as string[] },
|
|
45
|
+
// Loaded on the FIRST subscriber, so a component that mounts mid-run isn't empty:
|
|
46
|
+
// events it missed cannot be replayed.
|
|
47
|
+
snapshot: { type: 'GET_STATE', apply: (s, d) => ({ ...s, ...(d as object) }) },
|
|
48
|
+
on: {
|
|
49
|
+
PROGRESS: (s, p: { line: string }) => ({ ...s, lines: [...s.lines, p.line] }),
|
|
50
|
+
ENDED: (s, p: { ok: boolean }) => ({ ...s, status: p.ok ? 'done' : 'failed' }),
|
|
51
|
+
},
|
|
52
|
+
actions: ({ post }) => ({ start: (cfg: unknown) => post('START', { payload: cfg }) }),
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
// any number of components share ONE subscription:
|
|
56
|
+
const status = useDeploy((s) => s.status);
|
|
57
|
+
useDeploy.actions.start({ env: 'prod' });
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
Use the **store for shared or long-lived state** and **`useShenoraEvent` for a one-off reaction in a
|
|
61
|
+
single component**. A failed `post` has no promise to reject, so it is reported through the bridge's
|
|
62
|
+
`onPostError` rather than vanishing — it is bridge-wide, so wire it once at startup
|
|
63
|
+
(`getBridge()` takes no options):
|
|
64
|
+
|
|
65
|
+
```ts
|
|
66
|
+
configureBridge({ onPostError: (failure) => log.error(failure.module, failure.type, failure.error) });
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
### Requests in flight: a ready-made progress store
|
|
70
|
+
|
|
71
|
+
Every request the host handles is tracked automatically — there is nothing to declare on either side.
|
|
72
|
+
`useShenoraRequests()` is a `createShenoraStore` instance built the same way as the example above:
|
|
73
|
+
|
|
74
|
+
```ts
|
|
75
|
+
import { useShenoraRequests } from '@shenora/react';
|
|
76
|
+
|
|
77
|
+
const running = useShenoraRequests((s) => s.running); // every request still in flight
|
|
78
|
+
const finished = useShenoraRequests((s) => s.finished); // retained history, newest first
|
|
79
|
+
const importJob = useShenoraRequests((s) => s.byId[requestId]); // one, by id
|
|
80
|
+
|
|
81
|
+
useShenoraRequests.actions.cancel(requestId); // XMLHttpRequest.abort() — the id you sent with
|
|
82
|
+
useShenoraRequests.actions.clearFinished();
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
🔴 **The id is the one you already have.** `requestId` is the `id` of the request you sent — there is no
|
|
86
|
+
second identity to correlate. Cancelling it targets the token the route is running under.
|
|
87
|
+
|
|
88
|
+
⚠ **Most requests never appear here, and that is the design.** The host stays SILENT for the first
|
|
89
|
+
50 ms (`IpcRequestTrackerOptions.GracePeriod`): a request that finishes inside that window emits no
|
|
90
|
+
event at all — no running snapshot, no completion, nothing retained. So this store is a list of work
|
|
91
|
+
that is actually *taking a while*, not a log of every call your page made. Nobody wants a spinner for
|
|
92
|
+
five milliseconds of work, and the clock decides that at run time rather than a module author guessing
|
|
93
|
+
at authoring time.
|
|
94
|
+
|
|
95
|
+
`request.progress` is an exported `IpcProgress` (`{ value: number; total?: number; unit?: string }`) —
|
|
96
|
+
import the type rather than re-declaring the shape. It is the APP's own unit (bytes of a known total,
|
|
97
|
+
items of a known total, an absolute count with no known total, or a genuine percent), never a
|
|
98
|
+
kit-assumed percentage: `total` is the denominator when one is known and `undefined` when there is
|
|
99
|
+
none, and `unit` is app-defined and uninterpreted. The kit ships no percent helper — render a ratio
|
|
100
|
+
only when you have a `total`:
|
|
101
|
+
|
|
102
|
+
```ts
|
|
103
|
+
import type { IpcProgress } from '@shenora/react';
|
|
104
|
+
|
|
105
|
+
function format(progress?: IpcProgress): string {
|
|
106
|
+
if (!progress) return 'starting…';
|
|
107
|
+
return progress.total
|
|
108
|
+
? `${Math.round((progress.value / progress.total) * 100)}%`
|
|
109
|
+
: `${progress.value}${progress.unit ? ` ${progress.unit}` : ''}`; // no known total
|
|
110
|
+
}
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
That division is your own policy, not the kit's, which is why it lives here instead of in `src/`.
|
|
114
|
+
|
|
115
|
+
The store snapshots via `LIST` on first subscribe (so a progress strip that mounts mid-run is not
|
|
116
|
+
empty), then folds `REQUEST_UPDATED` by id — one subscription however many components read it. Two
|
|
117
|
+
bands, both derived from `byId` on every read: `running` and `finished`. Filtering by your own
|
|
118
|
+
`module`/`type` is a plain `Array.filter` over either.
|
|
119
|
+
|
|
120
|
+
`clearFinished` does not touch local state itself: the host's `REQUEST_REMOVED { requestIds }` is the
|
|
121
|
+
one authoritative removal signal the store folds, deleting exactly the named ids. History eviction and
|
|
122
|
+
`clearFinished` both publish it, so a long-lived store's mirror of bounded host history cannot drift
|
|
123
|
+
from what the host actually did.
|
|
124
|
+
|
|
125
|
+
⚠ **Work nobody requested does not belong here.** A scheduled job, a background sync, anything the host
|
|
126
|
+
starts on its own — those have no request behind them and no response to wait for, so they report on
|
|
127
|
+
their own event stream via `useShenoraEvent`. Squeezing them in here is what the previous design did,
|
|
128
|
+
and it is why it needed a "waiting" state nothing else could explain.
|
|
129
|
+
|
|
130
|
+
### Observing the whole stream
|
|
131
|
+
|
|
132
|
+
`useShenoraEvent` and `createShenoraStore` listen for an exact `(module, type)`. When the vocabulary
|
|
133
|
+
isn't knowable up front — plug-in-contributed events, a diagnostics tap, or an adoption shim keeping
|
|
134
|
+
a legacy "every host message" handler alive while features migrate one at a time — subscribe broadly
|
|
135
|
+
instead. Both mirror the host's `IEventBus` and return an unsubscribe:
|
|
136
|
+
|
|
137
|
+
```ts
|
|
138
|
+
const off = eventBus.subscribeToAll((event) => log.debug(event.module, event.type, event.payload));
|
|
139
|
+
eventBus.subscribeToModule('DEPLOY', (event) => audit(event)); // every type from one module
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
Delivery is narrowest-first — exact pair, then module, then catch-all — so a broad observer never
|
|
143
|
+
runs ahead of the feature code it is observing. Prefer `subscribe` when you know the pair: a
|
|
144
|
+
catch-all wakes for every event on the bus.
|
|
145
|
+
|
|
146
|
+
Pure-UI development in a plain browser: pass a `fallback` to `configureBridge` (gated behind
|
|
147
|
+
`import.meta.env.DEV`) to answer requests with canned data. Other shells (WebSocket,
|
|
148
|
+
mobile/Capacitor) implement the small `ShenoraTransport` seam and speak the same envelopes.
|
|
149
|
+
For CDP-driven testing, `installDevInterceptor()` records IPC/event traffic into ring buffers
|
|
150
|
+
and exposes `window.__shenora.call()/waitEvent()`.
|
|
151
|
+
|
|
152
|
+
MIT © Jiarong Gu
|
package/dist/bridge.d.ts
CHANGED
|
@@ -89,7 +89,7 @@ export declare class ShenoraBridge {
|
|
|
89
89
|
get isAvailable(): boolean;
|
|
90
90
|
/**
|
|
91
91
|
* Send a request and await its typed response data. A failed response rejects with the
|
|
92
|
-
* structured {@link
|
|
92
|
+
* structured {@link ShenoraError} (code + parameters); no response within the timeout
|
|
93
93
|
* rejects with code `TIMEOUT`; no transport and no fallback rejects with `NO_TRANSPORT`.
|
|
94
94
|
*/
|
|
95
95
|
invoke<TData = unknown, TPayload = unknown>(module: string, type: string, options?: InvokeOptions<TPayload>): Promise<TData>;
|
|
@@ -109,7 +109,9 @@ export declare class ShenoraBridge {
|
|
|
109
109
|
*
|
|
110
110
|
* Failures are not silent. There is no promise to reject, so a failed response is reported through
|
|
111
111
|
* `onPostError` (default `console.error`) rather than being dropped the way an unmatched response
|
|
112
|
-
* otherwise is.
|
|
112
|
+
* otherwise is. ⚠ Reporting it means the id IS remembered — see {@link ShenoraBridgeOptions.maxTrackedPosts},
|
|
113
|
+
* which caps the set drop-oldest so a host that never answers cannot grow it without bound. No TIMER is
|
|
114
|
+
* set, though, so there is no deadline and nothing to fire later.
|
|
113
115
|
*
|
|
114
116
|
* No transport (a plain browser tab) is a silent no-op, matching the fire-and-forget contract —
|
|
115
117
|
* unlike `invoke`, there is no caller waiting to be told. **A DISPOSED bridge is the same silent
|
package/dist/bridge.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { ShenoraError } from './errors.js';
|
|
2
2
|
import { eventBus as defaultEventBus } from './eventBus.js';
|
|
3
3
|
import { randomId } from './internal.js';
|
|
4
4
|
import { createHostTransport } from './transport.js';
|
|
@@ -43,7 +43,7 @@ export class ShenoraBridge {
|
|
|
43
43
|
}
|
|
44
44
|
/**
|
|
45
45
|
* Send a request and await its typed response data. A failed response rejects with the
|
|
46
|
-
* structured {@link
|
|
46
|
+
* structured {@link ShenoraError} (code + parameters); no response within the timeout
|
|
47
47
|
* rejects with code `TIMEOUT`; no transport and no fallback rejects with `NO_TRANSPORT`.
|
|
48
48
|
*/
|
|
49
49
|
invoke(module, type, options = {}) {
|
|
@@ -51,7 +51,7 @@ export class ShenoraBridge {
|
|
|
51
51
|
// Fail fast: the transport subscription is gone, so a response could never correlate —
|
|
52
52
|
// without this the call would burn the full timeout (stale references after
|
|
53
53
|
// configureBridge replaced the default are the typical way here).
|
|
54
|
-
return Promise.reject(new
|
|
54
|
+
return Promise.reject(new ShenoraError({
|
|
55
55
|
code: IpcErrorCodes.noTransport,
|
|
56
56
|
message: `Bridge disposed — ${module}.${type} cannot be sent.`,
|
|
57
57
|
}));
|
|
@@ -80,18 +80,25 @@ export class ShenoraBridge {
|
|
|
80
80
|
// value is already settled.
|
|
81
81
|
if (!isThenable(result))
|
|
82
82
|
return Promise.resolve(result);
|
|
83
|
+
// ⚠ The loser's timer must be CLEARED, as every other path in this file does — the real invoke's
|
|
84
|
+
// timeout handler, its transport-throw catch, its response path and `dispose`. Leaving it holds a
|
|
85
|
+
// live timer per call for the full timeout
|
|
86
|
+
// (30 s by default), each holding its closure. Harmless to a caller, because `race` has already
|
|
87
|
+
// settled and has a rejection handler attached either way, but it is the same "one site out of
|
|
88
|
+
// N" shape the rest of this file is careful about, and it keeps timers pending in a test run.
|
|
89
|
+
let timer;
|
|
83
90
|
return Promise.race([
|
|
84
91
|
Promise.resolve(result),
|
|
85
92
|
new Promise((_, reject) => {
|
|
86
|
-
setTimeout(() => reject(new
|
|
93
|
+
timer = setTimeout(() => reject(new ShenoraError({
|
|
87
94
|
code: IpcErrorCodes.timeout,
|
|
88
95
|
message: `${module}.${type} timed out after ${timeoutMs} ms (in the configured fallback).`,
|
|
89
96
|
parameters: { module, type },
|
|
90
97
|
})), timeoutMs);
|
|
91
98
|
}),
|
|
92
|
-
]);
|
|
99
|
+
]).finally(() => clearTimeout(timer));
|
|
93
100
|
}
|
|
94
|
-
return Promise.reject(new
|
|
101
|
+
return Promise.reject(new ShenoraError({
|
|
95
102
|
code: IpcErrorCodes.noTransport,
|
|
96
103
|
message: `No transport for ${module}.${type} — not inside a Shenora host, and no fallback is configured.`,
|
|
97
104
|
}));
|
|
@@ -99,7 +106,7 @@ export class ShenoraBridge {
|
|
|
99
106
|
return new Promise((resolve, reject) => {
|
|
100
107
|
const timer = setTimeout(() => {
|
|
101
108
|
this.pending.delete(request.id);
|
|
102
|
-
reject(new
|
|
109
|
+
reject(new ShenoraError({
|
|
103
110
|
code: IpcErrorCodes.timeout,
|
|
104
111
|
message: `${module}.${type} timed out after ${timeoutMs} ms.`,
|
|
105
112
|
parameters: { module, type },
|
|
@@ -136,7 +143,9 @@ export class ShenoraBridge {
|
|
|
136
143
|
*
|
|
137
144
|
* Failures are not silent. There is no promise to reject, so a failed response is reported through
|
|
138
145
|
* `onPostError` (default `console.error`) rather than being dropped the way an unmatched response
|
|
139
|
-
* otherwise is.
|
|
146
|
+
* otherwise is. ⚠ Reporting it means the id IS remembered — see {@link ShenoraBridgeOptions.maxTrackedPosts},
|
|
147
|
+
* which caps the set drop-oldest so a host that never answers cannot grow it without bound. No TIMER is
|
|
148
|
+
* set, though, so there is no deadline and nothing to fire later.
|
|
140
149
|
*
|
|
141
150
|
* No transport (a plain browser tab) is a silent no-op, matching the fire-and-forget contract —
|
|
142
151
|
* unlike `invoke`, there is no caller waiting to be told. **A DISPOSED bridge is the same silent
|
|
@@ -232,7 +241,7 @@ export class ShenoraBridge {
|
|
|
232
241
|
this.unsubscribe?.();
|
|
233
242
|
for (const [id, entry] of this.pending) {
|
|
234
243
|
clearTimeout(entry.timer);
|
|
235
|
-
entry.reject(new
|
|
244
|
+
entry.reject(new ShenoraError({ code: IpcErrorCodes.noTransport, message: 'Bridge disposed.' }));
|
|
236
245
|
this.pending.delete(id);
|
|
237
246
|
}
|
|
238
247
|
// Unawaited ids are pure bookkeeping with nothing to settle — drop them so a disposed bridge
|
|
@@ -285,7 +294,7 @@ export class ShenoraBridge {
|
|
|
285
294
|
entry.resolve(response.data);
|
|
286
295
|
}
|
|
287
296
|
else {
|
|
288
|
-
entry.reject(new
|
|
297
|
+
entry.reject(new ShenoraError(response.error ?? { code: IpcErrorCodes.unknownError }));
|
|
289
298
|
}
|
|
290
299
|
return;
|
|
291
300
|
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import type { ShenoraBridge } from './bridge.js';
|
|
2
|
+
import { BaseModuleService } from './moduleService.js';
|
|
3
|
+
/** Media type for PNG bytes — the interchange image format every platform and browser reads. */
|
|
4
|
+
export declare const PNG_IMAGE = "image/png";
|
|
5
|
+
/** Media type for UTF-8 HTML, for a paste that keeps its formatting. */
|
|
6
|
+
export declare const HTML = "text/html";
|
|
7
|
+
/**
|
|
8
|
+
* One clipboard item and every representation it offers.
|
|
9
|
+
*
|
|
10
|
+
* `text` and `files` are named because every platform has a first-class API for them; everything else
|
|
11
|
+
* lives in `formats`, keyed by media type — {@link PNG_IMAGE}, {@link HTML}, or your own
|
|
12
|
+
* `application/…` type, which the host carries verbatim so a paste can round-trip it losslessly.
|
|
13
|
+
*/
|
|
14
|
+
export interface ClipboardContent {
|
|
15
|
+
/** The plain-text representation. */
|
|
16
|
+
text?: string;
|
|
17
|
+
/**
|
|
18
|
+
* Absolute paths, for the copy a file manager can paste.
|
|
19
|
+
* ⚠ DESKTOP only — gate on {@link ClipboardHandle.canCopyFiles}.
|
|
20
|
+
*/
|
|
21
|
+
files?: string[];
|
|
22
|
+
/** Every other representation, as raw bytes keyed by media type. */
|
|
23
|
+
formats?: Record<string, Uint8Array>;
|
|
24
|
+
}
|
|
25
|
+
/** What actually crosses the wire: the same shape with the byte payloads base64-encoded. */
|
|
26
|
+
interface ClipboardWire {
|
|
27
|
+
text?: string;
|
|
28
|
+
files?: string[];
|
|
29
|
+
formats?: Record<string, string>;
|
|
30
|
+
}
|
|
31
|
+
interface ClipboardRequests {
|
|
32
|
+
READ: undefined;
|
|
33
|
+
WRITE: {
|
|
34
|
+
content: ClipboardWire;
|
|
35
|
+
};
|
|
36
|
+
CLEAR: undefined;
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Typed client for the host's `SHENORA.CLIPBOARD` module (`ClipboardModule`).
|
|
40
|
+
*
|
|
41
|
+
* ⚠ **`files` is a DESKTOP capability** and rejects with `IpcErrorCodes.capabilityNotSupported` on a
|
|
42
|
+
* phone. Do not catch that — ask first, via {@link useClipboard}, and do not render the control.
|
|
43
|
+
*/
|
|
44
|
+
export declare class ClipboardAccess extends BaseModuleService<ClipboardRequests> {
|
|
45
|
+
constructor(bridge?: ShenoraBridge);
|
|
46
|
+
/**
|
|
47
|
+
* Everything the clipboard is offering — **no user gesture, no permission prompt, no focus
|
|
48
|
+
* requirement**, which is the half `navigator.clipboard.read()` cannot give you.
|
|
49
|
+
*/
|
|
50
|
+
read(): Promise<ClipboardContent>;
|
|
51
|
+
/**
|
|
52
|
+
* Replace the clipboard with one item, every representation at once.
|
|
53
|
+
*
|
|
54
|
+
* ⚠ Bytes cross the IPC envelope as base64, so a large picture is a large message. A page copying
|
|
55
|
+
* something big should hand the host a path and let it read the file instead.
|
|
56
|
+
*/
|
|
57
|
+
write(content: ClipboardContent): Promise<void>;
|
|
58
|
+
/** Leave the clipboard holding nothing. */
|
|
59
|
+
clear(): Promise<void>;
|
|
60
|
+
}
|
|
61
|
+
/** What {@link useClipboard} returns: the client, plus what this shell will actually honour. */
|
|
62
|
+
export interface ClipboardHandle {
|
|
63
|
+
/** The typed client. Stable across renders. */
|
|
64
|
+
clipboard: ClipboardAccess;
|
|
65
|
+
/**
|
|
66
|
+
* This shell can put a FILE LIST on the clipboard — desktop only. Decide what to RENDER with it; a
|
|
67
|
+
* refused call rejects, which is the honest answer to a question that should not have been asked.
|
|
68
|
+
*/
|
|
69
|
+
canCopyFiles: boolean;
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* The clipboard client together with what the CURRENT shell can honour — read from the ready
|
|
73
|
+
* handshake, not sniffed from the platform (D36).
|
|
74
|
+
*
|
|
75
|
+
* ```tsx
|
|
76
|
+
* const { clipboard, canCopyFiles } = useClipboard();
|
|
77
|
+
* return (
|
|
78
|
+
* <>
|
|
79
|
+
* <button onClick={() => navigator.clipboard.writeText(name)}>Copy name</button>
|
|
80
|
+
* {canCopyFiles && (
|
|
81
|
+
* <button onClick={() => clipboard.write({ text: name, files: [path] })}>Copy file</button>
|
|
82
|
+
* )}
|
|
83
|
+
* </>
|
|
84
|
+
* );
|
|
85
|
+
* ```
|
|
86
|
+
*
|
|
87
|
+
* ⚠ Note the first button: plain text on a click is the browser's job and stays there. This hook is for
|
|
88
|
+
* the file copy beside it.
|
|
89
|
+
*
|
|
90
|
+
* ⚠ `canCopyFiles` is `false` until the handshake has landed, so await `bridge.notifyReady()` before
|
|
91
|
+
* rendering this tree — see {@link useShellInfo} for why the read is synchronous.
|
|
92
|
+
*/
|
|
93
|
+
export declare function useClipboard(clipboard?: ClipboardAccess): ClipboardHandle;
|
|
94
|
+
export {};
|