dsh-mobile 0.1.0-alpha.2 → 0.1.0-alpha.3
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 +3 -3
- package/README.zh.md +3 -3
- package/assets/brand/app-icon-master.png +0 -0
- package/lib/cli.js +185 -78
- package/lib/client.js +13 -1
- package/lib/client.js.map +1 -1
- package/lib/index.mjs +188 -2
- package/package.json +4 -4
package/README.md
CHANGED
|
@@ -61,9 +61,9 @@ pnpm dsh plugin --profile web exec dsh-mobile setup
|
|
|
61
61
|
pnpm dsh --profile web
|
|
62
62
|
```
|
|
63
63
|
|
|
64
|
-
Both paths install the public npm package into stock DSH without patching its source. Setup
|
|
64
|
+
Both paths install the public npm package into stock DSH without patching its source. Setup remembers the selected LAN interface, creates one local CA, and stores plugin data under `$DSH_HOME/mobile-access/`; run `dsh-mobile setup --help` through the same `plugin exec` command for manual network options. When DHCP changes that interface's IP, the plugin follows the new address and signs a matching server certificate from the same CA on the next DSH start.
|
|
65
65
|
|
|
66
|
-
1. Copy the generated `dsh-mobile-ca.cer` to Android and install it as a user CA certificate.
|
|
66
|
+
1. Copy the generated `dsh-mobile-ca.cer` to Android and install it once as a user CA certificate.
|
|
67
67
|
2. Open the Mobile card in the lower-left corner of the desktop DSH UI.
|
|
68
68
|
3. Start access and create a pairing link. The link is copied to the clipboard.
|
|
69
69
|
4. Paste the link into the Android app, or open it directly in a mobile browser.
|
|
@@ -162,7 +162,7 @@ flowchart LR
|
|
|
162
162
|
## Security model
|
|
163
163
|
|
|
164
164
|
- The gateway accepts only the selected private LAN CIDR, exact Host, and same-origin browser requests.
|
|
165
|
-
- The Android app never bypasses TLS validation. The certificate
|
|
165
|
+
- The Android app never bypasses TLS validation. The stable local CA signs a server certificate for the selected interface's current LAN address at each DSH start.
|
|
166
166
|
- Pairing creates an HttpOnly session; devices and active sessions can be revoked from the computer.
|
|
167
167
|
- Control and device-management routes are loopback-only.
|
|
168
168
|
- Authentication happens before any request reaches the stock DSH loopback server. DSH itself is never rebound to `0.0.0.0`.
|
package/README.zh.md
CHANGED
|
@@ -61,9 +61,9 @@ pnpm dsh plugin --profile web exec dsh-mobile setup
|
|
|
61
61
|
pnpm dsh --profile web
|
|
62
62
|
```
|
|
63
63
|
|
|
64
|
-
两种方式都会把公开 npm 包安装到原生 DSH,不修改 DSH
|
|
64
|
+
两种方式都会把公开 npm 包安装到原生 DSH,不修改 DSH 源码。设置向导会记住所选局域网网卡、生成一份稳定的本地 CA,并把插件数据保存到 `$DSH_HOME/mobile-access/`;需要手动指定网卡或端口时,通过同一个 `plugin exec` 命令运行 `dsh-mobile setup --help`。DHCP 改变该网卡的 IP 后,插件会在下次启动 DSH 时跟随新地址,并用同一 CA 自动签发匹配的服务器证书。
|
|
65
65
|
|
|
66
|
-
1. 将向导输出的 `dsh-mobile-ca.cer` 复制到 Android
|
|
66
|
+
1. 将向导输出的 `dsh-mobile-ca.cer` 复制到 Android,并在系统设置中一次性安装为用户 CA 证书。
|
|
67
67
|
2. 在电脑 DSH 左下角打开“移动端”卡片。
|
|
68
68
|
3. 确认服务已开启,点击“生成配对链接”。链接会复制到剪贴板。
|
|
69
69
|
4. 在 Android App 中粘贴链接,或直接在手机浏览器中打开链接。
|
|
@@ -167,7 +167,7 @@ flowchart LR
|
|
|
167
167
|
## 安全模型
|
|
168
168
|
|
|
169
169
|
- 网关只接受设置向导选定的私有局域网 CIDR、精确 Host 和同源浏览器请求。
|
|
170
|
-
- Android App 不跳过 TLS
|
|
170
|
+
- Android App 不跳过 TLS 校验;稳定的本地 CA 会在每次 DSH 启动时为所选网卡的当前局域网地址签发服务器证书。
|
|
171
171
|
- 配对设备获得短期 HttpOnly Session,设备和会话都可在电脑端撤销。
|
|
172
172
|
- 管理接口只允许从电脑回环地址访问,移动端无法打开、关闭网关或管理其他设备。
|
|
173
173
|
- 外层网关在认证后才代理到原生 DSH 的回环端口,不会把 DSH 自身绑定到 `0.0.0.0`。
|
|
Binary file
|
package/lib/cli.js
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import {
|
|
3
|
-
import { chmod, mkdir, readFile, rm, writeFile } from "node:fs/promises";
|
|
2
|
+
import { chmod, mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
|
|
4
3
|
import { homedir, networkInterfaces } from "node:os";
|
|
5
|
-
import { join, resolve } from "node:path";
|
|
4
|
+
import { basename, dirname, join, resolve } from "node:path";
|
|
5
|
+
import { X509Certificate, createPrivateKey, createPublicKey } from "node:crypto";
|
|
6
6
|
import { generate } from "selfsigned";
|
|
7
|
-
//#region src/
|
|
7
|
+
//#region src/managed-setup.ts
|
|
8
8
|
function privateIpv4(value) {
|
|
9
9
|
const parts = value.split(".").map(Number);
|
|
10
10
|
return parts.length === 4 && parts.every((part) => Number.isInteger(part) && part >= 0 && part <= 255) && (parts[0] === 10 || parts[0] === 172 && parts[1] >= 16 && parts[1] <= 31 || parts[0] === 192 && parts[1] === 168);
|
|
@@ -19,13 +19,157 @@ function networkCidr(address, cidr) {
|
|
|
19
19
|
0
|
|
20
20
|
].map((shift) => network >>> shift & 255).join(".")}/${String(prefix)}`;
|
|
21
21
|
}
|
|
22
|
-
|
|
23
|
-
|
|
22
|
+
/** List current private IPv4 candidates with their interface identity. */
|
|
23
|
+
function availableLanNetworks(table = networkInterfaces()) {
|
|
24
|
+
const candidates = Object.entries(table).flatMap(([name, entries]) => (entries ?? []).filter((entry) => entry.family === "IPv4" && !entry.internal && privateIpv4(entry.address) && entry.cidr !== null).map((entry) => ({
|
|
25
|
+
name,
|
|
24
26
|
address: entry.address,
|
|
25
27
|
cidr: networkCidr(entry.address, entry.cidr)
|
|
26
|
-
}));
|
|
27
|
-
return [...new Map(candidates.map((entry) => [entry.address
|
|
28
|
+
})));
|
|
29
|
+
return [...new Map(candidates.map((entry) => [`${entry.name}\0${entry.address}`, entry])).values()];
|
|
30
|
+
}
|
|
31
|
+
/** Select an active LAN, optionally by address or by a previously saved interface name. */
|
|
32
|
+
function selectLanNetwork(requestedAddress, requestedInterface, table) {
|
|
33
|
+
const candidates = availableLanNetworks(table);
|
|
34
|
+
if (requestedAddress !== void 0) {
|
|
35
|
+
const match = candidates.find((candidate) => candidate.address === requestedAddress);
|
|
36
|
+
if (match === void 0) throw new Error(`--address ${requestedAddress} is not an active private LAN address`);
|
|
37
|
+
return match;
|
|
38
|
+
}
|
|
39
|
+
if (requestedInterface !== void 0) {
|
|
40
|
+
const matches = candidates.filter((candidate) => candidate.name === requestedInterface);
|
|
41
|
+
if (matches.length === 1) return matches[0];
|
|
42
|
+
if (matches.length === 0) throw new Error(`saved LAN interface ${JSON.stringify(requestedInterface)} is not connected`);
|
|
43
|
+
throw new Error(`saved LAN interface ${JSON.stringify(requestedInterface)} has more than one private IPv4 address`);
|
|
44
|
+
}
|
|
45
|
+
if (candidates.length === 1) return candidates[0];
|
|
46
|
+
if (candidates.length === 0) throw new Error("no active private LAN address was found; connect to Wi-Fi or Ethernet");
|
|
47
|
+
throw new Error(`more than one LAN address is active; rerun with --address and one of: ${candidates.map((entry) => entry.address).join(", ")}`);
|
|
48
|
+
}
|
|
49
|
+
function assertMatchingCa(certPem, keyPem) {
|
|
50
|
+
const certificate = new X509Certificate(certPem);
|
|
51
|
+
if (!certificate.ca || certificate.subject !== certificate.issuer || !certificate.verify(certificate.publicKey)) throw new Error("managed TLS CA must be a self-signed CA certificate");
|
|
52
|
+
const privatePublic = createPublicKey(createPrivateKey(keyPem)).export({
|
|
53
|
+
format: "der",
|
|
54
|
+
type: "spki"
|
|
55
|
+
});
|
|
56
|
+
const certificatePublic = certificate.publicKey.export({
|
|
57
|
+
format: "der",
|
|
58
|
+
type: "spki"
|
|
59
|
+
});
|
|
60
|
+
if (!privatePublic.equals(certificatePublic)) throw new Error("managed TLS CA certificate and key do not match");
|
|
61
|
+
if (Date.parse(certificate.validFrom) > Date.now() || Date.parse(certificate.validTo) <= Date.now()) throw new Error("managed TLS CA certificate is not currently valid");
|
|
62
|
+
return certificate;
|
|
63
|
+
}
|
|
64
|
+
async function atomicWrite(file, contents) {
|
|
65
|
+
const directory = dirname(file);
|
|
66
|
+
await mkdir(directory, {
|
|
67
|
+
recursive: true,
|
|
68
|
+
mode: 448
|
|
69
|
+
});
|
|
70
|
+
const temporary = join(directory, `.${basename(file)}.${process.pid}.tmp`);
|
|
71
|
+
await writeFile(temporary, contents, { mode: 384 });
|
|
72
|
+
await chmod(temporary, 384);
|
|
73
|
+
await rename(temporary, file);
|
|
74
|
+
}
|
|
75
|
+
/** Create a long-lived CA or migrate the legacy self-signed server certificate as that CA. */
|
|
76
|
+
async function ensureManagedCa(setup, legacy) {
|
|
77
|
+
let certPem;
|
|
78
|
+
let keyPem;
|
|
79
|
+
try {
|
|
80
|
+
[certPem, keyPem] = await Promise.all([readFile(setup.caCertFile, "utf8"), readFile(setup.caKeyFile, "utf8")]);
|
|
81
|
+
} catch (error) {
|
|
82
|
+
if (error.code !== "ENOENT") throw error;
|
|
83
|
+
let migrated = false;
|
|
84
|
+
if (legacy !== void 0) try {
|
|
85
|
+
[certPem, keyPem] = await Promise.all([readFile(legacy.certFile, "utf8"), readFile(legacy.keyFile, "utf8")]);
|
|
86
|
+
assertMatchingCa(certPem, keyPem);
|
|
87
|
+
migrated = true;
|
|
88
|
+
} catch (legacyError) {
|
|
89
|
+
if (legacyError.code !== "ENOENT") throw legacyError;
|
|
90
|
+
}
|
|
91
|
+
if (!migrated) {
|
|
92
|
+
const now = /* @__PURE__ */ new Date();
|
|
93
|
+
const notAfter = new Date(now);
|
|
94
|
+
notAfter.setFullYear(notAfter.getFullYear() + 5);
|
|
95
|
+
const generated = await generate([{
|
|
96
|
+
name: "commonName",
|
|
97
|
+
value: "DeepSeek Harness Mobile CA"
|
|
98
|
+
}], {
|
|
99
|
+
keyType: "ec",
|
|
100
|
+
curve: "P-256",
|
|
101
|
+
algorithm: "sha256",
|
|
102
|
+
notBeforeDate: /* @__PURE__ */ new Date(now.getTime() - 3e5),
|
|
103
|
+
notAfterDate: notAfter,
|
|
104
|
+
extensions: [{
|
|
105
|
+
name: "basicConstraints",
|
|
106
|
+
cA: true,
|
|
107
|
+
critical: true
|
|
108
|
+
}, {
|
|
109
|
+
name: "keyUsage",
|
|
110
|
+
digitalSignature: true,
|
|
111
|
+
keyCertSign: true,
|
|
112
|
+
cRLSign: true,
|
|
113
|
+
critical: true
|
|
114
|
+
}]
|
|
115
|
+
});
|
|
116
|
+
certPem = generated.cert;
|
|
117
|
+
keyPem = generated.private;
|
|
118
|
+
}
|
|
119
|
+
if (certPem === void 0 || keyPem === void 0) throw new Error("managed TLS CA creation did not produce key material");
|
|
120
|
+
await Promise.all([atomicWrite(setup.caCertFile, certPem), atomicWrite(setup.caKeyFile, keyPem)]);
|
|
121
|
+
}
|
|
122
|
+
if (certPem === void 0 || keyPem === void 0) throw new Error("managed TLS CA creation did not produce key material");
|
|
123
|
+
return assertMatchingCa(certPem, keyPem);
|
|
124
|
+
}
|
|
125
|
+
/** Sign and atomically install a server leaf for the interface's current address. */
|
|
126
|
+
async function refreshManagedServerCertificate(setup, address) {
|
|
127
|
+
const [caCert, caKey] = await Promise.all([readFile(setup.tls.caCertFile, "utf8"), readFile(setup.tls.caKeyFile, "utf8")]);
|
|
128
|
+
assertMatchingCa(caCert, caKey);
|
|
129
|
+
const now = /* @__PURE__ */ new Date();
|
|
130
|
+
const notAfter = new Date(now);
|
|
131
|
+
notAfter.setDate(notAfter.getDate() + 397);
|
|
132
|
+
const server = await generate([{
|
|
133
|
+
name: "commonName",
|
|
134
|
+
value: "DeepSeek Harness Mobile"
|
|
135
|
+
}], {
|
|
136
|
+
keyType: "ec",
|
|
137
|
+
curve: "P-256",
|
|
138
|
+
algorithm: "sha256",
|
|
139
|
+
notBeforeDate: /* @__PURE__ */ new Date(now.getTime() - 3e5),
|
|
140
|
+
notAfterDate: notAfter,
|
|
141
|
+
ca: {
|
|
142
|
+
cert: caCert,
|
|
143
|
+
key: caKey
|
|
144
|
+
},
|
|
145
|
+
extensions: [
|
|
146
|
+
{
|
|
147
|
+
name: "basicConstraints",
|
|
148
|
+
cA: false,
|
|
149
|
+
critical: true
|
|
150
|
+
},
|
|
151
|
+
{
|
|
152
|
+
name: "keyUsage",
|
|
153
|
+
digitalSignature: true,
|
|
154
|
+
critical: true
|
|
155
|
+
},
|
|
156
|
+
{
|
|
157
|
+
name: "extKeyUsage",
|
|
158
|
+
serverAuth: true
|
|
159
|
+
},
|
|
160
|
+
{
|
|
161
|
+
name: "subjectAltName",
|
|
162
|
+
altNames: [{
|
|
163
|
+
type: 7,
|
|
164
|
+
ip: address
|
|
165
|
+
}]
|
|
166
|
+
}
|
|
167
|
+
]
|
|
168
|
+
});
|
|
169
|
+
await Promise.all([atomicWrite(setup.tls.certFile, server.cert), atomicWrite(setup.tls.keyFile, server.private)]);
|
|
28
170
|
}
|
|
171
|
+
//#endregion
|
|
172
|
+
//#region src/cli.ts
|
|
29
173
|
function parseOptions(args) {
|
|
30
174
|
let address;
|
|
31
175
|
let port = 3443;
|
|
@@ -52,30 +196,18 @@ function parseOptions(args) {
|
|
|
52
196
|
}
|
|
53
197
|
if (!Number.isSafeInteger(port) || port < 1024 || port > 65535) throw new Error("--port must be from 1024 through 65535");
|
|
54
198
|
if (!Number.isSafeInteger(dshPort) || dshPort < 1024 || dshPort > 65535) throw new Error("--dsh-port must be from 1024 through 65535");
|
|
55
|
-
if (address !== void 0 && !privateIpv4(address)) throw new Error("--address must be a private IPv4 address");
|
|
56
199
|
return {
|
|
57
200
|
...address === void 0 ? {} : { address },
|
|
58
201
|
port,
|
|
59
202
|
dshPort
|
|
60
203
|
};
|
|
61
204
|
}
|
|
62
|
-
function selectNetwork(requested) {
|
|
63
|
-
const candidates = availableAddresses();
|
|
64
|
-
if (requested !== void 0) {
|
|
65
|
-
const match = candidates.find((candidate) => candidate.address === requested);
|
|
66
|
-
if (match === void 0) throw new Error(`--address ${requested} is not an active private LAN address`);
|
|
67
|
-
return match;
|
|
68
|
-
}
|
|
69
|
-
if (candidates.length === 1) return candidates[0];
|
|
70
|
-
if (candidates.length === 0) throw new Error("no active private LAN address was found; connect to Wi-Fi or Ethernet");
|
|
71
|
-
throw new Error(`more than one LAN address is active; rerun with --address and one of: ${candidates.map((entry) => entry.address).join(", ")}`);
|
|
72
|
-
}
|
|
73
205
|
function dshHome() {
|
|
74
206
|
return resolve(process.env.DSH_HOME ?? join(homedir(), ".dsh"));
|
|
75
207
|
}
|
|
76
208
|
async function setup(args) {
|
|
77
209
|
const options = parseOptions(args);
|
|
78
|
-
const network =
|
|
210
|
+
const network = selectLanNetwork(options.address);
|
|
79
211
|
const home = dshHome();
|
|
80
212
|
const directory = join(home, "mobile-access");
|
|
81
213
|
const tls = join(directory, "tls");
|
|
@@ -83,53 +215,36 @@ async function setup(args) {
|
|
|
83
215
|
recursive: true,
|
|
84
216
|
mode: 448
|
|
85
217
|
});
|
|
86
|
-
const
|
|
87
|
-
const
|
|
88
|
-
|
|
89
|
-
const
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
}], {
|
|
93
|
-
keyType: "ec",
|
|
94
|
-
curve: "P-256",
|
|
95
|
-
algorithm: "sha256",
|
|
96
|
-
notBeforeDate: /* @__PURE__ */ new Date(now.getTime() - 3e5),
|
|
97
|
-
notAfterDate: notAfter,
|
|
98
|
-
extensions: [
|
|
99
|
-
{
|
|
100
|
-
name: "basicConstraints",
|
|
101
|
-
cA: true,
|
|
102
|
-
critical: true
|
|
103
|
-
},
|
|
104
|
-
{
|
|
105
|
-
name: "keyUsage",
|
|
106
|
-
digitalSignature: true,
|
|
107
|
-
keyCertSign: true,
|
|
108
|
-
cRLSign: true,
|
|
109
|
-
critical: true
|
|
110
|
-
},
|
|
111
|
-
{
|
|
112
|
-
name: "extKeyUsage",
|
|
113
|
-
serverAuth: true
|
|
114
|
-
},
|
|
115
|
-
{
|
|
116
|
-
name: "subjectAltName",
|
|
117
|
-
altNames: [{
|
|
118
|
-
type: 7,
|
|
119
|
-
ip: network.address
|
|
120
|
-
}]
|
|
121
|
-
}
|
|
122
|
-
]
|
|
123
|
-
});
|
|
124
|
-
const certFile = join(tls, "cert.pem");
|
|
125
|
-
const keyFile = join(tls, "key.pem");
|
|
218
|
+
const legacyCertFile = join(tls, "cert.pem");
|
|
219
|
+
const legacyKeyFile = join(tls, "key.pem");
|
|
220
|
+
const certFile = join(tls, "server-cert.pem");
|
|
221
|
+
const keyFile = join(tls, "server-key.pem");
|
|
222
|
+
const caCertFile = join(tls, "ca.pem");
|
|
223
|
+
const caKeyFile = join(tls, "ca-key.pem");
|
|
126
224
|
const androidCertificate = join(tls, "dsh-mobile-ca.cer");
|
|
225
|
+
const managedTls = {
|
|
226
|
+
mode: "managed",
|
|
227
|
+
caCertFile,
|
|
228
|
+
caKeyFile,
|
|
229
|
+
certFile,
|
|
230
|
+
keyFile
|
|
231
|
+
};
|
|
232
|
+
const ca = await ensureManagedCa(managedTls, {
|
|
233
|
+
certFile: legacyCertFile,
|
|
234
|
+
keyFile: legacyKeyFile
|
|
235
|
+
});
|
|
236
|
+
const managedSetup = {
|
|
237
|
+
version: 2,
|
|
238
|
+
networkInterface: network.name,
|
|
239
|
+
listenPort: options.port,
|
|
240
|
+
upstreamOrigin: `http://127.0.0.1:${String(options.dshPort)}`,
|
|
241
|
+
tls: managedTls
|
|
242
|
+
};
|
|
243
|
+
await refreshManagedServerCertificate(managedSetup, network.address);
|
|
244
|
+
await writeFile(androidCertificate, ca.raw, { mode: 384 });
|
|
127
245
|
await Promise.all([
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
writeFile(androidCertificate, new X509Certificate(certificate.cert).raw, { mode: 384 })
|
|
131
|
-
]);
|
|
132
|
-
await Promise.all([
|
|
246
|
+
chmod(caCertFile, 384),
|
|
247
|
+
chmod(caKeyFile, 384),
|
|
133
248
|
chmod(certFile, 384),
|
|
134
249
|
chmod(keyFile, 384),
|
|
135
250
|
chmod(androidCertificate, 384)
|
|
@@ -165,19 +280,11 @@ async function setup(args) {
|
|
|
165
280
|
}
|
|
166
281
|
const origin = `https://${network.address}:${String(options.port)}`;
|
|
167
282
|
await Promise.all([writeFile(join(directory, "setup.json"), `${JSON.stringify({
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
listenHost: network.address,
|
|
171
|
-
upstreamOrigin: `http://127.0.0.1:${String(options.dshPort)}`,
|
|
172
|
-
allowedCidrs: [network.cidr],
|
|
173
|
-
tls: {
|
|
174
|
-
mode: "provided",
|
|
175
|
-
certFile: certFile.replaceAll("\\", "/"),
|
|
176
|
-
keyFile: keyFile.replaceAll("\\", "/")
|
|
177
|
-
}
|
|
283
|
+
...managedSetup,
|
|
284
|
+
tls: Object.fromEntries(Object.entries(managedSetup.tls).map(([key, value]) => [key, typeof value === "string" ? value.replaceAll("\\", "/") : value]))
|
|
178
285
|
}, null, 2)}\n`, { mode: 384 }), writeFile(join(directory, "control.json"), "{\"version\":1,\"enabled\":true}\n", { mode: 384 })]);
|
|
179
|
-
console.log(`DSH Mobile is configured for ${origin}`);
|
|
180
|
-
console.log(`Install this certificate on Android
|
|
286
|
+
console.log(`DSH Mobile follows ${network.name} and is currently configured for ${origin}`);
|
|
287
|
+
console.log(`Install this CA certificate on Android once: ${androidCertificate}`);
|
|
181
288
|
console.log(`Ask DSH to customize the mobile Web UI and features in: ${customCss} and ${customScript}`);
|
|
182
289
|
console.log("Start DSH with: dsh --profile web");
|
|
183
290
|
console.log("Then open the Mobile card in the lower-left corner and create a pairing link.");
|
package/lib/client.js
CHANGED
|
@@ -92,9 +92,21 @@ window.__ModuleLoader__.load({
|
|
|
92
92
|
}
|
|
93
93
|
});
|
|
94
94
|
const body = await response.json();
|
|
95
|
-
if (!response.ok)
|
|
95
|
+
if (!response.ok) {
|
|
96
|
+
const code = typeof body.error === "string" ? body.error : `HTTP ${String(response.status)}`;
|
|
97
|
+
throw new Error(controlErrorMessage(code));
|
|
98
|
+
}
|
|
96
99
|
return body;
|
|
97
100
|
}
|
|
101
|
+
function controlErrorMessage(code) {
|
|
102
|
+
const zh = navigator.language.toLowerCase().startsWith("zh");
|
|
103
|
+
const message = {
|
|
104
|
+
network_address_changed: ["局域网地址已变化,请重启 DSH 后重试。", "The LAN address changed. Restart DSH and try again."],
|
|
105
|
+
network_interface_unavailable: ["保存的局域网网卡未连接,请连接网络后重启 DSH。", "The saved LAN interface is disconnected. Connect it and restart DSH."],
|
|
106
|
+
listen_port_in_use: ["移动访问端口已被占用。", "The mobile-access port is already in use."]
|
|
107
|
+
}[code];
|
|
108
|
+
return message === void 0 ? code : message[zh ? 0 : 1];
|
|
109
|
+
}
|
|
98
110
|
function mobileExtensionRequest(path, init = {}) {
|
|
99
111
|
const target = new URL(path, location.href);
|
|
100
112
|
if (target.origin !== location.origin) throw new TypeError("mobile extension requests must stay on the DSH origin");
|
package/lib/client.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"client.js","names":["createElement"],"sources":["../src/client.ts"],"sourcesContent":["import { createElement } from 'react'\n\ninterface ClientContext {\n effect(effect: () => void | (() => void), label?: string): void\n slots: {\n inject(key: string, callback: () => (() => void)): () => void\n register(options: { name: string; id: string }, component: (props: { wide: boolean }) => unknown): () => void\n }\n}\n\ninterface MobileExtensionContext {\n readonly document: Document\n readonly request: (path: string, init?: RequestInit) => Promise<Response>\n readonly root: HTMLElement\n readonly window: Window\n}\n\ntype MobileExtensionMount = (context: MobileExtensionContext) => void | (() => void)\n\ninterface MobileExtensionRegistry {\n register(mount: MobileExtensionMount): void\n}\n\ndeclare global {\n interface Window {\n dshMobile?: MobileExtensionRegistry\n }\n}\n\nconst MOBILE_QUERY = '(max-width: 720px)'\n\nconst BASE_STYLES = `\n@media ${MOBILE_QUERY} {\n :root {\n --dsh-mobile-accent: #2563eb;\n --dsh-mobile-font-scale: 1;\n --dsh-mobile-radius: 14px;\n }\n html { font-size: calc(16px * var(--dsh-mobile-font-scale)); }\n body { overscroll-behavior: none; }\n button, [role='button'], input, textarea, select { min-height: 44px; }\n input, textarea, select { font-size: 16px !important; }\n [data-sidebar-collapsed] {\n padding-top: env(safe-area-inset-top);\n padding-right: env(safe-area-inset-right);\n padding-bottom: env(safe-area-inset-bottom);\n padding-left: env(safe-area-inset-left);\n }\n [data-shell-overlay] { inset: env(safe-area-inset-top) env(safe-area-inset-right) env(safe-area-inset-bottom) env(safe-area-inset-left); }\n * { -webkit-tap-highlight-color: transparent; }\n}\n@media (prefers-reduced-motion: reduce) {\n *, *::before, *::after { scroll-behavior: auto !important; animation-duration: 0.01ms !important; transition-duration: 0.01ms !important; }\n}\n.dsh-mobile-control {\n position: fixed;\n left: 68px;\n bottom: 12px;\n z-index: 90;\n color: #172554;\n font: 14px/1.45 system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;\n}\n.dsh-mobile-control button {\n min-width: 44px;\n min-height: 44px;\n border: 1px solid #bfdbfe;\n border-radius: 12px;\n background: #fff;\n color: #1e3a8a;\n cursor: pointer;\n}\n.dsh-mobile-control__trigger {\n width: 100%;\n min-width: 44px;\n min-height: 44px;\n padding: 0 12px;\n border: 0;\n border-radius: 10px;\n background: transparent;\n color: inherit;\n font: inherit;\n font-weight: 700;\n cursor: pointer;\n}\n.dsh-mobile-control__trigger:hover { background: rgb(37 99 235 / 8%); }\n.dsh-mobile-control__panel {\n width: min(320px, calc(100vw - 24px));\n margin-bottom: 8px;\n padding: 16px;\n border: 1px solid #dbeafe;\n border-radius: 16px;\n background: #fff;\n box-shadow: 0 10px 30px rgb(15 23 42 / 12%);\n}\n.dsh-mobile-control__title { margin: 0 0 4px; font-size: 16px; font-weight: 800; }\n.dsh-mobile-control__status { margin: 0 0 14px; color: #475569; overflow-wrap: anywhere; }\n.dsh-mobile-control__actions { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; }\n.dsh-mobile-control__actions button[data-primary='true'] { background: #2563eb; border-color: #2563eb; color: #fff; }\n.dsh-mobile-control[hidden], .dsh-mobile-control__panel[hidden] { display: none; }\n`\n\nfunction isLoopbackHost(hostname: string): boolean {\n return hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '[::1]' || hostname === '::1'\n}\n\nfunction element<K extends keyof HTMLElementTagNameMap>(name: K, className?: string): HTMLElementTagNameMap[K] {\n const node = document.createElement(name)\n if (className !== undefined) node.className = className\n return node\n}\n\nasync function requestJson(path: string, init?: RequestInit): Promise<Record<string, unknown>> {\n const response = await fetch(path, {\n ...init,\n headers: { 'content-type': 'application/json', ...init?.headers },\n })\n const body = await response.json() as Record<string, unknown>\n if (!response.ok) throw new Error(typeof body.error === 'string' ? body.error : `HTTP ${String(response.status)}`)\n return body\n}\n\nfunction mobileExtensionRequest(path: string, init: RequestInit = {}): Promise<Response> {\n const target = new URL(path, location.href)\n if (target.origin !== location.origin) throw new TypeError('mobile extension requests must stay on the DSH origin')\n const headers = new Headers(init.headers)\n const method = (init.method ?? 'GET').toUpperCase()\n if (method !== 'GET' && method !== 'HEAD' && !headers.has('x-dsh-mobile-csrf')) {\n const csrf = document.cookie.split(';').map(entry => entry.trim())\n .find(entry => entry.startsWith('dsh_ma_csrf='))?.slice('dsh_ma_csrf='.length)\n if (csrf !== undefined) headers.set('x-dsh-mobile-csrf', csrf)\n }\n return fetch(target, {\n ...init,\n cache: 'no-store',\n credentials: 'same-origin',\n headers,\n redirect: 'error',\n })\n}\n\nfunction installControl(): { remove: () => void; toggle: () => void } {\n const zh = navigator.language.toLowerCase().startsWith('zh')\n const root = element('div', 'dsh-mobile-control')\n const panel = element('section', 'dsh-mobile-control__panel')\n panel.hidden = true\n const title = element('h2', 'dsh-mobile-control__title')\n title.textContent = zh ? '移动访问' : 'Mobile access'\n const status = element('p', 'dsh-mobile-control__status')\n status.textContent = zh ? '正在读取状态…' : 'Reading status…'\n const actions = element('div', 'dsh-mobile-control__actions')\n const toggle = element('button')\n toggle.dataset.primary = 'true'\n const pair = element('button')\n pair.textContent = zh ? '生成配对链接' : 'Create pairing link'\n actions.append(toggle, pair)\n panel.append(title, status, actions)\n root.append(panel)\n document.body.append(root)\n\n let running = false\n const render = (data: Record<string, unknown>): void => {\n running = data.running === true\n const origin = typeof data.origin === 'string' ? data.origin : undefined\n status.textContent = running\n ? (zh ? `已开启:${origin ?? ''}` : `Running: ${origin ?? ''}`)\n : (zh ? '已关闭。DSH 仍只在本机可用。' : 'Stopped. DSH remains local-only.')\n toggle.textContent = running ? (zh ? '关闭' : 'Stop') : (zh ? '开启' : 'Start')\n pair.disabled = !running\n }\n const refresh = async (): Promise<void> => {\n try { render(await requestJson('/api/mobile-access/control')) }\n catch (error) { status.textContent = error instanceof Error ? error.message : String(error) }\n }\n toggle.addEventListener('click', () => {\n toggle.disabled = true\n void requestJson('/api/mobile-access/control', {\n method: 'POST',\n body: JSON.stringify({ running: !running }),\n }).then(render, (error: unknown) => {\n status.textContent = error instanceof Error ? error.message : String(error)\n }).finally(() => { toggle.disabled = false })\n })\n pair.addEventListener('click', () => {\n pair.disabled = true\n void requestJson('/api/mobile-access/pairing/open', {\n method: 'POST',\n body: '{}',\n }).then(async (data) => {\n const url = typeof data.pairUrl === 'string' ? data.pairUrl : ''\n if (url !== '' && navigator.clipboard !== undefined) await navigator.clipboard.writeText(url)\n status.textContent = url === '' ? (zh ? '无法生成链接' : 'Could not create link')\n : (zh ? `链接已复制:${url}` : `Copied: ${url}`)\n }, (error: unknown) => {\n status.textContent = error instanceof Error ? error.message : String(error)\n }).finally(() => { pair.disabled = !running })\n })\n void refresh()\n return {\n remove: () => { root.remove() },\n toggle: () => { panel.hidden = !panel.hidden },\n }\n}\n\nfunction installCustomExtension(): () => void {\n let activeRoot: HTMLElement | undefined\n let activeDispose: (() => void) | undefined\n let observedSource = ''\n let loading = false\n let pendingMount: MobileExtensionMount | undefined\n const previousRegistry = window.dshMobile\n const registry: MobileExtensionRegistry = Object.freeze({\n register: (mount: MobileExtensionMount): void => {\n if (typeof mount !== 'function') throw new TypeError('dshMobile.register requires a mount function')\n pendingMount = mount\n },\n })\n const registeredMount = (): MobileExtensionMount | undefined => pendingMount\n window.dshMobile = registry\n\n const refresh = async (): Promise<void> => {\n if (loading || document.visibilityState === 'hidden') return\n loading = true\n try {\n const response = await fetch('/mobile-access/custom.js', {\n cache: 'no-store',\n credentials: 'same-origin',\n redirect: 'error',\n })\n if (!response.ok || !response.headers.get('content-type')?.startsWith('text/javascript')) return\n const source = await response.text()\n if (source === observedSource) return\n observedSource = source\n pendingMount = undefined\n const script = document.createElement('script')\n script.textContent = `${source}\\n//# sourceURL=dsh-mobile-custom.js`\n document.head.append(script)\n script.remove()\n const mount = registeredMount()\n if (mount === undefined) return\n\n const nextRoot = document.createElement('div')\n nextRoot.dataset.dshMobileExtension = 'true'\n document.body.append(nextRoot)\n try {\n const nextDispose = mount(Object.freeze({\n document,\n request: mobileExtensionRequest,\n root: nextRoot,\n window,\n }))\n if (nextDispose !== undefined && typeof nextDispose !== 'function') {\n throw new TypeError('the mobile extension mount function must return a disposer or undefined')\n }\n activeDispose?.()\n activeRoot?.remove()\n activeRoot = nextRoot\n activeDispose = typeof nextDispose === 'function' ? nextDispose : undefined\n } catch (error) {\n nextRoot.remove()\n console.error('DSH Mobile custom extension failed to mount', error)\n }\n } catch {\n // A transient LAN disconnect keeps the last successfully mounted extension.\n } finally {\n loading = false\n }\n }\n void refresh()\n const timer = setInterval(() => { void refresh() }, 1_000)\n return () => {\n clearInterval(timer)\n activeDispose?.()\n activeRoot?.remove()\n if (previousRegistry === undefined) delete window.dshMobile\n else window.dshMobile = previousRegistry\n }\n}\n\n/** Install the responsive mobile layer and the loopback-only control card. */\nexport function apply(ctx: ClientContext): void {\n ctx.effect(() => {\n const style = document.createElement('style')\n style.dataset.plugin = 'dsh-mobile'\n style.textContent = BASE_STYLES\n document.head.append(style)\n\n const loopback = isLoopbackHost(location.hostname)\n const custom = loopback ? undefined : document.createElement('style')\n let customRefresh: ReturnType<typeof setInterval> | undefined\n let customLoading = false\n if (custom !== undefined) {\n custom.dataset.plugin = 'dsh-mobile'\n document.head.append(custom)\n const refreshCustomCss = async (): Promise<void> => {\n if (customLoading || document.visibilityState === 'hidden') return\n customLoading = true\n try {\n const response = await fetch('/mobile-access/custom.css', {\n cache: 'no-store',\n credentials: 'same-origin',\n redirect: 'error',\n })\n if (!response.ok || !response.headers.get('content-type')?.startsWith('text/css')) return\n const css = await response.text()\n if (custom.textContent !== css) custom.textContent = css\n } catch {\n // A transient LAN disconnect keeps the last successfully applied stylesheet.\n } finally {\n customLoading = false\n }\n }\n void refreshCustomCss()\n customRefresh = setInterval(() => { void refreshCustomCss() }, 1_000)\n }\n const removeCustomExtension = loopback ? undefined : installCustomExtension()\n const control = loopback ? installControl() : undefined\n const disposeSlot = control === undefined ? undefined : ctx.slots.inject('sidebar.footer.action', () => ctx.slots.register({\n name: 'sidebar.footer.action',\n id: 'dsh-mobile',\n }, ({ wide }) => createElement('button', {\n className: 'dsh-mobile-control__trigger',\n type: 'button',\n title: controlLabel(),\n onClick: control.toggle,\n }, wide ? controlLabel() : 'M')))\n return () => {\n if (customRefresh !== undefined) clearInterval(customRefresh)\n disposeSlot?.()\n control?.remove()\n removeCustomExtension?.()\n custom?.remove()\n style.remove()\n }\n }, 'dsh-mobile: responsive UI and local control')\n}\n\n/** Client face has no service prerequisites. */\nexport const inject: readonly string[] = ['slots']\n\nfunction controlLabel(): string {\n return navigator.language.toLowerCase().startsWith('zh') ? '移动端' : 'Mobile access'\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"client.js","names":["createElement"],"sources":["../src/client.ts"],"sourcesContent":["import { createElement } from 'react'\n\ninterface ClientContext {\n effect(effect: () => void | (() => void), label?: string): void\n slots: {\n inject(key: string, callback: () => (() => void)): () => void\n register(options: { name: string; id: string }, component: (props: { wide: boolean }) => unknown): () => void\n }\n}\n\ninterface MobileExtensionContext {\n readonly document: Document\n readonly request: (path: string, init?: RequestInit) => Promise<Response>\n readonly root: HTMLElement\n readonly window: Window\n}\n\ntype MobileExtensionMount = (context: MobileExtensionContext) => void | (() => void)\n\ninterface MobileExtensionRegistry {\n register(mount: MobileExtensionMount): void\n}\n\ndeclare global {\n interface Window {\n dshMobile?: MobileExtensionRegistry\n }\n}\n\nconst MOBILE_QUERY = '(max-width: 720px)'\n\nconst BASE_STYLES = `\n@media ${MOBILE_QUERY} {\n :root {\n --dsh-mobile-accent: #2563eb;\n --dsh-mobile-font-scale: 1;\n --dsh-mobile-radius: 14px;\n }\n html { font-size: calc(16px * var(--dsh-mobile-font-scale)); }\n body { overscroll-behavior: none; }\n button, [role='button'], input, textarea, select { min-height: 44px; }\n input, textarea, select { font-size: 16px !important; }\n [data-sidebar-collapsed] {\n padding-top: env(safe-area-inset-top);\n padding-right: env(safe-area-inset-right);\n padding-bottom: env(safe-area-inset-bottom);\n padding-left: env(safe-area-inset-left);\n }\n [data-shell-overlay] { inset: env(safe-area-inset-top) env(safe-area-inset-right) env(safe-area-inset-bottom) env(safe-area-inset-left); }\n * { -webkit-tap-highlight-color: transparent; }\n}\n@media (prefers-reduced-motion: reduce) {\n *, *::before, *::after { scroll-behavior: auto !important; animation-duration: 0.01ms !important; transition-duration: 0.01ms !important; }\n}\n.dsh-mobile-control {\n position: fixed;\n left: 68px;\n bottom: 12px;\n z-index: 90;\n color: #172554;\n font: 14px/1.45 system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;\n}\n.dsh-mobile-control button {\n min-width: 44px;\n min-height: 44px;\n border: 1px solid #bfdbfe;\n border-radius: 12px;\n background: #fff;\n color: #1e3a8a;\n cursor: pointer;\n}\n.dsh-mobile-control__trigger {\n width: 100%;\n min-width: 44px;\n min-height: 44px;\n padding: 0 12px;\n border: 0;\n border-radius: 10px;\n background: transparent;\n color: inherit;\n font: inherit;\n font-weight: 700;\n cursor: pointer;\n}\n.dsh-mobile-control__trigger:hover { background: rgb(37 99 235 / 8%); }\n.dsh-mobile-control__panel {\n width: min(320px, calc(100vw - 24px));\n margin-bottom: 8px;\n padding: 16px;\n border: 1px solid #dbeafe;\n border-radius: 16px;\n background: #fff;\n box-shadow: 0 10px 30px rgb(15 23 42 / 12%);\n}\n.dsh-mobile-control__title { margin: 0 0 4px; font-size: 16px; font-weight: 800; }\n.dsh-mobile-control__status { margin: 0 0 14px; color: #475569; overflow-wrap: anywhere; }\n.dsh-mobile-control__actions { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; }\n.dsh-mobile-control__actions button[data-primary='true'] { background: #2563eb; border-color: #2563eb; color: #fff; }\n.dsh-mobile-control[hidden], .dsh-mobile-control__panel[hidden] { display: none; }\n`\n\nfunction isLoopbackHost(hostname: string): boolean {\n return hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '[::1]' || hostname === '::1'\n}\n\nfunction element<K extends keyof HTMLElementTagNameMap>(name: K, className?: string): HTMLElementTagNameMap[K] {\n const node = document.createElement(name)\n if (className !== undefined) node.className = className\n return node\n}\n\nasync function requestJson(path: string, init?: RequestInit): Promise<Record<string, unknown>> {\n const response = await fetch(path, {\n ...init,\n headers: { 'content-type': 'application/json', ...init?.headers },\n })\n const body = await response.json() as Record<string, unknown>\n if (!response.ok) {\n const code = typeof body.error === 'string' ? body.error : `HTTP ${String(response.status)}`\n throw new Error(controlErrorMessage(code))\n }\n return body\n}\n\nfunction controlErrorMessage(code: string): string {\n const zh = navigator.language.toLowerCase().startsWith('zh')\n const messages: Record<string, readonly [string, string]> = {\n network_address_changed: ['局域网地址已变化,请重启 DSH 后重试。', 'The LAN address changed. Restart DSH and try again.'],\n network_interface_unavailable: ['保存的局域网网卡未连接,请连接网络后重启 DSH。', 'The saved LAN interface is disconnected. Connect it and restart DSH.'],\n listen_port_in_use: ['移动访问端口已被占用。', 'The mobile-access port is already in use.'],\n }\n const message = messages[code]\n return message === undefined ? code : message[zh ? 0 : 1]\n}\n\nfunction mobileExtensionRequest(path: string, init: RequestInit = {}): Promise<Response> {\n const target = new URL(path, location.href)\n if (target.origin !== location.origin) throw new TypeError('mobile extension requests must stay on the DSH origin')\n const headers = new Headers(init.headers)\n const method = (init.method ?? 'GET').toUpperCase()\n if (method !== 'GET' && method !== 'HEAD' && !headers.has('x-dsh-mobile-csrf')) {\n const csrf = document.cookie.split(';').map(entry => entry.trim())\n .find(entry => entry.startsWith('dsh_ma_csrf='))?.slice('dsh_ma_csrf='.length)\n if (csrf !== undefined) headers.set('x-dsh-mobile-csrf', csrf)\n }\n return fetch(target, {\n ...init,\n cache: 'no-store',\n credentials: 'same-origin',\n headers,\n redirect: 'error',\n })\n}\n\nfunction installControl(): { remove: () => void; toggle: () => void } {\n const zh = navigator.language.toLowerCase().startsWith('zh')\n const root = element('div', 'dsh-mobile-control')\n const panel = element('section', 'dsh-mobile-control__panel')\n panel.hidden = true\n const title = element('h2', 'dsh-mobile-control__title')\n title.textContent = zh ? '移动访问' : 'Mobile access'\n const status = element('p', 'dsh-mobile-control__status')\n status.textContent = zh ? '正在读取状态…' : 'Reading status…'\n const actions = element('div', 'dsh-mobile-control__actions')\n const toggle = element('button')\n toggle.dataset.primary = 'true'\n const pair = element('button')\n pair.textContent = zh ? '生成配对链接' : 'Create pairing link'\n actions.append(toggle, pair)\n panel.append(title, status, actions)\n root.append(panel)\n document.body.append(root)\n\n let running = false\n const render = (data: Record<string, unknown>): void => {\n running = data.running === true\n const origin = typeof data.origin === 'string' ? data.origin : undefined\n status.textContent = running\n ? (zh ? `已开启:${origin ?? ''}` : `Running: ${origin ?? ''}`)\n : (zh ? '已关闭。DSH 仍只在本机可用。' : 'Stopped. DSH remains local-only.')\n toggle.textContent = running ? (zh ? '关闭' : 'Stop') : (zh ? '开启' : 'Start')\n pair.disabled = !running\n }\n const refresh = async (): Promise<void> => {\n try { render(await requestJson('/api/mobile-access/control')) }\n catch (error) { status.textContent = error instanceof Error ? error.message : String(error) }\n }\n toggle.addEventListener('click', () => {\n toggle.disabled = true\n void requestJson('/api/mobile-access/control', {\n method: 'POST',\n body: JSON.stringify({ running: !running }),\n }).then(render, (error: unknown) => {\n status.textContent = error instanceof Error ? error.message : String(error)\n }).finally(() => { toggle.disabled = false })\n })\n pair.addEventListener('click', () => {\n pair.disabled = true\n void requestJson('/api/mobile-access/pairing/open', {\n method: 'POST',\n body: '{}',\n }).then(async (data) => {\n const url = typeof data.pairUrl === 'string' ? data.pairUrl : ''\n if (url !== '' && navigator.clipboard !== undefined) await navigator.clipboard.writeText(url)\n status.textContent = url === '' ? (zh ? '无法生成链接' : 'Could not create link')\n : (zh ? `链接已复制:${url}` : `Copied: ${url}`)\n }, (error: unknown) => {\n status.textContent = error instanceof Error ? error.message : String(error)\n }).finally(() => { pair.disabled = !running })\n })\n void refresh()\n return {\n remove: () => { root.remove() },\n toggle: () => { panel.hidden = !panel.hidden },\n }\n}\n\nfunction installCustomExtension(): () => void {\n let activeRoot: HTMLElement | undefined\n let activeDispose: (() => void) | undefined\n let observedSource = ''\n let loading = false\n let pendingMount: MobileExtensionMount | undefined\n const previousRegistry = window.dshMobile\n const registry: MobileExtensionRegistry = Object.freeze({\n register: (mount: MobileExtensionMount): void => {\n if (typeof mount !== 'function') throw new TypeError('dshMobile.register requires a mount function')\n pendingMount = mount\n },\n })\n const registeredMount = (): MobileExtensionMount | undefined => pendingMount\n window.dshMobile = registry\n\n const refresh = async (): Promise<void> => {\n if (loading || document.visibilityState === 'hidden') return\n loading = true\n try {\n const response = await fetch('/mobile-access/custom.js', {\n cache: 'no-store',\n credentials: 'same-origin',\n redirect: 'error',\n })\n if (!response.ok || !response.headers.get('content-type')?.startsWith('text/javascript')) return\n const source = await response.text()\n if (source === observedSource) return\n observedSource = source\n pendingMount = undefined\n const script = document.createElement('script')\n script.textContent = `${source}\\n//# sourceURL=dsh-mobile-custom.js`\n document.head.append(script)\n script.remove()\n const mount = registeredMount()\n if (mount === undefined) return\n\n const nextRoot = document.createElement('div')\n nextRoot.dataset.dshMobileExtension = 'true'\n document.body.append(nextRoot)\n try {\n const nextDispose = mount(Object.freeze({\n document,\n request: mobileExtensionRequest,\n root: nextRoot,\n window,\n }))\n if (nextDispose !== undefined && typeof nextDispose !== 'function') {\n throw new TypeError('the mobile extension mount function must return a disposer or undefined')\n }\n activeDispose?.()\n activeRoot?.remove()\n activeRoot = nextRoot\n activeDispose = typeof nextDispose === 'function' ? nextDispose : undefined\n } catch (error) {\n nextRoot.remove()\n console.error('DSH Mobile custom extension failed to mount', error)\n }\n } catch {\n // A transient LAN disconnect keeps the last successfully mounted extension.\n } finally {\n loading = false\n }\n }\n void refresh()\n const timer = setInterval(() => { void refresh() }, 1_000)\n return () => {\n clearInterval(timer)\n activeDispose?.()\n activeRoot?.remove()\n if (previousRegistry === undefined) delete window.dshMobile\n else window.dshMobile = previousRegistry\n }\n}\n\n/** Install the responsive mobile layer and the loopback-only control card. */\nexport function apply(ctx: ClientContext): void {\n ctx.effect(() => {\n const style = document.createElement('style')\n style.dataset.plugin = 'dsh-mobile'\n style.textContent = BASE_STYLES\n document.head.append(style)\n\n const loopback = isLoopbackHost(location.hostname)\n const custom = loopback ? undefined : document.createElement('style')\n let customRefresh: ReturnType<typeof setInterval> | undefined\n let customLoading = false\n if (custom !== undefined) {\n custom.dataset.plugin = 'dsh-mobile'\n document.head.append(custom)\n const refreshCustomCss = async (): Promise<void> => {\n if (customLoading || document.visibilityState === 'hidden') return\n customLoading = true\n try {\n const response = await fetch('/mobile-access/custom.css', {\n cache: 'no-store',\n credentials: 'same-origin',\n redirect: 'error',\n })\n if (!response.ok || !response.headers.get('content-type')?.startsWith('text/css')) return\n const css = await response.text()\n if (custom.textContent !== css) custom.textContent = css\n } catch {\n // A transient LAN disconnect keeps the last successfully applied stylesheet.\n } finally {\n customLoading = false\n }\n }\n void refreshCustomCss()\n customRefresh = setInterval(() => { void refreshCustomCss() }, 1_000)\n }\n const removeCustomExtension = loopback ? undefined : installCustomExtension()\n const control = loopback ? installControl() : undefined\n const disposeSlot = control === undefined ? undefined : ctx.slots.inject('sidebar.footer.action', () => ctx.slots.register({\n name: 'sidebar.footer.action',\n id: 'dsh-mobile',\n }, ({ wide }) => createElement('button', {\n className: 'dsh-mobile-control__trigger',\n type: 'button',\n title: controlLabel(),\n onClick: control.toggle,\n }, wide ? controlLabel() : 'M')))\n return () => {\n if (customRefresh !== undefined) clearInterval(customRefresh)\n disposeSlot?.()\n control?.remove()\n removeCustomExtension?.()\n custom?.remove()\n style.remove()\n }\n }, 'dsh-mobile: responsive UI and local control')\n}\n\n/** Client face has no service prerequisites. */\nexport const inject: readonly string[] = ['slots']\n\nfunction controlLabel(): string {\n return navigator.language.toLowerCase().startsWith('zh') ? '移动端' : 'Mobile access'\n}\n"],"mappings":"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"}
|
package/lib/index.mjs
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { X509Certificate, createHash, randomBytes, timingSafeEqual } from "node:crypto";
|
|
1
|
+
import { X509Certificate, createHash, createPrivateKey, createPublicKey, randomBytes, timingSafeEqual } from "node:crypto";
|
|
2
2
|
import { basename, dirname, isAbsolute, join, resolve } from "node:path";
|
|
3
3
|
import z from "@deepseek-ai/schemastery";
|
|
4
4
|
import { connect, isIP } from "node:net";
|
|
@@ -7,6 +7,8 @@ import { createServer, request } from "node:http";
|
|
|
7
7
|
import { createServer as createServer$1 } from "node:https";
|
|
8
8
|
import { Transform } from "node:stream";
|
|
9
9
|
import { pipeline } from "node:stream/promises";
|
|
10
|
+
import { networkInterfaces } from "node:os";
|
|
11
|
+
import { generate } from "selfsigned";
|
|
10
12
|
//#region src/access.ts
|
|
11
13
|
/** Stable error categories converted to deliberately terse HTTP responses. */
|
|
12
14
|
var AccessError = class extends Error {
|
|
@@ -2016,13 +2018,188 @@ var MemoryDeviceStore = class {
|
|
|
2016
2018
|
}
|
|
2017
2019
|
};
|
|
2018
2020
|
//#endregion
|
|
2021
|
+
//#region src/managed-setup.ts
|
|
2022
|
+
function requiredString(value, name) {
|
|
2023
|
+
if (typeof value !== "string" || value.length === 0) throw new Error(`${name} must be a non-empty string`);
|
|
2024
|
+
return value;
|
|
2025
|
+
}
|
|
2026
|
+
/** Validate the durable managed setup before it controls network and filesystem operations. */
|
|
2027
|
+
function parseManagedSetup(value) {
|
|
2028
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) throw new Error("mobile setup file must be an object");
|
|
2029
|
+
const record = value;
|
|
2030
|
+
if (record.version !== 2 || Reflect.ownKeys(record).some((key) => typeof key !== "string" || ![
|
|
2031
|
+
"version",
|
|
2032
|
+
"networkInterface",
|
|
2033
|
+
"listenPort",
|
|
2034
|
+
"upstreamOrigin",
|
|
2035
|
+
"tls"
|
|
2036
|
+
].includes(key))) throw new Error("mobile setup file has an unsupported format");
|
|
2037
|
+
if (!Number.isSafeInteger(record.listenPort) || record.listenPort < 1024 || record.listenPort > 65535) throw new Error("mobile setup listenPort must be from 1024 through 65535");
|
|
2038
|
+
if (typeof record.tls !== "object" || record.tls === null || Array.isArray(record.tls)) throw new Error("mobile setup tls must be an object");
|
|
2039
|
+
const tls = record.tls;
|
|
2040
|
+
if (tls.mode !== "managed" || Reflect.ownKeys(tls).some((key) => typeof key !== "string" || ![
|
|
2041
|
+
"mode",
|
|
2042
|
+
"caCertFile",
|
|
2043
|
+
"caKeyFile",
|
|
2044
|
+
"certFile",
|
|
2045
|
+
"keyFile"
|
|
2046
|
+
].includes(key))) throw new Error("mobile setup tls has an unsupported format");
|
|
2047
|
+
return Object.freeze({
|
|
2048
|
+
version: 2,
|
|
2049
|
+
networkInterface: requiredString(record.networkInterface, "mobile setup networkInterface"),
|
|
2050
|
+
listenPort: record.listenPort,
|
|
2051
|
+
upstreamOrigin: requiredString(record.upstreamOrigin, "mobile setup upstreamOrigin"),
|
|
2052
|
+
tls: Object.freeze({
|
|
2053
|
+
mode: "managed",
|
|
2054
|
+
caCertFile: requiredString(tls.caCertFile, "mobile setup tls.caCertFile"),
|
|
2055
|
+
caKeyFile: requiredString(tls.caKeyFile, "mobile setup tls.caKeyFile"),
|
|
2056
|
+
certFile: requiredString(tls.certFile, "mobile setup tls.certFile"),
|
|
2057
|
+
keyFile: requiredString(tls.keyFile, "mobile setup tls.keyFile")
|
|
2058
|
+
})
|
|
2059
|
+
});
|
|
2060
|
+
}
|
|
2061
|
+
function privateIpv4(value) {
|
|
2062
|
+
const parts = value.split(".").map(Number);
|
|
2063
|
+
return parts.length === 4 && parts.every((part) => Number.isInteger(part) && part >= 0 && part <= 255) && (parts[0] === 10 || parts[0] === 172 && parts[1] >= 16 && parts[1] <= 31 || parts[0] === 192 && parts[1] === 168);
|
|
2064
|
+
}
|
|
2065
|
+
function networkCidr(address, cidr) {
|
|
2066
|
+
const prefix = Number(cidr.slice(cidr.lastIndexOf("/") + 1));
|
|
2067
|
+
const network = (address.split(".").reduce((total, part) => (total << 8 | Number(part)) >>> 0, 0) & (prefix === 0 ? 0 : 4294967295 << 32 - prefix >>> 0)) >>> 0;
|
|
2068
|
+
return `${[
|
|
2069
|
+
24,
|
|
2070
|
+
16,
|
|
2071
|
+
8,
|
|
2072
|
+
0
|
|
2073
|
+
].map((shift) => network >>> shift & 255).join(".")}/${String(prefix)}`;
|
|
2074
|
+
}
|
|
2075
|
+
/** List current private IPv4 candidates with their interface identity. */
|
|
2076
|
+
function availableLanNetworks(table = networkInterfaces()) {
|
|
2077
|
+
const candidates = Object.entries(table).flatMap(([name, entries]) => (entries ?? []).filter((entry) => entry.family === "IPv4" && !entry.internal && privateIpv4(entry.address) && entry.cidr !== null).map((entry) => ({
|
|
2078
|
+
name,
|
|
2079
|
+
address: entry.address,
|
|
2080
|
+
cidr: networkCidr(entry.address, entry.cidr)
|
|
2081
|
+
})));
|
|
2082
|
+
return [...new Map(candidates.map((entry) => [`${entry.name}\0${entry.address}`, entry])).values()];
|
|
2083
|
+
}
|
|
2084
|
+
/** Select an active LAN, optionally by address or by a previously saved interface name. */
|
|
2085
|
+
function selectLanNetwork(requestedAddress, requestedInterface, table) {
|
|
2086
|
+
const candidates = availableLanNetworks(table);
|
|
2087
|
+
if (requestedAddress !== void 0) {
|
|
2088
|
+
const match = candidates.find((candidate) => candidate.address === requestedAddress);
|
|
2089
|
+
if (match === void 0) throw new Error(`--address ${requestedAddress} is not an active private LAN address`);
|
|
2090
|
+
return match;
|
|
2091
|
+
}
|
|
2092
|
+
if (requestedInterface !== void 0) {
|
|
2093
|
+
const matches = candidates.filter((candidate) => candidate.name === requestedInterface);
|
|
2094
|
+
if (matches.length === 1) return matches[0];
|
|
2095
|
+
if (matches.length === 0) throw new Error(`saved LAN interface ${JSON.stringify(requestedInterface)} is not connected`);
|
|
2096
|
+
throw new Error(`saved LAN interface ${JSON.stringify(requestedInterface)} has more than one private IPv4 address`);
|
|
2097
|
+
}
|
|
2098
|
+
if (candidates.length === 1) return candidates[0];
|
|
2099
|
+
if (candidates.length === 0) throw new Error("no active private LAN address was found; connect to Wi-Fi or Ethernet");
|
|
2100
|
+
throw new Error(`more than one LAN address is active; rerun with --address and one of: ${candidates.map((entry) => entry.address).join(", ")}`);
|
|
2101
|
+
}
|
|
2102
|
+
function assertMatchingCa(certPem, keyPem) {
|
|
2103
|
+
const certificate = new X509Certificate(certPem);
|
|
2104
|
+
if (!certificate.ca || certificate.subject !== certificate.issuer || !certificate.verify(certificate.publicKey)) throw new Error("managed TLS CA must be a self-signed CA certificate");
|
|
2105
|
+
const privatePublic = createPublicKey(createPrivateKey(keyPem)).export({
|
|
2106
|
+
format: "der",
|
|
2107
|
+
type: "spki"
|
|
2108
|
+
});
|
|
2109
|
+
const certificatePublic = certificate.publicKey.export({
|
|
2110
|
+
format: "der",
|
|
2111
|
+
type: "spki"
|
|
2112
|
+
});
|
|
2113
|
+
if (!privatePublic.equals(certificatePublic)) throw new Error("managed TLS CA certificate and key do not match");
|
|
2114
|
+
if (Date.parse(certificate.validFrom) > Date.now() || Date.parse(certificate.validTo) <= Date.now()) throw new Error("managed TLS CA certificate is not currently valid");
|
|
2115
|
+
return certificate;
|
|
2116
|
+
}
|
|
2117
|
+
async function atomicWrite(file, contents) {
|
|
2118
|
+
const directory = dirname(file);
|
|
2119
|
+
await mkdir(directory, {
|
|
2120
|
+
recursive: true,
|
|
2121
|
+
mode: 448
|
|
2122
|
+
});
|
|
2123
|
+
const temporary = join(directory, `.${basename(file)}.${process.pid}.tmp`);
|
|
2124
|
+
await writeFile(temporary, contents, { mode: 384 });
|
|
2125
|
+
await chmod(temporary, 384);
|
|
2126
|
+
await rename(temporary, file);
|
|
2127
|
+
}
|
|
2128
|
+
/** Sign and atomically install a server leaf for the interface's current address. */
|
|
2129
|
+
async function refreshManagedServerCertificate(setup, address) {
|
|
2130
|
+
const [caCert, caKey] = await Promise.all([readFile(setup.tls.caCertFile, "utf8"), readFile(setup.tls.caKeyFile, "utf8")]);
|
|
2131
|
+
assertMatchingCa(caCert, caKey);
|
|
2132
|
+
const now = /* @__PURE__ */ new Date();
|
|
2133
|
+
const notAfter = new Date(now);
|
|
2134
|
+
notAfter.setDate(notAfter.getDate() + 397);
|
|
2135
|
+
const server = await generate([{
|
|
2136
|
+
name: "commonName",
|
|
2137
|
+
value: "DeepSeek Harness Mobile"
|
|
2138
|
+
}], {
|
|
2139
|
+
keyType: "ec",
|
|
2140
|
+
curve: "P-256",
|
|
2141
|
+
algorithm: "sha256",
|
|
2142
|
+
notBeforeDate: /* @__PURE__ */ new Date(now.getTime() - 3e5),
|
|
2143
|
+
notAfterDate: notAfter,
|
|
2144
|
+
ca: {
|
|
2145
|
+
cert: caCert,
|
|
2146
|
+
key: caKey
|
|
2147
|
+
},
|
|
2148
|
+
extensions: [
|
|
2149
|
+
{
|
|
2150
|
+
name: "basicConstraints",
|
|
2151
|
+
cA: false,
|
|
2152
|
+
critical: true
|
|
2153
|
+
},
|
|
2154
|
+
{
|
|
2155
|
+
name: "keyUsage",
|
|
2156
|
+
digitalSignature: true,
|
|
2157
|
+
critical: true
|
|
2158
|
+
},
|
|
2159
|
+
{
|
|
2160
|
+
name: "extKeyUsage",
|
|
2161
|
+
serverAuth: true
|
|
2162
|
+
},
|
|
2163
|
+
{
|
|
2164
|
+
name: "subjectAltName",
|
|
2165
|
+
altNames: [{
|
|
2166
|
+
type: 7,
|
|
2167
|
+
ip: address
|
|
2168
|
+
}]
|
|
2169
|
+
}
|
|
2170
|
+
]
|
|
2171
|
+
});
|
|
2172
|
+
await Promise.all([atomicWrite(setup.tls.certFile, server.cert), atomicWrite(setup.tls.keyFile, server.private)]);
|
|
2173
|
+
}
|
|
2174
|
+
/** Resolve the saved interface to the ordinary gateway config consumed by the Host plugin. */
|
|
2175
|
+
async function materializeManagedSetup(setup, table) {
|
|
2176
|
+
const network = selectLanNetwork(void 0, setup.networkInterface, table);
|
|
2177
|
+
await refreshManagedServerCertificate(setup, network.address);
|
|
2178
|
+
return {
|
|
2179
|
+
publicOrigin: `https://${network.address}:${String(setup.listenPort)}`,
|
|
2180
|
+
listenHost: network.address,
|
|
2181
|
+
upstreamOrigin: setup.upstreamOrigin,
|
|
2182
|
+
allowedCidrs: [network.cidr],
|
|
2183
|
+
tls: {
|
|
2184
|
+
mode: "provided",
|
|
2185
|
+
certFile: setup.tls.certFile,
|
|
2186
|
+
keyFile: setup.tls.keyFile
|
|
2187
|
+
}
|
|
2188
|
+
};
|
|
2189
|
+
}
|
|
2190
|
+
//#endregion
|
|
2019
2191
|
//#region src/plugin.ts
|
|
2020
2192
|
/** Stable Cordis plugin name. */
|
|
2021
2193
|
const name = "dsh-mobile";
|
|
2022
2194
|
/** The stock WebServer is the only DSH Host service this plugin requires. */
|
|
2023
2195
|
const inject = ["webServer"];
|
|
2024
2196
|
function mapAdminError(error) {
|
|
2025
|
-
|
|
2197
|
+
if (error instanceof HttpError) return error;
|
|
2198
|
+
const code = error.code;
|
|
2199
|
+
if (code === "EADDRNOTAVAIL") return new HttpError(409, "network_address_changed");
|
|
2200
|
+
if (code === "EADDRINUSE") return new HttpError(409, "listen_port_in_use");
|
|
2201
|
+
if (error instanceof Error && error.message.startsWith("saved LAN interface ")) return new HttpError(409, "network_interface_unavailable");
|
|
2202
|
+
return new HttpError(500, "internal_error");
|
|
2026
2203
|
}
|
|
2027
2204
|
const SETUP_KEYS = /* @__PURE__ */ new Set([
|
|
2028
2205
|
"version",
|
|
@@ -2052,6 +2229,15 @@ async function loadSetup(config) {
|
|
|
2052
2229
|
}
|
|
2053
2230
|
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) throw new Error("mobile setup file must be an object");
|
|
2054
2231
|
const record = parsed;
|
|
2232
|
+
if (record.version === 2) {
|
|
2233
|
+
const setup = await materializeManagedSetup(parseManagedSetup(record));
|
|
2234
|
+
const merged = { ...config };
|
|
2235
|
+
for (const key of SETUP_KEYS) if (key !== "version") delete merged[key];
|
|
2236
|
+
return {
|
|
2237
|
+
...merged,
|
|
2238
|
+
...setup
|
|
2239
|
+
};
|
|
2240
|
+
}
|
|
2055
2241
|
if (record.version !== 1 || Reflect.ownKeys(record).some((key) => typeof key !== "string" || !SETUP_KEYS.has(key))) throw new Error("mobile setup file has an unsupported format");
|
|
2056
2242
|
const { version: _version, ...setup } = record;
|
|
2057
2243
|
const merged = { ...config };
|
package/package.json
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-mobile",
|
|
3
|
-
"version": "0.1.0-alpha.
|
|
3
|
+
"version": "0.1.0-alpha.3",
|
|
4
4
|
"private": false,
|
|
5
5
|
"description": "Authenticated Android and mobile-browser access for the stock DeepSeek Harness Web UI",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"bin": {
|
|
8
|
-
"dsh-mobile": "lib/cli.js"
|
|
8
|
+
"dsh-mobile": "lib/cli.js"
|
|
9
9
|
},
|
|
10
10
|
"main": "./lib/index.mjs",
|
|
11
11
|
"types": "./lib/index.d.mts",
|
|
@@ -20,8 +20,8 @@
|
|
|
20
20
|
"./package.json": "./package.json"
|
|
21
21
|
},
|
|
22
22
|
"files": [
|
|
23
|
-
"lib/**/*.mjs",
|
|
24
|
-
"lib/cli.js",
|
|
23
|
+
"lib/**/*.mjs",
|
|
24
|
+
"lib/cli.js",
|
|
25
25
|
"lib/**/*.d.mts",
|
|
26
26
|
"lib/client.js",
|
|
27
27
|
"lib/client.js.map",
|