@shenora/react 0.10.0 → 0.12.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 +29 -46
- package/dist/bridge.js +47 -71
- package/dist/clipboard.d.ts +89 -0
- package/dist/clipboard.js +119 -0
- package/dist/devInterceptor.d.ts +11 -8
- package/dist/devInterceptor.js +16 -10
- package/dist/errors.d.ts +5 -6
- package/dist/errors.js +6 -7
- package/dist/eventBus.d.ts +21 -26
- package/dist/eventBus.js +38 -40
- package/dist/fileDialogs.d.ts +9 -12
- package/dist/fileDialogs.js +12 -16
- package/dist/hooks.d.ts +19 -20
- package/dist/hooks.js +26 -29
- package/dist/index.d.ts +8 -2
- package/dist/index.js +14 -10
- 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 +103 -0
- package/dist/mediaPlayer.js +202 -0
- package/dist/moduleService.d.ts +17 -21
- package/dist/moduleService.js +17 -21
- package/dist/requests.d.ts +145 -0
- package/dist/requests.js +113 -0
- package/dist/segmentBinder.d.ts +74 -0
- package/dist/segmentBinder.js +239 -0
- package/dist/segmentStream.d.ts +125 -0
- package/dist/segmentStream.js +239 -0
- package/dist/store.d.ts +18 -26
- package/dist/store.js +69 -36
- package/dist/transport.d.ts +9 -18
- package/dist/transport.js +9 -18
- package/dist/types.d.ts +33 -42
- package/dist/types.js +29 -25
- package/dist/useDropZone.d.ts +23 -22
- package/dist/useDropZone.js +41 -37
- package/dist/windowCommands.d.ts +15 -19
- package/dist/windowCommands.js +18 -25
- package/package.json +10 -3
- package/dist/operations.d.ts +0 -256
- package/dist/operations.js +0 -191
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';
|
|
@@ -8,10 +8,10 @@ const newId = () => randomId();
|
|
|
8
8
|
const isThenable = (value) => typeof value === 'object' && value !== null
|
|
9
9
|
&& typeof value.then === 'function';
|
|
10
10
|
/**
|
|
11
|
-
* The client side of the Shenora IPC contract
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
11
|
+
* The client side of the Shenora IPC contract: correlated request/response over a pluggable
|
|
12
|
+
* transport, category routing of host messages (`ipc` → resolve the pending call, `notification` →
|
|
13
|
+
* unbundle the batch into the event bus), per-request timeout, the ready handshake, and a browser
|
|
14
|
+
* fallback seam for pure-UI development.
|
|
15
15
|
*
|
|
16
16
|
* Most apps use the lazy default instance via {@link getBridge}/{@link configureBridge}; create
|
|
17
17
|
* instances directly for tests or multi-transport setups.
|
|
@@ -20,7 +20,7 @@ export class ShenoraBridge {
|
|
|
20
20
|
constructor(options = {}) {
|
|
21
21
|
this.pending = new Map();
|
|
22
22
|
// Ids of one-way sends, kept ONLY so a failed response can be reported instead of vanishing.
|
|
23
|
-
//
|
|
23
|
+
// A Map for its insertion-ordered keys, so the cap can evict the oldest.
|
|
24
24
|
this.unawaited = new Map();
|
|
25
25
|
this.disposed = false;
|
|
26
26
|
this.transport = options.transport !== undefined ? options.transport : createHostTransport();
|
|
@@ -34,24 +34,21 @@ export class ShenoraBridge {
|
|
|
34
34
|
}
|
|
35
35
|
/**
|
|
36
36
|
* True when this bridge can actually send: a transport to a host exists AND the bridge has not been
|
|
37
|
-
* disposed.
|
|
38
|
-
* replaced still reported itself available while every `invoke` on it rejected with `NO_TRANSPORT` —
|
|
39
|
-
* the exact case the disposed check in `invoke` exists for (P5.5 H2).
|
|
37
|
+
* disposed. The check to make on a reference that may have outlived a {@link configureBridge} swap.
|
|
40
38
|
*/
|
|
41
39
|
get isAvailable() {
|
|
42
40
|
return !this.disposed && this.transport !== null;
|
|
43
41
|
}
|
|
44
42
|
/**
|
|
45
43
|
* Send a request and await its typed response data. A failed response rejects with the
|
|
46
|
-
* structured {@link
|
|
44
|
+
* structured {@link ShenoraError} (code + parameters); no response within the timeout
|
|
47
45
|
* rejects with code `TIMEOUT`; no transport and no fallback rejects with `NO_TRANSPORT`.
|
|
48
46
|
*/
|
|
49
47
|
invoke(module, type, options = {}) {
|
|
50
48
|
if (this.disposed) {
|
|
51
|
-
// Fail fast: the transport subscription is gone, so a response could never correlate
|
|
52
|
-
//
|
|
53
|
-
|
|
54
|
-
return Promise.reject(new OperationError({
|
|
49
|
+
// Fail fast: the transport subscription is gone, so a response could never correlate and the
|
|
50
|
+
// call would otherwise burn the full timeout.
|
|
51
|
+
return Promise.reject(new ShenoraError({
|
|
55
52
|
code: IpcErrorCodes.noTransport,
|
|
56
53
|
message: `Bridge disposed — ${module}.${type} cannot be sent.`,
|
|
57
54
|
}));
|
|
@@ -74,24 +71,25 @@ export class ShenoraBridge {
|
|
|
74
71
|
catch (error) {
|
|
75
72
|
return Promise.reject(error);
|
|
76
73
|
}
|
|
77
|
-
//
|
|
78
|
-
//
|
|
79
|
-
// none of the diagnostics the real path gives (P5.5 H2). Only a thenable needs racing; a plain
|
|
80
|
-
// value is already settled.
|
|
74
|
+
// An async fallback is raced against the timeout too, or one that never settles hangs the
|
|
75
|
+
// caller forever. Only a thenable needs racing; a plain value has already settled.
|
|
81
76
|
if (!isThenable(result))
|
|
82
77
|
return Promise.resolve(result);
|
|
78
|
+
// ⚠ The loser's timer is cleared in `finally` — otherwise every call holds a live timer, and
|
|
79
|
+
// its closure, for the full timeout.
|
|
80
|
+
let timer;
|
|
83
81
|
return Promise.race([
|
|
84
82
|
Promise.resolve(result),
|
|
85
83
|
new Promise((_, reject) => {
|
|
86
|
-
setTimeout(() => reject(new
|
|
84
|
+
timer = setTimeout(() => reject(new ShenoraError({
|
|
87
85
|
code: IpcErrorCodes.timeout,
|
|
88
86
|
message: `${module}.${type} timed out after ${timeoutMs} ms (in the configured fallback).`,
|
|
89
87
|
parameters: { module, type },
|
|
90
88
|
})), timeoutMs);
|
|
91
89
|
}),
|
|
92
|
-
]);
|
|
90
|
+
]).finally(() => clearTimeout(timer));
|
|
93
91
|
}
|
|
94
|
-
return Promise.reject(new
|
|
92
|
+
return Promise.reject(new ShenoraError({
|
|
95
93
|
code: IpcErrorCodes.noTransport,
|
|
96
94
|
message: `No transport for ${module}.${type} — not inside a Shenora host, and no fallback is configured.`,
|
|
97
95
|
}));
|
|
@@ -99,7 +97,7 @@ export class ShenoraBridge {
|
|
|
99
97
|
return new Promise((resolve, reject) => {
|
|
100
98
|
const timer = setTimeout(() => {
|
|
101
99
|
this.pending.delete(request.id);
|
|
102
|
-
reject(new
|
|
100
|
+
reject(new ShenoraError({
|
|
103
101
|
code: IpcErrorCodes.timeout,
|
|
104
102
|
message: `${module}.${type} timed out after ${timeoutMs} ms.`,
|
|
105
103
|
parameters: { module, type },
|
|
@@ -123,26 +121,17 @@ export class ShenoraBridge {
|
|
|
123
121
|
/**
|
|
124
122
|
* Send WITHOUT awaiting a reply, and return the request id.
|
|
125
123
|
*
|
|
126
|
-
* This is the default shape for a desktop shell
|
|
127
|
-
* `
|
|
128
|
-
*
|
|
129
|
-
* design, because the dispatch pipeline preserves the caller's synchronization context so facades
|
|
130
|
-
* can touch the window. Reserve `invoke` for calls that are quick AND safe on the UI thread — the
|
|
131
|
-
* window commands are the model — and post everything else, streaming results back as notifications.
|
|
124
|
+
* This is the default shape for a desktop shell and {@link invoke} is the special case (D23):
|
|
125
|
+
* reserve `invoke` for calls that are quick AND safe on the host's UI thread, and post everything
|
|
126
|
+
* else, streaming results back as notifications.
|
|
132
127
|
*
|
|
133
|
-
*
|
|
134
|
-
*
|
|
135
|
-
*
|
|
128
|
+
* A failed response is reported through `onPostError` (default `console.error`) rather than
|
|
129
|
+
* dropped. The id is remembered for that — see {@link ShenoraBridgeOptions.maxTrackedPosts}. No
|
|
130
|
+
* timer is set, so there is no deadline.
|
|
136
131
|
*
|
|
137
|
-
*
|
|
138
|
-
* `
|
|
139
|
-
*
|
|
140
|
-
*
|
|
141
|
-
* No transport (a plain browser tab) is a silent no-op, matching the fire-and-forget contract —
|
|
142
|
-
* unlike `invoke`, there is no caller waiting to be told. **A DISPOSED bridge is the same silent
|
|
143
|
-
* no-op**, and that one can surprise: `invoke` on a disposed bridge rejects with `NO_TRANSPORT`
|
|
144
|
-
* while `post` just returns the id, so a stale reference kept across a `configureBridge` swap looks
|
|
145
|
-
* like it is still sending. `isAvailable` is the check.
|
|
132
|
+
* ⚠ No transport (a plain browser tab) is a silent no-op, and **so is a DISPOSED bridge** — where
|
|
133
|
+
* `invoke` would reject with `NO_TRANSPORT`, this just returns the id, so a stale reference kept
|
|
134
|
+
* across a {@link configureBridge} swap looks like it is still sending. `isAvailable` is the check.
|
|
146
135
|
*/
|
|
147
136
|
post(module, type, options = {}) {
|
|
148
137
|
const request = {
|
|
@@ -155,8 +144,7 @@ export class ShenoraBridge {
|
|
|
155
144
|
};
|
|
156
145
|
if (this.disposed || !this.transport)
|
|
157
146
|
return request.id;
|
|
158
|
-
//
|
|
159
|
-
// cannot grow this without bound; an evicted id simply loses its error report.
|
|
147
|
+
// Remembered ONLY to report a failure, drop-oldest at the cap.
|
|
160
148
|
if (this.unawaited.size >= this.maxTrackedPosts) {
|
|
161
149
|
const oldest = this.unawaited.keys().next();
|
|
162
150
|
if (!oldest.done)
|
|
@@ -187,14 +175,10 @@ export class ShenoraBridge {
|
|
|
187
175
|
* also the host's cue to reset per-page state. No-transport is a silent no-op so browser dev
|
|
188
176
|
* doesn't error.
|
|
189
177
|
*
|
|
190
|
-
*
|
|
191
|
-
*
|
|
192
|
-
* was acked. `DropZoneManager` now clears on DOCUMENT CHANGE instead, which cannot race the client.
|
|
193
|
-
* The general point still stands for any per-page state YOUR host resets here — a reset keyed on
|
|
194
|
-
* the handshake races anything the page sends before it, and in React that is structural rather
|
|
195
|
-
* than a mistake, because CHILD effects run before PARENT effects.
|
|
178
|
+
* ⚠ Any per-page state YOUR host resets on this handshake races whatever the page sent before it,
|
|
179
|
+
* and in React that is structural rather than bad luck: CHILD effects run before PARENT effects.
|
|
196
180
|
*
|
|
197
|
-
* The returned promise REJECTS on a failed handshake (disposed bridge, timeout). Handle it —
|
|
181
|
+
* ⚠ The returned promise REJECTS on a failed handshake (disposed bridge, timeout). Handle it —
|
|
198
182
|
* `void bridge.notifyReady()` turns that into an unhandled rejection, which in a WebView2 page is
|
|
199
183
|
* a silent console error.
|
|
200
184
|
*/
|
|
@@ -202,14 +186,9 @@ export class ShenoraBridge {
|
|
|
202
186
|
if (!this.transport)
|
|
203
187
|
return undefined;
|
|
204
188
|
// The host answers with what it IS and what it can do, so a page can render one tree on every
|
|
205
|
-
// shell instead of sniffing the platform. A host that says nothing
|
|
206
|
-
//
|
|
207
|
-
// desktop".
|
|
208
|
-
//
|
|
209
|
-
// undefined rather than null on purpose: JSON null means absent on this wire and the client
|
|
210
|
-
// convention is undefined (see .claude/knowledge/ipc-contracts.md). Returning null broke two
|
|
211
|
-
// existing tests that assert this resolves to undefined, which is the convention catching a
|
|
212
|
-
// deviation exactly where it should.
|
|
189
|
+
// shell instead of sniffing the platform. A host that says nothing leaves it UNDEFINED (never
|
|
190
|
+
// null — JSON null means absent on this wire) — and absent means "assume nothing", never
|
|
191
|
+
// "assume desktop".
|
|
213
192
|
const shell = await this.invoke(HANDSHAKE_MODULE, HANDSHAKE_TYPE, { payload });
|
|
214
193
|
this.shellInfo = shell && typeof shell === 'object' && typeof shell.name === 'string'
|
|
215
194
|
? { name: shell.name, capabilities: Array.isArray(shell.capabilities) ? shell.capabilities : [] }
|
|
@@ -218,8 +197,8 @@ export class ShenoraBridge {
|
|
|
218
197
|
}
|
|
219
198
|
/**
|
|
220
199
|
* What the host said it was during {@link notifyReady} — undefined before the handshake, or when
|
|
221
|
-
* the host advertised nothing. Cached so components can read it synchronously while rendering
|
|
222
|
-
*
|
|
200
|
+
* the host advertised nothing. Cached so components can read it synchronously while rendering; a
|
|
201
|
+
* capability learned after layout is a visible flash.
|
|
223
202
|
*/
|
|
224
203
|
get shell() {
|
|
225
204
|
return this.shellInfo;
|
|
@@ -232,11 +211,10 @@ export class ShenoraBridge {
|
|
|
232
211
|
this.unsubscribe?.();
|
|
233
212
|
for (const [id, entry] of this.pending) {
|
|
234
213
|
clearTimeout(entry.timer);
|
|
235
|
-
entry.reject(new
|
|
214
|
+
entry.reject(new ShenoraError({ code: IpcErrorCodes.noTransport, message: 'Bridge disposed.' }));
|
|
236
215
|
this.pending.delete(id);
|
|
237
216
|
}
|
|
238
|
-
//
|
|
239
|
-
// holds no references (this is the instance `configureBridge` replaces).
|
|
217
|
+
// Pure bookkeeping with nothing to settle — dropped so a disposed bridge holds no references.
|
|
240
218
|
this.unawaited.clear();
|
|
241
219
|
}
|
|
242
220
|
onHostMessage(message) {
|
|
@@ -248,10 +226,9 @@ export class ShenoraBridge {
|
|
|
248
226
|
console.error('[shenora] ignored unparseable host message:', error);
|
|
249
227
|
return;
|
|
250
228
|
}
|
|
251
|
-
// A literal `null` is VALID JSON
|
|
252
|
-
//
|
|
253
|
-
//
|
|
254
|
-
// yields undefined; null and only null did. Anything that isn't an object simply isn't ours.
|
|
229
|
+
// ⚠ A literal `null` is VALID JSON and survives the parse, and property access on it throws —
|
|
230
|
+
// out of a transport listener, i.e. an uncaught page error with no caller to catch it. Anything
|
|
231
|
+
// that is not an object simply is not ours.
|
|
255
232
|
if (parsed === null || typeof parsed !== 'object')
|
|
256
233
|
return;
|
|
257
234
|
const envelope = parsed;
|
|
@@ -261,10 +238,9 @@ export class ShenoraBridge {
|
|
|
261
238
|
return;
|
|
262
239
|
const entry = this.pending.get(response.id);
|
|
263
240
|
if (!entry) {
|
|
264
|
-
// No pending call. Either this answers a one-way `post
|
|
265
|
-
//
|
|
266
|
-
//
|
|
267
|
-
// which stays ignored.
|
|
241
|
+
// No pending call. Either this answers a one-way `post`, whose FAILURE must be surfaced
|
|
242
|
+
// because there is no promise to reject, or it is a late/foreign response, which stays
|
|
243
|
+
// ignored.
|
|
268
244
|
const posted = this.unawaited.get(response.id);
|
|
269
245
|
if (posted) {
|
|
270
246
|
this.unawaited.delete(response.id);
|
|
@@ -285,7 +261,7 @@ export class ShenoraBridge {
|
|
|
285
261
|
entry.resolve(response.data);
|
|
286
262
|
}
|
|
287
263
|
else {
|
|
288
|
-
entry.reject(new
|
|
264
|
+
entry.reject(new ShenoraError(response.error ?? { code: IpcErrorCodes.unknownError }));
|
|
289
265
|
}
|
|
290
266
|
return;
|
|
291
267
|
}
|
|
@@ -0,0 +1,89 @@
|
|
|
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. `text` and `files` are named because every
|
|
9
|
+
* platform has a first-class API for them; everything else lives in `formats`, keyed by media type —
|
|
10
|
+
* {@link PNG_IMAGE}, {@link HTML}, or your own `application/…` type, which the host carries verbatim.
|
|
11
|
+
*/
|
|
12
|
+
export interface ClipboardContent {
|
|
13
|
+
/** The plain-text representation. */
|
|
14
|
+
text?: string;
|
|
15
|
+
/**
|
|
16
|
+
* Absolute paths, for the copy a file manager can paste.
|
|
17
|
+
* ⚠ DESKTOP only — gate on {@link ClipboardHandle.canCopyFiles}.
|
|
18
|
+
*/
|
|
19
|
+
files?: string[];
|
|
20
|
+
/** Every other representation, as raw bytes keyed by media type. */
|
|
21
|
+
formats?: Record<string, Uint8Array>;
|
|
22
|
+
}
|
|
23
|
+
/** What actually crosses the wire: the same shape with the byte payloads base64-encoded. */
|
|
24
|
+
interface ClipboardWire {
|
|
25
|
+
text?: string;
|
|
26
|
+
files?: string[];
|
|
27
|
+
formats?: Record<string, string>;
|
|
28
|
+
}
|
|
29
|
+
interface ClipboardRequests {
|
|
30
|
+
READ: undefined;
|
|
31
|
+
WRITE: {
|
|
32
|
+
content: ClipboardWire;
|
|
33
|
+
};
|
|
34
|
+
CLEAR: undefined;
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Typed client for the host's `SHENORA.CLIPBOARD` module (`ClipboardModule`).
|
|
38
|
+
*
|
|
39
|
+
* ⚠ **`files` is a DESKTOP capability** and rejects with `IpcErrorCodes.capabilityNotSupported` on a
|
|
40
|
+
* phone. Do not catch that — ask first, via {@link useClipboard}, and do not render the control.
|
|
41
|
+
*/
|
|
42
|
+
export declare class ClipboardAccess extends BaseModuleService<ClipboardRequests> {
|
|
43
|
+
constructor(bridge?: ShenoraBridge);
|
|
44
|
+
/**
|
|
45
|
+
* Everything the clipboard is offering — **no user gesture, no permission prompt, no focus
|
|
46
|
+
* requirement**, unlike `navigator.clipboard.read()`.
|
|
47
|
+
*/
|
|
48
|
+
read(): Promise<ClipboardContent>;
|
|
49
|
+
/**
|
|
50
|
+
* Replace the clipboard with one item, every representation at once.
|
|
51
|
+
*
|
|
52
|
+
* ⚠ Bytes cross the IPC envelope as base64, so a large picture is a large message. A page copying
|
|
53
|
+
* something big should hand the host a path and let it read the file instead.
|
|
54
|
+
*/
|
|
55
|
+
write(content: ClipboardContent): Promise<void>;
|
|
56
|
+
/** Leave the clipboard holding nothing. */
|
|
57
|
+
clear(): Promise<void>;
|
|
58
|
+
}
|
|
59
|
+
/** What {@link useClipboard} returns: the client, plus what this shell will actually honour. */
|
|
60
|
+
export interface ClipboardHandle {
|
|
61
|
+
/** The typed client. Stable across renders. */
|
|
62
|
+
clipboard: ClipboardAccess;
|
|
63
|
+
/** This shell can put a FILE LIST on the clipboard — desktop only. Use it to decide what to RENDER. */
|
|
64
|
+
canCopyFiles: boolean;
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* The clipboard client together with what the CURRENT shell can honour — read from the ready
|
|
68
|
+
* handshake, not sniffed from the platform (D36).
|
|
69
|
+
*
|
|
70
|
+
* ```tsx
|
|
71
|
+
* const { clipboard, canCopyFiles } = useClipboard();
|
|
72
|
+
* return (
|
|
73
|
+
* <>
|
|
74
|
+
* <button onClick={() => navigator.clipboard.writeText(name)}>Copy name</button>
|
|
75
|
+
* {canCopyFiles && (
|
|
76
|
+
* <button onClick={() => clipboard.write({ text: name, files: [path] })}>Copy file</button>
|
|
77
|
+
* )}
|
|
78
|
+
* </>
|
|
79
|
+
* );
|
|
80
|
+
* ```
|
|
81
|
+
*
|
|
82
|
+
* ⚠ Note the first button: plain text on a click is the browser's job and stays there. This hook is for
|
|
83
|
+
* the file copy beside it.
|
|
84
|
+
*
|
|
85
|
+
* ⚠ `canCopyFiles` is `false` until the handshake has landed, so await `bridge.notifyReady()` before
|
|
86
|
+
* rendering this tree — see {@link useShellInfo} for why the read is synchronous.
|
|
87
|
+
*/
|
|
88
|
+
export declare function useClipboard(clipboard?: ClipboardAccess): ClipboardHandle;
|
|
89
|
+
export {};
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The page's half of the host's `SHENORA.CLIPBOARD` module — the parts of the native clipboard the web
|
|
3
|
+
* platform withholds. Mirrors `Shenora.Modules.Clipboard.ClipboardModule`, pinned by `WireMirrorTests`.
|
|
4
|
+
*
|
|
5
|
+
* 🔴 **Reach for `navigator.clipboard` first.** A gesture-driven "copy this text" or "copy this image"
|
|
6
|
+
* already works and needs no host round trip. Two things it cannot do are why this module exists —
|
|
7
|
+
*
|
|
8
|
+
* 1. **Files.** No web API puts a file list on the clipboard, so the user cannot paste into Explorer,
|
|
9
|
+
* Finder or a file manager. There is no polyfill.
|
|
10
|
+
* 2. **Access with no user gesture or focus.** `navigator.clipboard.read()` needs transient activation,
|
|
11
|
+
* document focus and a permission. A host has none of those constraints.
|
|
12
|
+
*
|
|
13
|
+
* ⚠ **The choice is per-COPY, not per-format**: a clipboard set is atomic, one item, last writer wins.
|
|
14
|
+
* An item that includes files must be written entirely through {@link ClipboardAccess.write} — writing
|
|
15
|
+
* its text half with `navigator.clipboard` and the files here leaves only the files, silently.
|
|
16
|
+
*/
|
|
17
|
+
import { useMemo } from 'react';
|
|
18
|
+
import { useShellInfo } from './hooks.js';
|
|
19
|
+
import { BaseModuleService } from './moduleService.js';
|
|
20
|
+
import { ShellCapabilities } from './types.js';
|
|
21
|
+
/** Media type for PNG bytes — the interchange image format every platform and browser reads. */
|
|
22
|
+
export const PNG_IMAGE = 'image/png';
|
|
23
|
+
/** Media type for UTF-8 HTML, for a paste that keeps its formatting. */
|
|
24
|
+
export const HTML = 'text/html';
|
|
25
|
+
/**
|
|
26
|
+
* Base64 without a `Buffer`.
|
|
27
|
+
*
|
|
28
|
+
* ⚠ Never `btoa(String.fromCharCode(...bytes))`: the spread overflows the argument list and throws
|
|
29
|
+
* `RangeError` on anything past roughly a small screenshot. A chunked loop has no such ceiling.
|
|
30
|
+
*/
|
|
31
|
+
function toBase64(bytes) {
|
|
32
|
+
let binary = '';
|
|
33
|
+
const chunk = 0x8000;
|
|
34
|
+
for (let i = 0; i < bytes.length; i += chunk) {
|
|
35
|
+
binary += String.fromCharCode(...bytes.subarray(i, i + chunk));
|
|
36
|
+
}
|
|
37
|
+
return btoa(binary);
|
|
38
|
+
}
|
|
39
|
+
function fromBase64(value) {
|
|
40
|
+
const binary = atob(value);
|
|
41
|
+
const bytes = new Uint8Array(binary.length);
|
|
42
|
+
for (let i = 0; i < binary.length; i += 1)
|
|
43
|
+
bytes[i] = binary.charCodeAt(i);
|
|
44
|
+
return bytes;
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Typed client for the host's `SHENORA.CLIPBOARD` module (`ClipboardModule`).
|
|
48
|
+
*
|
|
49
|
+
* ⚠ **`files` is a DESKTOP capability** and rejects with `IpcErrorCodes.capabilityNotSupported` on a
|
|
50
|
+
* phone. Do not catch that — ask first, via {@link useClipboard}, and do not render the control.
|
|
51
|
+
*/
|
|
52
|
+
export class ClipboardAccess extends BaseModuleService {
|
|
53
|
+
constructor(bridge) {
|
|
54
|
+
super('SHENORA.CLIPBOARD', bridge);
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* Everything the clipboard is offering — **no user gesture, no permission prompt, no focus
|
|
58
|
+
* requirement**, unlike `navigator.clipboard.read()`.
|
|
59
|
+
*/
|
|
60
|
+
async read() {
|
|
61
|
+
const wire = await this.send('READ');
|
|
62
|
+
const formats = {};
|
|
63
|
+
for (const [mediaType, value] of Object.entries(wire.formats ?? {})) {
|
|
64
|
+
formats[mediaType] = fromBase64(value);
|
|
65
|
+
}
|
|
66
|
+
return { text: wire.text, files: wire.files, formats };
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Replace the clipboard with one item, every representation at once.
|
|
70
|
+
*
|
|
71
|
+
* ⚠ Bytes cross the IPC envelope as base64, so a large picture is a large message. A page copying
|
|
72
|
+
* something big should hand the host a path and let it read the file instead.
|
|
73
|
+
*/
|
|
74
|
+
write(content) {
|
|
75
|
+
const formats = {};
|
|
76
|
+
for (const [mediaType, bytes] of Object.entries(content.formats ?? {})) {
|
|
77
|
+
formats[mediaType] = toBase64(bytes);
|
|
78
|
+
}
|
|
79
|
+
return this.send('WRITE', {
|
|
80
|
+
payload: { content: { text: content.text, files: content.files, formats } },
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
/** Leave the clipboard holding nothing. */
|
|
84
|
+
clear() {
|
|
85
|
+
return this.send('CLEAR');
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* The clipboard client together with what the CURRENT shell can honour — read from the ready
|
|
90
|
+
* handshake, not sniffed from the platform (D36).
|
|
91
|
+
*
|
|
92
|
+
* ```tsx
|
|
93
|
+
* const { clipboard, canCopyFiles } = useClipboard();
|
|
94
|
+
* return (
|
|
95
|
+
* <>
|
|
96
|
+
* <button onClick={() => navigator.clipboard.writeText(name)}>Copy name</button>
|
|
97
|
+
* {canCopyFiles && (
|
|
98
|
+
* <button onClick={() => clipboard.write({ text: name, files: [path] })}>Copy file</button>
|
|
99
|
+
* )}
|
|
100
|
+
* </>
|
|
101
|
+
* );
|
|
102
|
+
* ```
|
|
103
|
+
*
|
|
104
|
+
* ⚠ Note the first button: plain text on a click is the browser's job and stays there. This hook is for
|
|
105
|
+
* the file copy beside it.
|
|
106
|
+
*
|
|
107
|
+
* ⚠ `canCopyFiles` is `false` until the handshake has landed, so await `bridge.notifyReady()` before
|
|
108
|
+
* rendering this tree — see {@link useShellInfo} for why the read is synchronous.
|
|
109
|
+
*/
|
|
110
|
+
export function useClipboard(clipboard) {
|
|
111
|
+
const shell = useShellInfo();
|
|
112
|
+
// Cached so it is usable as an effect dependency, not because the service is expensive.
|
|
113
|
+
const client = useMemo(() => clipboard ?? new ClipboardAccess(), [clipboard]);
|
|
114
|
+
const capabilities = shell?.capabilities;
|
|
115
|
+
return useMemo(() => ({
|
|
116
|
+
clipboard: client,
|
|
117
|
+
canCopyFiles: capabilities?.includes(ShellCapabilities.clipboardFiles) ?? false,
|
|
118
|
+
}), [client, capabilities]);
|
|
119
|
+
}
|
package/dist/devInterceptor.d.ts
CHANGED
|
@@ -1,11 +1,10 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Dev-only IPC + event-hub interceptor
|
|
3
|
-
*
|
|
2
|
+
* Dev-only IPC + event-hub interceptor. ⚠ NEVER ship it in prod — gate the single call site with
|
|
3
|
+
* `import.meta.env.DEV`.
|
|
4
4
|
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
* request/response/event into ring buffers, and (2) expose a window global so a CDP eval can
|
|
5
|
+
* For driving a page over CDP, where native dialogs and event-driven flows cannot be exercised by
|
|
6
|
+
* clicking. It wraps the bridge's `invoke` and the event bus's `emit` to (1) record + console.debug
|
|
7
|
+
* every request/response/event into ring buffers, and (2) expose a window global so a CDP eval can
|
|
9
8
|
* invoke ANY IPC directly and await events:
|
|
10
9
|
*
|
|
11
10
|
* window.__shenora.call('NOTES', 'ADD', { title: 'x' }) // drive an IPC, bypass the UI
|
|
@@ -44,7 +43,11 @@ export interface DevInterceptorOptions {
|
|
|
44
43
|
bus?: ShenoraEventBus;
|
|
45
44
|
}
|
|
46
45
|
/**
|
|
47
|
-
* Install the interceptor
|
|
48
|
-
*
|
|
46
|
+
* Install the interceptor. Idempotent across HMR and StrictMode's double-invoke.
|
|
47
|
+
*
|
|
48
|
+
* 🔴 **Idempotency is keyed on WHICH bridge and bus were wrapped, not on "something is installed".**
|
|
49
|
+
* The wrapping mutates a specific `ShenoraBridge` INSTANCE, so a guard that only asks whether the
|
|
50
|
+
* window global exists leaves the interceptor pointing at the bridge `configureBridge()` disposed:
|
|
51
|
+
* installed-looking, recording NOTHING, and its silence reads as "no traffic".
|
|
49
52
|
*/
|
|
50
53
|
export declare function installDevInterceptor(options?: DevInterceptorOptions): void;
|
package/dist/devInterceptor.js
CHANGED
|
@@ -1,11 +1,10 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Dev-only IPC + event-hub interceptor
|
|
3
|
-
*
|
|
2
|
+
* Dev-only IPC + event-hub interceptor. ⚠ NEVER ship it in prod — gate the single call site with
|
|
3
|
+
* `import.meta.env.DEV`.
|
|
4
4
|
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
* request/response/event into ring buffers, and (2) expose a window global so a CDP eval can
|
|
5
|
+
* For driving a page over CDP, where native dialogs and event-driven flows cannot be exercised by
|
|
6
|
+
* clicking. It wraps the bridge's `invoke` and the event bus's `emit` to (1) record + console.debug
|
|
7
|
+
* every request/response/event into ring buffers, and (2) expose a window global so a CDP eval can
|
|
9
8
|
* invoke ANY IPC directly and await events:
|
|
10
9
|
*
|
|
11
10
|
* window.__shenora.call('NOTES', 'ADD', { title: 'x' }) // drive an IPC, bypass the UI
|
|
@@ -15,19 +14,26 @@
|
|
|
15
14
|
import { getBridge } from './bridge.js';
|
|
16
15
|
import { eventBus as defaultEventBus } from './eventBus.js';
|
|
17
16
|
/**
|
|
18
|
-
* Install the interceptor
|
|
19
|
-
*
|
|
17
|
+
* Install the interceptor. Idempotent across HMR and StrictMode's double-invoke.
|
|
18
|
+
*
|
|
19
|
+
* 🔴 **Idempotency is keyed on WHICH bridge and bus were wrapped, not on "something is installed".**
|
|
20
|
+
* The wrapping mutates a specific `ShenoraBridge` INSTANCE, so a guard that only asks whether the
|
|
21
|
+
* window global exists leaves the interceptor pointing at the bridge `configureBridge()` disposed:
|
|
22
|
+
* installed-looking, recording NOTHING, and its silence reads as "no traffic".
|
|
20
23
|
*/
|
|
21
24
|
export function installDevInterceptor(options = {}) {
|
|
22
25
|
if (typeof window === 'undefined')
|
|
23
26
|
return;
|
|
24
27
|
const globalName = options.globalName ?? '__shenora';
|
|
25
28
|
const w = window;
|
|
26
|
-
if (w[globalName])
|
|
27
|
-
return;
|
|
28
29
|
const ringSize = options.ringSize ?? 300;
|
|
30
|
+
// Resolved BEFORE the guard, because the guard's question is about these two objects.
|
|
29
31
|
const bridge = options.bridge ?? getBridge();
|
|
30
32
|
const bus = options.bus ?? defaultEventBus;
|
|
33
|
+
// Same pair = the HMR/StrictMode case; wrapping twice doubles every log line.
|
|
34
|
+
const installed = w[globalName];
|
|
35
|
+
if (installed && installed.bridge === bridge && installed.eventBus === bus)
|
|
36
|
+
return;
|
|
31
37
|
const ipc = [];
|
|
32
38
|
const events = [];
|
|
33
39
|
const push = (buffer, entry) => {
|
package/dist/errors.d.ts
CHANGED
|
@@ -1,12 +1,11 @@
|
|
|
1
1
|
import type { IpcError } from './types.js';
|
|
2
2
|
/**
|
|
3
|
-
* The rejection type for every failed bridge call — the client mirror of the host's
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
* transport) reject through this same shape with the client-reserved codes.
|
|
3
|
+
* The rejection type for every failed bridge call — the client mirror of the host's `ShenoraException`.
|
|
4
|
+
* Carries the structured code + parameters so callers translate `errors.{code}` instead of matching
|
|
5
|
+
* message strings. Client-side failures (timeout, missing transport) reject through this same shape
|
|
6
|
+
* with the client-reserved codes.
|
|
8
7
|
*/
|
|
9
|
-
export declare class
|
|
8
|
+
export declare class ShenoraError extends Error {
|
|
10
9
|
/** Error code / i18n key (e.g. `"IMPORT_FAILED"`, `"TIMEOUT"`). */
|
|
11
10
|
readonly code: string;
|
|
12
11
|
/** Values the client interpolates into the translated message. */
|
package/dist/errors.js
CHANGED
|
@@ -1,14 +1,13 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The rejection type for every failed bridge call — the client mirror of the host's
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
* transport) reject through this same shape with the client-reserved codes.
|
|
2
|
+
* The rejection type for every failed bridge call — the client mirror of the host's `ShenoraException`.
|
|
3
|
+
* Carries the structured code + parameters so callers translate `errors.{code}` instead of matching
|
|
4
|
+
* message strings. Client-side failures (timeout, missing transport) reject through this same shape
|
|
5
|
+
* with the client-reserved codes.
|
|
7
6
|
*/
|
|
8
|
-
export class
|
|
7
|
+
export class ShenoraError extends Error {
|
|
9
8
|
constructor(error) {
|
|
10
9
|
super(error.message ?? error.code);
|
|
11
|
-
this.name = '
|
|
10
|
+
this.name = 'ShenoraError';
|
|
12
11
|
this.code = error.code;
|
|
13
12
|
this.parameters = error.parameters;
|
|
14
13
|
}
|