@shenora/react 0.14.0 → 0.15.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.
@@ -0,0 +1,50 @@
1
+ import { type ShenoraEventBus } from './eventBus.js';
2
+ /** The module these events are published under. */
3
+ export declare const LIFECYCLE_MODULE = "SHENORA.LIFECYCLE";
4
+ /** The app left the foreground. No payload. */
5
+ export declare const LIFECYCLE_STOPPED = "STOPPED";
6
+ /** The app came back, carrying an {@link AppLifecycleReport}. */
7
+ export declare const LIFECYCLE_RESUMED = "RESUMED";
8
+ /** What a {@link LIFECYCLE_RESUMED} event carries. */
9
+ export interface AppLifecycleReport {
10
+ /**
11
+ * How long the app was away, or `null` when there was no preceding stop to measure from — a first
12
+ * launch, or a shell that reported only the resume.
13
+ *
14
+ * ⚠ **`null` is not `0`.** Zero would be a measurement; this is the absence of one. Treating them
15
+ * alike makes the first resume after launch skip the reconnect every other resume performs.
16
+ */
17
+ backgroundMilliseconds: number | null;
18
+ }
19
+ /** What {@link useAppLifecycle} watches for. Every handler is optional. */
20
+ export interface AppLifecycleHandlers {
21
+ /** The app left the foreground. */
22
+ onStopped?: () => void;
23
+ /**
24
+ * The app came back. `backgroundMilliseconds` is null when there was nothing to measure.
25
+ *
26
+ * ⚠ Branch on a THRESHOLD rather than on the event: reconnecting on every resume makes switching
27
+ * apps expensive, and doing nothing makes a long absence look like a hung page.
28
+ */
29
+ onResumed?: (report: AppLifecycleReport) => void;
30
+ }
31
+ /**
32
+ * Run something when the app leaves the foreground and when it comes back.
33
+ *
34
+ * ```tsx
35
+ * useAppLifecycle({
36
+ * onResumed: ({ backgroundMilliseconds }) => {
37
+ * // A short trip to the notification shade costs nothing; a long one means the socket is gone.
38
+ * if (backgroundMilliseconds === null || backgroundMilliseconds > 30_000) reconnect();
39
+ * },
40
+ * });
41
+ * ```
42
+ *
43
+ * ⚠ On a shell that reports no lifecycle — the desktop, or a mobile app that did not compose
44
+ * `MobileAppLifecycle` — neither handler ever runs. There is no capability to branch on, because there
45
+ * is nothing to render differently: an app that also wants a visibility signal should use
46
+ * `document.visibilitychange`, which works everywhere including a plain browser tab.
47
+ */
48
+ export declare function useAppLifecycle(handlers: AppLifecycleHandlers, options?: {
49
+ eventBus?: ShenoraEventBus;
50
+ }): void;
@@ -0,0 +1,65 @@
1
+ /**
2
+ * When the APP went away and came back — and how long it was gone, which is the part this page cannot
3
+ * measure itself. Mirrors `Shenora.Modules.Platform.AppLifecycle`, pinned by `WireMirrorTests`.
4
+ *
5
+ * 🔴 **For "am I on screen", use `document.visibilitychange`.** It fires on both mobile shells and is
6
+ * the web platform's own answer; this would only arrive later and over IPC. What it adds is the two
7
+ * things a hidden document genuinely cannot know:
8
+ *
9
+ * 1. **How long it was away, on a clock that was not throttled.** A backgrounded page's timers are
10
+ * throttled and its process may be frozen, so a `Date.now()` delta across the gap is unreliable —
11
+ * and the duration is what the decision actually turns on. Three seconds in the notification shade
12
+ * needs no reconnect; forty minutes means the socket is dead.
13
+ * 2. **That this was the user leaving the APP**, rather than anything else that can hide a document.
14
+ *
15
+ * ⚠ It reports; it does not act. Reconnecting, re-probing or refetching is yours — the host has no way
16
+ * to know what this page was holding.
17
+ */
18
+ import { useEffect, useRef } from 'react';
19
+ import { eventBus as defaultEventBus } from './eventBus.js';
20
+ /** The module these events are published under. */
21
+ export const LIFECYCLE_MODULE = 'SHENORA.LIFECYCLE';
22
+ /** The app left the foreground. No payload. */
23
+ export const LIFECYCLE_STOPPED = 'STOPPED';
24
+ /** The app came back, carrying an {@link AppLifecycleReport}. */
25
+ export const LIFECYCLE_RESUMED = 'RESUMED';
26
+ /**
27
+ * Run something when the app leaves the foreground and when it comes back.
28
+ *
29
+ * ```tsx
30
+ * useAppLifecycle({
31
+ * onResumed: ({ backgroundMilliseconds }) => {
32
+ * // A short trip to the notification shade costs nothing; a long one means the socket is gone.
33
+ * if (backgroundMilliseconds === null || backgroundMilliseconds > 30_000) reconnect();
34
+ * },
35
+ * });
36
+ * ```
37
+ *
38
+ * ⚠ On a shell that reports no lifecycle — the desktop, or a mobile app that did not compose
39
+ * `MobileAppLifecycle` — neither handler ever runs. There is no capability to branch on, because there
40
+ * is nothing to render differently: an app that also wants a visibility signal should use
41
+ * `document.visibilitychange`, which works everywhere including a plain browser tab.
42
+ */
43
+ export function useAppLifecycle(handlers, options = {}) {
44
+ const eventBus = options.eventBus ?? defaultEventBus;
45
+ // Read through a ref so a handler rebuilt every render — the normal case for inline arrows — does not
46
+ // tear down and rebuild the subscription, losing any transition that lands in the gap.
47
+ const latest = useRef(handlers);
48
+ latest.current = handlers;
49
+ useEffect(() => {
50
+ const subscriptions = [
51
+ eventBus.subscribe(LIFECYCLE_MODULE, LIFECYCLE_STOPPED, () => {
52
+ latest.current.onStopped?.();
53
+ }),
54
+ eventBus.subscribe(LIFECYCLE_MODULE, LIFECYCLE_RESUMED, (message) => {
55
+ // ⚠ Absent rather than 0 when the host sent nothing usable — the same distinction the payload
56
+ // documents, preserved here so a missing field cannot read as "away for no time".
57
+ const reported = message.payload?.backgroundMilliseconds;
58
+ latest.current.onResumed?.({
59
+ backgroundMilliseconds: typeof reported === 'number' ? reported : null,
60
+ });
61
+ }),
62
+ ];
63
+ return () => subscriptions.forEach((off) => off());
64
+ }, [eventBus]);
65
+ }
@@ -0,0 +1,101 @@
1
+ import type { ShenoraBridge } from './bridge.js';
2
+ import { type ShenoraEventBus } from './eventBus.js';
3
+ import { BaseModuleService } from './moduleService.js';
4
+ /** The module a back press is published under, and the one this page answers on. */
5
+ export declare const BACK_MODULE = "SHENORA.BACK";
6
+ /** The event type carrying one press. */
7
+ export declare const BACK_PRESSED = "PRESSED";
8
+ /** The payload of a {@link BACK_PRESSED} event. */
9
+ export interface BackNavigationEvent {
10
+ /**
11
+ * Identifies this press. Return it verbatim — an answer naming a press that already timed out is
12
+ * refused rather than applied to the one after it.
13
+ */
14
+ token: string;
15
+ }
16
+ /** What `RESOLVE` answers with. */
17
+ export interface BackNavigationResult {
18
+ /**
19
+ * False when the press was no longer waiting — it timed out, or was already answered.
20
+ *
21
+ * ⚠ Not an error; the platform has already taken the press. But seeing it repeatedly means this
22
+ * page's back handling is not running at all, and the user is getting the platform default.
23
+ */
24
+ accepted: boolean;
25
+ }
26
+ interface BackRequests {
27
+ INTERCEPT: {
28
+ enabled: boolean;
29
+ };
30
+ RESOLVE: {
31
+ token: string;
32
+ handled: boolean;
33
+ };
34
+ }
35
+ /**
36
+ * Typed client for the host's `SHENORA.BACK` module (`BackNavigationModule`).
37
+ *
38
+ * ⚠ On a shell with no system back gesture — iOS, desktop — {@link intercept} is accepted and no press
39
+ * ever arrives, because there is nothing to intercept. Harmless, but indistinguishable from a broken
40
+ * handler, so use {@link useBackNavigation}'s `supported` to decide what to RENDER.
41
+ */
42
+ export declare class BackNavigationAccess extends BaseModuleService<BackRequests> {
43
+ constructor(bridge?: ShenoraBridge);
44
+ /**
45
+ * Take or release responsibility for the back gesture.
46
+ *
47
+ * ⚠ Ask again after a navigation that replaces the document: the host cannot know the new page wanted
48
+ * it, and asking twice is harmless.
49
+ */
50
+ intercept(enabled: boolean): Promise<void>;
51
+ /**
52
+ * Answer one press. `handled: true` consumes it; `false` sends it to the platform, which is how a page
53
+ * at the root of its own history lets the user leave.
54
+ */
55
+ resolve(token: string, handled: boolean): Promise<BackNavigationResult>;
56
+ }
57
+ /** What {@link useBackNavigation} reports back. */
58
+ export interface BackNavigationHandle {
59
+ /** The typed client. Stable across renders. */
60
+ back: BackNavigationAccess;
61
+ /**
62
+ * This shell has a system back gesture at all. False on iOS and the desktop — read from the ready
63
+ * handshake rather than sniffed from the user agent (D36).
64
+ */
65
+ supported: boolean;
66
+ }
67
+ /**
68
+ * Handle the system back gesture for as long as the component is mounted.
69
+ *
70
+ * Your handler returns `true` if it consumed the press and `false` to let the user leave. The
71
+ * subscription, the answer and the release on unmount are all done for you — which matters, because
72
+ * every one of those is a way to silently end up back at "the back button quits the app".
73
+ *
74
+ * ```tsx
75
+ * const { supported } = useBackNavigation(() => {
76
+ * if (playerExpanded) { collapsePlayer(); return true; } // consumed
77
+ * if (history.length > 1) { history.back(); return true; } // consumed
78
+ * return false; // at the root — let them exit
79
+ * });
80
+ * ```
81
+ *
82
+ * ⚠ **The handler may be async**, for a page that has to ask something before deciding — but the host
83
+ * gives it a bounded window (2 seconds by default) and then hands the press to the platform. Do not put
84
+ * a confirmation dialog behind it.
85
+ *
86
+ * ⚠ A THROWING handler answers `false`, so a bug in your own code leaves the user able to leave rather
87
+ * than trapping them in an app whose back button does nothing.
88
+ *
89
+ * ⚠ **Several components may use this at once**, which is the layered case above: the most recently
90
+ * mounted handler is asked FIRST — a modal before the player behind it — and the first to return `true`
91
+ * claims the press. The host is told once, however many components are listening.
92
+ *
93
+ * ⚠ **Ask again after a navigation that replaces the document.** The host has no document-lifecycle
94
+ * signal to reset on, so a new page that does not re-register leaves the old interception standing and
95
+ * every press waits the full timeout before reaching the platform.
96
+ */
97
+ export declare function useBackNavigation(onBack: () => boolean | Promise<boolean>, options?: {
98
+ client?: BackNavigationAccess;
99
+ eventBus?: ShenoraEventBus;
100
+ }): BackNavigationHandle;
101
+ export {};
@@ -0,0 +1,189 @@
1
+ /**
2
+ * The page's half of the host's `SHENORA.BACK` module — Android's system back gesture, offered to this
3
+ * page before the platform acts on it. Mirrors `Shenora.Modules.Platform.BackNavigationModule`, pinned
4
+ * by `WireMirrorTests`.
5
+ *
6
+ * 🔴 **This is the one shell primitive whose absence is a BROKEN APP rather than a missing feature.**
7
+ * Unhandled, Android's back finishes the activity from whatever screen the user is on — so a user two
8
+ * levels into your UI is dumped to the home screen instead of going back one step. There is no web API
9
+ * for it: `popstate` fires only for history the page itself pushed, and it cannot tell you that the
10
+ * press would otherwise EXIT.
11
+ *
12
+ * ⚠ **Every press must be answered**, including the ones you do not want. An unanswered press is held
13
+ * for the host's timeout and then goes to the platform — so a page that stops answering does not freeze
14
+ * back, it silently reverts to quitting the app.
15
+ */
16
+ import { useEffect, useRef } from 'react';
17
+ import { eventBus as defaultEventBus } from './eventBus.js';
18
+ import { useShellInfo } from './hooks.js';
19
+ import { BaseModuleService } from './moduleService.js';
20
+ import { ShellCapabilities } from './types.js';
21
+ /** The module a back press is published under, and the one this page answers on. */
22
+ export const BACK_MODULE = 'SHENORA.BACK';
23
+ /** The event type carrying one press. */
24
+ export const BACK_PRESSED = 'PRESSED';
25
+ /**
26
+ * Typed client for the host's `SHENORA.BACK` module (`BackNavigationModule`).
27
+ *
28
+ * ⚠ On a shell with no system back gesture — iOS, desktop — {@link intercept} is accepted and no press
29
+ * ever arrives, because there is nothing to intercept. Harmless, but indistinguishable from a broken
30
+ * handler, so use {@link useBackNavigation}'s `supported` to decide what to RENDER.
31
+ */
32
+ export class BackNavigationAccess extends BaseModuleService {
33
+ constructor(bridge) {
34
+ super(BACK_MODULE, bridge);
35
+ }
36
+ /**
37
+ * Take or release responsibility for the back gesture.
38
+ *
39
+ * ⚠ Ask again after a navigation that replaces the document: the host cannot know the new page wanted
40
+ * it, and asking twice is harmless.
41
+ */
42
+ intercept(enabled) {
43
+ return this.send('INTERCEPT', { payload: { enabled } });
44
+ }
45
+ /**
46
+ * Answer one press. `handled: true` consumes it; `false` sends it to the platform, which is how a page
47
+ * at the root of its own history lets the user leave.
48
+ */
49
+ resolve(token, handled) {
50
+ return this.send('RESOLVE', { payload: { token, handled } });
51
+ }
52
+ }
53
+ /**
54
+ * Handle the system back gesture for as long as the component is mounted.
55
+ *
56
+ * Your handler returns `true` if it consumed the press and `false` to let the user leave. The
57
+ * subscription, the answer and the release on unmount are all done for you — which matters, because
58
+ * every one of those is a way to silently end up back at "the back button quits the app".
59
+ *
60
+ * ```tsx
61
+ * const { supported } = useBackNavigation(() => {
62
+ * if (playerExpanded) { collapsePlayer(); return true; } // consumed
63
+ * if (history.length > 1) { history.back(); return true; } // consumed
64
+ * return false; // at the root — let them exit
65
+ * });
66
+ * ```
67
+ *
68
+ * ⚠ **The handler may be async**, for a page that has to ask something before deciding — but the host
69
+ * gives it a bounded window (2 seconds by default) and then hands the press to the platform. Do not put
70
+ * a confirmation dialog behind it.
71
+ *
72
+ * ⚠ A THROWING handler answers `false`, so a bug in your own code leaves the user able to leave rather
73
+ * than trapping them in an app whose back button does nothing.
74
+ *
75
+ * ⚠ **Several components may use this at once**, which is the layered case above: the most recently
76
+ * mounted handler is asked FIRST — a modal before the player behind it — and the first to return `true`
77
+ * claims the press. The host is told once, however many components are listening.
78
+ *
79
+ * ⚠ **Ask again after a navigation that replaces the document.** The host has no document-lifecycle
80
+ * signal to reset on, so a new page that does not re-register leaves the old interception standing and
81
+ * every press waits the full timeout before reaching the platform.
82
+ */
83
+ export function useBackNavigation(onBack, options = {}) {
84
+ const { client, eventBus = defaultEventBus } = options;
85
+ const shell = useShellInfo();
86
+ const supported = shell?.capabilities?.includes(ShellCapabilities.backNavigation) ?? false;
87
+ const back = client ?? defaultClient();
88
+ // 🔴 The handler is read through a ref rather than depended on. An inline arrow — which is how this
89
+ // hook will always be called — is a new function every render, so depending on it would unsubscribe
90
+ // and resubscribe on every render, and a press landing in that gap is answered by NOBODY. It then
91
+ // sits until the host's timeout and goes to the platform, i.e. the app quits mid-interaction.
92
+ const handler = useRef(onBack);
93
+ handler.current = onBack;
94
+ // 🔴 INTERCEPT UNLESS THE SHELL IS KNOWN NOT TO HAVE IT — never "only when known to have it".
95
+ // `useShellInfo` is a synchronous cache read that does NOT re-render when the handshake lands later,
96
+ // and child effects run before parent effects — so a page that calls `notifyReady()` from a root
97
+ // effect mounts every child while `shell` is still undefined. Gating on `supported` there would
98
+ // silently never intercept, for the whole session, and back would quit the app from every screen:
99
+ // the exact defect this hook exists to prevent, arriving from the most ordinary bootstrap. Asking to
100
+ // intercept on a shell that turns out to have no back gesture costs nothing — no press ever arrives.
101
+ const declined = shell !== undefined && !supported;
102
+ useEffect(() => {
103
+ if (declined)
104
+ return;
105
+ return register(back, eventBus, handler);
106
+ }, [back, eventBus, declined, handler]);
107
+ return { back, supported };
108
+ }
109
+ /**
110
+ * The live handlers, newest LAST — module scope on purpose.
111
+ *
112
+ * 🔴 **Two components may use the hook at once, and that is the shape D79 describes** ("close the
113
+ * expanded player, then walk the history"). Per-component `intercept(true)/(false)` calls would make
114
+ * the *first* unmount switch interception off for everyone still mounted, and back would quit the app
115
+ * with every remaining handler still subscribed and looking healthy. So the host is told once, on
116
+ * 0→1, and told again only on 1→0.
117
+ */
118
+ const handlers = [];
119
+ let release;
120
+ function register(back, eventBus, handler) {
121
+ handlers.push(handler);
122
+ if (handlers.length === 1)
123
+ release = subscribe(back, eventBus);
124
+ return () => {
125
+ const at = handlers.indexOf(handler);
126
+ if (at >= 0)
127
+ handlers.splice(at, 1);
128
+ if (handlers.length === 0) {
129
+ release?.();
130
+ release = undefined;
131
+ }
132
+ };
133
+ }
134
+ function subscribe(back, eventBus) {
135
+ // ⚠ A rejection HERE means interception was never established — the adopter did half the host-side
136
+ // pair, or a payload key drifted. Silence would leave `supported` true, the handlers subscribed and
137
+ // back quitting the app, which is the failure the kit's own registration doc lectures about. The
138
+ // teardown call below is the opposite case and is correctly silent.
139
+ back.intercept(true).catch((error) => {
140
+ console.warn('[shenora] the host refused to hand over the back gesture, so back will quit the app. Check that '
141
+ + 'the host called AddShenoraBackNavigation() AND constructed MobileBackNavigation.', error);
142
+ });
143
+ const unsubscribe = eventBus.subscribe(BACK_MODULE, BACK_PRESSED, async (message) => {
144
+ const token = message.payload?.token;
145
+ if (typeof token !== 'string')
146
+ return;
147
+ // Innermost first: the most recently mounted component is the one on top of the user's screen, so
148
+ // a modal gets the press before the player behind it. The first to claim it wins.
149
+ // Snapshot: a handler may unmount another while deciding, and splicing the live array mid-walk
150
+ // would skip its neighbour.
151
+ let handled = false;
152
+ for (const entry of [...handlers].reverse()) {
153
+ if (handled)
154
+ break;
155
+ try {
156
+ handled = await entry.current();
157
+ }
158
+ catch {
159
+ // Answering false is the safe direction: the user can still leave. Swallowing the press here
160
+ // would be a back button that does nothing, which is not recoverable from the outside.
161
+ handled = false;
162
+ }
163
+ }
164
+ try {
165
+ const answer = await back.resolve(token, handled);
166
+ if (answer && answer.accepted === false) {
167
+ // The host had already given the press to the platform. Not an error — but a page seeing this
168
+ // is a page whose back handling is not running, and this is the only place that is visible
169
+ // without a device attached.
170
+ console.warn('[shenora] a back press was answered too late — the platform already took it.');
171
+ }
172
+ }
173
+ catch {
174
+ // The host went away mid-answer; the press is the platform's now, which is the safe direction.
175
+ }
176
+ });
177
+ return () => {
178
+ unsubscribe();
179
+ // Releasing is what stops a page whose handlers have all unmounted from holding every press for
180
+ // the host's whole timeout before the platform gets it. Silent: the one moment this reliably
181
+ // rejects is a host that has already gone, which is exactly when a page unmounts.
182
+ back.intercept(false).catch(() => { });
183
+ };
184
+ }
185
+ let shared;
186
+ function defaultClient() {
187
+ shared ?? (shared = new BackNavigationAccess());
188
+ return shared;
189
+ }
package/dist/index.d.ts CHANGED
@@ -11,6 +11,8 @@ export { useDropZone, DROP_ZONE_MODULE, type DropZoneFileDrop, type UseDropZoneO
11
11
  export { useShenora, useShenoraEvent, useShenoraQuery, useShellInfo, type ShenoraQueryResult, } from './hooks.js';
12
12
  export { FileDialogs, useFileDialogs, type FileDialogFilter, type FileDialogOptions, type OpenFileOptions, type OpenFolderOptions, type SaveFileOptions, type FileDialogResult, type FileDialogsHandle, } from './fileDialogs.js';
13
13
  export { ClipboardAccess, useClipboard, PNG_IMAGE, HTML, type ClipboardContent, type ClipboardHandle, } from './clipboard.js';
14
+ export { BackNavigationAccess, useBackNavigation, BACK_MODULE, BACK_PRESSED, type BackNavigationEvent, type BackNavigationResult, type BackNavigationHandle, } from './backNavigation.js';
15
+ export { useAppLifecycle, LIFECYCLE_MODULE, LIFECYCLE_STOPPED, LIFECYCLE_RESUMED, type AppLifecycleReport, type AppLifecycleHandlers, } from './appLifecycle.js';
14
16
  export { installDevInterceptor, type DevInterceptorOptions, type DevIpcEntry, type DevEventEntry, } from './devInterceptor.js';
15
17
  export { mediaUrl, encodeMediaPayload, decodeMediaPayload } from './media.js';
16
18
  export { parseManifest, pickMediaSource, nextSegment, segmentMimeType, codecsFromInitSegment, remoteSegmentUrl, SEGMENT_REMOTE_PREFIX, } from './segmentStream.js';
package/dist/index.js CHANGED
@@ -21,6 +21,12 @@ export { FileDialogs, useFileDialogs, } from './fileDialogs.js';
21
21
  // The native clipboard, for the two things navigator.clipboard cannot do: FILES, and access with no
22
22
  // user gesture. The client half of the host's SHENORA.CLIPBOARD module.
23
23
  export { ClipboardAccess, useClipboard, PNG_IMAGE, HTML, } from './clipboard.js';
24
+ // Android's system back gesture. Unhandled it FINISHES THE ACTIVITY from any screen, so a page with
25
+ // its own navigation must answer it — the client half of the host's SHENORA.BACK module.
26
+ export { BackNavigationAccess, useBackNavigation, BACK_MODULE, BACK_PRESSED, } from './backNavigation.js';
27
+ // When the APP went away and came back, and how long it was gone — the part a throttled, possibly
28
+ // frozen page cannot measure. For "am I on screen", use document.visibilitychange instead.
29
+ export { useAppLifecycle, LIFECYCLE_MODULE, LIFECYCLE_STOPPED, LIFECYCLE_RESUMED, } from './appLifecycle.js';
24
30
  export { installDevInterceptor, } from './devInterceptor.js';
25
31
  // Addressing local content the page cannot reach itself. Pure functions, not hooks.
26
32
  export { mediaUrl, encodeMediaPayload, decodeMediaPayload } from './media.js';
package/dist/types.d.ts CHANGED
@@ -106,6 +106,14 @@ export declare const ShellCapabilities: {
106
106
  readonly savePicker: "savePicker";
107
107
  readonly secondaryWindows: "secondaryWindows";
108
108
  readonly tray: "tray";
109
+ /**
110
+ * The shell has a system BACK gesture this page can take responsibility for — Android only. Absent
111
+ * on iOS and the desktop, which have none.
112
+ *
113
+ * ⚠ Branch on it. Asking to intercept where there is no gesture is accepted and no press ever
114
+ * arrives, which looks exactly like a handler that is broken.
115
+ */
116
+ readonly backNavigation: "backNavigation";
109
117
  /**
110
118
  * The host can put a FILE LIST on the clipboard, so the user can paste into Explorer, Finder or a
111
119
  * file manager. No web API expresses this, so it is the one part of the clipboard worth branching on.
package/dist/types.js CHANGED
@@ -76,6 +76,14 @@ export const ShellCapabilities = {
76
76
  savePicker: 'savePicker',
77
77
  secondaryWindows: 'secondaryWindows',
78
78
  tray: 'tray',
79
+ /**
80
+ * The shell has a system BACK gesture this page can take responsibility for — Android only. Absent
81
+ * on iOS and the desktop, which have none.
82
+ *
83
+ * ⚠ Branch on it. Asking to intercept where there is no gesture is accepted and no press ever
84
+ * arrives, which looks exactly like a handler that is broken.
85
+ */
86
+ backNavigation: 'backNavigation',
79
87
  /**
80
88
  * The host can put a FILE LIST on the clipboard, so the user can paste into Explorer, Finder or a
81
89
  * file manager. No web API expresses this, so it is the one part of the clipboard worth branching on.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@shenora/react",
3
- "version": "0.14.0",
3
+ "version": "0.15.0",
4
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.",
5
5
  "license": "MIT",
6
6
  "author": "Jiarong Gu",