@mudlet/mudlet-web 0.3.0 → 0.3.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/LICENSE +338 -338
- package/README.md +150 -150
- package/dist-lib/import/defaults/mudlet-mapper.xml +13023 -13023
- package/dist-lib/index.js +5675 -4943
- package/dist-lib/mud/connection/MudClient.d.ts +16 -3
- package/dist-lib/mud/events.d.ts +67 -0
- package/dist-lib/mud/protocol/msspTls.d.ts +54 -0
- package/dist-lib/mud/protocol/tlsCodes.d.ts +4 -0
- package/dist-lib/scripting/IScriptingRuntime.d.ts +6 -0
- package/dist-lib/scripting/ScriptingAPI.d.ts +8 -0
- package/dist-lib/scripting/ScriptingEngine.d.ts +1 -1
- package/dist-lib/scripting/lua/LuaRuntime.d.ts +8 -0
- package/dist-lib/storage/index.d.ts +1 -1
- package/dist-lib/storage/schema.d.ts +50 -4
- package/dist-lib/styles.css +1 -1
- package/dist-lib/ui/CharLoginModal.d.ts +5 -1
- package/dist-lib/ui/FileBrowserModal.d.ts +1 -0
- package/dist-lib/ui/SettingsModal.d.ts +5 -1
- package/dist-lib/ui/TlsAlertBanner.d.ts +18 -0
- package/dist-lib/ui/TlsCertificateBox.d.ts +20 -0
- package/dist-lib/ui/TlsUpgradeModal.d.ts +16 -0
- package/dist-lib/ui/components/useModalFocus.d.ts +6 -0
- package/dist-lib/ui/labels/LabelManager.d.ts +25 -0
- package/dist-lib/ui/labels/labelLinks.d.ts +52 -0
- package/dist-lib/ui/output/linkNavigation.d.ts +22 -0
- package/dist-lib/ui/windows/WindowManager.d.ts +17 -0
- package/dist-lib/vfs-sw.js +131 -131
- package/package.json +93 -93
|
@@ -19,6 +19,10 @@ interface CharLoginModalProps {
|
|
|
19
19
|
/** Decline GMCP login — sends the empty reply so the server falls back to
|
|
20
20
|
* its text login prompt. */
|
|
21
21
|
onCancel: () => void;
|
|
22
|
+
/** Where focus goes once the popup closes. The server raised this dialog,
|
|
23
|
+
* not a click, so there is no meaningful opener to return to — the caller
|
|
24
|
+
* points this at the command line so the player can type straight away. */
|
|
25
|
+
restoreFocusTo?: () => HTMLElement | null | undefined;
|
|
22
26
|
}
|
|
23
27
|
/**
|
|
24
28
|
* Credentials popup for GMCP `Char.Login` authentication. Rendered as a real
|
|
@@ -28,5 +32,5 @@ interface CharLoginModalProps {
|
|
|
28
32
|
* the inline warning); the password is otherwise handed straight to `onSubmit`
|
|
29
33
|
* and relayed to the server, never stored. Cancelling falls back to text login.
|
|
30
34
|
*/
|
|
31
|
-
export declare function CharLoginModal({ connectionName, error, initialAccount, initialPassword, initialRemember, allowRemember, onSubmit, onCancel, }: CharLoginModalProps): import("react/jsx-runtime").JSX.Element;
|
|
35
|
+
export declare function CharLoginModal({ connectionName, error, initialAccount, initialPassword, initialRemember, allowRemember, onSubmit, onCancel, restoreFocusTo, }: CharLoginModalProps): import("react/jsx-runtime").JSX.Element;
|
|
32
36
|
export {};
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { ProfileVFS } from '../scripting/vfs/ProfileVFS';
|
|
2
|
+
export declare function collectZipEntries(vfs: ProfileVFS, dirPath: string, prefix: string, out: Record<string, Uint8Array>, depth?: number): void;
|
|
2
3
|
export interface VFSNode {
|
|
3
4
|
name: string;
|
|
4
5
|
path: string;
|
|
@@ -1,9 +1,13 @@
|
|
|
1
1
|
import type { ProfileVFS } from '../scripting/vfs/ProfileVFS';
|
|
2
|
+
import type { TlsStatus } from '../mud/events';
|
|
2
3
|
interface SettingsModalProps {
|
|
3
4
|
onClose: () => void;
|
|
4
5
|
/** Active profile id; null on the connection screen (only theme is editable). */
|
|
5
6
|
connectionId: string | null;
|
|
6
7
|
vfs?: ProfileVFS | null;
|
|
8
|
+
/** TLS state of the live session, when there is one. Drives the certificate
|
|
9
|
+
* box below the secure-connection reminder. */
|
|
10
|
+
tlsStatus?: TlsStatus | null;
|
|
7
11
|
}
|
|
8
|
-
export declare function SettingsModal({ onClose, connectionId, vfs }: SettingsModalProps): import("react/jsx-runtime").JSX.Element;
|
|
12
|
+
export declare function SettingsModal({ onClose, connectionId, vfs, tlsStatus }: SettingsModalProps): import("react/jsx-runtime").JSX.Element;
|
|
9
13
|
export {};
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { TlsStatus } from '../mud/events';
|
|
2
|
+
interface Props {
|
|
3
|
+
status: TlsStatus;
|
|
4
|
+
/** The plaintext port to fall back to, when one was remembered. */
|
|
5
|
+
revertPort?: number;
|
|
6
|
+
onRevert: () => void;
|
|
7
|
+
onDismiss: () => void;
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* Explains a TLS connection that didn't work, and offers the way back.
|
|
11
|
+
*
|
|
12
|
+
* Mudlet reacts to a certificate failure by opening Preferences on the
|
|
13
|
+
* connection tab with the offending checkbox highlighted; the browser
|
|
14
|
+
* equivalent is to say which setting would allow it and let the user undo the
|
|
15
|
+
* port change in one click.
|
|
16
|
+
*/
|
|
17
|
+
export declare function TlsAlertBanner({ status, revertPort, onRevert, onDismiss }: Props): import("react/jsx-runtime").JSX.Element | null;
|
|
18
|
+
export {};
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { TlsCertInfo } from '../mud/events';
|
|
2
|
+
interface Props {
|
|
3
|
+
cert: TlsCertInfo | null;
|
|
4
|
+
/** False when the proxy runtime cannot read peer certificates at all. */
|
|
5
|
+
certInspection: boolean;
|
|
6
|
+
protocol?: string;
|
|
7
|
+
cipher?: string;
|
|
8
|
+
/** Faults the profile's ignore-flags waved through, if any. */
|
|
9
|
+
acceptedDespite?: string[];
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* The certificate of the current secure connection.
|
|
13
|
+
*
|
|
14
|
+
* Shows the same four fields Mudlet surfaces in Preferences → Connection
|
|
15
|
+
* (issuer, issued-to, expiry, serial), plus the negotiated protocol. When the
|
|
16
|
+
* proxy can't inspect certificates it says so rather than rendering an empty
|
|
17
|
+
* box — "unknown" and "none" would look identical otherwise.
|
|
18
|
+
*/
|
|
19
|
+
export declare function TlsCertificateBox({ cert, certInspection, protocol, cipher, acceptedDespite }: Props): import("react/jsx-runtime").JSX.Element | null;
|
|
20
|
+
export {};
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
interface Props {
|
|
2
|
+
/** The secure port the server advertised via MSSP. */
|
|
3
|
+
port: number;
|
|
4
|
+
onAccept: () => void;
|
|
5
|
+
onDecline: () => void;
|
|
6
|
+
}
|
|
7
|
+
/**
|
|
8
|
+
* Offers the MSSP-advertised secure port. Wording follows Mudlet's prompt
|
|
9
|
+
* (cTelnet::promptTlsConnectionAvailable) so the choice reads the same to
|
|
10
|
+
* anyone arriving from the desktop client.
|
|
11
|
+
*
|
|
12
|
+
* Declining is permanent for the profile, so it is stated on the button's
|
|
13
|
+
* description rather than hidden behind a "don't ask again" checkbox.
|
|
14
|
+
*/
|
|
15
|
+
export declare function TlsUpgradeModal({ port, onAccept, onDecline }: Props): import("react/jsx-runtime").JSX.Element;
|
|
16
|
+
export {};
|
|
@@ -14,5 +14,11 @@ export interface ModalFocusOptions {
|
|
|
14
14
|
/** Close on Escape via `onClose`. Default true. Set false when the modal has
|
|
15
15
|
* its own Escape handling (then `onClose` may be omitted). */
|
|
16
16
|
closeOnEscape?: boolean;
|
|
17
|
+
/** Where focus should land on close, instead of the opener. Use when the
|
|
18
|
+
* dialog was raised by the game rather than by a click, so "the opener" is
|
|
19
|
+
* either nothing or a control that has since gone away — the GMCP login
|
|
20
|
+
* popup hands focus to the command line this way. Falls back to the opener
|
|
21
|
+
* when it returns null. */
|
|
22
|
+
restoreFocusTo?: () => HTMLElement | null | undefined;
|
|
17
23
|
}
|
|
18
24
|
export declare function useModalFocus<T extends HTMLElement = HTMLDivElement>(onClose?: () => void, opts?: ModalFocusOptions): React.RefObject<T | null>;
|
|
@@ -70,6 +70,13 @@ export interface LabelState {
|
|
|
70
70
|
visitedColor?: string;
|
|
71
71
|
underline: boolean;
|
|
72
72
|
};
|
|
73
|
+
/** Hrefs clicked in this label, tracked so `linkStyle.visitedColor` can be
|
|
74
|
+
* applied explicitly — CSS `:visited` only ever matches real browser
|
|
75
|
+
* history, so it never fires for the `send:`/`prompt:` pseudo-schemes label
|
|
76
|
+
* links use. Mirrors Mudlet's `TLabel::mVisitedLinks`, which exists for the
|
|
77
|
+
* same reason (Qt won't style them either). Only populated while a visited
|
|
78
|
+
* color is set, as Mudlet does. */
|
|
79
|
+
visitedLinks?: Set<string>;
|
|
73
80
|
/** Click handler installed by setLabelClickCallback. The event table mirrors
|
|
74
81
|
* Mudlet's `mudlet.mouse_button` shape: `{button, x, y, alt, ctrl, shift, meta}`. */
|
|
75
82
|
onClick?: (event: LabelMouseEvent) => void;
|
|
@@ -117,11 +124,16 @@ export interface LabelWheelEvent extends LabelMouseEvent {
|
|
|
117
124
|
};
|
|
118
125
|
}
|
|
119
126
|
type Listener = (labels: LabelState[]) => void;
|
|
127
|
+
/** Runs the action behind an `<a href>` clicked inside a label — installed by
|
|
128
|
+
* ScriptingAPI, which owns the send/prompt/openUrl/Lua machinery the schemes
|
|
129
|
+
* map onto (see {@link LabelManager.setLinkActivator}). */
|
|
130
|
+
type LinkActivator = (href: string) => void;
|
|
120
131
|
export declare class LabelManager {
|
|
121
132
|
readonly overlayZ: OverlayLayerOrder;
|
|
122
133
|
private readonly labels;
|
|
123
134
|
private readonly byParent;
|
|
124
135
|
private readonly listeners;
|
|
136
|
+
private linkActivator;
|
|
125
137
|
private indexAdd;
|
|
126
138
|
private indexRemove;
|
|
127
139
|
constructor(overlayZ?: OverlayLayerOrder);
|
|
@@ -175,6 +187,19 @@ export declare class LabelManager {
|
|
|
175
187
|
setStyleSheet(name: string, css: string): boolean;
|
|
176
188
|
/** Mudlet `setLinkStyle(labelName, linkColor, linkVisitedColor, underline)`. */
|
|
177
189
|
setLinkStyle(name: string, color: string, visitedColor: string, underline: boolean): boolean;
|
|
190
|
+
/**
|
|
191
|
+
* Install the handler that runs a clicked label link (`<a href="send:…">`).
|
|
192
|
+
* The manager itself has no scripting access, so LabelOverlay routes clicks
|
|
193
|
+
* here and ScriptingAPI supplies the behaviour.
|
|
194
|
+
*/
|
|
195
|
+
setLinkActivator(fn: LinkActivator | null): void;
|
|
196
|
+
/**
|
|
197
|
+
* A link inside `name`'s rich text was clicked. Records it as visited (when
|
|
198
|
+
* a visited color is configured) and runs the activator — Mudlet's
|
|
199
|
+
* `TLabel::slot_linkActivated`, which does the same two things in the same
|
|
200
|
+
* order.
|
|
201
|
+
*/
|
|
202
|
+
activateLink(name: string, href: string): void;
|
|
178
203
|
/** Mudlet `resetLinkStyle(labelName)` — drop any setLinkStyle override. */
|
|
179
204
|
resetLinkStyle(name: string): boolean;
|
|
180
205
|
/** Mudlet `getLabelStyleSheet(name)` — the Qt-style CSS last set via
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `<a href>` links inside a label's rich text — Mudlet's `TLabel::slot_linkActivated`
|
|
3
|
+
* (src/TLabel.cpp).
|
|
4
|
+
*
|
|
5
|
+
* Geyser packages build clickable UI by echoing anchors into a label:
|
|
6
|
+
*
|
|
7
|
+
* myLabel:echo([[<a href="send:help topics">Help</a>]])
|
|
8
|
+
*
|
|
9
|
+
* and the href's scheme decides what a click does. The rules deliberately differ
|
|
10
|
+
* from the OSC 8 links the main window renders (`classifyHyperlinkUri`):
|
|
11
|
+
*
|
|
12
|
+
* - a scheme-less href is **Lua source** to run, not a dropped link;
|
|
13
|
+
* - only `send:`, `prompt:`, `http:` and `https:` are recognised (no `ftp:`);
|
|
14
|
+
* - the payload is **not** percent-decoded — Qt hands `linkActivated` the href
|
|
15
|
+
* verbatim, so `send:cast fireball` reaches the MUD with its literal space.
|
|
16
|
+
*
|
|
17
|
+
* Any other scheme is ignored rather than falling through to the Lua branch,
|
|
18
|
+
* which is what stops a `javascript:`/`file:`/`data:` href from executing.
|
|
19
|
+
*/
|
|
20
|
+
export type LabelLinkAction = {
|
|
21
|
+
kind: 'send';
|
|
22
|
+
command: string;
|
|
23
|
+
} | {
|
|
24
|
+
kind: 'prompt';
|
|
25
|
+
command: string;
|
|
26
|
+
} | {
|
|
27
|
+
kind: 'url';
|
|
28
|
+
url: string;
|
|
29
|
+
}
|
|
30
|
+
/** No scheme at all — the whole href is a Lua chunk. */
|
|
31
|
+
| {
|
|
32
|
+
kind: 'lua';
|
|
33
|
+
code: string;
|
|
34
|
+
};
|
|
35
|
+
/**
|
|
36
|
+
* Classify a label link href. Returns null when the link does nothing (empty,
|
|
37
|
+
* or an unrecognised scheme).
|
|
38
|
+
*
|
|
39
|
+
* Like Mudlet, the split is on the first `:` rather than an RFC 3986 scheme
|
|
40
|
+
* match — so a scheme-less chunk that happens to contain a colon
|
|
41
|
+
* (`myTable:method()`) is treated as an unknown scheme and ignored.
|
|
42
|
+
*/
|
|
43
|
+
export declare function classifyLabelLink(href: string): LabelLinkAction | null;
|
|
44
|
+
/**
|
|
45
|
+
* The href of the anchor an event target sits inside, or null when the click
|
|
46
|
+
* didn't land on a link.
|
|
47
|
+
*
|
|
48
|
+
* Read through `getAttribute` on purpose: the `.href` *property* resolves the
|
|
49
|
+
* value against the page URL and percent-encodes it, which would turn
|
|
50
|
+
* `send:help topics` into `send:help%20topics` and mangle the command.
|
|
51
|
+
*/
|
|
52
|
+
export declare function labelLinkHref(target: EventTarget | null): string | null;
|
|
@@ -11,7 +11,29 @@
|
|
|
11
11
|
* the send/prompt/url action, reveals a spoiler, toggles selection, arms
|
|
12
12
|
* visibility); the Menu key or Shift+F10 opens its context menu. Mirrors
|
|
13
13
|
* Mudlet's TTextEdit key handling.
|
|
14
|
+
*
|
|
15
|
+
* Being focusable has a mouse-side cost, which `restoreFocusAfterLinkClick`
|
|
16
|
+
* pays back: a tabIndex of -1 keeps a span out of the Tab order but still lets
|
|
17
|
+
* a click focus it, and Mudlet's TTextEdit never takes focus off the command
|
|
18
|
+
* line that way.
|
|
19
|
+
*/
|
|
20
|
+
/**
|
|
21
|
+
* Give focus back to the command line after a link is clicked with the mouse,
|
|
22
|
+
* so the player can keep typing — Mudlet's behaviour, where the output never
|
|
23
|
+
* takes focus at all.
|
|
24
|
+
*
|
|
25
|
+
* Call from a *capture*-phase click listener on the output container: a link's
|
|
26
|
+
* own handler stops propagation, so a bubble-phase listener never sees it. The
|
|
27
|
+
* hand-back is deferred to a microtask (i.e. after the click has been fully
|
|
28
|
+
* dispatched) and happens only if a link is still holding focus — a link that
|
|
29
|
+
* opened a dialog or moved focus deliberately keeps its say. Keyboard
|
|
30
|
+
* activation (`link.click()` from Enter/Space, which reports `detail === 0`)
|
|
31
|
+
* leaves focus on the link so Ctrl+]/Ctrl+[ can carry on from there.
|
|
14
32
|
*/
|
|
33
|
+
export declare function restoreFocusAfterLinkClick(e: {
|
|
34
|
+
detail: number;
|
|
35
|
+
target: EventTarget | null;
|
|
36
|
+
}, commandInput: () => HTMLElement | null | undefined): void;
|
|
15
37
|
/**
|
|
16
38
|
* One representative element per logical link under `root`, in document order.
|
|
17
39
|
* A multicolour link is rendered as several spans (one per colour run) sharing
|
|
@@ -146,6 +146,9 @@ export declare class WindowManager {
|
|
|
146
146
|
/** Bridge to ScriptingEngine.downloadMap — the map UI's "download map from
|
|
147
147
|
* game" action (Mudlet dlgMapper's Download button). */
|
|
148
148
|
onDownloadMap?: () => void;
|
|
149
|
+
/** Bridge to the Lua speedwalk entry point (Mudlet Host::startSpeedWalk),
|
|
150
|
+
* wired by ScriptingEngine. Driven by {@link startSpeedWalk}. */
|
|
151
|
+
onStartSpeedWalk?: (from: number, to: number) => void;
|
|
149
152
|
/** The MMP map URL from GMCP `Client.Map`, or '' when none announced. */
|
|
150
153
|
get mmpMapLocation(): string;
|
|
151
154
|
/** Record the game-announced map URL (Mudlet TMap::setMmpMapLocation). */
|
|
@@ -327,6 +330,20 @@ export declare class WindowManager {
|
|
|
327
330
|
registerMapCallback(id: string, cb: (roomId: number) => void): void;
|
|
328
331
|
unregisterMapCallback(id: string): void;
|
|
329
332
|
centerView(roomId: number): boolean;
|
|
333
|
+
/**
|
|
334
|
+
* Mudlet `T2DMap::initiateSpeedWalk` — start a speedwalk from the player's
|
|
335
|
+
* current room to `targetRoomId`, the room the user double-clicked on the
|
|
336
|
+
* map (or the far side of a double-clicked out-of-area exit).
|
|
337
|
+
*
|
|
338
|
+
* Like Mudlet, an unknown target room is ignored outright, while an unknown
|
|
339
|
+
* *player* room is not: it pathfinds from -1 (Mudlet uses the missing
|
|
340
|
+
* profile entry, 0), finds nothing, and the Lua side reports the mapper's
|
|
341
|
+
* "cannot find a path" message rather than failing silently.
|
|
342
|
+
*
|
|
343
|
+
* Returns false when the gesture didn't start a walk — no such room, or no
|
|
344
|
+
* scripting runtime attached to walk it.
|
|
345
|
+
*/
|
|
346
|
+
startSpeedWalk(targetRoomId: number): boolean;
|
|
330
347
|
registerMapControl(id: string, ctrl: MapControl): void;
|
|
331
348
|
unregisterMapControl(id: string): void;
|
|
332
349
|
/** Live zoom of the displayed 2D map, or null when no map panel is mounted.
|
package/dist-lib/vfs-sw.js
CHANGED
|
@@ -1,131 +1,131 @@
|
|
|
1
|
-
// Service worker that serves ProfileVFS-backed assets at /__vfs/<connectionId>/<path>.
|
|
2
|
-
// The page is the source of truth for VFS contents (the SW can't share folder
|
|
3
|
-
// handles or always-open the same IDB store), so each request round-trips to
|
|
4
|
-
// the client via MessageChannel.
|
|
5
|
-
|
|
6
|
-
const CACHE_NAME = 'mudix-vfs-v1';
|
|
7
|
-
// Scope path always ends with '/'. On a root-served deploy this is '/'; on
|
|
8
|
-
// GitHub Pages or any subpath deploy it's '/<repo>/'. The intercept prefix is
|
|
9
|
-
// '<scope>__vfs/' so SW-controlled URLs stay inside scope.
|
|
10
|
-
const SCOPE_PATH = new URL(self.registration.scope).pathname;
|
|
11
|
-
const PREFIX = `${SCOPE_PATH}__vfs/`;
|
|
12
|
-
const READ_TIMEOUT_MS = 5000;
|
|
13
|
-
|
|
14
|
-
self.addEventListener('install', () => self.skipWaiting());
|
|
15
|
-
self.addEventListener('activate', (e) => e.waitUntil(self.clients.claim()));
|
|
16
|
-
|
|
17
|
-
self.addEventListener('fetch', (event) => {
|
|
18
|
-
const url = new URL(event.request.url);
|
|
19
|
-
if (url.origin !== self.location.origin) return;
|
|
20
|
-
if (!url.pathname.startsWith(PREFIX)) return;
|
|
21
|
-
event.respondWith(handle(event, url));
|
|
22
|
-
});
|
|
23
|
-
|
|
24
|
-
async function handle(event, url) {
|
|
25
|
-
const rest = url.pathname.slice(PREFIX.length).split('/').filter(Boolean);
|
|
26
|
-
if (rest.length < 2) return new Response('Bad request', { status: 400 });
|
|
27
|
-
const connectionId = decodeURIComponent(rest[0]);
|
|
28
|
-
const filePath = '/' + rest.slice(1).map(decodeURIComponent).join('/');
|
|
29
|
-
|
|
30
|
-
const cache = await caches.open(CACHE_NAME);
|
|
31
|
-
const cached = await cache.match(event.request);
|
|
32
|
-
if (cached) return cached;
|
|
33
|
-
|
|
34
|
-
const client = await pickClient(event);
|
|
35
|
-
if (!client) {
|
|
36
|
-
// Either there's no app tab loaded (direct URL hit in fresh tab) or
|
|
37
|
-
// the only candidate is the tab currently navigating to this URL.
|
|
38
|
-
return new Response('No app tab loaded; open mudix first', { status: 503 });
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
const reply = await ask(client, { type: 'vfs:read', connectionId, path: filePath });
|
|
42
|
-
if (!reply || !reply.ok) {
|
|
43
|
-
return new Response(reply?.error ?? 'Not found', { status: 404 });
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
const headers = new Headers();
|
|
47
|
-
headers.set('Content-Type', reply.contentType || 'application/octet-stream');
|
|
48
|
-
// no-store keeps this response out of the browser's own HTTP cache, which
|
|
49
|
-
// has no validator to revalidate against (we set none) and would otherwise
|
|
50
|
-
// race our explicit Cache Storage entry above — that race is what produced
|
|
51
|
-
// stale 304-with-no-body responses for cached images. The Cache Storage
|
|
52
|
-
// entry (invalidated on writes via postMessage) is the only cache that
|
|
53
|
-
// should own this decision.
|
|
54
|
-
headers.set('Cache-Control', 'no-store');
|
|
55
|
-
const response = new Response(reply.bytes, { headers });
|
|
56
|
-
cache.put(event.request, response.clone()).catch(() => {});
|
|
57
|
-
return response;
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
async function pickClient(event) {
|
|
61
|
-
// Prefer the client that issued this request — for sub-resource fetches
|
|
62
|
-
// that's the loaded app page, which can reply immediately.
|
|
63
|
-
const requestingId = event.clientId || event.resultingClientId || '';
|
|
64
|
-
if (requestingId) {
|
|
65
|
-
const c = await self.clients.get(requestingId);
|
|
66
|
-
if (c && isUsable(c)) return c;
|
|
67
|
-
}
|
|
68
|
-
const all = await self.clients.matchAll({ type: 'window', includeUncontrolled: true });
|
|
69
|
-
const usable = all.filter(isUsable);
|
|
70
|
-
return usable.find((c) => c.focused) ?? usable[0] ?? null;
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
// Skip clients that are themselves navigating to a /__vfs/ URL — those are
|
|
74
|
-
// fresh tabs in the middle of loading the SW response; they have no JS running
|
|
75
|
-
// and can't reply, so picking one guarantees a timeout.
|
|
76
|
-
function isUsable(client) {
|
|
77
|
-
try {
|
|
78
|
-
return !new URL(client.url).pathname.startsWith(PREFIX);
|
|
79
|
-
} catch {
|
|
80
|
-
return false;
|
|
81
|
-
}
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
function ask(client, message) {
|
|
85
|
-
return new Promise((resolve) => {
|
|
86
|
-
const channel = new MessageChannel();
|
|
87
|
-
const timer = setTimeout(() => {
|
|
88
|
-
channel.port1.close();
|
|
89
|
-
resolve({ ok: false, error: 'timeout' });
|
|
90
|
-
}, READ_TIMEOUT_MS);
|
|
91
|
-
channel.port1.onmessage = (e) => {
|
|
92
|
-
clearTimeout(timer);
|
|
93
|
-
channel.port1.close();
|
|
94
|
-
resolve(e.data);
|
|
95
|
-
};
|
|
96
|
-
client.postMessage(message, [channel.port2]);
|
|
97
|
-
});
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
self.addEventListener('message', (event) => {
|
|
101
|
-
const data = event.data;
|
|
102
|
-
if (!data) return;
|
|
103
|
-
if (data.type === 'vfs:invalidate') {
|
|
104
|
-
event.waitUntil(invalidate(data));
|
|
105
|
-
}
|
|
106
|
-
});
|
|
107
|
-
|
|
108
|
-
async function invalidate({ connectionId, path }) {
|
|
109
|
-
if (!connectionId) {
|
|
110
|
-
await caches.delete(CACHE_NAME);
|
|
111
|
-
return;
|
|
112
|
-
}
|
|
113
|
-
const cache = await caches.open(CACHE_NAME);
|
|
114
|
-
const connPrefix = `${PREFIX}${encodeURIComponent(connectionId)}/`;
|
|
115
|
-
const keys = await cache.keys();
|
|
116
|
-
if (path) {
|
|
117
|
-
const segs = path.split('/').filter(Boolean).map(encodeURIComponent).join('/');
|
|
118
|
-
const target = `${connPrefix}${segs}`;
|
|
119
|
-
await Promise.all(
|
|
120
|
-
keys
|
|
121
|
-
.filter((req) => new URL(req.url).pathname === target)
|
|
122
|
-
.map((req) => cache.delete(req)),
|
|
123
|
-
);
|
|
124
|
-
return;
|
|
125
|
-
}
|
|
126
|
-
await Promise.all(
|
|
127
|
-
keys
|
|
128
|
-
.filter((req) => new URL(req.url).pathname.startsWith(connPrefix))
|
|
129
|
-
.map((req) => cache.delete(req)),
|
|
130
|
-
);
|
|
131
|
-
}
|
|
1
|
+
// Service worker that serves ProfileVFS-backed assets at /__vfs/<connectionId>/<path>.
|
|
2
|
+
// The page is the source of truth for VFS contents (the SW can't share folder
|
|
3
|
+
// handles or always-open the same IDB store), so each request round-trips to
|
|
4
|
+
// the client via MessageChannel.
|
|
5
|
+
|
|
6
|
+
const CACHE_NAME = 'mudix-vfs-v1';
|
|
7
|
+
// Scope path always ends with '/'. On a root-served deploy this is '/'; on
|
|
8
|
+
// GitHub Pages or any subpath deploy it's '/<repo>/'. The intercept prefix is
|
|
9
|
+
// '<scope>__vfs/' so SW-controlled URLs stay inside scope.
|
|
10
|
+
const SCOPE_PATH = new URL(self.registration.scope).pathname;
|
|
11
|
+
const PREFIX = `${SCOPE_PATH}__vfs/`;
|
|
12
|
+
const READ_TIMEOUT_MS = 5000;
|
|
13
|
+
|
|
14
|
+
self.addEventListener('install', () => self.skipWaiting());
|
|
15
|
+
self.addEventListener('activate', (e) => e.waitUntil(self.clients.claim()));
|
|
16
|
+
|
|
17
|
+
self.addEventListener('fetch', (event) => {
|
|
18
|
+
const url = new URL(event.request.url);
|
|
19
|
+
if (url.origin !== self.location.origin) return;
|
|
20
|
+
if (!url.pathname.startsWith(PREFIX)) return;
|
|
21
|
+
event.respondWith(handle(event, url));
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
async function handle(event, url) {
|
|
25
|
+
const rest = url.pathname.slice(PREFIX.length).split('/').filter(Boolean);
|
|
26
|
+
if (rest.length < 2) return new Response('Bad request', { status: 400 });
|
|
27
|
+
const connectionId = decodeURIComponent(rest[0]);
|
|
28
|
+
const filePath = '/' + rest.slice(1).map(decodeURIComponent).join('/');
|
|
29
|
+
|
|
30
|
+
const cache = await caches.open(CACHE_NAME);
|
|
31
|
+
const cached = await cache.match(event.request);
|
|
32
|
+
if (cached) return cached;
|
|
33
|
+
|
|
34
|
+
const client = await pickClient(event);
|
|
35
|
+
if (!client) {
|
|
36
|
+
// Either there's no app tab loaded (direct URL hit in fresh tab) or
|
|
37
|
+
// the only candidate is the tab currently navigating to this URL.
|
|
38
|
+
return new Response('No app tab loaded; open mudix first', { status: 503 });
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const reply = await ask(client, { type: 'vfs:read', connectionId, path: filePath });
|
|
42
|
+
if (!reply || !reply.ok) {
|
|
43
|
+
return new Response(reply?.error ?? 'Not found', { status: 404 });
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const headers = new Headers();
|
|
47
|
+
headers.set('Content-Type', reply.contentType || 'application/octet-stream');
|
|
48
|
+
// no-store keeps this response out of the browser's own HTTP cache, which
|
|
49
|
+
// has no validator to revalidate against (we set none) and would otherwise
|
|
50
|
+
// race our explicit Cache Storage entry above — that race is what produced
|
|
51
|
+
// stale 304-with-no-body responses for cached images. The Cache Storage
|
|
52
|
+
// entry (invalidated on writes via postMessage) is the only cache that
|
|
53
|
+
// should own this decision.
|
|
54
|
+
headers.set('Cache-Control', 'no-store');
|
|
55
|
+
const response = new Response(reply.bytes, { headers });
|
|
56
|
+
cache.put(event.request, response.clone()).catch(() => {});
|
|
57
|
+
return response;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
async function pickClient(event) {
|
|
61
|
+
// Prefer the client that issued this request — for sub-resource fetches
|
|
62
|
+
// that's the loaded app page, which can reply immediately.
|
|
63
|
+
const requestingId = event.clientId || event.resultingClientId || '';
|
|
64
|
+
if (requestingId) {
|
|
65
|
+
const c = await self.clients.get(requestingId);
|
|
66
|
+
if (c && isUsable(c)) return c;
|
|
67
|
+
}
|
|
68
|
+
const all = await self.clients.matchAll({ type: 'window', includeUncontrolled: true });
|
|
69
|
+
const usable = all.filter(isUsable);
|
|
70
|
+
return usable.find((c) => c.focused) ?? usable[0] ?? null;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Skip clients that are themselves navigating to a /__vfs/ URL — those are
|
|
74
|
+
// fresh tabs in the middle of loading the SW response; they have no JS running
|
|
75
|
+
// and can't reply, so picking one guarantees a timeout.
|
|
76
|
+
function isUsable(client) {
|
|
77
|
+
try {
|
|
78
|
+
return !new URL(client.url).pathname.startsWith(PREFIX);
|
|
79
|
+
} catch {
|
|
80
|
+
return false;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function ask(client, message) {
|
|
85
|
+
return new Promise((resolve) => {
|
|
86
|
+
const channel = new MessageChannel();
|
|
87
|
+
const timer = setTimeout(() => {
|
|
88
|
+
channel.port1.close();
|
|
89
|
+
resolve({ ok: false, error: 'timeout' });
|
|
90
|
+
}, READ_TIMEOUT_MS);
|
|
91
|
+
channel.port1.onmessage = (e) => {
|
|
92
|
+
clearTimeout(timer);
|
|
93
|
+
channel.port1.close();
|
|
94
|
+
resolve(e.data);
|
|
95
|
+
};
|
|
96
|
+
client.postMessage(message, [channel.port2]);
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
self.addEventListener('message', (event) => {
|
|
101
|
+
const data = event.data;
|
|
102
|
+
if (!data) return;
|
|
103
|
+
if (data.type === 'vfs:invalidate') {
|
|
104
|
+
event.waitUntil(invalidate(data));
|
|
105
|
+
}
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
async function invalidate({ connectionId, path }) {
|
|
109
|
+
if (!connectionId) {
|
|
110
|
+
await caches.delete(CACHE_NAME);
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
const cache = await caches.open(CACHE_NAME);
|
|
114
|
+
const connPrefix = `${PREFIX}${encodeURIComponent(connectionId)}/`;
|
|
115
|
+
const keys = await cache.keys();
|
|
116
|
+
if (path) {
|
|
117
|
+
const segs = path.split('/').filter(Boolean).map(encodeURIComponent).join('/');
|
|
118
|
+
const target = `${connPrefix}${segs}`;
|
|
119
|
+
await Promise.all(
|
|
120
|
+
keys
|
|
121
|
+
.filter((req) => new URL(req.url).pathname === target)
|
|
122
|
+
.map((req) => cache.delete(req)),
|
|
123
|
+
);
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
await Promise.all(
|
|
127
|
+
keys
|
|
128
|
+
.filter((req) => new URL(req.url).pathname.startsWith(connPrefix))
|
|
129
|
+
.map((req) => cache.delete(req)),
|
|
130
|
+
);
|
|
131
|
+
}
|