@cplieger/web-terminal-ui 7.3.2 → 8.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +114 -18
- package/css/30-tabs.css +21 -0
- package/css/31-switcher.css +15 -0
- package/css/32-split.css +176 -0
- package/css/MANIFEST +1 -0
- package/css/MANIFEST.tabbed +1 -0
- package/package.json +13 -12
- package/src/composition.ts +165 -237
- package/src/features/activity-monitor.ts +20 -55
- package/src/features/animations.ts +12 -30
- package/src/features/clipboard.ts +36 -64
- package/src/features/connection-banner.ts +10 -19
- package/src/features/context-menu.ts +70 -145
- package/src/features/dom.ts +44 -54
- package/src/features/menu-position.ts +24 -28
- package/src/features/mobile-toolbar.ts +32 -62
- package/src/features/predictive-echo.ts +15 -49
- package/src/features/scroll-to-bottom.ts +12 -24
- package/src/features/tabs/index.ts +1398 -1806
- package/src/features/tabs/ink-centre.ts +45 -54
- package/src/features/tabs/model.ts +301 -383
- package/src/features/tabs/strip.ts +45 -97
- package/src/features/tabs/switcher.ts +70 -48
- package/src/index.ts +13 -13
- package/src/kernel/a11y.ts +106 -27
- package/src/kernel/attention.ts +157 -0
- package/src/kernel/conn-state.ts +28 -22
- package/src/kernel/fatal.ts +76 -0
- package/src/kernel/feature-host.ts +136 -0
- package/src/kernel/gesture.ts +9 -13
- package/src/kernel/kernel.ts +57 -2139
- package/src/kernel/layout-policy.ts +44 -0
- package/src/kernel/loading-status.ts +30 -88
- package/src/kernel/navigation.ts +11 -17
- package/src/kernel/notify.ts +158 -0
- package/src/kernel/pane.ts +1415 -0
- package/src/kernel/realm.ts +11 -0
- package/src/kernel/regions.ts +8 -11
- package/src/kernel/scrollback.ts +88 -168
- package/src/kernel/selection.ts +25 -0
- package/src/kernel/shell.ts +1434 -0
- package/src/kernel/split-handle.ts +280 -0
- package/src/kernel/status-share.ts +100 -0
- package/src/kernel/types.ts +457 -441
- package/src/predict.ts +144 -170
- package/src/viewport.ts +124 -197
- package/src/features/tabs/attention.ts +0 -250
- package/src/features/tabs/notify.ts +0 -265
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
// The layout breakpoints, in ROOT pixels: root dimensions rather than viewport
|
|
2
|
+
// dimensions, so an embedded terminal in a narrow panel counts as narrow. For
|
|
3
|
+
// every full-page consumer root size equals viewport size, so the width half
|
|
4
|
+
// matches the old (max-width: 600px) media queries exactly.
|
|
5
|
+
|
|
6
|
+
import type { PaneSide } from "./types.js";
|
|
7
|
+
|
|
8
|
+
/** A root at or under this width is narrow (a portrait phone, a narrow panel). */
|
|
9
|
+
const NARROW_MAX_PX = 600;
|
|
10
|
+
/** A root at or under this height is narrow: a landscape phone (an iPhone 14 Pro
|
|
11
|
+
* Max is 932x430) is wider than NARROW_MAX_PX but still a phone in the hand,
|
|
12
|
+
* and the smallest iPad is 744 CSS px tall in landscape, so 500 separates the
|
|
13
|
+
* two with margin on both sides. */
|
|
14
|
+
const SHORT_MAX_PX = 500;
|
|
15
|
+
|
|
16
|
+
/** Whether a root of this size takes the compact (phone) layout. */
|
|
17
|
+
export function isNarrow(width: number, height: number): boolean {
|
|
18
|
+
return width <= NARROW_MAX_PX || height <= SHORT_MAX_PX;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Whether an untyped caller's value names a pane side. */
|
|
22
|
+
export function isPaneSide(value: unknown): value is PaneSide {
|
|
23
|
+
return value === "left" || value === "right";
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** The narrowest pane the split keeps: the narrowest common phone width. */
|
|
27
|
+
export const MIN_PANE_PX = 360;
|
|
28
|
+
/** The grid column between the two panes, showing the shell root's background. */
|
|
29
|
+
export const SPLIT_GUTTER_PX = 10;
|
|
30
|
+
/** The pane-row width two panes need: two minimum panes and the gutter. Under
|
|
31
|
+
* it the split buttons hide and an open split collapses to the selected pane. */
|
|
32
|
+
export const MIN_SPLIT_AREA_PX = 2 * MIN_PANE_PX + SPLIT_GUTTER_PX;
|
|
33
|
+
|
|
34
|
+
/** The EFFECTIVE left share a remembered `committed` share yields on a pane row
|
|
35
|
+
* `width` px wide: both panes are kept at or over MIN_PANE_PX without the
|
|
36
|
+
* remembered value being rewritten, the two bounds meet at 0.5 at exactly
|
|
37
|
+
* MIN_SPLIT_AREA_PX, and a narrower row (a collapsed split) reads 0.5. */
|
|
38
|
+
export function clampRatio(committed: number, width: number): number {
|
|
39
|
+
if (width <= MIN_SPLIT_AREA_PX) {
|
|
40
|
+
return 0.5;
|
|
41
|
+
}
|
|
42
|
+
const min = MIN_PANE_PX / (width - SPLIT_GUTTER_PX);
|
|
43
|
+
return Math.min(Math.max(committed, min), 1 - min);
|
|
44
|
+
}
|
|
@@ -1,39 +1,10 @@
|
|
|
1
|
-
// Progressive status text for the consumer's pre-JS loading overlay
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
// library can say nothing a sighted user can see. On a fast boot that does not
|
|
9
|
-
// matter; the overlay is gone in well under a second. On web-terminal-kiro's
|
|
10
|
-
// first boot it matters a lot: the server answers session creation with 503
|
|
11
|
-
// "tools installing" while its tool engine installs, the session owner retries
|
|
12
|
-
// for up to twenty minutes, and the user watched a black screen with an
|
|
13
|
-
// unlabelled sweep bar the whole time, unable to tell a server that is
|
|
14
|
-
// deliberately waiting from an app that has hung.
|
|
15
|
-
//
|
|
16
|
-
// The design, in order of what a user experiences:
|
|
17
|
-
//
|
|
18
|
-
// 1. SILENCE FIRST. Nothing is written for the first few seconds. A message
|
|
19
|
-
// that flashes up and vanishes on a normal boot is worse than no message,
|
|
20
|
-
// and most boots never reach the first threshold at all.
|
|
21
|
-
// 2. ONE calm line once the wait is real ("Loading terminal...").
|
|
22
|
-
// 3. After a minute, ROTATING reassurance. A single sentence frozen on screen
|
|
23
|
-
// for nineteen more minutes reads exactly as hung as no sentence -- the
|
|
24
|
-
// whole point is to show the system is still alive, so the line changes.
|
|
25
|
-
// 4. A LIVE REASON supersedes all of the above the moment one is known. The
|
|
26
|
-
// truthful wording for "tools installing" lives on the server, arrives in
|
|
27
|
-
// the 503 body, and is pushed in by the session owner; it is strictly more
|
|
28
|
-
// useful than anything this module could guess.
|
|
29
|
-
//
|
|
30
|
-
// Accessibility, and the trap that shapes the DOM here: the overlay is a
|
|
31
|
-
// role="status" live region, so anything written into it is ANNOUNCED. Rotating
|
|
32
|
-
// four messages every twenty seconds for twenty minutes would interrupt a screen
|
|
33
|
-
// reader roughly sixty times to say nothing new. So the visible line is
|
|
34
|
-
// aria-hidden and a separate off-screen live line carries only MEANINGFUL
|
|
35
|
-
// changes -- the first threshold, and each new live reason. Sighted users get
|
|
36
|
-
// motion; assistive tech gets information; neither gets the other's noise.
|
|
1
|
+
// Progressive status text for the consumer's pre-JS loading overlay: the one
|
|
2
|
+
// surface the library can speak through during startup, because the overlay
|
|
3
|
+
// paints over every piece of chrome inside .wt-root until the first frame lands.
|
|
4
|
+
// Silence first, one calm line once the wait is real, rotating reassurance after
|
|
5
|
+
// a minute, and a live reason from the server supersedes all of it.
|
|
6
|
+
|
|
7
|
+
import { windowOf } from "./realm.js";
|
|
37
8
|
|
|
38
9
|
/** Wording for the progressive loading status. Every field has a library
|
|
39
10
|
* default; a consumer overrides only what it wants to reword. */
|
|
@@ -91,11 +62,12 @@ export interface LoadingStatus {
|
|
|
91
62
|
export function attachLoadingStatus(
|
|
92
63
|
overlay: HTMLElement | undefined,
|
|
93
64
|
messages: LoadingMessages = DEFAULT_LOADING_MESSAGES,
|
|
94
|
-
doc: Document = document,
|
|
95
65
|
): LoadingStatus {
|
|
96
66
|
if (!overlay) {
|
|
97
67
|
return { reason: () => undefined, stop: () => undefined };
|
|
98
68
|
}
|
|
69
|
+
const doc = overlay.ownerDocument;
|
|
70
|
+
const win = windowOf(doc);
|
|
99
71
|
|
|
100
72
|
// The visible line. aria-hidden because the overlay is a live region and this
|
|
101
73
|
// element's whole job is to CHANGE often; the live line below is what speaks.
|
|
@@ -129,17 +101,13 @@ export function attachLoadingStatus(
|
|
|
129
101
|
let current = ""; // last reason shown, so a repeat call is a no-op
|
|
130
102
|
|
|
131
103
|
const later = (fn: () => void, ms: number): void => {
|
|
132
|
-
timers.push(
|
|
104
|
+
timers.push(win.setTimeout(fn, ms));
|
|
133
105
|
};
|
|
134
106
|
|
|
135
|
-
// Swap
|
|
136
|
-
//
|
|
137
|
-
// out) so the line does not arrive half-transparent.
|
|
138
|
-
//
|
|
139
|
-
// No `stopped` check, here or in the deferred half below, and that is
|
|
140
|
-
// deliberate rather than an omission: see stop(), which owns the invariant.
|
|
107
|
+
// Swap through a fade so a change reads as deliberate. No `stopped` check here
|
|
108
|
+
// or in the deferred half: stop() owns that invariant.
|
|
141
109
|
const show = (text: string): void => {
|
|
142
|
-
//
|
|
110
|
+
// The first write has nothing to fade out, so it must not arrive half-transparent.
|
|
143
111
|
if (visible.textContent === "") {
|
|
144
112
|
visible.textContent = text;
|
|
145
113
|
return;
|
|
@@ -165,19 +133,11 @@ export function attachLoadingStatus(
|
|
|
165
133
|
|
|
166
134
|
later(() => {
|
|
167
135
|
const waiting = messages.waiting;
|
|
168
|
-
// `pinned` only
|
|
169
|
-
// has cancelled it before it could read the flag.
|
|
170
|
-
// was as dead as the ones deleted above — and invisible to the mutation
|
|
171
|
-
// report, because a mutator works on binary-expression nodes and never
|
|
172
|
-
// isolates a bare identifier operand, so `pinned || stopped` is mutated as a
|
|
173
|
-
// pair and the pair is killable through `pinned`.
|
|
136
|
+
// `pinned` only, not `stopped`: this callback is held by `timers`, so stop()
|
|
137
|
+
// has cancelled it before it could read the flag.
|
|
174
138
|
if (pinned || waiting.length === 0) {
|
|
175
139
|
return;
|
|
176
140
|
}
|
|
177
|
-
// showAt reads through an undefined check rather than an index assertion:
|
|
178
|
-
// under noUncheckedIndexedAccess every element read is `string | undefined`,
|
|
179
|
-
// and the check is the only form both the assertion-style and
|
|
180
|
-
// no-non-null-assertion lint rules accept.
|
|
181
141
|
const showAt = (i: number): void => {
|
|
182
142
|
const text = waiting[i];
|
|
183
143
|
if (text !== undefined) {
|
|
@@ -187,14 +147,10 @@ export function attachLoadingStatus(
|
|
|
187
147
|
// Deliberately NOT announced, now or on any rotation: these carry no
|
|
188
148
|
// information a screen-reader user has not already been told once.
|
|
189
149
|
showAt(0);
|
|
190
|
-
rotation =
|
|
191
|
-
// No pinned/stopped guard
|
|
192
|
-
//
|
|
193
|
-
//
|
|
194
|
-
// real production sequence puts reason() FIRST -- the server refuses
|
|
195
|
-
// session creation within a second or two, long before this 60s threshold
|
|
196
|
-
// -- and without it the rotation would start late and overwrite the
|
|
197
|
-
// server's own explanation with generic reassurance.
|
|
150
|
+
rotation = win.setInterval(() => {
|
|
151
|
+
// No pinned/stopped guard: reason() and stop() both clear this interval.
|
|
152
|
+
// The `pinned` check on the enclosing timer is the one that matters, since
|
|
153
|
+
// the server's refusal arrives within seconds, long before this threshold.
|
|
198
154
|
rotateIndex = (rotateIndex + 1) % waiting.length;
|
|
199
155
|
showAt(rotateIndex);
|
|
200
156
|
}, ROTATE_EVERY_MS);
|
|
@@ -202,50 +158,36 @@ export function attachLoadingStatus(
|
|
|
202
158
|
|
|
203
159
|
return {
|
|
204
160
|
reason(text: string): void {
|
|
205
|
-
// Idempotent by text
|
|
206
|
-
//
|
|
207
|
-
// on every tick, and re-writing the announced line would re-announce the
|
|
208
|
-
// same sentence to a screen reader every few seconds for the whole wait.
|
|
209
|
-
// Guarding here means a caller may call this as often as it likes.
|
|
161
|
+
// Idempotent by text: the caller is a retry loop that knows the reason on
|
|
162
|
+
// every tick, and a re-written live line is re-announced to a screen reader.
|
|
210
163
|
if (stopped || text === "" || text === current) {
|
|
211
164
|
return;
|
|
212
165
|
}
|
|
213
166
|
current = text;
|
|
214
167
|
pinned = true;
|
|
215
168
|
if (rotation !== undefined) {
|
|
216
|
-
|
|
169
|
+
win.clearInterval(rotation);
|
|
217
170
|
rotation = undefined;
|
|
218
171
|
}
|
|
219
172
|
show(text);
|
|
220
173
|
announce(text);
|
|
221
174
|
},
|
|
222
175
|
stop(): void {
|
|
223
|
-
//
|
|
224
|
-
//
|
|
225
|
-
//
|
|
226
|
-
//
|
|
227
|
-
//
|
|
228
|
-
//
|
|
229
|
-
// cancellable callback — which is `reason()`, and `reason()` is where
|
|
230
|
-
// `stopped` is read.
|
|
231
|
-
//
|
|
232
|
-
// `show()` and `announce()` used to re-test it as well, and the mutation
|
|
233
|
-
// pass measured what that was worth: five permanently unkillable mutants,
|
|
234
|
-
// because no input could reach either function with `stopped` true. They
|
|
235
|
-
// were not even protecting anything if they had — the two nodes are
|
|
236
|
-
// detached three lines below, so a late write goes nowhere. The invariant
|
|
237
|
-
// is pinned by a test instead ("stop() leaves no timer armed"), which,
|
|
238
|
-
// unlike a guard, fails for the change that would actually break it: a
|
|
239
|
-
// future timer armed outside `later()`.
|
|
176
|
+
// The one owner of "nothing further happens": every callback is armed
|
|
177
|
+
// through later() and lands in `timers`, the rotation is the one handle
|
|
178
|
+
// held apart, so after this line only reason(), the one entry outside a
|
|
179
|
+
// cancellable callback, can run, and it is the one reader of `stopped`.
|
|
180
|
+
// show() and announce() do not re-test it; a timer armed outside later()
|
|
181
|
+
// is what the "stop() leaves no timer armed" test catches.
|
|
240
182
|
if (stopped) {
|
|
241
183
|
return;
|
|
242
184
|
}
|
|
243
185
|
stopped = true;
|
|
244
186
|
for (const t of timers) {
|
|
245
|
-
|
|
187
|
+
win.clearTimeout(t);
|
|
246
188
|
}
|
|
247
189
|
if (rotation !== undefined) {
|
|
248
|
-
|
|
190
|
+
win.clearInterval(rotation);
|
|
249
191
|
}
|
|
250
192
|
visible.remove();
|
|
251
193
|
live.remove();
|
package/src/kernel/navigation.ts
CHANGED
|
@@ -1,19 +1,13 @@
|
|
|
1
|
-
//
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
// global, and letting the real call run navigates the test runner's own frame
|
|
9
|
-
// and takes the whole run down with it.
|
|
10
|
-
//
|
|
11
|
-
// A separate module is the seam that works: a caller's import is a live binding
|
|
12
|
-
// the test framework can replace, which an intra-module call is not. Keeping the
|
|
13
|
-
// call here also means there is exactly one place in the library that navigates,
|
|
14
|
-
// so the next one has to be added deliberately.
|
|
1
|
+
// The library's one page-navigation call, in its own module because a test
|
|
2
|
+
// cannot substitute `window.location.reload`: `reload` is an own sealed property
|
|
3
|
+
// of the location instance, `Location.prototype` has no `reload`, and `window`
|
|
4
|
+
// and `globalThis.location` are non-configurable, so the real call would
|
|
5
|
+
// navigate the test runner's own frame. A caller's import of this module is the
|
|
6
|
+
// live binding a test can replace; keeping the call here also means exactly one
|
|
7
|
+
// place in the library navigates.
|
|
15
8
|
|
|
16
|
-
/** Reload the
|
|
17
|
-
|
|
18
|
-
|
|
9
|
+
/** Reload the page `win` shows, the terminal's own document. The only navigation
|
|
10
|
+
* this library performs. */
|
|
11
|
+
export function reloadPage(win: Window): void {
|
|
12
|
+
win.location.reload();
|
|
19
13
|
}
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
// The non-persistent `Notification` constructor is the one notification surface
|
|
2
|
+
// a page without a service worker has. browserNotifierEnv() is the one place
|
|
3
|
+
// that reads globals; everything else takes a NotifierEnv.
|
|
4
|
+
|
|
5
|
+
import type { Notifier } from "./types.js";
|
|
6
|
+
|
|
7
|
+
interface NotificationLike {
|
|
8
|
+
onclick: ((event: Event) => void) | null;
|
|
9
|
+
close: () => void;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/** The slice of `window.Notification` used here. */
|
|
13
|
+
export type NotificationCtorLike = new (
|
|
14
|
+
title: string,
|
|
15
|
+
options?: { body?: string; tag?: string },
|
|
16
|
+
) => NotificationLike;
|
|
17
|
+
|
|
18
|
+
/** The capabilities a notifier needs. `permission` is a plain string rather than
|
|
19
|
+
* the DOM's union: a browser may report a value this code does not know, and an
|
|
20
|
+
* unknown value must degrade rather than be asserted. */
|
|
21
|
+
export interface NotifierEnv {
|
|
22
|
+
/** The Notification constructor, or undefined where the API does not exist
|
|
23
|
+
* (iOS Safari outside an installed PWA, a non-secure context). */
|
|
24
|
+
ctor: NotificationCtorLike | undefined;
|
|
25
|
+
/** Current permission: "default" | "granted" | "denied" (or anything else). */
|
|
26
|
+
permission: () => string;
|
|
27
|
+
/** Ask for permission. Must be called from a user gesture. */
|
|
28
|
+
request: () => void;
|
|
29
|
+
/** Is the page currently visible to the user? */
|
|
30
|
+
pageVisible: () => boolean;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** The suppression rule: skip ONLY when the user is already looking at the
|
|
34
|
+
* terminal that produced the notification. A hidden page (another browser tab, a
|
|
35
|
+
* locked phone) notifies even for the active session; keying on the active tab
|
|
36
|
+
* alone would swallow the notification of the session the user left running. */
|
|
37
|
+
export function shouldNotify(sessionIsActive: boolean, pageVisible: boolean): boolean {
|
|
38
|
+
return !(sessionIsActive && pageVisible);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Bound on the per-session dedupe map: each key is a live session server-side,
|
|
42
|
+
* so a real page is nowhere near this and a hostile stream cannot grow it. */
|
|
43
|
+
const MAX_TRACKED_SESSIONS = 200;
|
|
44
|
+
|
|
45
|
+
export function createNotifier(env: NotifierEnv): Notifier {
|
|
46
|
+
// Highest sequence delivered per session. The engine never replays a
|
|
47
|
+
// notification; this makes a doubled frame unable to notify twice for one event.
|
|
48
|
+
const lastSeq = new Map<string, number>();
|
|
49
|
+
let armed = false;
|
|
50
|
+
let requested = false;
|
|
51
|
+
|
|
52
|
+
function evict(): void {
|
|
53
|
+
while (lastSeq.size > MAX_TRACKED_SESSIONS) {
|
|
54
|
+
const oldest = lastSeq.keys().next().value;
|
|
55
|
+
if (oldest === undefined) {
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
lastSeq.delete(oldest);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
return {
|
|
63
|
+
deliver(ev, view): boolean {
|
|
64
|
+
const text = ev.notification ?? "";
|
|
65
|
+
if (text === "") {
|
|
66
|
+
return false;
|
|
67
|
+
}
|
|
68
|
+
const seq = ev.notificationSeq ?? 0;
|
|
69
|
+
if (seq !== 0) {
|
|
70
|
+
if (seq <= (lastSeq.get(ev.id) ?? 0)) {
|
|
71
|
+
return false;
|
|
72
|
+
}
|
|
73
|
+
lastSeq.set(ev.id, seq);
|
|
74
|
+
evict();
|
|
75
|
+
}
|
|
76
|
+
if (!shouldNotify(view.sessionIsActive, env.pageVisible())) {
|
|
77
|
+
return false;
|
|
78
|
+
}
|
|
79
|
+
const Ctor = env.ctor;
|
|
80
|
+
if (!Ctor || env.permission() !== "granted") {
|
|
81
|
+
return false;
|
|
82
|
+
}
|
|
83
|
+
try {
|
|
84
|
+
// The message is untrusted program output: it is a DATA string to the
|
|
85
|
+
// Notification API and never enters this document.
|
|
86
|
+
const posted = new Ctor(view.label, { body: text, tag: ev.id });
|
|
87
|
+
// The click's own default already focuses the page, so no preventDefault
|
|
88
|
+
// and no window.focus(). Not the `navigate` option: it reloads the page,
|
|
89
|
+
// dropping every socket to land where the page already is.
|
|
90
|
+
posted.onclick = (): void => {
|
|
91
|
+
view.activate();
|
|
92
|
+
posted.close();
|
|
93
|
+
};
|
|
94
|
+
return true;
|
|
95
|
+
} catch {
|
|
96
|
+
// Safari throws on `new Notification` outside an installed PWA.
|
|
97
|
+
return false;
|
|
98
|
+
}
|
|
99
|
+
},
|
|
100
|
+
arm(): void {
|
|
101
|
+
armed = true;
|
|
102
|
+
},
|
|
103
|
+
gesture(): void {
|
|
104
|
+
// Safari rejects and Chrome penalises a prompt with no gesture behind it.
|
|
105
|
+
// Gated on `armed` so a plain shell, which emits no OSC 9, never prompts.
|
|
106
|
+
if (!armed || requested) {
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
const Ctor = env.ctor;
|
|
110
|
+
if (!Ctor || env.permission() !== "default") {
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
requested = true;
|
|
114
|
+
try {
|
|
115
|
+
env.request();
|
|
116
|
+
} catch {
|
|
117
|
+
/* a browser that refuses to be asked is a tab-only browser */
|
|
118
|
+
}
|
|
119
|
+
},
|
|
120
|
+
forget(id): void {
|
|
121
|
+
lastSeq.delete(id);
|
|
122
|
+
},
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** Bind a notifier to `doc`'s browser realm. `Notification` is read through
|
|
127
|
+
* `unknown` because it is absent on iOS Safari and in test DOMs, and a
|
|
128
|
+
* non-callable value (a host page's shim) reads as absent. Both
|
|
129
|
+
* `requestPermission` shapes are tolerated, since the result is not awaited: the
|
|
130
|
+
* answer is read from `permission()` at the next notification. */
|
|
131
|
+
export function browserNotifierEnv(doc: Document): NotifierEnv {
|
|
132
|
+
const ctor = (): unknown => (doc.defaultView as { Notification?: unknown } | null)?.Notification;
|
|
133
|
+
const api = (): { permission?: unknown; requestPermission?: unknown } | undefined => {
|
|
134
|
+
const value = ctor();
|
|
135
|
+
return typeof value === "function"
|
|
136
|
+
? (value as unknown as { permission?: unknown; requestPermission?: unknown })
|
|
137
|
+
: undefined;
|
|
138
|
+
};
|
|
139
|
+
return {
|
|
140
|
+
get ctor(): NotificationCtorLike | undefined {
|
|
141
|
+
const value = ctor();
|
|
142
|
+
return typeof value === "function" ? (value as NotificationCtorLike) : undefined;
|
|
143
|
+
},
|
|
144
|
+
permission: (): string => {
|
|
145
|
+
const value = api()?.permission;
|
|
146
|
+
return typeof value === "string" ? value : "denied";
|
|
147
|
+
},
|
|
148
|
+
request: (): void => {
|
|
149
|
+
const fn = api()?.requestPermission;
|
|
150
|
+
if (typeof fn === "function") {
|
|
151
|
+
(fn as () => unknown).call(api());
|
|
152
|
+
}
|
|
153
|
+
},
|
|
154
|
+
// Not document.hasFocus(): that is false for a visible-but-unfocused window,
|
|
155
|
+
// where the terminal IS on screen.
|
|
156
|
+
pageVisible: (): boolean => doc.visibilityState !== "hidden",
|
|
157
|
+
};
|
|
158
|
+
}
|