@linxin666/dsh-remote-web-ui 0.3.6 → 0.3.10
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.i18n.yaml +2 -2
- package/README.md +77 -468
- package/README.zh.md +83 -113
- package/lib/client.js +993 -203
- package/lib/client.js.map +1 -1
- package/lib/index.js +1136 -1466
- package/lib/types/client/FooterRemoteEntry.d.ts +6 -7
- package/lib/types/client/FooterRemoteEntry.d.ts.map +1 -1
- package/lib/types/client/FooterRemoteEntry.js +1 -1
- package/lib/types/client/RemoteEntry.d.ts +7 -4
- package/lib/types/client/RemoteEntry.d.ts.map +1 -1
- package/lib/types/client/RemoteEntry.js +7 -10
- package/lib/types/client/RemotePanel.d.ts +2 -2
- package/lib/types/client/RemotePanel.d.ts.map +1 -1
- package/lib/types/client/RemotePanel.js +2 -2
- package/lib/types/client/RemoteSettingsCard.d.ts +9 -5
- package/lib/types/client/RemoteSettingsCard.d.ts.map +1 -1
- package/lib/types/client/RemoteSettingsCard.js +56 -3
- package/lib/types/client/deep-link.d.ts +10 -13
- package/lib/types/client/deep-link.d.ts.map +1 -1
- package/lib/types/client/deep-link.js +14 -74
- package/lib/types/client/index.d.ts +2 -6
- package/lib/types/client/index.d.ts.map +1 -1
- package/lib/types/client/index.js +79 -39
- package/lib/types/client/locales.d.ts +32 -12
- package/lib/types/client/locales.d.ts.map +1 -1
- package/lib/types/client/locales.js +42 -22
- package/lib/types/client/mobile-adapt.d.ts +49 -0
- package/lib/types/client/mobile-adapt.d.ts.map +1 -0
- package/lib/types/client/mobile-adapt.js +1002 -0
- package/lib/types/client/pair-api.d.ts +24 -6
- package/lib/types/client/pair-api.d.ts.map +1 -1
- package/lib/types/client/pair-api.js +9 -14
- package/lib/types/client/remote-channel.d.ts +3 -0
- package/lib/types/client/remote-channel.d.ts.map +1 -1
- package/lib/types/client/remote-channel.js +35 -3
- package/lib/types/client/settings-form.d.ts +35 -43
- package/lib/types/client/settings-form.d.ts.map +1 -1
- package/lib/types/client/settings-form.js +60 -53
- package/lib/types/firewall.d.ts +75 -0
- package/lib/types/firewall.d.ts.map +1 -0
- package/lib/types/firewall.js +184 -0
- package/lib/types/gate.d.ts +11 -5
- package/lib/types/gate.d.ts.map +1 -1
- package/lib/types/gate.js +11 -5
- package/lib/types/index.d.ts +39 -10
- package/lib/types/index.d.ts.map +1 -1
- package/lib/types/index.js +206 -58
- package/lib/types/inner-auth.d.ts +32 -0
- package/lib/types/inner-auth.d.ts.map +1 -0
- package/lib/types/inner-auth.js +72 -0
- package/lib/types/lan-bind-plan.d.ts +55 -0
- package/lib/types/lan-bind-plan.d.ts.map +1 -0
- package/lib/types/lan-bind-plan.js +60 -0
- package/lib/types/lan-bind.d.ts +69 -0
- package/lib/types/lan-bind.d.ts.map +1 -0
- package/lib/types/lan-bind.js +128 -0
- package/lib/types/loopback-proxy.d.ts +9 -2
- package/lib/types/loopback-proxy.d.ts.map +1 -1
- package/lib/types/loopback-proxy.js +24 -2
- package/lib/types/pairing.d.ts +15 -3
- package/lib/types/pairing.d.ts.map +1 -1
- package/lib/types/pairing.js +20 -6
- package/lib/types/remote-api.d.ts +30 -13
- package/lib/types/remote-api.d.ts.map +1 -1
- package/lib/types/remote-api.js +55 -42
- package/lib/types/remote-channel-boot.d.ts +21 -2
- package/lib/types/remote-channel-boot.d.ts.map +1 -1
- package/lib/types/remote-channel-boot.js +65 -3
- package/lib/types/remote-channel-rules.d.ts +8 -0
- package/lib/types/remote-channel-rules.d.ts.map +1 -1
- package/lib/types/remote-channel-rules.js +9 -2
- package/lib/types/remote-methods.d.ts +45 -11
- package/lib/types/remote-methods.d.ts.map +1 -1
- package/lib/types/remote-methods.js +56 -27
- package/lib/types/remote-presence-pet.d.ts +51 -0
- package/lib/types/remote-presence-pet.d.ts.map +1 -0
- package/lib/types/remote-presence-pet.js +101 -0
- package/lib/types/routes.d.ts +76 -4
- package/lib/types/routes.d.ts.map +1 -1
- package/lib/types/routes.js +336 -19
- package/lib/types/tunnel.d.ts.map +1 -1
- package/lib/types/tunnel.js +7 -1
- package/package.json +20 -18
- package/src/client/FooterRemoteEntry.tsx +9 -10
- package/src/client/RemoteEntry.tsx +13 -14
- package/src/client/RemotePanel.tsx +5 -16
- package/src/client/RemoteSettingsCard.tsx +72 -13
- package/src/client/deep-link.ts +14 -75
- package/src/client/index.ts +82 -39
- package/src/client/locales.ts +42 -22
- package/src/client/mobile-adapt.ts +954 -0
- package/src/client/pair-api.ts +26 -15
- package/src/client/remote-channel.ts +31 -3
- package/src/client/settings-form.ts +78 -92
- package/src/dsh-home.ts +1 -1
- package/src/firewall.ts +230 -0
- package/src/gate.ts +11 -5
- package/src/index.ts +235 -65
- package/src/inner-auth.ts +85 -0
- package/src/lan-bind-plan.ts +81 -0
- package/src/lan-bind.ts +145 -0
- package/src/loopback-proxy.ts +34 -0
- package/src/pairing.persist.test.ts +1 -1
- package/src/pairing.ts +20 -6
- package/src/remote-api.ts +67 -45
- package/src/remote-channel-boot.ts +70 -3
- package/src/remote-channel-rules.ts +17 -2
- package/src/remote-methods.ts +58 -27
- package/src/remote-presence-pet.ts +129 -0
- package/src/routes.ts +358 -19
- package/src/tunnel.ts +7 -1
- package/assets/apple-touch-icon.png +0 -0
- package/assets/mobile-icon-192.png +0 -0
- package/assets/mobile-icon-512.png +0 -0
- package/assets/mobile-service-worker.js +0 -63
- package/lib/mobile.js +0 -17658
- package/lib/mobile.js.map +0 -1
- package/lib/types/mobile/PairRequiredView.d.ts +0 -7
- package/lib/types/mobile/PairRequiredView.d.ts.map +0 -1
- package/lib/types/mobile/PairRequiredView.js +0 -28
- package/lib/types/mobile/PairRequiredView.test.d.ts +0 -2
- package/lib/types/mobile/PairRequiredView.test.d.ts.map +0 -1
- package/lib/types/mobile/PairRequiredView.test.js +0 -24
- package/lib/types/mobile/api.d.ts +0 -145
- package/lib/types/mobile/api.d.ts.map +0 -1
- package/lib/types/mobile/api.js +0 -103
- package/lib/types/mobile/index.d.ts +0 -10
- package/lib/types/mobile/index.d.ts.map +0 -1
- package/lib/types/mobile/index.js +0 -38
- package/lib/types/mobile/markdown.d.ts +0 -34
- package/lib/types/mobile/markdown.d.ts.map +0 -1
- package/lib/types/mobile/markdown.js +0 -285
- package/lib/types/mobile/markdown.test.d.ts +0 -2
- package/lib/types/mobile/markdown.test.d.ts.map +0 -1
- package/lib/types/mobile/markdown.test.js +0 -79
- package/lib/types/mobile/messages.d.ts +0 -149
- package/lib/types/mobile/messages.d.ts.map +0 -1
- package/lib/types/mobile/messages.js +0 -561
- package/lib/types/mobile/messages.test.d.ts +0 -2
- package/lib/types/mobile/messages.test.d.ts.map +0 -1
- package/lib/types/mobile/messages.test.js +0 -356
- package/lib/types/mobile/mobile-styles.d.ts +0 -6
- package/lib/types/mobile/mobile-styles.d.ts.map +0 -1
- package/lib/types/mobile/mobile-styles.js +0 -1438
- package/lib/types/mobile/mobile-theme.d.ts +0 -22
- package/lib/types/mobile/mobile-theme.d.ts.map +0 -1
- package/lib/types/mobile/mobile-theme.js +0 -70
- package/lib/types/mobile/mobile-theme.test.d.ts +0 -2
- package/lib/types/mobile/mobile-theme.test.d.ts.map +0 -1
- package/lib/types/mobile/mobile-theme.test.js +0 -71
- package/lib/types/mobile/mux.d.ts +0 -128
- package/lib/types/mobile/mux.d.ts.map +0 -1
- package/lib/types/mobile/mux.js +0 -305
- package/lib/types/mobile/mux.test.d.ts +0 -2
- package/lib/types/mobile/mux.test.d.ts.map +0 -1
- package/lib/types/mobile/mux.test.js +0 -267
- package/lib/types/mobile/pairing.d.ts +0 -31
- package/lib/types/mobile/pairing.d.ts.map +0 -1
- package/lib/types/mobile/pairing.js +0 -62
- package/lib/types/mobile/rpc.d.ts +0 -38
- package/lib/types/mobile/rpc.d.ts.map +0 -1
- package/lib/types/mobile/rpc.js +0 -83
- package/lib/types/mobile/theme-toggle.d.ts +0 -12
- package/lib/types/mobile/theme-toggle.d.ts.map +0 -1
- package/lib/types/mobile/theme-toggle.js +0 -26
- package/lib/types/mobile/views/App.d.ts +0 -57
- package/lib/types/mobile/views/App.d.ts.map +0 -1
- package/lib/types/mobile/views/App.js +0 -150
- package/lib/types/mobile/views/App.test.d.ts +0 -2
- package/lib/types/mobile/views/App.test.d.ts.map +0 -1
- package/lib/types/mobile/views/App.test.js +0 -52
- package/lib/types/mobile/views/ChatView.d.ts +0 -63
- package/lib/types/mobile/views/ChatView.d.ts.map +0 -1
- package/lib/types/mobile/views/ChatView.js +0 -821
- package/lib/types/mobile/views/ChatView.stream.test.d.ts +0 -2
- package/lib/types/mobile/views/ChatView.stream.test.d.ts.map +0 -1
- package/lib/types/mobile/views/ChatView.stream.test.js +0 -236
- package/lib/types/mobile/views/ChatView.test.d.ts +0 -2
- package/lib/types/mobile/views/ChatView.test.d.ts.map +0 -1
- package/lib/types/mobile/views/ChatView.test.js +0 -718
- package/lib/types/mobile/views/SessionListView.d.ts +0 -27
- package/lib/types/mobile/views/SessionListView.d.ts.map +0 -1
- package/lib/types/mobile/views/SessionListView.js +0 -139
- package/lib/types/mobile/views/SessionListView.test.d.ts +0 -2
- package/lib/types/mobile/views/SessionListView.test.d.ts.map +0 -1
- package/lib/types/mobile/views/SessionListView.test.js +0 -167
- package/lib/types/mobile/views/WorkspaceView.d.ts +0 -21
- package/lib/types/mobile/views/WorkspaceView.d.ts.map +0 -1
- package/lib/types/mobile/views/WorkspaceView.js +0 -93
- package/lib/types/mobile/views/WorkspaceView.test.d.ts +0 -2
- package/lib/types/mobile/views/WorkspaceView.test.d.ts.map +0 -1
- package/lib/types/mobile/views/WorkspaceView.test.js +0 -183
- package/lib/types/mobile-api.d.ts +0 -65
- package/lib/types/mobile-api.d.ts.map +0 -1
- package/lib/types/mobile-api.js +0 -404
- package/lib/types/mobile-pending.d.ts +0 -38
- package/lib/types/mobile-pending.d.ts.map +0 -1
- package/lib/types/mobile-pending.js +0 -59
- package/lib/types/mobile-pwa.d.ts +0 -6
- package/lib/types/mobile-pwa.d.ts.map +0 -1
- package/lib/types/mobile-pwa.js +0 -17
- package/lib/types/mobile-routes.d.ts +0 -13
- package/lib/types/mobile-routes.d.ts.map +0 -1
- package/lib/types/mobile-routes.js +0 -217
- package/lib/types/paired-model-catalog.d.ts +0 -17
- package/lib/types/paired-model-catalog.d.ts.map +0 -1
- package/lib/types/paired-model-catalog.js +0 -311
- package/src/mobile/PairRequiredView.test.tsx +0 -28
- package/src/mobile/PairRequiredView.tsx +0 -58
- package/src/mobile/api.ts +0 -229
- package/src/mobile/index.tsx +0 -41
- package/src/mobile/markdown.test.ts +0 -91
- package/src/mobile/markdown.ts +0 -291
- package/src/mobile/messages.test.ts +0 -402
- package/src/mobile/messages.ts +0 -685
- package/src/mobile/mobile-styles.ts +0 -1439
- package/src/mobile/mobile-theme.test.ts +0 -84
- package/src/mobile/mobile-theme.ts +0 -76
- package/src/mobile/mux.test.ts +0 -314
- package/src/mobile/mux.ts +0 -336
- package/src/mobile/pairing.ts +0 -77
- package/src/mobile/rpc.ts +0 -90
- package/src/mobile/theme-toggle.tsx +0 -47
- package/src/mobile/views/App.test.tsx +0 -62
- package/src/mobile/views/App.tsx +0 -213
- package/src/mobile/views/ChatView.stream.test.tsx +0 -268
- package/src/mobile/views/ChatView.test.tsx +0 -816
- package/src/mobile/views/ChatView.tsx +0 -1342
- package/src/mobile/views/SessionListView.test.tsx +0 -207
- package/src/mobile/views/SessionListView.tsx +0 -244
- package/src/mobile/views/WorkspaceView.test.tsx +0 -227
- package/src/mobile/views/WorkspaceView.tsx +0 -245
- package/src/mobile-api.ts +0 -418
- package/src/mobile-pending.ts +0 -88
- package/src/mobile-pwa.ts +0 -19
- package/src/mobile-routes.ts +0 -231
- package/src/paired-model-catalog.ts +0 -272
package/src/routes.ts
CHANGED
|
@@ -3,15 +3,21 @@
|
|
|
3
3
|
* under /api: the webserver matches exact paths before the connection
|
|
4
4
|
* plugin's /api prefix, so these handlers own the full response lifecycle
|
|
5
5
|
* and apply their own trust fence (loopback-only for control endpoints;
|
|
6
|
-
* loopback-or-LAN for the phone-facing accept/heartbeat/status). The
|
|
7
|
-
*
|
|
8
|
-
*
|
|
6
|
+
* loopback-or-LAN for the phone-facing accept/heartbeat/status). The cookie
|
|
7
|
+
* set on accept is the device identity the plugin's own surfaces enforce:
|
|
8
|
+
* the /remote channel gate and the api/gate listener. Alongside the JSON
|
|
9
|
+
* family sit the phone-facing top-level pages (exact routes /pair-accept,
|
|
10
|
+
* /pair-app, /pair-app.sw.js) with their own LAN-or-public fence. Note that
|
|
11
|
+
* on the 0.1.2-alpha.2 cohort nothing emits api/gate — direct /api is
|
|
12
|
+
* governed by the harness fence + browser auth — while the /remote channel
|
|
13
|
+
* always enforces the pairing cookie itself.
|
|
9
14
|
*/
|
|
10
15
|
|
|
11
16
|
import type { IncomingMessage, ServerResponse } from 'node:http'
|
|
12
17
|
import type { WebRoute } from '@deepseek-ai/dsh-host-webserver'
|
|
13
18
|
import { z, type ZodType } from 'zod'
|
|
14
19
|
import { UnknownLanAddressError, type PairingService, type PairingSnapshot } from './pairing.ts'
|
|
20
|
+
import { isLoopbackAddress } from './loopback.ts'
|
|
15
21
|
import { isLoopbackClient, readCookie } from './gate.ts'
|
|
16
22
|
import { readJsonBody, writeJson } from './http.ts'
|
|
17
23
|
|
|
@@ -63,6 +69,22 @@ export function isTrustedApiRequest(request: IncomingMessage, trustedHosts: read
|
|
|
63
69
|
/** Cap on pairing request bodies (tokens and workspace ids are tiny). */
|
|
64
70
|
const MAX_BODY_BYTES = 4096
|
|
65
71
|
|
|
72
|
+
/**
|
|
73
|
+
* The rate-limit bucket key for one accept attempt (pure; unit-tested).
|
|
74
|
+
* The first client-visible XFF hop separates buckets behind the auto-tunnel
|
|
75
|
+
* (every internet client arrives from 127.0.0.1 there) — but only for
|
|
76
|
+
* loopback peers, since a direct LAN client can rotate the header freely.
|
|
77
|
+
* @param socketIp - the socket peer address.
|
|
78
|
+
* @param forwarded - the first XFF hop, already trimmed, if any.
|
|
79
|
+
* @param bucket - page (GET /pair-accept) vs api (POST /api/pair/accept).
|
|
80
|
+
*/
|
|
81
|
+
export function acceptLimitKey(socketIp: string, forwarded: string | undefined, bucket: 'page' | 'api'): string {
|
|
82
|
+
if (isLoopbackAddress(socketIp) && forwarded !== undefined && forwarded !== '') {
|
|
83
|
+
return `${bucket}|${socketIp}|${forwarded}`
|
|
84
|
+
}
|
|
85
|
+
return `${bucket}|${socketIp}`
|
|
86
|
+
}
|
|
87
|
+
|
|
66
88
|
/**
|
|
67
89
|
* The host authority of a configured public base URL, e.g. `foo.trycloudflare.com`
|
|
68
90
|
* from `https://foo.trycloudflare.com`. Undefined when the URL does not parse —
|
|
@@ -83,6 +105,130 @@ export function publicHostOf(url: string | undefined): string | undefined {
|
|
|
83
105
|
/** Cookie lifetime: one year; revoked sessions die at the gate regardless. */
|
|
84
106
|
const COOKIE_MAX_AGE_SEC = 365 * 24 * 60 * 60
|
|
85
107
|
|
|
108
|
+
/**
|
|
109
|
+
* The cookieless device credential: pass the device id from the /pair-app
|
|
110
|
+
* URL into sessionStorage (and localStorage for tab reloads) before any app
|
|
111
|
+
* script runs - same key the boot patch and the channel gate read. The
|
|
112
|
+
* replaceState to '/' hides the credential URL from the address bar and
|
|
113
|
+
* leaves the SPA at its canonical root path. The reopen service worker is
|
|
114
|
+
* registered in the same breath: later navigations to '/' (history,
|
|
115
|
+
* bookmark, tab restore) must not fall through to the harness index gate,
|
|
116
|
+
* which the cookieless flow can never satisfy.
|
|
117
|
+
*/
|
|
118
|
+
export const APP_DEVICE_STORAGE_KEY = 'dsh-remote-device'
|
|
119
|
+
|
|
120
|
+
export function appShellCaptureScript(deviceId: string): string {
|
|
121
|
+
const safeId = JSON.stringify(deviceId)
|
|
122
|
+
const register = `try{if('serviceWorker' in navigator){navigator.serviceWorker.register(${JSON.stringify(PAIR_PATHS.appServiceWorker)},{scope:'/'}).catch(function(e){})}}catch(e){}`
|
|
123
|
+
return `<script>(function(){try{sessionStorage.setItem(${JSON.stringify(APP_DEVICE_STORAGE_KEY)},${safeId});localStorage.setItem(${JSON.stringify(APP_DEVICE_STORAGE_KEY)},${safeId});}catch(e){}try{history.replaceState(null,'','/')}catch(e){}${register}})()</script>`
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** Patch the official index document with the device-capture script. */
|
|
127
|
+
export function patchAppShell(html: string, deviceId: string): string {
|
|
128
|
+
const script = appShellCaptureScript(deviceId)
|
|
129
|
+
const marker = '</head>'
|
|
130
|
+
const at = html.indexOf(marker)
|
|
131
|
+
if (at === -1) return script + html
|
|
132
|
+
return html.slice(0, at) + script + html.slice(at)
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* The reopen service worker served at PAIR_PATHS.appServiceWorker. A paired
|
|
137
|
+
* phone comes back to `/` from history, bookmarks, or tab restore; the
|
|
138
|
+
* harness fallback seat answers that navigation with its browser-auth 401,
|
|
139
|
+
* and the cookieless mobile flow never holds a browser-auth cookie. The
|
|
140
|
+
* worker owns navigations to `/` instead: network-first through /pair-app
|
|
141
|
+
* (which validates the device cookie and refreshes its presence), the
|
|
142
|
+
* cached shell for offline opens, and a pass-through of the original
|
|
143
|
+
* request when the plugin no longer answers. Plain-HTTP LAN origins are not
|
|
144
|
+
* secure contexts, so the worker never registers there — the LAN reopen
|
|
145
|
+
* path stays "scan a fresh QR", which is cheap in-network.
|
|
146
|
+
*
|
|
147
|
+
* Kept as a plain string (same pattern as the capture script): it must load
|
|
148
|
+
* with no build step, run on every JS engine that ships service workers,
|
|
149
|
+
* and be assertable as source in tests. Bump SHELL_CACHE when the storage
|
|
150
|
+
* layout changes so old caches are pruned on activate.
|
|
151
|
+
*/
|
|
152
|
+
export function appServiceWorkerScript(): string {
|
|
153
|
+
return `'use strict';
|
|
154
|
+
var SHELL_CACHE = 'dsh-remote-shell-v1';
|
|
155
|
+
var SHELL_KEY = '/dsh-remote-shell';
|
|
156
|
+
var APP_URL = '/pair-app';
|
|
157
|
+
self.addEventListener('install', function (event) {
|
|
158
|
+
event.waitUntil(refreshShell().then(function () { return self.skipWaiting(); }));
|
|
159
|
+
});
|
|
160
|
+
self.addEventListener('activate', function (event) {
|
|
161
|
+
event.waitUntil(self.caches.keys().then(function (names) {
|
|
162
|
+
return Promise.all(names.map(function (name) {
|
|
163
|
+
return name === SHELL_CACHE ? Promise.resolve() : self.caches.delete(name);
|
|
164
|
+
}));
|
|
165
|
+
}).then(function () { return self.clients.claim(); }));
|
|
166
|
+
});
|
|
167
|
+
self.addEventListener('fetch', function (event) {
|
|
168
|
+
var request = event.request;
|
|
169
|
+
if (request.method !== 'GET' || request.mode !== 'navigate') return;
|
|
170
|
+
var path;
|
|
171
|
+
try { path = new URL(request.url).pathname; } catch (error) { return; }
|
|
172
|
+
if (path !== '/') return;
|
|
173
|
+
event.respondWith(reopen(event));
|
|
174
|
+
});
|
|
175
|
+
/* Network-first: a live pairing gets the current shell (the /pair-app check
|
|
176
|
+
refreshes lastSeenAt, so every reopen also keeps the session alive); a
|
|
177
|
+
refused shell passes the original navigation through for the harness's
|
|
178
|
+
own response; an unreachable server falls back to the cached shell. */
|
|
179
|
+
function reopen(event) {
|
|
180
|
+
var request = event.request;
|
|
181
|
+
return fetch(APP_URL, { credentials: 'same-origin', cache: 'no-store' }).then(function (response) {
|
|
182
|
+
if (!response.ok) return fetch(request);
|
|
183
|
+
var copy = response.clone();
|
|
184
|
+
event.waitUntil(self.caches.open(SHELL_CACHE).then(function (cache) {
|
|
185
|
+
return cache.put(SHELL_KEY, copy);
|
|
186
|
+
}));
|
|
187
|
+
return response;
|
|
188
|
+
}, function () {
|
|
189
|
+
return self.caches.match(SHELL_KEY).then(function (cached) {
|
|
190
|
+
return cached !== undefined ? cached : fetch(request);
|
|
191
|
+
});
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
/* Best-effort shell warm-up and refresh; never rejects. */
|
|
195
|
+
function refreshShell() {
|
|
196
|
+
return fetch(APP_URL, { credentials: 'same-origin', cache: 'no-store' }).then(function (response) {
|
|
197
|
+
if (!response.ok) return undefined;
|
|
198
|
+
var copy = response.clone();
|
|
199
|
+
return self.caches.open(SHELL_CACHE).then(function (cache) {
|
|
200
|
+
return cache.put(SHELL_KEY, copy);
|
|
201
|
+
});
|
|
202
|
+
}, function () { return undefined; });
|
|
203
|
+
}`
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* The dead-end guard for a failed /pair-accept and for a dead reopen (the
|
|
208
|
+
* reopen service worker serves this page at `/` when the pairing no longer
|
|
209
|
+
* passes): a device without a live pairing has no harness browser-auth
|
|
210
|
+
* cookie, so a redirect to bare `/` would land on the harness browser-auth
|
|
211
|
+
* 401 page ("authentication required"), which reads like a broken server.
|
|
212
|
+
* Serve a plain bilingual explanation instead; an already-paired device (live
|
|
213
|
+
* device cookie, browser credential redeemed during its first scan) keeps
|
|
214
|
+
* the old behavior and is sent on to the app.
|
|
215
|
+
*/
|
|
216
|
+
function pairingFailurePage(): string {
|
|
217
|
+
return [
|
|
218
|
+
'<!doctype html><html><head><meta charset="utf-8">',
|
|
219
|
+
'<meta name="viewport" content="width=device-width,initial-scale=1">',
|
|
220
|
+
'<meta name="referrer" content="no-referrer">',
|
|
221
|
+
'<title>Pairing link invalid</title></head>',
|
|
222
|
+
'<body style="font-family:system-ui,sans-serif;padding:24px;max-width:32em;margin:0 auto;line-height:1.6">',
|
|
223
|
+
'<p><strong>配对已失效,或配对链接已过期。</strong></p>',
|
|
224
|
+
'<p>请在桌面端打开远程控制面板,刷新二维码后重新扫码;重新配对后本机即可恢复访问。</p>',
|
|
225
|
+
'<hr style="border:none;border-top:1px solid #ccc;margin:16px 0">',
|
|
226
|
+
'<p><strong>This pairing has expired or the link is no longer valid.</strong></p>',
|
|
227
|
+
'<p>Open the remote panel on the desktop, refresh the QR code, and scan again — re-pairing restores access on this device.</p>',
|
|
228
|
+
'</body></html>',
|
|
229
|
+
].join('')
|
|
230
|
+
}
|
|
231
|
+
|
|
86
232
|
/** Route paths (exact matches under /api). */
|
|
87
233
|
export const PAIR_PATHS = {
|
|
88
234
|
issue: '/api/pair/issue',
|
|
@@ -92,6 +238,16 @@ export const PAIR_PATHS = {
|
|
|
92
238
|
heartbeat: '/api/pair/heartbeat',
|
|
93
239
|
status: '/api/pair/status',
|
|
94
240
|
events: '/api/pair/events',
|
|
241
|
+
lanBind: '/api/pair/lan-bind',
|
|
242
|
+
/** Top-level accept-and-redirect entry the QR link points at. */
|
|
243
|
+
acceptPage: '/pair-accept',
|
|
244
|
+
/** The cookieless app landing: serves the official shell for a paired device. */
|
|
245
|
+
appPage: '/pair-app',
|
|
246
|
+
/**
|
|
247
|
+
* The reopen service worker (registered by the capture script). Root-level
|
|
248
|
+
* path so its default script-directory scope already covers `/`.
|
|
249
|
+
*/
|
|
250
|
+
appServiceWorker: '/pair-app.sw.js',
|
|
95
251
|
} as const
|
|
96
252
|
|
|
97
253
|
/**
|
|
@@ -103,7 +259,6 @@ export const PAIR_PATHS = {
|
|
|
103
259
|
* are tolerated exactly as the previous manual reads ignored them.
|
|
104
260
|
*/
|
|
105
261
|
export const issuePayloadSchema = z.object({
|
|
106
|
-
workspaceId: z.string().min(1).optional(),
|
|
107
262
|
address: z.string().min(1).optional(),
|
|
108
263
|
})
|
|
109
264
|
export const acceptPayloadSchema = z.object({
|
|
@@ -188,6 +343,22 @@ export interface PairRoutesDeps {
|
|
|
188
343
|
lanAddresses: readonly string[]
|
|
189
344
|
/** Current desktop gate policy, re-read for every status response. */
|
|
190
345
|
requirePairingForLan?: boolean | (() => boolean)
|
|
346
|
+
/**
|
|
347
|
+
* LAN-bind facts for the settings card (managed patch block state, live
|
|
348
|
+
* bind host/port, firewall summary). Re-read per request so a hot-reloaded
|
|
349
|
+
* rebind and a fresh toggle round are both reflected without a restart.
|
|
350
|
+
* The route is loopback-only; undefined drops it (tests).
|
|
351
|
+
*/
|
|
352
|
+
lanBindStatus?: () => Record<string, unknown>
|
|
353
|
+
/**
|
|
354
|
+
* The official index document to serve on the /pair-app landing. The
|
|
355
|
+
* plugin fetches it from the inner loopback with its own credential and
|
|
356
|
+
* patches it with the device-capture script, so a paired device loads
|
|
357
|
+
* the app shell WITHOUT passing the harness index gate - the mobile flow
|
|
358
|
+
* then needs no browser cookie at all (the channel gate accepts the
|
|
359
|
+
* cookieless header/query credential). Undefined drops the route (tests).
|
|
360
|
+
*/
|
|
361
|
+
indexDocument?: (deviceId: string) => Promise<string | undefined>
|
|
191
362
|
}
|
|
192
363
|
|
|
193
364
|
/**
|
|
@@ -204,10 +375,13 @@ export function makeRoutes(deps: PairRoutesDeps): WebRoute[] {
|
|
|
204
375
|
|
|
205
376
|
/** Loopback-only fence: the desktop panel's control endpoints. */
|
|
206
377
|
const loopbackFence = (req: IncomingMessage): boolean => isTrustedApiRequest(req, [])
|
|
207
|
-
/** Phone-facing fence: loopback, the
|
|
378
|
+
/** Phone-facing fence: loopback, the service's live LAN literals, or the configured public host. */
|
|
208
379
|
const lanFence = (req: IncomingMessage): boolean => {
|
|
209
380
|
const publicHost = publicHostOf(service.publicBaseUrl)
|
|
210
|
-
|
|
381
|
+
// The service's LAN bases re-read per request: a hot rebind (the lan-bind
|
|
382
|
+
// toggle) updates them mid-process, and the fence must follow.
|
|
383
|
+
const bases = service.lanAddresses
|
|
384
|
+
return isTrustedApiRequest(req, publicHost === undefined ? bases : [...bases, publicHost])
|
|
211
385
|
}
|
|
212
386
|
|
|
213
387
|
const requireMethod = (req: IncomingMessage, res: ServerResponse, method: string): boolean => {
|
|
@@ -221,17 +395,20 @@ export function makeRoutes(deps: PairRoutesDeps): WebRoute[] {
|
|
|
221
395
|
const acceptAttempts = new Map<string, { count: number; windowStart: number }>()
|
|
222
396
|
const ACCEPT_MAX_ATTEMPTS = 10
|
|
223
397
|
const ACCEPT_WINDOW_MS = 30_000
|
|
224
|
-
|
|
398
|
+
/**
|
|
399
|
+
* @param bucket - the POST /api/pair/accept and the GET /pair-accept flows
|
|
400
|
+
* count separately: a QR re-scan (page navigation) must not consume a
|
|
401
|
+
* brute-force budget that belongs to token guessing (and vice versa).
|
|
402
|
+
*/
|
|
403
|
+
const rateLimitAccept = (req: IncomingMessage, bucket: 'page' | 'api'): boolean => {
|
|
225
404
|
const socketIp = (req.socket as { remoteAddress?: string } | undefined)?.remoteAddress ?? 'unknown'
|
|
226
|
-
//
|
|
227
|
-
//
|
|
228
|
-
//
|
|
229
|
-
// client-visible XFF hop (set by the tunnel edge): XFF is untrusted for
|
|
230
|
-
// authentication and only separates buckets, it never grants access.
|
|
405
|
+
// XFF is honored only for loopback peers (the tunnel edge); see
|
|
406
|
+
// acceptLimitKey. It is untrusted for authentication and never grants
|
|
407
|
+
// access.
|
|
231
408
|
const forwarded = typeof req.headers['x-forwarded-for'] === 'string'
|
|
232
409
|
? (req.headers['x-forwarded-for'].split(',')[0] ?? '').trim()
|
|
233
410
|
: undefined
|
|
234
|
-
const ip =
|
|
411
|
+
const ip = acceptLimitKey(socketIp, forwarded, bucket)
|
|
235
412
|
const nowMs = Date.now()
|
|
236
413
|
// The map lives as long as the plugin: prune expired windows once the
|
|
237
414
|
// table grows past a modest size so distinct source IPs (LAN clients,
|
|
@@ -262,18 +439,21 @@ export function makeRoutes(deps: PairRoutesDeps): WebRoute[] {
|
|
|
262
439
|
writeJson(res, 400, { ok: false, code: 'bad-payload' })
|
|
263
440
|
return
|
|
264
441
|
}
|
|
265
|
-
const {
|
|
442
|
+
const { address } = payload
|
|
266
443
|
try {
|
|
267
|
-
|
|
444
|
+
// The QR link is the official Web GUI itself: after the accept round
|
|
445
|
+
// trip every device — phone or PC — boots the desktop SPA (phones get
|
|
446
|
+
// the injected portrait adaptation, PCs the full desktop UI), so the
|
|
447
|
+
// remote surface can never drift from the official one.
|
|
448
|
+
const { token, expiresAt } = service.issue(undefined, address)
|
|
268
449
|
// The default base is the public (tunneled) URL when configured — a
|
|
269
450
|
// phone anywhere can reach it — and the first LAN interface otherwise.
|
|
270
451
|
// An explicit address always names a LAN literal.
|
|
271
452
|
const base = address === undefined ? (service.publicBaseUrl ?? service.lanBaseUrl) : service.lanBaseUrlFor(address)
|
|
272
453
|
if (base === undefined) throw new Error('remote-web-ui: base unavailable')
|
|
273
|
-
const workspaceQuery = workspaceId === undefined ? '' : `&workspace=${encodeURIComponent(workspaceId)}`
|
|
274
454
|
writeJson(res, 200, {
|
|
275
455
|
ok: true,
|
|
276
|
-
url: `${base}/
|
|
456
|
+
url: `${base}/pair-accept?pair=${token}`,
|
|
277
457
|
token,
|
|
278
458
|
expiresAt,
|
|
279
459
|
// Every constructible base, so a multi-homed panel can switch the
|
|
@@ -300,7 +480,7 @@ export function makeRoutes(deps: PairRoutesDeps): WebRoute[] {
|
|
|
300
480
|
writeJson(res, 403, { ok: false, code: 'forbidden' })
|
|
301
481
|
return
|
|
302
482
|
}
|
|
303
|
-
if (rateLimitAccept(req)) {
|
|
483
|
+
if (rateLimitAccept(req, 'api')) {
|
|
304
484
|
writeJson(res, 429, { ok: false, code: 'rate-limited' })
|
|
305
485
|
return
|
|
306
486
|
}
|
|
@@ -412,7 +592,148 @@ export function makeRoutes(deps: PairRoutesDeps): WebRoute[] {
|
|
|
412
592
|
events.push(service.snapshot())
|
|
413
593
|
}
|
|
414
594
|
|
|
415
|
-
|
|
595
|
+
/** LAN-bind facts for the settings card; loopback-only, read per request. */
|
|
596
|
+
const handleLanBind = (req: IncomingMessage, res: ServerResponse): void => {
|
|
597
|
+
if (!requireMethod(req, res, 'GET')) return
|
|
598
|
+
if (!loopbackFence(req)) {
|
|
599
|
+
writeJson(res, 403, { ok: false, code: 'forbidden' })
|
|
600
|
+
return
|
|
601
|
+
}
|
|
602
|
+
writeJson(res, 200, { ok: true, ...(deps.lanBindStatus?.() ?? {}) })
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
/**
|
|
606
|
+
* The QR entry: navigate here with ?pair=<token>. Sets the device cookie,
|
|
607
|
+
* then redirects to the authenticated home (the connection service's
|
|
608
|
+
* launch-token URL), so a LAN device that has never seen this authority
|
|
609
|
+
* clears the browser-auth gate and boots the paired official UI in one
|
|
610
|
+
* chain: /pair-accept → /?token=<launch> → /. A device that is already
|
|
611
|
+
* authenticated (or loopback) skips straight through the same way.
|
|
612
|
+
*/
|
|
613
|
+
const handleAcceptPage = async (req: IncomingMessage, res: ServerResponse): Promise<void> => {
|
|
614
|
+
if (!requireMethod(req, res, 'GET')) return
|
|
615
|
+
if (!lanFence(req)) {
|
|
616
|
+
res.writeHead(403, { 'content-type': 'text/plain; charset=utf-8' })
|
|
617
|
+
res.end('forbidden')
|
|
618
|
+
return
|
|
619
|
+
}
|
|
620
|
+
if (rateLimitAccept(req, 'page')) {
|
|
621
|
+
res.writeHead(429, { 'content-type': 'text/plain; charset=utf-8' })
|
|
622
|
+
res.end('rate limited')
|
|
623
|
+
return
|
|
624
|
+
}
|
|
625
|
+
const url = new URL(req.url ?? '/', 'http://pair.invalid')
|
|
626
|
+
const token = url.searchParams.get('pair') ?? ''
|
|
627
|
+
const ua = req.headers['user-agent']
|
|
628
|
+
const result = token === '' ? { ok: false as const, code: 'invalid' as const } : service.accept(token, typeof ua === 'string' ? ua : undefined)
|
|
629
|
+
if (!result.ok) {
|
|
630
|
+
// accept() now refuses only expired/unknown/stopped tokens: a consumed
|
|
631
|
+
// token stays re-usable until its expiry or replacement, so a mobile
|
|
632
|
+
// flow that split across cookie contexts (camera preview, in-app
|
|
633
|
+
// browser, system browser) can re-pair from the same link. A failure
|
|
634
|
+
// here means the link is truly dead: an already-paired device is sent
|
|
635
|
+
// on to the app, everyone else gets the bilingual explanation page
|
|
636
|
+
// instead of the harness 401 dead end.
|
|
637
|
+
const deviceId = readCookie(req.headers.cookie, service.config.cookieName)
|
|
638
|
+
if (deviceId !== undefined && service.hasDevice(deviceId)) {
|
|
639
|
+
// An already-paired device re-opening a dead link goes straight to
|
|
640
|
+
// the cookieless app landing with its live device credential.
|
|
641
|
+
res.writeHead(303, { location: `${appOrigin(req)}/pair-app?device=${encodeURIComponent(deviceId)}`, 'cache-control': 'no-store', 'referrer-policy': 'no-referrer' })
|
|
642
|
+
res.end()
|
|
643
|
+
return
|
|
644
|
+
}
|
|
645
|
+
res.writeHead(200, { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store', 'referrer-policy': 'no-referrer' })
|
|
646
|
+
res.end(pairingFailurePage())
|
|
647
|
+
return
|
|
648
|
+
}
|
|
649
|
+
// Land the paired device on the cookieless app page: the official shell
|
|
650
|
+
// served by this plugin, with the device id in the URL. No harness index
|
|
651
|
+
// gate, no browser-auth cookie hop - the channel gate accepts the device
|
|
652
|
+
// credential whether or not the browser stores cookies.
|
|
653
|
+
res.writeHead(303, {
|
|
654
|
+
location: `${appOrigin(req)}/pair-app?device=${encodeURIComponent(result.deviceId)}`,
|
|
655
|
+
'cache-control': 'no-store',
|
|
656
|
+
'referrer-policy': 'no-referrer',
|
|
657
|
+
'set-cookie': [
|
|
658
|
+
`${service.config.cookieName}=${result.deviceId}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${String(COOKIE_MAX_AGE_SEC)}`,
|
|
659
|
+
],
|
|
660
|
+
})
|
|
661
|
+
res.end()
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
/**
|
|
665
|
+
* The cookieless app landing. A paired device (device query or live
|
|
666
|
+
* pairing cookie) receives the official index shell patched with the
|
|
667
|
+
* device-capture script; the shell itself is the official document and
|
|
668
|
+
* carries no data, so serving it needs only the device credential.
|
|
669
|
+
*/
|
|
670
|
+
const handleAppPage = async (req: IncomingMessage, res: ServerResponse): Promise<void> => {
|
|
671
|
+
if (!requireMethod(req, res, 'GET')) return
|
|
672
|
+
if (!lanFence(req)) {
|
|
673
|
+
res.writeHead(403, { 'content-type': 'text/plain; charset=utf-8' })
|
|
674
|
+
res.end('forbidden')
|
|
675
|
+
return
|
|
676
|
+
}
|
|
677
|
+
if (rateLimitAccept(req, 'page')) {
|
|
678
|
+
res.writeHead(429, { 'content-type': 'text/plain; charset=utf-8' })
|
|
679
|
+
res.end('rate limited')
|
|
680
|
+
return
|
|
681
|
+
}
|
|
682
|
+
const url = new URL(req.url ?? '/', 'http://pair.invalid')
|
|
683
|
+
const device = url.searchParams.get('device') ?? ''
|
|
684
|
+
const cookieDevice = readCookie(req.headers.cookie, service.config.cookieName)
|
|
685
|
+
const id = (device !== '' && service.touchDevice(device))
|
|
686
|
+
? device
|
|
687
|
+
: (cookieDevice !== undefined && service.touchDevice(cookieDevice))
|
|
688
|
+
? cookieDevice
|
|
689
|
+
: undefined
|
|
690
|
+
if (id === undefined) {
|
|
691
|
+
res.writeHead(200, { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store', 'referrer-policy': 'no-referrer' })
|
|
692
|
+
res.end(pairingFailurePage())
|
|
693
|
+
return
|
|
694
|
+
}
|
|
695
|
+
const html = await deps.indexDocument?.(id).catch(() => undefined)
|
|
696
|
+
if (html === undefined) {
|
|
697
|
+
res.writeHead(502, { 'content-type': 'text/plain; charset=utf-8', 'cache-control': 'no-store' })
|
|
698
|
+
res.end('remote device app unavailable')
|
|
699
|
+
return
|
|
700
|
+
}
|
|
701
|
+
res.writeHead(200, {
|
|
702
|
+
'content-type': 'text/html; charset=utf-8',
|
|
703
|
+
'cache-control': 'no-store',
|
|
704
|
+
'referrer-policy': 'no-referrer',
|
|
705
|
+
'set-cookie': [
|
|
706
|
+
`${service.config.cookieName}=${id}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${String(COOKIE_MAX_AGE_SEC)}`,
|
|
707
|
+
],
|
|
708
|
+
})
|
|
709
|
+
res.end(patchAppShell(html, id))
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
/**
|
|
713
|
+
* The reopen service worker script. Same phone-facing fence as the app
|
|
714
|
+
* page but no rate limit and no device check: the script is inert logic
|
|
715
|
+
* (no secrets, no data), and browsers re-fetch it on navigations for
|
|
716
|
+
* update checks, which must not trip a brute-force budget. no-store keeps
|
|
717
|
+
* the update check meaningful across deploys.
|
|
718
|
+
*/
|
|
719
|
+
const handleAppServiceWorker = (req: IncomingMessage, res: ServerResponse): void => {
|
|
720
|
+
if (!requireMethod(req, res, 'GET')) return
|
|
721
|
+
if (!lanFence(req)) {
|
|
722
|
+
res.writeHead(403, { 'content-type': 'text/plain; charset=utf-8' })
|
|
723
|
+
res.end('forbidden')
|
|
724
|
+
return
|
|
725
|
+
}
|
|
726
|
+
res.writeHead(200, {
|
|
727
|
+
'content-type': 'text/javascript; charset=utf-8',
|
|
728
|
+
'cache-control': 'no-store',
|
|
729
|
+
// Defensive: the root-level script path already allows a `/` scope,
|
|
730
|
+
// the header keeps that explicit for engines that want it stated.
|
|
731
|
+
'service-worker-allowed': '/',
|
|
732
|
+
})
|
|
733
|
+
res.end(appServiceWorkerScript())
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
const routes: WebRoute[] = [
|
|
416
737
|
{ kind: 'exact', path: PAIR_PATHS.issue, handler: handleIssue },
|
|
417
738
|
{ kind: 'exact', path: PAIR_PATHS.accept, handler: handleAccept },
|
|
418
739
|
{ kind: 'exact', path: PAIR_PATHS.stop, handler: handleStop },
|
|
@@ -421,4 +742,22 @@ export function makeRoutes(deps: PairRoutesDeps): WebRoute[] {
|
|
|
421
742
|
{ kind: 'exact', path: PAIR_PATHS.status, handler: handleStatus },
|
|
422
743
|
{ kind: 'exact', path: PAIR_PATHS.events, handler: handleEvents },
|
|
423
744
|
]
|
|
745
|
+
if (deps.lanBindStatus !== undefined) {
|
|
746
|
+
routes.push({ kind: 'exact', path: PAIR_PATHS.lanBind, handler: handleLanBind })
|
|
747
|
+
}
|
|
748
|
+
routes.push({ kind: 'exact', path: PAIR_PATHS.acceptPage, handler: handleAcceptPage })
|
|
749
|
+
if (deps.indexDocument !== undefined) {
|
|
750
|
+
routes.push({ kind: 'exact', path: PAIR_PATHS.appPage, handler: handleAppPage })
|
|
751
|
+
// The reopen worker is only meaningful when the app landing it
|
|
752
|
+
// re-serves exists (same condition as /pair-app).
|
|
753
|
+
routes.push({ kind: 'exact', path: PAIR_PATHS.appServiceWorker, handler: handleAppServiceWorker })
|
|
754
|
+
}
|
|
755
|
+
return routes
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
/** The request origin (https when a tunnel edge says so). */
|
|
759
|
+
function appOrigin(req: IncomingMessage): string {
|
|
760
|
+
const proto = req.headers['x-forwarded-proto'] === 'https' ? 'https' : 'http'
|
|
761
|
+
const origin = `http://${req.headers.host ?? '127.0.0.1'}`
|
|
762
|
+
return proto === 'https' ? origin.replace('http://', 'https://') : origin
|
|
424
763
|
}
|
package/src/tunnel.ts
CHANGED
|
@@ -60,7 +60,13 @@ async function defaultEnsureBinary(): Promise<void> {
|
|
|
60
60
|
function defaultFactory(targetUrl: string): TunnelHandle {
|
|
61
61
|
// `--no-autoupdate`: the binary must never upgrade itself out from under
|
|
62
62
|
// the manager (a self-updated binary would break the pinned lifecycle).
|
|
63
|
-
|
|
63
|
+
// `--protocol http2`: the default `auto` prefers QUIC (UDP 7844), which
|
|
64
|
+
// wedges on fake-ip TUN proxies and QUIC-hostile networks — the connector
|
|
65
|
+
// holds the hostname but reports readyConnections: 0 forever (edge 1033),
|
|
66
|
+
// and the auto fallback never fires on a half-open QUIC handshake. http2
|
|
67
|
+
// rides TCP through the same paths reliably (verified live: auto stuck at
|
|
68
|
+
// ready 0 on two fresh registrations; http2 ready immediately).
|
|
69
|
+
return Tunnel.quick(targetUrl, { '--no-autoupdate': true, '--protocol': 'http2' })
|
|
64
70
|
}
|
|
65
71
|
|
|
66
72
|
/** Node timers. */
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -1,63 +0,0 @@
|
|
|
1
|
-
/* PWA worker for the standalone /m mobile shell. */
|
|
2
|
-
const CACHE_NAME = 'dsh-remote-mobile-shell-v2'
|
|
3
|
-
const OFFLINE_URL = '/m/offline.html'
|
|
4
|
-
const SHELL_PATHS = new Set([
|
|
5
|
-
'/m/',
|
|
6
|
-
'/m/manifest.webmanifest',
|
|
7
|
-
'/m/apple-touch-icon.png',
|
|
8
|
-
'/m/icon-192.png',
|
|
9
|
-
'/m/icon-512.png',
|
|
10
|
-
OFFLINE_URL,
|
|
11
|
-
])
|
|
12
|
-
|
|
13
|
-
self.addEventListener('install', event => {
|
|
14
|
-
event.waitUntil(caches.open(CACHE_NAME).then(cache => cache.add(OFFLINE_URL)))
|
|
15
|
-
})
|
|
16
|
-
|
|
17
|
-
self.addEventListener('activate', event => {
|
|
18
|
-
event.waitUntil(caches.keys().then(keys => Promise.all(
|
|
19
|
-
keys
|
|
20
|
-
.filter(key => key.startsWith('dsh-remote-mobile-shell-') && key !== CACHE_NAME)
|
|
21
|
-
.map(key => caches.delete(key)),
|
|
22
|
-
)))
|
|
23
|
-
})
|
|
24
|
-
|
|
25
|
-
self.addEventListener('fetch', event => {
|
|
26
|
-
const request = event.request
|
|
27
|
-
if (request.method !== 'GET') return
|
|
28
|
-
|
|
29
|
-
const url = new URL(request.url)
|
|
30
|
-
if (url.origin !== self.location.origin) return
|
|
31
|
-
if (url.pathname === '/api' || url.pathname.startsWith('/api/') || url.pathname === '/m/api' || url.pathname.startsWith('/m/api/')) return
|
|
32
|
-
|
|
33
|
-
const isMobileNavigation = request.mode === 'navigate' && (url.pathname === '/m/' || url.pathname === '/m')
|
|
34
|
-
if (isMobileNavigation) {
|
|
35
|
-
event.respondWith(networkFirst(request, OFFLINE_URL, false))
|
|
36
|
-
return
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
if (SHELL_PATHS.has(url.pathname)) event.respondWith(networkFirst(request, url.pathname))
|
|
40
|
-
})
|
|
41
|
-
|
|
42
|
-
async function networkFirst(request, fallbackPath, allowCachedResponse = true) {
|
|
43
|
-
try {
|
|
44
|
-
const response = await fetch(request)
|
|
45
|
-
if (response.status >= 500) throw new Error('mobile shell unavailable')
|
|
46
|
-
if (response.ok && new URL(request.url).search === '') {
|
|
47
|
-
const cache = await caches.open(CACHE_NAME)
|
|
48
|
-
await cache.put(request, response.clone())
|
|
49
|
-
}
|
|
50
|
-
return response
|
|
51
|
-
} catch {
|
|
52
|
-
const cache = await caches.open(CACHE_NAME)
|
|
53
|
-
if (allowCachedResponse) {
|
|
54
|
-
const cached = await cache.match(request)
|
|
55
|
-
if (cached !== undefined) return cached
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
const fallback = await cache.match(fallbackPath)
|
|
59
|
-
if (fallback !== undefined) return fallback
|
|
60
|
-
|
|
61
|
-
return new Response('', { status: 503, statusText: 'Service Unavailable' })
|
|
62
|
-
}
|
|
63
|
-
}
|