@shenora/react 0.16.0 → 0.17.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/README.md CHANGED
@@ -1,7 +1,7 @@
1
1
  # @shenora/react
2
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`
3
+ React client for [Shenora](https://github.com/JiarongGu/Shenora) hosts: the Windows shell (WebView2 or
4
+ Chromium), the Chromium shell, Android and iOS. The typed bridge between a React frontend and the Shenora host: correlated `invoke`
5
5
  with timeouts and structured errors, the event hub host notifications stream into, typed module
6
6
  services, React hooks, and a pluggable transport with a browser fallback so the UI can be
7
7
  developed in a plain browser. Headless by design — no UI components, bring your own design
@@ -144,8 +144,9 @@ runs ahead of the feature code it is observing. Prefer `subscribe` when you know
144
144
  catch-all wakes for every event on the bus.
145
145
 
146
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.
147
+ `import.meta.env.DEV`) to answer requests with canned data. The kit's own hosts are found by
148
+ `createHostTransport()`; another host (a WebSocket, say) implements the small `ShenoraTransport` seam and
149
+ speaks the same envelopes.
149
150
  For CDP-driven testing, `installDevInterceptor()` records IPC/event traffic into ring buffers
150
151
  and exposes `window.__shenora.call()/waitEvent()`.
151
152
 
package/dist/index.d.ts CHANGED
@@ -1,12 +1,12 @@
1
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 { ShenoraError } from './errors.js';
3
- export { isShenoraAvailable, createHostTransport, createWebView2Transport, createHybridWebViewTransport, type ShenoraTransport, } from './transport.js';
3
+ export { isShenoraAvailable, createHostTransport, createWebView2Transport, createHybridWebViewTransport, createChromiumTransport, type ShenoraTransport, } from './transport.js';
4
4
  export { ShenoraEventBus, eventBus, type SubscribeOptions, } 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';
7
7
  export { BaseModuleService } from './moduleService.js';
8
8
  export { IpcRequestStates, IpcRequestEventTypes, IpcRequestRoutes, IpcRequestsModuleName, createRequestsStore, useShenoraRequests, type IpcRequestState, type IpcLabel, type IpcProgress, type IpcRequestStatus, type RequestsState, type RequestsActions, type RequestsStoreOptions, } from './requests.js';
9
- export { WindowCommands, useWindowMaximized, type WindowResizeEdge, type CaptionButtonKind, type CaptionButtonRect, } from './windowCommands.js';
9
+ export { WindowCommands, WindowEventTypes, useCaptionButtonState, useWindowMaximized, type WindowResizeEdge, type CaptionButtonKind, type CaptionButtonRect, type CaptionButtonState, type CaptionButtonColors, } from './windowCommands.js';
10
10
  export { WindowOrientation, type WindowOrientationKind, } from './windowOrientation.js';
11
11
  export { useDropZone, DROP_ZONE_MODULE, type DropZoneFileDrop, type UseDropZoneOptions, } from './useDropZone.js';
12
12
  export { useShenora, useShenoraEvent, useShenoraQuery, useShellInfo, type ShenoraQueryResult, } from './hooks.js';
@@ -21,3 +21,5 @@ export { bindSegmentStream, SegmentBinderError } from './segmentBinder.js';
21
21
  export type { SegmentBinderOptions, SegmentBinding } from './segmentBinder.js';
22
22
  export type { SegmentEntry, SegmentManifest, MediaSourceKind, MediaSourceGlobals, FetchState, FetchPolicy, } from './segmentStream.js';
23
23
  export { useMediaPlayer, MEDIA_PLAYER_MODULE, MEDIA_PLAYER_REPORT, MEDIA_PLAYER_STATUS, MediaPlayerCommands, MediaConversionEvents, MediaConversionErrorCodes, type MediaPlayerReport, type MediaPlayerReportState, type UseMediaPlayerOptions, } from './mediaPlayer.js';
24
+ export { useMediaSurface, MediaSurfaceCommands, type UseMediaSurfaceOptions, } from './mediaSurface.js';
25
+ export { useMediaTransport, type MediaTransport, type MediaTransportStatus, type UseMediaTransportOptions, } from './mediaTransport.js';
package/dist/index.js CHANGED
@@ -4,7 +4,7 @@
4
4
  // components, no design-system dependency — apps bring their own.
5
5
  export { IpcCategories, IpcErrorCodes, HANDSHAKE_MODULE, HANDSHAKE_TYPE, ShellCapabilities, } from './types.js';
6
6
  export { ShenoraError } from './errors.js';
7
- export { isShenoraAvailable, createHostTransport, createWebView2Transport, createHybridWebViewTransport, } from './transport.js';
7
+ export { isShenoraAvailable, createHostTransport, createWebView2Transport, createHybridWebViewTransport, createChromiumTransport, } 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';
@@ -13,7 +13,7 @@ export { IpcRequestStates,
13
13
  // The event and route vocabulary + the default module name, so an app handling or cancelling a
14
14
  // request without `useShenoraRequests` never types the wire literals by hand.
15
15
  IpcRequestEventTypes, IpcRequestRoutes, IpcRequestsModuleName, createRequestsStore, useShenoraRequests, } from './requests.js';
16
- export { WindowCommands, useWindowMaximized, } from './windowCommands.js';
16
+ export { WindowCommands, WindowEventTypes, useCaptionButtonState, useWindowMaximized, } from './windowCommands.js';
17
17
  export { WindowOrientation, } from './windowOrientation.js';
18
18
  export { useDropZone, DROP_ZONE_MODULE, } from './useDropZone.js';
19
19
  export { useShenora, useShenoraEvent, useShenoraQuery, useShellInfo, } from './hooks.js';
@@ -35,3 +35,7 @@ export { parseManifest, pickMediaSource, nextSegment, segmentMimeType, codecsFro
35
35
  export { bindSegmentStream, SegmentBinderError } from './segmentBinder.js';
36
36
  // The HOST-owned player (D58): .NET holds the lifecycle, the page's element is the display and the sound.
37
37
  export { useMediaPlayer, MEDIA_PLAYER_MODULE, MEDIA_PLAYER_REPORT, MEDIA_PLAYER_STATUS, MediaPlayerCommands, MediaConversionEvents, MediaConversionErrorCodes, } from './mediaPlayer.js';
38
+ // The SHELL-owned picture — the same player's second surface, for what the element cannot decode.
39
+ export { useMediaSurface, MediaSurfaceCommands, } from './mediaSurface.js';
40
+ // Driving and reading that same player, for when the shell owns the picture and the page owns the controls.
41
+ export { useMediaTransport, } from './mediaTransport.js';
@@ -1,6 +1,9 @@
1
1
  /**
2
2
  * Shared internals for `@shenora/react`. NOT exported from the barrel — nothing here is public
3
3
  * surface, and it must not become so by accident.
4
+ *
5
+ * ⚠ No React here: the bridge imports this module, and the bridge must load without React (a page importing
6
+ * `bridge.js` directly, a worker, a test). React-only internals live in `internalHooks.ts`.
4
7
  */
5
8
  /** A debounced void callback with a `cancel` for effect teardown. */
6
9
  export interface Debounced {
package/dist/internal.js CHANGED
@@ -1,6 +1,9 @@
1
1
  /**
2
2
  * Shared internals for `@shenora/react`. NOT exported from the barrel — nothing here is public
3
3
  * surface, and it must not become so by accident.
4
+ *
5
+ * ⚠ No React here: the bridge imports this module, and the bridge must load without React (a page importing
6
+ * `bridge.js` directly, a worker, a test). React-only internals live in `internalHooks.ts`.
4
7
  */
5
8
  /**
6
9
  * Trailing-edge debounce: the callback runs `ms` after the LAST call. `cancel` must be called from a
@@ -0,0 +1,12 @@
1
+ /**
2
+ * React-only internals for `@shenora/react`'s hooks, apart from `internal.ts`, which the React-free bridge
3
+ * imports. NOT exported from the barrel.
4
+ */
5
+ import { type RefObject } from 'react';
6
+ /**
7
+ * A ref's CONTENT, as state. A ref is a stable object, so an effect keyed on it runs once, and a target that
8
+ * is not there on that run (rendered conditionally, or attached after the first commit) is never seen: the
9
+ * hook is silently dead for the component's whole life. A ref mutation triggers no render, so this effect has
10
+ * NO dependency array; setting an unchanged value is a React no-op, so it cannot loop.
11
+ */
12
+ export declare function useRefElement<T extends Element>(ref: RefObject<T | null>): T | null;
@@ -0,0 +1,18 @@
1
+ /**
2
+ * React-only internals for `@shenora/react`'s hooks, apart from `internal.ts`, which the React-free bridge
3
+ * imports. NOT exported from the barrel.
4
+ */
5
+ import { useEffect, useState } from 'react';
6
+ /**
7
+ * A ref's CONTENT, as state. A ref is a stable object, so an effect keyed on it runs once, and a target that
8
+ * is not there on that run (rendered conditionally, or attached after the first commit) is never seen: the
9
+ * hook is silently dead for the component's whole life. A ref mutation triggers no render, so this effect has
10
+ * NO dependency array; setting an unchanged value is a React no-op, so it cannot loop.
11
+ */
12
+ export function useRefElement(ref) {
13
+ const [element, setElement] = useState(null);
14
+ useEffect(() => {
15
+ setElement(ref.current ?? null);
16
+ });
17
+ return element;
18
+ }
@@ -0,0 +1,62 @@
1
+ import { type RefObject } from 'react';
2
+ import { type ShenoraBridge } from './bridge.js';
3
+ /**
4
+ * The routes that move the SHELL's picture. **A wire contract**: these strings are duplicated in C# as
5
+ * `MediaPlayerModule.SurfaceShowType` / `SurfaceHideType`, and the two halves agree by string or not at all.
6
+ */
7
+ export declare const MediaSurfaceCommands: {
8
+ /** `{ x, y, width, height, onTop? }` in CSS pixels. */
9
+ readonly show: "SURFACE_SHOW";
10
+ /** No payload. */
11
+ readonly hide: "SURFACE_HIDE";
12
+ };
13
+ /** Inputs for {@link useMediaSurface}. */
14
+ export interface UseMediaSurfaceOptions {
15
+ /**
16
+ * Draw the picture ABOVE the page instead of behind it. Default `false`, which is what lets you paint
17
+ * captions and controls over it.
18
+ */
19
+ onTop?: boolean;
20
+ /**
21
+ * Stop measuring and hide the picture. Use it to turn the surface off without unmounting — flipping this
22
+ * to `false` sends one `hide`.
23
+ */
24
+ enabled?: boolean;
25
+ /** Override the module. Must match the host's `MediaPlayerOptions.Access.Module`. */
26
+ module?: string;
27
+ /** Test seam. */
28
+ bridge?: ShenoraBridge;
29
+ }
30
+ /**
31
+ * Tell the shell where to draw the picture: this element's rectangle becomes a hole, and the host's own
32
+ * player fills it from underneath (D58's second surface).
33
+ *
34
+ * ```tsx
35
+ * const stage = useRef<HTMLDivElement>(null);
36
+ * useMediaSurface(stage);
37
+ * return <div ref={stage} style={{ background: 'transparent' }} />;
38
+ * ```
39
+ *
40
+ * **Use this instead of {@link useMediaPlayer} when the webview cannot decode the file** — the shell's
41
+ * player opens what a `<video>` element refuses. Everywhere else the element is the better answer, and
42
+ * both are the same `IMediaPlayer` underneath.
43
+ *
44
+ * 🔴 **The element must be genuinely TRANSPARENT, and so must everything behind it.** The picture is drawn
45
+ * BELOW the webview, so any opaque ancestor — most often a `body` background — hides it completely. That
46
+ * failure looks exactly like a player that never started, so check the backgrounds before the player.
47
+ * ⚠ **A transparent `body` is necessary and not sufficient for a full-bleed stage**: your own content is
48
+ * still painting over the picture. Hide it for the duration — `docs/guides/media.md` has the two rules.
49
+ *
50
+ * ⚠ **Gate it on the `mediaSurface` capability**, by rendering this component only on a shell that has one:
51
+ * a host without a surface answers every post with `MEDIA_SURFACE_UNAVAILABLE`. Read the capability from a
52
+ * source that RE-RENDERS when the handshake lands — a synchronous cache read taken during the first render
53
+ * is `false` for the whole session on a page that mounts before the host answers.
54
+ *
55
+ * ⚠ **The rectangle is in CSS pixels and crosses to the shell unconverted** — do not scale it by
56
+ * `devicePixelRatio`.
57
+ *
58
+ * ⚠ **It follows scroll and resize, coalesced to one post per animation frame**, and posts nothing when
59
+ * the rectangle has not moved. A collapsed or unmounted element measures zero, which the host reads as
60
+ * "hide" rather than drawing a dot at the origin.
61
+ */
62
+ export declare function useMediaSurface(ref: RefObject<HTMLElement | null>, options?: UseMediaSurfaceOptions): void;
@@ -0,0 +1,133 @@
1
+ import { useEffect, useRef } from 'react';
2
+ import { getBridge } from './bridge.js';
3
+ import { useRefElement } from './internalHooks.js';
4
+ import { MEDIA_PLAYER_MODULE } from './mediaPlayer.js';
5
+ /**
6
+ * The routes that move the SHELL's picture. **A wire contract**: these strings are duplicated in C# as
7
+ * `MediaPlayerModule.SurfaceShowType` / `SurfaceHideType`, and the two halves agree by string or not at all.
8
+ */
9
+ export const MediaSurfaceCommands = {
10
+ /** `{ x, y, width, height, onTop? }` in CSS pixels. */
11
+ show: 'SURFACE_SHOW',
12
+ /** No payload. */
13
+ hide: 'SURFACE_HIDE',
14
+ };
15
+ /**
16
+ * Tell the shell where to draw the picture: this element's rectangle becomes a hole, and the host's own
17
+ * player fills it from underneath (D58's second surface).
18
+ *
19
+ * ```tsx
20
+ * const stage = useRef<HTMLDivElement>(null);
21
+ * useMediaSurface(stage);
22
+ * return <div ref={stage} style={{ background: 'transparent' }} />;
23
+ * ```
24
+ *
25
+ * **Use this instead of {@link useMediaPlayer} when the webview cannot decode the file** — the shell's
26
+ * player opens what a `<video>` element refuses. Everywhere else the element is the better answer, and
27
+ * both are the same `IMediaPlayer` underneath.
28
+ *
29
+ * 🔴 **The element must be genuinely TRANSPARENT, and so must everything behind it.** The picture is drawn
30
+ * BELOW the webview, so any opaque ancestor — most often a `body` background — hides it completely. That
31
+ * failure looks exactly like a player that never started, so check the backgrounds before the player.
32
+ * ⚠ **A transparent `body` is necessary and not sufficient for a full-bleed stage**: your own content is
33
+ * still painting over the picture. Hide it for the duration — `docs/guides/media.md` has the two rules.
34
+ *
35
+ * ⚠ **Gate it on the `mediaSurface` capability**, by rendering this component only on a shell that has one:
36
+ * a host without a surface answers every post with `MEDIA_SURFACE_UNAVAILABLE`. Read the capability from a
37
+ * source that RE-RENDERS when the handshake lands — a synchronous cache read taken during the first render
38
+ * is `false` for the whole session on a page that mounts before the host answers.
39
+ *
40
+ * ⚠ **The rectangle is in CSS pixels and crosses to the shell unconverted** — do not scale it by
41
+ * `devicePixelRatio`.
42
+ *
43
+ * ⚠ **It follows scroll and resize, coalesced to one post per animation frame**, and posts nothing when
44
+ * the rectangle has not moved. A collapsed or unmounted element measures zero, which the host reads as
45
+ * "hide" rather than drawing a dot at the origin.
46
+ */
47
+ export function useMediaSurface(ref, options = {}) {
48
+ const { onTop = false, enabled = true, module = MEDIA_PLAYER_MODULE, bridge } = options;
49
+ // The ref's CONTENT: a stage rendered after the first commit (conditionally, say) would otherwise never bind.
50
+ const element = useRefElement(ref);
51
+ // Whether the shell is drawing for this hook now, so a stage that is not there yet posts no hide.
52
+ const shown = useRef(false);
53
+ useEffect(() => {
54
+ /* 🔴 NOTHING HERE MAY THROW AT THE PAGE, and the call sites are why.
55
+ *
56
+ * These run from a scroll handler, a ResizeObserver and an effect CLEANUP — three places where an
57
+ * exception is not a caught error but a broken render or a leaked observer. And the throw is reachable:
58
+ * a page rendering the same component in a browser has no host at all, which is exactly the fallback
59
+ * case the capability check is supposed to make safe.
60
+ */
61
+ const post = (type, payload) => {
62
+ try {
63
+ (bridge ?? getBridge()).post(module, type, payload ? { payload } : {});
64
+ }
65
+ catch { /* a picture is never worth taking the page down for */ }
66
+ };
67
+ const hide = () => {
68
+ if (!shown.current)
69
+ return;
70
+ shown.current = false;
71
+ post(MediaSurfaceCommands.hide);
72
+ };
73
+ if (!element || !enabled) {
74
+ // Not a no-op once shown: turning the surface off has to reach the shell, or the picture stays where it was.
75
+ hide();
76
+ return;
77
+ }
78
+ let frame = 0;
79
+ let pending = false;
80
+ let last = '';
81
+ const measure = () => {
82
+ pending = false;
83
+ const box = element.getBoundingClientRect();
84
+ // Rounded before the comparison, not after: sub-pixel jitter during a scroll otherwise makes every
85
+ // frame look like a move and posts one message per frame forever.
86
+ const payload = {
87
+ x: Math.round(box.left),
88
+ y: Math.round(box.top),
89
+ width: Math.round(box.width),
90
+ height: Math.round(box.height),
91
+ onTop,
92
+ };
93
+ // ⚠ `onTop` is part of the dedupe key, or a surface that changes ONLY its z-order is never told.
94
+ const key = JSON.stringify(payload);
95
+ if (key === last)
96
+ return;
97
+ last = key;
98
+ shown.current = true;
99
+ post(MediaSurfaceCommands.show, payload);
100
+ };
101
+ // Coalesced to a frame: scroll fires far more often than the compositor draws, and every post is a
102
+ // trip across the bridge.
103
+ //
104
+ // ⚠ The guard is a FLAG, not the frame id. Clearing `frame` inside the callback only works while the
105
+ // callback is asynchronous — and the id is assigned AFTER it returns — so a synchronous frame leaves
106
+ // the id set for good and the surface never moves again.
107
+ const schedule = () => {
108
+ if (pending)
109
+ return;
110
+ pending = true;
111
+ frame = requestAnimationFrame(measure);
112
+ };
113
+ measure();
114
+ // `capture: true` is what makes a NESTED scroller count — a scroll inside a div does not bubble, and
115
+ // without it the picture stays behind whenever the stage is inside its own scroll area.
116
+ window.addEventListener('scroll', schedule, { capture: true, passive: true });
117
+ window.addEventListener('resize', schedule, { passive: true });
118
+ // Size and layout changes that no scroll or resize event reports: a sibling collapsing, a font
119
+ // loading, the element itself being animated.
120
+ const observer = new ResizeObserver(schedule);
121
+ observer.observe(element);
122
+ return () => {
123
+ if (frame !== 0)
124
+ cancelAnimationFrame(frame);
125
+ window.removeEventListener('scroll', schedule, { capture: true });
126
+ window.removeEventListener('resize', schedule);
127
+ observer.disconnect();
128
+ // 🔴 The picture is the SHELL's and outlives this component. Without this, unmounting the stage
129
+ // leaves a native rectangle painted over whatever the page navigates to next.
130
+ hide();
131
+ };
132
+ }, [element, onTop, enabled, module, bridge]);
133
+ }
@@ -0,0 +1,92 @@
1
+ import { type ShenoraBridge } from './bridge.js';
2
+ import { type MediaPlayerReportState } from './mediaPlayer.js';
3
+ /**
4
+ * What the HOST's player is doing. The same vocabulary `MediaPlayerStatus` uses on the other side, with
5
+ * seconds where C# has `TimeSpan`.
6
+ */
7
+ export interface MediaTransportStatus {
8
+ state: MediaPlayerReportState;
9
+ /** Seconds. */
10
+ position: number;
11
+ /** Seconds, or null for a live stream and — briefly — while opening. */
12
+ duration: number | null;
13
+ /** The rate ASKED FOR; a platform may clamp it. */
14
+ rate: number;
15
+ /** A short reason when `state` is `Failed`; never the platform's raw text. */
16
+ error?: string | null;
17
+ /**
18
+ * Which player produced this reading — `AndroidMediaPlayer`, `IosMediaPlayer`, or an app's own.
19
+ *
20
+ * 🔴 **Log it before forming a theory about playback.** With a pluggable player behind the shell's
21
+ * surface, "which decoder ran" is not answerable from anywhere else, and assuming it wrong is the
22
+ * cheapest way to debug the wrong code.
23
+ *
24
+ * ⚠ **A diagnostic, not a branch** — it names an implementation, so anything conditional on it is
25
+ * coupled to a class name. Branch on the shell's capabilities instead.
26
+ */
27
+ engine?: string | null;
28
+ }
29
+ /** Inputs for {@link useMediaTransport}. */
30
+ export interface UseMediaTransportOptions {
31
+ /** How often to sample the position, in ms. Default 250 — the rate a scrubber redraws at. */
32
+ intervalMs?: number;
33
+ /**
34
+ * Poll at all. Default `true`. Set it `false` while no player surface is on screen: a paused player's
35
+ * position does not move, and the host answers every ask.
36
+ */
37
+ enabled?: boolean;
38
+ /** Override the module. Must match the host's `MediaPlayerOptions.Access.Module`. */
39
+ module?: string;
40
+ /** Test seam. */
41
+ bridge?: ShenoraBridge;
42
+ }
43
+ /** What {@link useMediaTransport} gives you. */
44
+ export interface MediaTransport {
45
+ /** The most recent trustworthy reading, or `null` before the first one lands. */
46
+ status: MediaTransportStatus | null;
47
+ /**
48
+ * The host has stopped answering: eight asks in a row failed. About 2 s at the default interval when the
49
+ * host refuses them, about 10 s when it has gone quiet (each ask waits up to a second).
50
+ *
51
+ * 🔴 **This exists because a dead poll has NO symptom of its own.** The callback simply stops running:
52
+ * the scrubber keeps its last value, the play button keeps whatever the last press set, and nothing
53
+ * anywhere says the transport is gone. Show it, or at least log it — the alternative is diagnosing it
54
+ * from an ABSENCE, which is the hardest evidence there is to notice.
55
+ */
56
+ unanswered: boolean;
57
+ /** Point the host's player at a source and prepare it. Does not start playback. */
58
+ load(uri: string): Promise<void>;
59
+ play(): Promise<void>;
60
+ pause(): Promise<void>;
61
+ /** Move to an absolute position, in seconds. */
62
+ seek(position: number): Promise<void>;
63
+ /** Set the speed multiplier; 1 is normal. */
64
+ setRate(rate: number): Promise<void>;
65
+ /** Release the source and free the decoder. */
66
+ unload(): Promise<void>;
67
+ }
68
+ /**
69
+ * Drive the HOST's player and read what it is doing — the companion to {@link useMediaSurface}, for when
70
+ * the shell owns the picture and the page owns the controls.
71
+ *
72
+ * ```tsx
73
+ * const { status, unanswered, play, pause, seek } = useMediaTransport();
74
+ * ```
75
+ *
76
+ * **Use it when the shell is the player.** With the picture on the shell's surface the page's own element
77
+ * is not playing, so its `timeupdate` says nothing and the host is the only clock. Running both is two
78
+ * clocks that disagree.
79
+ *
80
+ * 🔴 **THE COMMANDS ARE HERE, AND NOT BY CONVENIENCE.** A status answer that was asked for BEFORE a
81
+ * command is stale in every field, not just in `state` — so this hook drops it, which it can only do if
82
+ * it knows when a command happened. Calling the routes directly instead re-opens exactly that hole:
83
+ * the poll returns the pre-command reading, the UI flips back to it, and the next poll flips it again.
84
+ *
85
+ * ⚠ **A command's own answer is applied at once.** The host's drive routes return the resulting status,
86
+ * so a press updates the UI without waiting up to `intervalMs` for the next sample.
87
+ *
88
+ * ⚠ **Failures are swallowed, deliberately.** A poll that cannot answer is not worth breaking playback
89
+ * over, and a refused command resolves quietly: it is the host's answer, not a reason to throw at the page.
90
+ * Watch {@link MediaTransport.unanswered} for the case that matters.
91
+ */
92
+ export declare function useMediaTransport(options?: UseMediaTransportOptions): MediaTransport;
@@ -0,0 +1,135 @@
1
+ import { useCallback, useEffect, useRef, useState } from 'react';
2
+ import { getBridge } from './bridge.js';
3
+ import { MEDIA_PLAYER_MODULE, MEDIA_PLAYER_STATUS, MediaPlayerCommands, } from './mediaPlayer.js';
4
+ /**
5
+ * Consecutive failed asks before {@link MediaTransport.unanswered} goes true.
6
+ *
7
+ * ⚠ Counted in TICKS rather than elapsed ms so the threshold means the same thing however fast a caller
8
+ * samples: ONE dropped reply is ordinary, eight in a row is a transport that has gone.
9
+ */
10
+ const UNANSWERED_AFTER_TICKS = 8;
11
+ /**
12
+ * How long one status ask waits: four intervals, and never under a second. Without its own bound an ask
13
+ * waits the bridge's default of 30 s, and eight of them made a host that went quiet take four minutes to
14
+ * notice.
15
+ */
16
+ const askTimeoutMs = (intervalMs) => Math.max(1000, intervalMs * 4);
17
+ /**
18
+ * Drive the HOST's player and read what it is doing — the companion to {@link useMediaSurface}, for when
19
+ * the shell owns the picture and the page owns the controls.
20
+ *
21
+ * ```tsx
22
+ * const { status, unanswered, play, pause, seek } = useMediaTransport();
23
+ * ```
24
+ *
25
+ * **Use it when the shell is the player.** With the picture on the shell's surface the page's own element
26
+ * is not playing, so its `timeupdate` says nothing and the host is the only clock. Running both is two
27
+ * clocks that disagree.
28
+ *
29
+ * 🔴 **THE COMMANDS ARE HERE, AND NOT BY CONVENIENCE.** A status answer that was asked for BEFORE a
30
+ * command is stale in every field, not just in `state` — so this hook drops it, which it can only do if
31
+ * it knows when a command happened. Calling the routes directly instead re-opens exactly that hole:
32
+ * the poll returns the pre-command reading, the UI flips back to it, and the next poll flips it again.
33
+ *
34
+ * ⚠ **A command's own answer is applied at once.** The host's drive routes return the resulting status,
35
+ * so a press updates the UI without waiting up to `intervalMs` for the next sample.
36
+ *
37
+ * ⚠ **Failures are swallowed, deliberately.** A poll that cannot answer is not worth breaking playback
38
+ * over, and a refused command resolves quietly: it is the host's answer, not a reason to throw at the page.
39
+ * Watch {@link MediaTransport.unanswered} for the case that matters.
40
+ */
41
+ export function useMediaTransport(options = {}) {
42
+ const { intervalMs = 250, enabled = true, module = MEDIA_PLAYER_MODULE, bridge } = options;
43
+ const [status, setStatus] = useState(null);
44
+ const [unanswered, setUnanswered] = useState(false);
45
+ /**
46
+ * How many commands have been issued. A reading asked for while this was lower describes a player that
47
+ * has since been told to do something else.
48
+ *
49
+ * ⚠ A ref, not state: bumping it must not re-render, and every reader needs the value as of NOW rather
50
+ * than as of the render it closed over.
51
+ */
52
+ const commands = useRef(0);
53
+ const live = useRef(true);
54
+ useEffect(() => {
55
+ live.current = true;
56
+ return () => { live.current = false; };
57
+ }, []);
58
+ const send = useCallback(async (type, payload) => {
59
+ commands.current += 1;
60
+ try {
61
+ const answer = await (bridge ?? getBridge())
62
+ .invoke(module, type, payload ? { payload } : {});
63
+ // The host answers a drive command with the status it produced — apply it rather than waiting for
64
+ // the next sample. ⚠ Only if this component is still mounted; a resolve after unmount is ordinary.
65
+ if (live.current && answer)
66
+ setStatus(normalise(answer));
67
+ }
68
+ catch {
69
+ /* A refused command is the host's answer, not a reason to throw at the page. */
70
+ }
71
+ }, [module, bridge]);
72
+ useEffect(() => {
73
+ if (!enabled)
74
+ return;
75
+ let stopped = false;
76
+ let timer;
77
+ let misses = 0;
78
+ const tick = async () => {
79
+ if (stopped)
80
+ return;
81
+ // Read BEFORE the ask goes out: everything after this point is a command the answer cannot have seen.
82
+ const asked = commands.current;
83
+ let answer = null;
84
+ try {
85
+ answer = await (bridge ?? getBridge())
86
+ .invoke(module, MEDIA_PLAYER_STATUS, { timeoutMs: askTimeoutMs(intervalMs) });
87
+ }
88
+ catch {
89
+ answer = null;
90
+ }
91
+ if (stopped)
92
+ return;
93
+ if (answer) {
94
+ misses = 0;
95
+ setUnanswered(false);
96
+ // 🔴 DROPPED, not reported-with-a-caveat: a pre-command reading is stale in position as well as
97
+ // state, so there is nothing in it a caller could safely keep.
98
+ if (commands.current === asked)
99
+ setStatus(normalise(answer));
100
+ }
101
+ else if (++misses >= UNANSWERED_AFTER_TICKS) {
102
+ setUnanswered(true);
103
+ }
104
+ // ⚠ Scheduled from the ANSWER, never on a bare interval: a slow host stretches the gap instead of
105
+ // queueing asks behind each other.
106
+ timer = setTimeout(() => { void tick(); }, intervalMs);
107
+ };
108
+ void tick();
109
+ return () => { stopped = true; if (timer)
110
+ clearTimeout(timer); };
111
+ }, [enabled, intervalMs, module, bridge]);
112
+ const load = useCallback((uri) => send(MediaPlayerCommands.load, { uri }), [send]);
113
+ const play = useCallback(() => send(MediaPlayerCommands.play), [send]);
114
+ const pause = useCallback(() => send(MediaPlayerCommands.pause), [send]);
115
+ const seek = useCallback((position) => send(MediaPlayerCommands.seek, { position }), [send]);
116
+ const setRate = useCallback((rate) => send(MediaPlayerCommands.rate, { rate }), [send]);
117
+ const unload = useCallback(() => send(MediaPlayerCommands.unload), [send]);
118
+ return { status, unanswered, load, play, pause, seek, setRate, unload };
119
+ }
120
+ /**
121
+ * A host answer, with every field given a safe shape.
122
+ *
123
+ * ⚠ `duration` is null for a live stream and while opening, and a UI that reads a missing one as 0 puts
124
+ * the playhead at the end of something that has just started.
125
+ */
126
+ function normalise(answer) {
127
+ return {
128
+ state: (answer.state ?? 'Empty'),
129
+ position: Number.isFinite(answer.position) ? Number(answer.position) : 0,
130
+ duration: Number.isFinite(answer.duration) ? Number(answer.duration) : null,
131
+ rate: Number.isFinite(answer.rate) ? Number(answer.rate) : 1,
132
+ error: answer.error ?? null,
133
+ engine: answer.engine ?? null,
134
+ };
135
+ }
@@ -1,3 +1,18 @@
1
+ /**
2
+ * The Chromium shell's marker. The shell writes it into every HTML document it serves, so its presence
3
+ * IS the host advertising itself (D36, D83), never a guess about the engine. The page posts to `ipc`, a
4
+ * same-origin route the shell answers; the shell pushes by calling `receive`, which the transport sets.
5
+ */
6
+ export interface ChromiumHost {
7
+ ipc: string;
8
+ receive?: ChromiumReceive;
9
+ }
10
+ /** A named type, not inline: WireMirrorTests reads the interface's fields and would count a parameter. */
11
+ type ChromiumReceive = (message: unknown) => void;
12
+ /** The global the Chromium shell marks a document with. Mirrored by `ChromiumTransport.HostGlobal`. */
13
+ export declare const CHROMIUM_HOST_GLOBAL = "__shenora_chromium";
14
+ /** What the Chromium shell calls to push a message. Mirrored by `ChromiumTransport.ReceiveMember`. */
15
+ export declare const CHROMIUM_RECEIVE = "receive";
1
16
  /**
2
17
  * A message channel the bridge speaks over — the transport-pluggable seam (design D16): WebView2
3
18
  * postMessage on desktop today; a WebSocket or a mobile shell's native channel speaks the same
@@ -10,8 +25,8 @@ export interface ShenoraTransport {
10
25
  subscribe(listener: (message: string) => void): () => void;
11
26
  }
12
27
  /**
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
28
+ * True when running inside ANY Shenora host — a WebView2 desktop shell, the Chromium shell or a
29
+ * `ChromiumView`, or a MAUI `HybridWebView` — i.e. when a transport to the host exists. In a plain browser this is false and callers should fall back to
15
30
  * browser-only behavior. The question is "is there a host", never "is it WebView2".
16
31
  */
17
32
  export declare function isShenoraAvailable(): boolean;
@@ -26,10 +41,17 @@ export declare function createWebView2Transport(): ShenoraTransport | null;
26
41
  * `Shenora.Maui.MauiIpcBridge`.
27
42
  */
28
43
  export declare function createHybridWebViewTransport(): ShenoraTransport | null;
44
+ /**
45
+ * The Chromium shell's transport, or null outside it. There is no code in the renderer: the page posts
46
+ * each envelope with `fetch` to the marker's same-origin `ipc` route, which the shell answers from its
47
+ * resource handler, and the shell pushes by calling the marker's `receive`.
48
+ */
49
+ export declare function createChromiumTransport(): ShenoraTransport | null;
29
50
  /**
30
51
  * The transport for whichever Shenora host this page is running in, or null in a plain browser.
31
52
  * This is what the bridge uses by default, so an app that simply calls `invoke`/`post` works on the
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.
53
+ * desktop shell, the MAUI shell and the Chromium shell without knowing which one it is. A page is only
54
+ * ever in one of them; the order decides only if several objects are somehow present.
34
55
  */
35
56
  export declare function createHostTransport(): ShenoraTransport | null;
57
+ export {};
package/dist/transport.js CHANGED
@@ -1,12 +1,16 @@
1
+ /** The global the Chromium shell marks a document with. Mirrored by `ChromiumTransport.HostGlobal`. */
2
+ export const CHROMIUM_HOST_GLOBAL = '__shenora_chromium';
3
+ /** What the Chromium shell calls to push a message. Mirrored by `ChromiumTransport.ReceiveMember`. */
4
+ export const CHROMIUM_RECEIVE = 'receive';
1
5
  const webViewWindow = () => typeof window === 'undefined' ? undefined : window;
2
6
  /**
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
7
+ * True when running inside ANY Shenora host — a WebView2 desktop shell, the Chromium shell or a
8
+ * `ChromiumView`, or a MAUI `HybridWebView` — i.e. when a transport to the host exists. In a plain browser this is false and callers should fall back to
5
9
  * browser-only behavior. The question is "is there a host", never "is it WebView2".
6
10
  */
7
11
  export function isShenoraAvailable() {
8
12
  const host = webViewWindow();
9
- return !!host?.chrome?.webview || !!host?.HybridWebView;
13
+ return !!host?.chrome?.webview || !!host?.HybridWebView || !!chromiumHost(host);
10
14
  }
11
15
  /** The WebView2 postMessage transport, or null outside a WebView2 host. */
12
16
  export function createWebView2Transport() {
@@ -55,12 +59,59 @@ export function createHybridWebViewTransport() {
55
59
  },
56
60
  };
57
61
  }
62
+ /** The marker, when it is well-formed; anything else on that global is not ours. */
63
+ function chromiumHost(host) {
64
+ const marker = host?.[CHROMIUM_HOST_GLOBAL];
65
+ return marker && typeof marker === 'object' && typeof marker.ipc === 'string' ? marker : undefined;
66
+ }
67
+ /**
68
+ * Everyone listening on one marker. The host calls ONE function, so it fans out here: a second
69
+ * transport (a bridge replaced by `configureBridge`, say) must not silence the first, which is how two
70
+ * WebView2 listeners behave too.
71
+ */
72
+ const chromiumListeners = new WeakMap();
73
+ /**
74
+ * The Chromium shell's transport, or null outside it. There is no code in the renderer: the page posts
75
+ * each envelope with `fetch` to the marker's same-origin `ipc` route, which the shell answers from its
76
+ * resource handler, and the shell pushes by calling the marker's `receive`.
77
+ */
78
+ export function createChromiumTransport() {
79
+ const marker = chromiumHost(webViewWindow());
80
+ if (!marker)
81
+ return null;
82
+ let listeners = chromiumListeners.get(marker);
83
+ if (!listeners) {
84
+ const set = new Set();
85
+ chromiumListeners.set(marker, set);
86
+ marker[CHROMIUM_RECEIVE] = (message) => {
87
+ // Narrow first: anything on the page can call this, and only strings are ours.
88
+ if (typeof message === 'string')
89
+ for (const listener of [...set])
90
+ listener(message);
91
+ };
92
+ listeners = set;
93
+ }
94
+ const own = listeners;
95
+ return {
96
+ // Each message is a request of its own. They reach the host in the order they were posted: measured,
97
+ // 1,500 of 1,500 one-way posts in order, though no spec promises it. A failed post is swallowed, since an
98
+ // unhandled rejection here would name nothing: an `invoke` then fails at the bridge's request timeout,
99
+ // which names the call, and a one-way `post` is lost.
100
+ post: (message) => {
101
+ void fetch(marker.ipc, { method: 'POST', body: message }).catch(() => undefined);
102
+ },
103
+ subscribe: (listener) => {
104
+ own.add(listener);
105
+ return () => { own.delete(listener); };
106
+ },
107
+ };
108
+ }
58
109
  /**
59
110
  * The transport for whichever Shenora host this page is running in, or null in a plain browser.
60
111
  * This is what the bridge uses by default, so an app that simply calls `invoke`/`post` works on the
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.
112
+ * desktop shell, the MAUI shell and the Chromium shell without knowing which one it is. A page is only
113
+ * ever in one of them; the order decides only if several objects are somehow present.
63
114
  */
64
115
  export function createHostTransport() {
65
- return createWebView2Transport() ?? createHybridWebViewTransport();
116
+ return createWebView2Transport() ?? createHybridWebViewTransport() ?? createChromiumTransport();
66
117
  }
package/dist/types.d.ts CHANGED
@@ -140,6 +140,22 @@ export declare const ShellCapabilities: {
140
140
  * fullscreen first or leave rotation alone.
141
141
  */
142
142
  readonly windowOrientation: "windowOrientation";
143
+ /**
144
+ * The shell can draw the PICTURE itself, under a transparent region the page leaves — see
145
+ * `useMediaSurface`. The mobile shells; the desktop has none and does not need one.
146
+ *
147
+ * ⚠ Branch on it, but the fallback is a real player rather than a degraded one: absent, a `<video>`
148
+ * element is the picture, which is the right answer wherever the webview can decode the file. Present,
149
+ * the shell's own player opens what that element refuses.
150
+ *
151
+ * 🔴 It asserts TWO things: a surface exists AND a player is attached to draw into it. A host that
152
+ * advertises it on the strength of the surface alone gives you a hole with no decoder behind it — the
153
+ * controls render, nothing ever appears, and it is indistinguishable from a refused file.
154
+ *
155
+ * ⚠ It still says nothing about a GIVEN file — what the platform decodes is a per-stream question the
156
+ * host answers.
157
+ */
158
+ readonly mediaSurface: "mediaSurface";
143
159
  };
144
160
  /** The response envelope the host returns for an {@link IpcRequest}. */
145
161
  export interface IpcResponse<TData = unknown> {
package/dist/types.js CHANGED
@@ -110,4 +110,20 @@ export const ShellCapabilities = {
110
110
  * fullscreen first or leave rotation alone.
111
111
  */
112
112
  windowOrientation: 'windowOrientation',
113
+ /**
114
+ * The shell can draw the PICTURE itself, under a transparent region the page leaves — see
115
+ * `useMediaSurface`. The mobile shells; the desktop has none and does not need one.
116
+ *
117
+ * ⚠ Branch on it, but the fallback is a real player rather than a degraded one: absent, a `<video>`
118
+ * element is the picture, which is the right answer wherever the webview can decode the file. Present,
119
+ * the shell's own player opens what that element refuses.
120
+ *
121
+ * 🔴 It asserts TWO things: a surface exists AND a player is attached to draw into it. A host that
122
+ * advertises it on the strength of the surface alone gives you a hole with no decoder behind it — the
123
+ * controls render, nothing ever appears, and it is indistinguishable from a refused file.
124
+ *
125
+ * ⚠ It still says nothing about a GIVEN file — what the platform decodes is a per-stream question the
126
+ * host answers.
127
+ */
128
+ mediaSurface: 'mediaSurface',
113
129
  };
@@ -56,13 +56,16 @@ export interface UseDropZoneOptions {
56
56
  * across the IPC boundary before the app knows whether it wants any of them. This gives you `string[]`
57
57
  * OS paths instead — open lazily, stream, hash incrementally, move or link without copying.
58
58
  *
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.
59
+ * **On the WebView2 shell** the host positions a transparent native overlay over the element to capture
60
+ * those paths, including for drags started while the app is in the background. Bounds re-sync
61
+ * (debounced) on resize/scroll/intersection changes; the host converts the CSS rect to physical pixels
62
+ * per-monitor. How the visibility dance works: mouse leaves the element → SHOW (overlay up, ready to
63
+ * catch a drag); mouse enters → the host hides the overlay (hover effects keep working); an inactive
64
+ * window always shows overlays (background drag-drop); while the overlay is visible the host emits
65
+ * DRAG_ENTER/DRAG_LEAVE for CSS feedback.
62
66
  *
63
- * How the visibility dance works: mouse leaves the element → SHOW (overlay up, ready to catch a
64
- * drag); mouse enters → the host hides the overlay (hover effects keep working); an inactive
65
- * window always shows overlays (background drag-drop); while the overlay is visible the host
66
- * emits DRAG_ENTER/DRAG_LEAVE for CSS feedback.
67
+ * **On the Chromium shell there is no overlay.** The engine hands the host the drag's real paths as it
68
+ * enters, so the host answers REGISTER with `pageDrop`, this hook takes the page's own drag events on the
69
+ * element, and a drop there asks the host for the paths. Your code is the same on both.
67
70
  */
68
71
  export declare function useDropZone(options: UseDropZoneOptions): void;
@@ -2,6 +2,7 @@ import { useEffect, useRef, useState } from 'react';
2
2
  import { getBridge } from './bridge.js';
3
3
  import { eventBus as defaultEventBus } from './eventBus.js';
4
4
  import { debounce, randomId } from './internal.js';
5
+ import { useRefElement } from './internalHooks.js';
5
6
  /** The reserved module the drop-zone stack speaks (host: `DropZoneManager`/`DropZoneModule`). */
6
7
  export const DROP_ZONE_MODULE = 'SHENORA.DROPZONE';
7
8
  const newZoneId = () => randomId('drop-zone-');
@@ -12,14 +13,17 @@ const newZoneId = () => randomId('drop-zone-');
12
13
  * across the IPC boundary before the app knows whether it wants any of them. This gives you `string[]`
13
14
  * OS paths instead — open lazily, stream, hash incrementally, move or link without copying.
14
15
  *
15
- * The host positions a transparent native overlay over the element to capture those paths, including
16
- * for drags started while the app is in the background. Bounds re-sync (debounced) on
17
- * resize/scroll/intersection changes; the host converts the CSS rect to physical pixels per-monitor.
16
+ * **On the WebView2 shell** the host positions a transparent native overlay over the element to capture
17
+ * those paths, including for drags started while the app is in the background. Bounds re-sync
18
+ * (debounced) on resize/scroll/intersection changes; the host converts the CSS rect to physical pixels
19
+ * per-monitor. How the visibility dance works: mouse leaves the element → SHOW (overlay up, ready to
20
+ * catch a drag); mouse enters → the host hides the overlay (hover effects keep working); an inactive
21
+ * window always shows overlays (background drag-drop); while the overlay is visible the host emits
22
+ * DRAG_ENTER/DRAG_LEAVE for CSS feedback.
18
23
  *
19
- * How the visibility dance works: mouse leaves the element → SHOW (overlay up, ready to catch a
20
- * drag); mouse enters → the host hides the overlay (hover effects keep working); an inactive
21
- * window always shows overlays (background drag-drop); while the overlay is visible the host
22
- * emits DRAG_ENTER/DRAG_LEAVE for CSS feedback.
24
+ * **On the Chromium shell there is no overlay.** The engine hands the host the drag's real paths as it
25
+ * enters, so the host answers REGISTER with `pageDrop`, this hook takes the page's own drag events on the
26
+ * element, and a drop there asks the host for the paths. Your code is the same on both.
23
27
  */
24
28
  export function useDropZone(options) {
25
29
  const { targetRef, enabled = true } = options;
@@ -44,16 +48,8 @@ export function useDropZone(options) {
44
48
  reportRef.current = (error, route) => onErrorRef.current
45
49
  ? onErrorRef.current(error, route)
46
50
  : console.error(`[shenora] drop-zone ${route} failed:`, error);
47
- // 🔴 Make the ref's CONTENT reactive. `targetRef` is a stable object, so an effect keyed on it runs
48
- // exactly once — and if `targetRef.current` is null on that run (a conditionally-rendered target, or
49
- // any order where the ref is attached after the first commit) the effect bails out and NEVER re-runs:
50
- // the zone is silently dead for the component's whole life, with no error anywhere. A ref mutation
51
- // triggers no render, so this effect has NO dependency array; `setElement` with an unchanged value is
52
- // a React no-op, so it cannot loop.
53
- const [element, setElement] = useState(null);
54
- useEffect(() => {
55
- setElement(targetRef.current ?? null);
56
- });
51
+ // 🔴 The ref's CONTENT, reactive: a target rendered after the first commit would otherwise never register.
52
+ const element = useRefElement(targetRef);
57
53
  const isRegisteredRef = useRef(false);
58
54
  // Whether a REGISTER has ever been SENT for this zone (even if not yet acked). The cleanup
59
55
  // unregisters on THIS (not on the ack) so a fast unmount before REGISTER resolves still tears
@@ -66,6 +62,9 @@ export function useDropZone(options) {
66
62
  // never exists again. Cleanup bumps the epoch; acks from an older epoch are ignored.
67
63
  const epochRef = useRef(0);
68
64
  const lastBoundsRef = useRef({ x: 0, y: 0, width: 0, height: 0 });
65
+ // The host said the PAGE delivers drops (the Chromium shell). Read by the DOM listeners at event time,
66
+ // so they do nothing until REGISTER is answered, and nothing at all on the WebView2 shell.
67
+ const pageDropRef = useRef(false);
69
68
  const syncBoundsRef = useRef(() => { });
70
69
  syncBoundsRef.current = () => {
71
70
  const element = targetRef.current;
@@ -93,9 +92,11 @@ export function useDropZone(options) {
93
92
  const epoch = epochRef.current;
94
93
  bridge
95
94
  .invoke(DROP_ZONE_MODULE, 'REGISTER', { payload: { zoneId: zoneIdRef.current, ...bounds } })
96
- .then(() => {
97
- if (epochRef.current === epoch)
98
- isRegisteredRef.current = true;
95
+ .then((registration) => {
96
+ if (epochRef.current !== epoch)
97
+ return;
98
+ isRegisteredRef.current = true;
99
+ pageDropRef.current = registration?.pageDrop === true;
99
100
  }, (error) => reportRef.current(error, 'REGISTER'))
100
101
  .finally(() => {
101
102
  if (epochRef.current === epoch)
@@ -118,6 +119,8 @@ export function useDropZone(options) {
118
119
  const syncBounds = debounce(() => syncBoundsRef.current(), 100);
119
120
  syncBoundsRef.current();
120
121
  const sendShow = debounce(() => {
122
+ if (pageDropRef.current)
123
+ return; // no overlay to raise
121
124
  (bridgeRef.current ?? getBridge())
122
125
  .invoke(DROP_ZONE_MODULE, 'SHOW', { payload: { zoneId: zoneIdRef.current } })
123
126
  .catch((error) => reportRef.current(error, 'SHOW'));
@@ -161,20 +164,85 @@ export function useDropZone(options) {
161
164
  isRegisteredRef.current = false;
162
165
  registeringRef.current = false; // a remount must re-send immediately
163
166
  attemptedRef.current = false;
167
+ pageDropRef.current = false;
164
168
  }
165
169
  };
166
170
  }, [enabled, element]);
167
- // Drag-hover CSS feedback.
171
+ // The Chromium shell's drops: the page's own drag events on the element, acted on only once the host
172
+ // answered REGISTER with `pageDrop`. Claiming `dragover` is what makes the element a drop target at all.
173
+ useEffect(() => {
174
+ if (!enabled || !element)
175
+ return;
176
+ const dropClass = dropClassRef.current;
177
+ const carriesFiles = (event) => [...(event.dataTransfer?.types ?? [])].includes('Files');
178
+ // dragenter/dragleave fire for every child the pointer crosses, so count them rather than trusting one.
179
+ let depth = 0;
180
+ const onDragEnter = (event) => {
181
+ if (!pageDropRef.current || !carriesFiles(event))
182
+ return;
183
+ event.preventDefault();
184
+ depth++;
185
+ element.classList.add(dropClass);
186
+ };
187
+ const onDragOver = (event) => {
188
+ if (!pageDropRef.current || !carriesFiles(event))
189
+ return;
190
+ event.preventDefault();
191
+ if (event.dataTransfer)
192
+ event.dataTransfer.dropEffect = 'copy';
193
+ };
194
+ const onDragLeave = () => {
195
+ if (!pageDropRef.current)
196
+ return;
197
+ depth = Math.max(0, depth - 1);
198
+ if (depth === 0)
199
+ element.classList.remove(dropClass);
200
+ };
201
+ const onDrop = (event) => {
202
+ if (!pageDropRef.current || !carriesFiles(event))
203
+ return;
204
+ event.preventDefault();
205
+ depth = 0;
206
+ element.classList.remove(dropClass);
207
+ const rect = element.getBoundingClientRect();
208
+ const position = {
209
+ x: Math.round((event.clientX - rect.left) * devicePixelRatio),
210
+ y: Math.round((event.clientY - rect.top) * devicePixelRatio),
211
+ };
212
+ const zoneId = zoneIdRef.current;
213
+ (bridgeRef.current ?? getBridge())
214
+ .invoke(DROP_ZONE_MODULE, 'DROP', { payload: { zoneId } })
215
+ .then((answer) => {
216
+ const files = answer?.files ?? [];
217
+ if (files.length > 0)
218
+ onDropRef.current(files, { zoneId, files, position });
219
+ }, (error) => reportRef.current(error, 'DROP'));
220
+ };
221
+ element.addEventListener('dragenter', onDragEnter);
222
+ element.addEventListener('dragover', onDragOver);
223
+ element.addEventListener('dragleave', onDragLeave);
224
+ element.addEventListener('drop', onDrop);
225
+ return () => {
226
+ element.removeEventListener('dragenter', onDragEnter);
227
+ element.removeEventListener('dragover', onDragOver);
228
+ element.removeEventListener('dragleave', onDragLeave);
229
+ element.removeEventListener('drop', onDrop);
230
+ element.classList.remove(dropClass);
231
+ };
232
+ }, [enabled, element]);
233
+ // Drag-hover CSS feedback, from the WebView2 shell's overlay. Where the page delivers drops itself, these bus events
234
+ // are some OTHER page's: an app with both engines has a WebView2 page announcing its drags to every page, and a zone
235
+ // of the same id there would otherwise light this one, or drop its files here.
168
236
  useEffect(() => {
169
237
  if (!enabled || !element)
170
238
  return;
171
239
  const dropClass = dropClassRef.current;
172
240
  const offEnter = bus.subscribe(DROP_ZONE_MODULE, 'DRAG_ENTER', (event) => {
173
- if (event.payload?.zoneId === zoneIdRef.current)
241
+ if (!pageDropRef.current && event.payload?.zoneId === zoneIdRef.current)
174
242
  element.classList.add(dropClass);
175
243
  });
176
244
  const offLeave = bus.subscribe(DROP_ZONE_MODULE, 'DRAG_LEAVE', (event) => {
177
- if (event.payload?.zoneId === zoneIdRef.current)
245
+ if (!pageDropRef.current && event.payload?.zoneId === zoneIdRef.current)
178
246
  element.classList.remove(dropClass);
179
247
  });
180
248
  return () => {
@@ -189,8 +257,8 @@ export function useDropZone(options) {
189
257
  return;
190
258
  return bus.subscribe(DROP_ZONE_MODULE, 'FILE_DROP', (event) => {
191
259
  const drop = event.payload;
192
- if (!drop || drop.zoneId !== zoneIdRef.current)
193
- return;
260
+ if (pageDropRef.current || !drop || drop.zoneId !== zoneIdRef.current)
261
+ return; // see the hover feedback above
194
262
  targetRef.current?.classList.remove(dropClassRef.current);
195
263
  onDropRef.current(drop.files, drop);
196
264
  });
@@ -1,4 +1,5 @@
1
1
  import type { ShenoraBridge } from './bridge.js';
2
+ import type { ShenoraEventBus } from './eventBus.js';
2
3
  import { BaseModuleService } from './moduleService.js';
3
4
  /** The top resize edges — the only ones that exist: the frameless technique keeps the native
4
5
  * side/bottom resize borders, so only the top (covered by the WebView) needs page-side help. */
@@ -6,8 +7,8 @@ export type WindowResizeEdge = 'top' | 'topLeft' | 'topRight';
6
7
  /** Which system caption button a page-drawn region stands in for (mirrors the host's enum). */
7
8
  export type CaptionButtonKind = 'minimize' | 'maximize' | 'close';
8
9
  /**
9
- * Where the page drew one caption button, in CSS px relative to the WebView2 — i.e. straight out of
10
- * `getBoundingClientRect()`. The host converts to physical px using the control's DeviceDpi.
10
+ * Where the page drew one caption button, in CSS px relative to the page — i.e. straight out of
11
+ * `getBoundingClientRect()`. The host converts to physical px at the window's DPI.
11
12
  */
12
13
  export interface CaptionButtonRect {
13
14
  kind: CaptionButtonKind;
@@ -16,6 +17,42 @@ export interface CaptionButtonRect {
16
17
  width: number;
17
18
  height: number;
18
19
  }
20
+ /**
21
+ * What the OS is doing to the page's caption buttons: the one the pointer is over, and the one being pressed.
22
+ * Absent means none.
23
+ */
24
+ export interface CaptionButtonState {
25
+ hot?: CaptionButtonKind;
26
+ pressed?: CaptionButtonKind;
27
+ }
28
+ /**
29
+ * The colours of the caption buttons the window paints (`NativeCaptionButtons`), each a CSS hex colour: `#rgb`,
30
+ * `#rgba`, `#rrggbb` or `#rrggbbaa`. Mirrors the host's `CaptionButtonColors`.
31
+ */
32
+ export interface CaptionButtonColors {
33
+ /** The title bar's own colour. The WebView2 shell paints it behind its buttons; the Chromium shell's idle buttons
34
+ * show the page through them, so it is unused there. */
35
+ surface: string;
36
+ /** A hovered minimize or maximize button's background. */
37
+ hover: string;
38
+ /** A pressed minimize or maximize button's background. */
39
+ pressed: string;
40
+ /** The glyphs. */
41
+ glyph: string;
42
+ /** A hovered close button's background, red by the platform's convention. */
43
+ closeHover: string;
44
+ /** A pressed close button's background. */
45
+ closePressed: string;
46
+ /** The close glyph while close is hovered or pressed. Absent: `glyph`. */
47
+ closeGlyphHot?: string;
48
+ /** The idle glyphs while the window is inactive. Absent: `glyph` at about a third of its opacity. */
49
+ inactiveGlyph?: string;
50
+ }
51
+ /** The host's `SHENORA.WINDOW` event names, pinned against it by `WireMirrorTests`. */
52
+ export declare const WindowEventTypes: {
53
+ /** A {@link CaptionButtonState}, sent to the window's own page. See {@link useCaptionButtonState}. */
54
+ readonly CaptionButtonState: "CAPTION_BUTTON_STATE";
55
+ };
19
56
  interface WindowRequests {
20
57
  MINIMIZE: void;
21
58
  TOGGLE_MAXIMIZE: void;
@@ -25,12 +62,16 @@ interface WindowRequests {
25
62
  START_RESIZE: {
26
63
  edge: WindowResizeEdge;
27
64
  };
65
+ SHOW_SYSTEM_MENU: void;
28
66
  SET_THEME: {
29
67
  dark: boolean;
30
68
  };
31
69
  SET_CAPTION_BUTTONS: {
32
70
  buttons: CaptionButtonRect[];
33
71
  };
72
+ SET_CAPTION_BUTTON_COLORS: {
73
+ colors?: CaptionButtonColors;
74
+ };
34
75
  }
35
76
  /**
36
77
  * Typed client for the host's `SHENORA.WINDOW` module (`WindowCommandModule` in Shenora.Windows) —
@@ -51,6 +92,13 @@ export declare class WindowCommands extends BaseModuleService<WindowRequests> {
51
92
  startDrag(): Promise<void>;
52
93
  /** Call from the top strip's `onMouseDown` — hands off to the OS size loop. */
53
94
  startResize(edge?: WindowResizeEdge): Promise<void>;
95
+ /**
96
+ * Open the window's system menu at the pointer, as a right click on a real caption does. Call from the header's
97
+ * `onContextMenu`, and `preventDefault()` there so the browser's own menu does not open too. It resolves as the
98
+ * menu opens, not when it closes. A Chromium page's `-webkit-app-region: drag` area opens the menu on a right click
99
+ * without it.
100
+ */
101
+ showSystemMenu(): Promise<void>;
54
102
  /** Resync the native chrome to the app theme (host `WindowCommandOptions.ApplyTheme`). */
55
103
  setTheme(dark: boolean): Promise<void>;
56
104
  /**
@@ -58,15 +106,41 @@ export declare class WindowCommands extends BaseModuleService<WindowRequests> {
58
106
  * chiefly so Windows 11 offers **Snap Layouts** on the maximize button.
59
107
  *
60
108
  * ⚠ The host then takes over CLICKS in those rects and performs minimize/maximize/close itself, so
61
- * your `onClick` handlers stop firing there. CSS `:hover` stops firing too — subscribe to the host's
62
- * caption-button state to render hot/pressed, which is also the only way to stay hot while the
63
- * pointer is over the snap flyout, a different window.
109
+ * your `onClick` handlers stop firing there. CSS `:hover` stops firing too: render hot and pressed from
110
+ * {@link useCaptionButtonState}.
111
+ *
112
+ * ⚠ The Chromium shell forgets the rects when a new document loads, so send them from the page on
113
+ * every load as well as on every layout change.
114
+ *
115
+ * On either shell the window can paint the buttons itself instead (`NativeCaptionButtons` on
116
+ * `OptimizedFormOptions` or `ChromiumWindowOptions`): then the page reserves the rects and draws nothing
117
+ * there. The Chromium shell's painted buttons follow {@link WindowCommands.setTheme}.
64
118
  *
65
119
  * ⚠ Re-send on every layout change: the rectangles are a snapshot, and a stale one moves the
66
120
  * hit-test off the button the user can see. Pass an empty array to hand the pixels back to the page.
67
121
  */
68
122
  setCaptionButtons(buttons: CaptionButtonRect[]): Promise<void>;
123
+ /**
124
+ * Colour the caption buttons the window paints, to match the page's title bar; send them again when the page's
125
+ * theme changes. `null` goes back to the default: on the Chromium shell the colours of {@link WindowCommands.setTheme},
126
+ * on the WebView2 shell a fallback from the form's own colour (it replaces `OptimizedForm.CaptionButtonColors`).
127
+ * The row's height is the page's: the rects sent to {@link WindowCommands.setCaptionButtons}.
128
+ *
129
+ * ⚠ Rejects with `NO_ROUTE` where the window does not paint its buttons, and with `INVALID_PAYLOAD_VALUE` for a
130
+ * colour that is not CSS hex.
131
+ */
132
+ setCaptionButtonColors(colors: CaptionButtonColors | null): Promise<void>;
69
133
  }
134
+ /**
135
+ * Which caption button to render hot or pressed, for buttons registered with
136
+ * {@link WindowCommands.setCaptionButtons}, where CSS `:hover` no longer fires. The Chromium shell sends it to
137
+ * the window's own page as the pointer moves. The WebView2 shell sends nothing by itself: with
138
+ * `NativeCaptionButtons` it paints the caption buttons itself, and an app drawing its own there can emit this
139
+ * event from `OptimizedForm.CaptionButtonStateChanged`.
140
+ */
141
+ export declare function useCaptionButtonState(options?: {
142
+ bus?: ShenoraEventBus;
143
+ }): CaptionButtonState;
70
144
  /**
71
145
  * The authoritative maximize state, re-queried when a resize SETTLES — a maximize/restore always
72
146
  * resizes the window, and the DOM has no other signal for the manual work-area maximize. Failures
@@ -1,6 +1,12 @@
1
1
  import { useEffect, useRef, useState } from 'react';
2
+ import { useShenoraEvent } from './hooks.js';
2
3
  import { debounce } from './internal.js';
3
4
  import { BaseModuleService } from './moduleService.js';
5
+ /** The host's `SHENORA.WINDOW` event names, pinned against it by `WireMirrorTests`. */
6
+ export const WindowEventTypes = {
7
+ /** A {@link CaptionButtonState}, sent to the window's own page. See {@link useCaptionButtonState}. */
8
+ CaptionButtonState: 'CAPTION_BUTTON_STATE',
9
+ };
4
10
  /**
5
11
  * Typed client for the host's `SHENORA.WINDOW` module (`WindowCommandModule` in Shenora.Windows) —
6
12
  * drive the frameless window's chrome from the page: chrome buttons call
@@ -35,6 +41,15 @@ export class WindowCommands extends BaseModuleService {
35
41
  startResize(edge = 'top') {
36
42
  return this.send('START_RESIZE', { payload: { edge } });
37
43
  }
44
+ /**
45
+ * Open the window's system menu at the pointer, as a right click on a real caption does. Call from the header's
46
+ * `onContextMenu`, and `preventDefault()` there so the browser's own menu does not open too. It resolves as the
47
+ * menu opens, not when it closes. A Chromium page's `-webkit-app-region: drag` area opens the menu on a right click
48
+ * without it.
49
+ */
50
+ showSystemMenu() {
51
+ return this.send('SHOW_SYSTEM_MENU');
52
+ }
38
53
  /** Resync the native chrome to the app theme (host `WindowCommandOptions.ApplyTheme`). */
39
54
  setTheme(dark) {
40
55
  return this.send('SET_THEME', { payload: { dark } });
@@ -44,9 +59,15 @@ export class WindowCommands extends BaseModuleService {
44
59
  * chiefly so Windows 11 offers **Snap Layouts** on the maximize button.
45
60
  *
46
61
  * ⚠ The host then takes over CLICKS in those rects and performs minimize/maximize/close itself, so
47
- * your `onClick` handlers stop firing there. CSS `:hover` stops firing too — subscribe to the host's
48
- * caption-button state to render hot/pressed, which is also the only way to stay hot while the
49
- * pointer is over the snap flyout, a different window.
62
+ * your `onClick` handlers stop firing there. CSS `:hover` stops firing too: render hot and pressed from
63
+ * {@link useCaptionButtonState}.
64
+ *
65
+ * ⚠ The Chromium shell forgets the rects when a new document loads, so send them from the page on
66
+ * every load as well as on every layout change.
67
+ *
68
+ * On either shell the window can paint the buttons itself instead (`NativeCaptionButtons` on
69
+ * `OptimizedFormOptions` or `ChromiumWindowOptions`): then the page reserves the rects and draws nothing
70
+ * there. The Chromium shell's painted buttons follow {@link WindowCommands.setTheme}.
50
71
  *
51
72
  * ⚠ Re-send on every layout change: the rectangles are a snapshot, and a stale one moves the
52
73
  * hit-test off the button the user can see. Pass an empty array to hand the pixels back to the page.
@@ -54,6 +75,30 @@ export class WindowCommands extends BaseModuleService {
54
75
  setCaptionButtons(buttons) {
55
76
  return this.send('SET_CAPTION_BUTTONS', { payload: { buttons } });
56
77
  }
78
+ /**
79
+ * Colour the caption buttons the window paints, to match the page's title bar; send them again when the page's
80
+ * theme changes. `null` goes back to the default: on the Chromium shell the colours of {@link WindowCommands.setTheme},
81
+ * on the WebView2 shell a fallback from the form's own colour (it replaces `OptimizedForm.CaptionButtonColors`).
82
+ * The row's height is the page's: the rects sent to {@link WindowCommands.setCaptionButtons}.
83
+ *
84
+ * ⚠ Rejects with `NO_ROUTE` where the window does not paint its buttons, and with `INVALID_PAYLOAD_VALUE` for a
85
+ * colour that is not CSS hex.
86
+ */
87
+ setCaptionButtonColors(colors) {
88
+ return this.send('SET_CAPTION_BUTTON_COLORS', { payload: colors ? { colors } : {} });
89
+ }
90
+ }
91
+ /**
92
+ * Which caption button to render hot or pressed, for buttons registered with
93
+ * {@link WindowCommands.setCaptionButtons}, where CSS `:hover` no longer fires. The Chromium shell sends it to
94
+ * the window's own page as the pointer moves. The WebView2 shell sends nothing by itself: with
95
+ * `NativeCaptionButtons` it paints the caption buttons itself, and an app drawing its own there can emit this
96
+ * event from `OptimizedForm.CaptionButtonStateChanged`.
97
+ */
98
+ export function useCaptionButtonState(options = {}) {
99
+ const [state, setState] = useState({});
100
+ useShenoraEvent('SHENORA.WINDOW', WindowEventTypes.CaptionButtonState, (payload) => setState(payload ?? {}), { bus: options.bus });
101
+ return state;
57
102
  }
58
103
  /**
59
104
  * The authoritative maximize state, re-queried when a resize SETTLES — a maximize/restore always
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@shenora/react",
3
- "version": "0.16.0",
4
- "description": "React client for Shenora hosts on Windows, Android and iOS: correlated invoke/send/subscribe over the desktop postMessage bridge or the MAUI HybridWebView transport, typed module services, host-backed stores, and hooks for request tracking and media playback. Drop zones and window commands are desktop-only, because the capabilities are. Ships a browser fallback for pure-UI development.",
3
+ "version": "0.17.1",
4
+ "description": "React client for Shenora hosts on Windows, macOS, Android and iOS: correlated invoke/send/subscribe over whichever host transport the page is in (WebView2 postMessage, the Chromium hosts' same-origin route, or MAUI HybridWebView), typed module services, host-backed stores, and hooks for request tracking and media playback. Drop zones and window commands are desktop-only, because the capabilities are. Ships a browser fallback for pure-UI development.",
5
5
  "license": "MIT",
6
6
  "author": "Jiarong Gu",
7
7
  "repository": {
@@ -13,6 +13,8 @@
13
13
  "keywords": [
14
14
  "shenora",
15
15
  "webview2",
16
+ "chromium",
17
+ "cef",
16
18
  "desktop",
17
19
  "android",
18
20
  "ios",