@shenora/react 0.4.0 → 0.5.1
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 +12 -5
- package/dist/bridge.js +25 -4
- package/dist/index.d.ts +2 -2
- package/dist/index.js +2 -2
- package/dist/transport.d.ts +28 -2
- package/dist/transport.js +52 -3
- package/dist/types.d.ts +36 -0
- package/dist/types.js +13 -0
- package/dist/windowCommands.d.ts +1 -1
- package/dist/windowCommands.js +1 -1
- package/package.json +1 -1
package/dist/bridge.d.ts
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
import { type ShenoraEventBus } from './eventBus.js';
|
|
2
2
|
import { type ShenoraTransport } from './transport.js';
|
|
3
|
-
import { type IpcError, type IpcRequest } from './types.js';
|
|
3
|
+
import { type IpcError, type IpcRequest, type ShellInfo } from './types.js';
|
|
4
4
|
/** Inputs for {@link ShenoraBridge}. */
|
|
5
5
|
export interface ShenoraBridgeOptions {
|
|
6
6
|
/**
|
|
7
|
-
* The channel to the host. Default:
|
|
8
|
-
*
|
|
9
|
-
*
|
|
7
|
+
* The channel to the host. Default: whichever Shenora host this page is in — WebView2 postMessage
|
|
8
|
+
* on the desktop shell, `HybridWebView` on the MAUI shell — else null (plain browser). Supply your
|
|
9
|
+
* own for another shell (a WebSocket, D16) or a scripted fake for tests/preview harnesses.
|
|
10
10
|
*/
|
|
11
11
|
transport?: ShenoraTransport | null;
|
|
12
12
|
/** The event bus host notifications are unbundled into. Default: the shared bus. */
|
|
@@ -78,6 +78,7 @@ export declare class ShenoraBridge {
|
|
|
78
78
|
private readonly onPostError;
|
|
79
79
|
private readonly unsubscribe?;
|
|
80
80
|
private disposed;
|
|
81
|
+
private shellInfo;
|
|
81
82
|
constructor(options?: ShenoraBridgeOptions);
|
|
82
83
|
/**
|
|
83
84
|
* True when this bridge can actually send: a transport to a host exists AND the bridge has not been
|
|
@@ -135,7 +136,13 @@ export declare class ShenoraBridge {
|
|
|
135
136
|
* `void bridge.notifyReady()` turns that into an unhandled rejection, which in a WebView2 page is
|
|
136
137
|
* a silent console error.
|
|
137
138
|
*/
|
|
138
|
-
notifyReady<TPayload = unknown>(payload?: TPayload): Promise<
|
|
139
|
+
notifyReady<TPayload = unknown>(payload?: TPayload): Promise<ShellInfo | undefined>;
|
|
140
|
+
/**
|
|
141
|
+
* What the host said it was during {@link notifyReady} — undefined before the handshake, or when
|
|
142
|
+
* the host advertised nothing. Cached so components can read it synchronously while rendering,
|
|
143
|
+
* which is the whole point: a capability learned after layout is a flash.
|
|
144
|
+
*/
|
|
145
|
+
get shell(): ShellInfo | undefined;
|
|
139
146
|
/** Reject everything in flight and detach from the transport. */
|
|
140
147
|
dispose(): void;
|
|
141
148
|
private onHostMessage;
|
package/dist/bridge.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { OperationError } from './errors.js';
|
|
2
2
|
import { eventBus as defaultEventBus } from './eventBus.js';
|
|
3
3
|
import { randomId } from './internal.js';
|
|
4
|
-
import {
|
|
4
|
+
import { createHostTransport } from './transport.js';
|
|
5
5
|
import { HANDSHAKE_MODULE, HANDSHAKE_TYPE, IpcCategories, IpcErrorCodes, } from './types.js';
|
|
6
6
|
const newId = () => randomId();
|
|
7
7
|
/** A promise-like: only these need racing against a timeout — a plain value has already settled. */
|
|
@@ -23,7 +23,7 @@ export class ShenoraBridge {
|
|
|
23
23
|
// Insertion-ordered and capped: a Map is used for its ordered keys, not for the values.
|
|
24
24
|
this.unawaited = new Map();
|
|
25
25
|
this.disposed = false;
|
|
26
|
-
this.transport = options.transport !== undefined ? options.transport :
|
|
26
|
+
this.transport = options.transport !== undefined ? options.transport : createHostTransport();
|
|
27
27
|
this.eventBus = options.eventBus ?? defaultEventBus;
|
|
28
28
|
this.defaultTimeoutMs = options.defaultTimeoutMs ?? 30000;
|
|
29
29
|
this.fallback = options.fallback;
|
|
@@ -200,8 +200,29 @@ export class ShenoraBridge {
|
|
|
200
200
|
*/
|
|
201
201
|
async notifyReady(payload) {
|
|
202
202
|
if (!this.transport)
|
|
203
|
-
return;
|
|
204
|
-
|
|
203
|
+
return undefined;
|
|
204
|
+
// 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 (no descriptor configured, or
|
|
206
|
+
// one predating this) leaves it UNDEFINED — absent means "assume nothing", never "assume
|
|
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.
|
|
213
|
+
const shell = await this.invoke(HANDSHAKE_MODULE, HANDSHAKE_TYPE, { payload });
|
|
214
|
+
this.shellInfo = shell && typeof shell === 'object' && typeof shell.name === 'string'
|
|
215
|
+
? { name: shell.name, capabilities: Array.isArray(shell.capabilities) ? shell.capabilities : [] }
|
|
216
|
+
: undefined;
|
|
217
|
+
return this.shellInfo;
|
|
218
|
+
}
|
|
219
|
+
/**
|
|
220
|
+
* 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
|
+
* which is the whole point: a capability learned after layout is a flash.
|
|
223
|
+
*/
|
|
224
|
+
get shell() {
|
|
225
|
+
return this.shellInfo;
|
|
205
226
|
}
|
|
206
227
|
/** Reject everything in flight and detach from the transport. */
|
|
207
228
|
dispose() {
|
package/dist/index.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
export { IpcCategories, IpcErrorCodes, HANDSHAKE_MODULE, HANDSHAKE_TYPE, type IpcRequest, type IpcResponse, type IpcError, type IpcNotification, type IpcNotificationBatch, type EventMessage, } from './types.js';
|
|
1
|
+
export { IpcCategories, IpcErrorCodes, HANDSHAKE_MODULE, HANDSHAKE_TYPE, type IpcRequest, type IpcResponse, type IpcError, type IpcNotification, type IpcNotificationBatch, type EventMessage, ShellCapabilities, type ShellInfo, } from './types.js';
|
|
2
2
|
export { OperationError } from './errors.js';
|
|
3
|
-
export { isShenoraAvailable, createWebView2Transport, type ShenoraTransport } from './transport.js';
|
|
3
|
+
export { isShenoraAvailable, createHostTransport, createWebView2Transport, createHybridWebViewTransport, type ShenoraTransport, } from './transport.js';
|
|
4
4
|
export { ShenoraEventBus, eventBus } from './eventBus.js';
|
|
5
5
|
export { ShenoraBridge, getBridge, configureBridge, type ShenoraBridgeOptions, type InvokeOptions, type PostOptions, type PostFailure, } from './bridge.js';
|
|
6
6
|
export { createShenoraStore, type ShenoraStore, type ShenoraStoreOptions, type ShenoraStoreIo, type ShenoraStoreSnapshot, } from './store.js';
|
package/dist/index.js
CHANGED
|
@@ -2,9 +2,9 @@
|
|
|
2
2
|
// pluggable transport, the event hub host notifications unbundle into, typed module services,
|
|
3
3
|
// React hooks, and the dev interceptor for CDP-driven testing. Headless by design (D13): no UI
|
|
4
4
|
// components, no design-system dependency — apps bring their own.
|
|
5
|
-
export { IpcCategories, IpcErrorCodes, HANDSHAKE_MODULE, HANDSHAKE_TYPE, } from './types.js';
|
|
5
|
+
export { IpcCategories, IpcErrorCodes, HANDSHAKE_MODULE, HANDSHAKE_TYPE, ShellCapabilities, } from './types.js';
|
|
6
6
|
export { OperationError } from './errors.js';
|
|
7
|
-
export { isShenoraAvailable, createWebView2Transport } from './transport.js';
|
|
7
|
+
export { isShenoraAvailable, createHostTransport, createWebView2Transport, createHybridWebViewTransport, } from './transport.js';
|
|
8
8
|
export { ShenoraEventBus, eventBus } from './eventBus.js';
|
|
9
9
|
export { ShenoraBridge, getBridge, configureBridge, } from './bridge.js';
|
|
10
10
|
export { createShenoraStore, } from './store.js';
|
package/dist/transport.d.ts
CHANGED
|
@@ -10,9 +10,35 @@ export interface ShenoraTransport {
|
|
|
10
10
|
subscribe(listener: (message: string) => void): () => void;
|
|
11
11
|
}
|
|
12
12
|
/**
|
|
13
|
-
* True when running inside a WebView2 desktop
|
|
14
|
-
*
|
|
13
|
+
* True when running inside ANY Shenora host — a WebView2 desktop shell or a MAUI
|
|
14
|
+
* `HybridWebView` — i.e. when a transport to the host exists. In a plain browser this is false and
|
|
15
|
+
* callers should fall back to browser-only behavior.
|
|
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".
|
|
15
21
|
*/
|
|
16
22
|
export declare function isShenoraAvailable(): boolean;
|
|
17
23
|
/** The WebView2 postMessage transport, or null outside a WebView2 host. */
|
|
18
24
|
export declare function createWebView2Transport(): ShenoraTransport | null;
|
|
25
|
+
/**
|
|
26
|
+
* The MAUI `HybridWebView` transport, or null outside a MAUI host.
|
|
27
|
+
*
|
|
28
|
+
* Asymmetric by the platform's design, which is the only thing interesting here: sending goes
|
|
29
|
+
* through `window.HybridWebView.SendRawMessage`, while receiving is a `HybridWebViewMessageReceived`
|
|
30
|
+
* CustomEvent dispatched on `window`. Both directions carry the same JSON envelopes the desktop
|
|
31
|
+
* shell speaks — the host side is `Shenora.Maui.MauiIpcBridge`, and the envelope itself never
|
|
32
|
+
* changed, which is the whole point of the transport seam (D16).
|
|
33
|
+
*/
|
|
34
|
+
export declare function createHybridWebViewTransport(): ShenoraTransport | null;
|
|
35
|
+
/**
|
|
36
|
+
* The transport for whichever Shenora host this page is running in, or null in a plain browser.
|
|
37
|
+
* 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.
|
|
43
|
+
*/
|
|
44
|
+
export declare function createHostTransport(): ShenoraTransport | null;
|
package/dist/transport.js
CHANGED
|
@@ -1,10 +1,17 @@
|
|
|
1
1
|
const webViewWindow = () => typeof window === 'undefined' ? undefined : window;
|
|
2
2
|
/**
|
|
3
|
-
* True when running inside a WebView2 desktop
|
|
4
|
-
*
|
|
3
|
+
* True when running inside ANY Shenora host — a WebView2 desktop shell or a MAUI
|
|
4
|
+
* `HybridWebView` — i.e. when a transport to the host exists. In a plain browser this is false and
|
|
5
|
+
* callers should fall back to browser-only behavior.
|
|
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".
|
|
5
11
|
*/
|
|
6
12
|
export function isShenoraAvailable() {
|
|
7
|
-
|
|
13
|
+
const host = webViewWindow();
|
|
14
|
+
return !!host?.chrome?.webview || !!host?.HybridWebView;
|
|
8
15
|
}
|
|
9
16
|
/** The WebView2 postMessage transport, or null outside a WebView2 host. */
|
|
10
17
|
export function createWebView2Transport() {
|
|
@@ -24,3 +31,45 @@ export function createWebView2Transport() {
|
|
|
24
31
|
},
|
|
25
32
|
};
|
|
26
33
|
}
|
|
34
|
+
/**
|
|
35
|
+
* The MAUI `HybridWebView` transport, or null outside a MAUI host.
|
|
36
|
+
*
|
|
37
|
+
* Asymmetric by the platform's design, which is the only thing interesting here: sending goes
|
|
38
|
+
* through `window.HybridWebView.SendRawMessage`, while receiving is a `HybridWebViewMessageReceived`
|
|
39
|
+
* CustomEvent dispatched on `window`. Both directions carry the same JSON envelopes the desktop
|
|
40
|
+
* shell speaks — the host side is `Shenora.Maui.MauiIpcBridge`, and the envelope itself never
|
|
41
|
+
* changed, which is the whole point of the transport seam (D16).
|
|
42
|
+
*/
|
|
43
|
+
export function createHybridWebViewTransport() {
|
|
44
|
+
const host = webViewWindow();
|
|
45
|
+
const hybrid = host?.HybridWebView;
|
|
46
|
+
if (!hybrid)
|
|
47
|
+
return null;
|
|
48
|
+
return {
|
|
49
|
+
post: (message) => hybrid.SendRawMessage(message),
|
|
50
|
+
subscribe: (listener) => {
|
|
51
|
+
const handler = (event) => {
|
|
52
|
+
// Narrow before reading: any code on the page can dispatch an event with this name, and a
|
|
53
|
+
// non-string detail must be ignored rather than handed to JSON.parse. Same rule the inbound
|
|
54
|
+
// WebView2 path follows.
|
|
55
|
+
const message = event.detail?.message;
|
|
56
|
+
if (typeof message === 'string')
|
|
57
|
+
listener(message);
|
|
58
|
+
};
|
|
59
|
+
window.addEventListener('HybridWebViewMessageReceived', handler);
|
|
60
|
+
return () => window.removeEventListener('HybridWebViewMessageReceived', handler);
|
|
61
|
+
},
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* The transport for whichever Shenora host this page is running in, or null in a plain browser.
|
|
66
|
+
* 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.
|
|
72
|
+
*/
|
|
73
|
+
export function createHostTransport() {
|
|
74
|
+
return createWebView2Transport() ?? createHybridWebViewTransport();
|
|
75
|
+
}
|
package/dist/types.d.ts
CHANGED
|
@@ -70,6 +70,42 @@ export interface IpcError {
|
|
|
70
70
|
/** Values interpolated into the translated message. */
|
|
71
71
|
parameters?: Record<string, string>;
|
|
72
72
|
}
|
|
73
|
+
/**
|
|
74
|
+
* What the host is and what it can do — the handshake's response data (mirror of the host's
|
|
75
|
+
* `ShellInfo`).
|
|
76
|
+
*
|
|
77
|
+
* This is what lets ONE page ship to every shell. Render on the data rather than sniffing the
|
|
78
|
+
* platform:
|
|
79
|
+
*
|
|
80
|
+
* ```tsx
|
|
81
|
+
* const shell = useShellInfo();
|
|
82
|
+
* return <>{shell?.capabilities.includes(ShellCapabilities.windowChrome) && <TitleBar />}</>;
|
|
83
|
+
* ```
|
|
84
|
+
*
|
|
85
|
+
* A desktop shell that draws its own chrome advertises `windowChrome` and `dropZones`; a mobile one
|
|
86
|
+
* has neither, and the same bundle renders correctly on both. Undefined means no host said
|
|
87
|
+
* anything — a plain browser tab, or a host predating this — so treat absent as "assume nothing",
|
|
88
|
+
* never as "assume desktop".
|
|
89
|
+
*/
|
|
90
|
+
export interface ShellInfo {
|
|
91
|
+
/** Short host identifier, for diagnostics (`"winforms"`, `"maui"`). Never branch on this — branch on the capabilities. */
|
|
92
|
+
name: string;
|
|
93
|
+
/** The capabilities this host offers; see {@link ShellCapabilities}. */
|
|
94
|
+
capabilities: string[];
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* The well-known capability names, mirroring the host's `ShellCapability` constants. Apps may
|
|
98
|
+
* advertise their own strings too — this is the shared vocabulary, not the whole set.
|
|
99
|
+
*/
|
|
100
|
+
export declare const ShellCapabilities: {
|
|
101
|
+
readonly windowChrome: "windowChrome";
|
|
102
|
+
readonly dropZones: "dropZones";
|
|
103
|
+
readonly filePicker: "filePicker";
|
|
104
|
+
readonly folderPicker: "folderPicker";
|
|
105
|
+
readonly savePicker: "savePicker";
|
|
106
|
+
readonly secondaryWindows: "secondaryWindows";
|
|
107
|
+
readonly tray: "tray";
|
|
108
|
+
};
|
|
73
109
|
/** The response envelope the host returns for an {@link IpcRequest}. */
|
|
74
110
|
export interface IpcResponse<TData = unknown> {
|
|
75
111
|
category: typeof IpcCategories.ipc;
|
package/dist/types.js
CHANGED
|
@@ -51,3 +51,16 @@ export const ClientOnlyIpcErrorCodes = [
|
|
|
51
51
|
IpcErrorCodes.timeout,
|
|
52
52
|
IpcErrorCodes.noTransport,
|
|
53
53
|
];
|
|
54
|
+
/**
|
|
55
|
+
* The well-known capability names, mirroring the host's `ShellCapability` constants. Apps may
|
|
56
|
+
* advertise their own strings too — this is the shared vocabulary, not the whole set.
|
|
57
|
+
*/
|
|
58
|
+
export const ShellCapabilities = {
|
|
59
|
+
windowChrome: 'windowChrome',
|
|
60
|
+
dropZones: 'dropZones',
|
|
61
|
+
filePicker: 'filePicker',
|
|
62
|
+
folderPicker: 'folderPicker',
|
|
63
|
+
savePicker: 'savePicker',
|
|
64
|
+
secondaryWindows: 'secondaryWindows',
|
|
65
|
+
tray: 'tray',
|
|
66
|
+
};
|
package/dist/windowCommands.d.ts
CHANGED
|
@@ -33,7 +33,7 @@ interface WindowRequests {
|
|
|
33
33
|
};
|
|
34
34
|
}
|
|
35
35
|
/**
|
|
36
|
-
* Typed client for the host's `WINDOW` module (`WindowCommandFacade` in Shenora.
|
|
36
|
+
* Typed client for the host's `WINDOW` module (`WindowCommandFacade` in Shenora.Windows) —
|
|
37
37
|
* drive the frameless window's chrome from the page: chrome buttons call
|
|
38
38
|
* `minimize`/`toggleMaximize`/`close`, the header's `onMouseDown` calls `startDrag` (the window
|
|
39
39
|
* then drags natively — snap and multi-monitor included), and a thin strip at the very top
|
package/dist/windowCommands.js
CHANGED
|
@@ -2,7 +2,7 @@ import { useEffect, useRef, useState } from 'react';
|
|
|
2
2
|
import { debounce } from './internal.js';
|
|
3
3
|
import { BaseModuleService } from './moduleService.js';
|
|
4
4
|
/**
|
|
5
|
-
* Typed client for the host's `WINDOW` module (`WindowCommandFacade` in Shenora.
|
|
5
|
+
* Typed client for the host's `WINDOW` module (`WindowCommandFacade` in Shenora.Windows) —
|
|
6
6
|
* drive the frameless window's chrome from the page: chrome buttons call
|
|
7
7
|
* `minimize`/`toggleMaximize`/`close`, the header's `onMouseDown` calls `startDrag` (the window
|
|
8
8
|
* then drags natively — snap and multi-monitor included), and a thin strip at the very top
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@shenora/react",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.1",
|
|
4
4
|
"description": "React client for Shenora desktop hosts: correlated invoke/send/subscribe over the WebView2 bridge, typed module services, hooks, and a browser fallback for pure-UI development.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Jiarong Gu",
|