@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.
- package/dist/appLifecycle.d.ts +50 -0
- package/dist/appLifecycle.js +65 -0
- package/dist/backNavigation.d.ts +101 -0
- package/dist/backNavigation.js +189 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +6 -0
- package/dist/types.d.ts +8 -0
- package/dist/types.js +8 -0
- package/package.json +1 -1
|
@@ -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.
|
|
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",
|