dsh-mobile 0.1.0-alpha.2 → 0.1.0-alpha.4

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 CHANGED
@@ -1,5 +1,5 @@
1
1
  <p align="center">
2
- <img src="https://unpkg.com/dsh-mobile@0.1.0-alpha.2/assets/brand/repository-hero.png" alt="A lively whale girl using a phone beside a desktop running DSH" width="100%">
2
+ <img src="https://unpkg.com/dsh-mobile@latest/assets/brand/repository-hero.png" alt="A lively whale girl using a phone beside a desktop running DSH" width="100%">
3
3
  </p>
4
4
 
5
5
  <h1 align="center">DSH Mobile</h1>
@@ -61,14 +61,14 @@ 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 selects the LAN address, creates the local TLS certificate, and stores plugin data under `$DSH_HOME/mobile-access/`; run `dsh-mobile setup --help` through the same `plugin exec` command for manual network options.
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
- 3. Start access and create a pairing link. The link is copied to the clipboard.
69
- 4. Paste the link into the Android app, or open it directly in a mobile browser.
68
+ 3. Start access and select **Create pairing key**. The key is copied to the clipboard; the same card also shows a complete browser link.
69
+ 4. Open the Android app; it finds DSH on the LAN automatically. Paste the key and connect. Alternatively, open the browser link directly on the phone.
70
70
 
71
- Pairing links expire after two minutes and work once. The app and each browser have separate cookie stores, so pair them separately.
71
+ The key and browser link expire after two minutes and work once. After pairing, Android stores a revocable device credential encrypted by Android Keystore. The app can rediscover the same DSH installation on the default port after its LAN address changes and renew its short Web session without another key. Browser profiles still pair separately.
72
72
 
73
73
  ## Everyday control
74
74
 
@@ -79,6 +79,8 @@ The two access modes are equivalent:
79
79
  - Android app: a thin system WebView shell without browser address and tab bars.
80
80
  - Mobile browser: open the setup origin directly; no app installation is required.
81
81
 
82
+ Android discovery scans only the current Wi-Fi `/24` on the default HTTPS port `3443`. It returns a stable, non-secret installation identifier; credentials never appear in discovery responses. A custom port or unusual subnet remains available through manual address entry.
83
+
82
84
  ## Customize the mobile Web UI
83
85
 
84
86
  This is a same-origin Web extension surface, not a fixed theme editor. DSH can edit two files on the computer:
@@ -162,7 +164,7 @@ flowchart LR
162
164
  ## Security model
163
165
 
164
166
  - 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 must cover the LAN address in use.
167
+ - 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
168
  - Pairing creates an HttpOnly session; devices and active sessions can be revoked from the computer.
167
169
  - Control and device-management routes are loopback-only.
168
170
  - 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
@@ -1,5 +1,5 @@
1
1
  <p align="center">
2
- <img src="https://unpkg.com/dsh-mobile@0.1.0-alpha.2/assets/brand/repository-hero.png" alt="鲸鱼娘拿着手机连接桌面上的 DSH" width="100%">
2
+ <img src="https://unpkg.com/dsh-mobile@latest/assets/brand/repository-hero.png" alt="鲸鱼娘拿着手机连接桌面上的 DSH" width="100%">
3
3
  </p>
4
4
 
5
5
  <h1 align="center">DSH Mobile</h1>
@@ -61,14 +61,14 @@ pnpm dsh plugin --profile web exec dsh-mobile setup
61
61
  pnpm dsh --profile web
62
62
  ```
63
63
 
64
- 两种方式都会把公开 npm 包安装到原生 DSH,不修改 DSH 源码。设置向导会选择局域网地址、生成本地 TLS 证书,并把插件数据保存到 `$DSH_HOME/mobile-access/`;需要手动指定网卡或端口时,通过同一个 `plugin exec` 命令运行 `dsh-mobile setup --help`。
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,并在系统设置中安装为用户 CA 证书。
66
+ 1. 将向导输出的 `dsh-mobile-ca.cer` 复制到 Android,并在系统设置中一次性安装为用户 CA 证书。
67
67
  2. 在电脑 DSH 左下角打开“移动端”卡片。
68
- 3. 确认服务已开启,点击“生成配对链接”。链接会复制到剪贴板。
69
- 4. Android App 中粘贴链接,或直接在手机浏览器中打开链接。
68
+ 3. 确认服务已开启,点击“生成配对密钥”。密钥会复制到剪贴板,同一卡片也会显示完整的浏览器链接。
69
+ 4. 打开 Android App,它会自动在局域网中找到 DSH。粘贴密钥并连接;也可直接在手机浏览器打开卡片中的链接。
70
70
 
71
- 配对链接两分钟内有效且只能使用一次。App 与浏览器拥有各自的 Cookie 存储,因此需要分别配对。
71
+ 密钥和浏览器链接两分钟内有效且只能使用一次。配对后,Android 会用 Android Keystore 加密保存可随时撤销的设备凭据。局域网 IP 变化后,App 能在默认端口重新找到同一个 DSH,并自动续期短期 Web 会话,不需要新密钥。浏览器仍需单独配对。
72
72
 
73
73
  ## 日常使用
74
74
 
@@ -79,6 +79,8 @@ pnpm dsh --profile web
79
79
  - Android App:使用系统 WebView 打开同一 DSH 页面,没有浏览器地址栏和标签栏。
80
80
  - 手机浏览器:直接访问向导显示的 `https://局域网地址:端口`,无需安装 App。
81
81
 
82
+ Android 自动发现只扫描当前 Wi-Fi 的 `/24` 网段和默认 HTTPS 端口 `3443`。发现响应只包含稳定的非机密安装标识,不包含任何凭据。使用自定义端口或特殊子网时,仍可手动输入地址。
83
+
82
84
  ## 自定义移动端 Web UI
83
85
 
84
86
  这不是一个只能换颜色的主题编辑器,而是与 DSH 页面同源的 Web 扩展面。电脑上的 DSH 可以编辑两个文件:
@@ -167,7 +169,7 @@ flowchart LR
167
169
  ## 安全模型
168
170
 
169
171
  - 网关只接受设置向导选定的私有局域网 CIDR、精确 Host 和同源浏览器请求。
170
- - Android App 不跳过 TLS 校验;证书必须覆盖实际访问的局域网 IP。
172
+ - Android App 不跳过 TLS 校验;稳定的本地 CA 会在每次 DSH 启动时为所选网卡的当前局域网地址签发服务器证书。
171
173
  - 配对设备获得短期 HttpOnly Session,设备和会话都可在电脑端撤销。
172
174
  - 管理接口只允许从电脑回环地址访问,移动端无法打开、关闭网关或管理其他设备。
173
175
  - 外层网关在认证后才代理到原生 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 { X509Certificate } from "node:crypto";
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/cli.ts
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
- function availableAddresses() {
23
- const candidates = Object.values(networkInterfaces()).flatMap((entries) => entries ?? []).filter((entry) => entry.family === "IPv4" && !entry.internal && privateIpv4(entry.address) && entry.cidr !== null).map((entry) => ({
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, entry])).values()];
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 = selectNetwork(options.address);
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 now = /* @__PURE__ */ new Date();
87
- const notAfter = new Date(now);
88
- notAfter.setDate(notAfter.getDate() + 397);
89
- const certificate = await generate([{
90
- name: "commonName",
91
- value: network.address
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
- writeFile(certFile, certificate.cert, { mode: 384 }),
129
- writeFile(keyFile, certificate.private, { mode: 384 }),
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
- version: 1,
169
- publicOrigin: origin,
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 before connecting: ${androidCertificate}`);
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) throw new Error(typeof body.error === "string" ? body.error : `HTTP ${String(response.status)}`);
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");
@@ -125,7 +137,7 @@ window.__ModuleLoader__.load({
125
137
  const toggle = element("button");
126
138
  toggle.dataset.primary = "true";
127
139
  const pair = element("button");
128
- pair.textContent = zh ? "生成配对链接" : "Create pairing link";
140
+ pair.textContent = zh ? "生成配对密钥" : "Create pairing key";
129
141
  actions.append(toggle, pair);
130
142
  panel.append(title, status, actions);
131
143
  root.append(panel);
@@ -163,8 +175,10 @@ window.__ModuleLoader__.load({
163
175
  body: "{}"
164
176
  }).then(async (data) => {
165
177
  const url = typeof data.pairUrl === "string" ? data.pairUrl : "";
166
- if (url !== "" && navigator.clipboard !== void 0) await navigator.clipboard.writeText(url);
167
- status.textContent = url === "" ? zh ? "无法生成链接" : "Could not create link" : zh ? `链接已复制:${url}` : `Copied: ${url}`;
178
+ const token = typeof data.token === "string" ? data.token : "";
179
+ const copied = token !== "" ? token : url;
180
+ if (copied !== "" && navigator.clipboard !== void 0) await navigator.clipboard.writeText(copied);
181
+ status.textContent = copied === "" ? zh ? "无法生成密钥" : "Could not create key" : zh ? `配对密钥已复制:${copied}${url === "" ? "" : `\n手机浏览器链接:${url}`}` : `Pairing key copied: ${copied}${url === "" ? "" : `\nMobile browser link: ${url}`}`;
168
182
  }, (error) => {
169
183
  status.textContent = error instanceof Error ? error.message : String(error);
170
184
  }).finally(() => {
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 key'\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 const token = typeof data.token === 'string' ? data.token : ''\n const copied = token !== '' ? token : url\n if (copied !== '' && navigator.clipboard !== undefined) await navigator.clipboard.writeText(copied)\n status.textContent = copied === '' ? (zh ? '无法生成密钥' : 'Could not create key')\n : (zh\n ? `配对密钥已复制:${copied}${url === '' ? '' : `\\n手机浏览器链接:${url}`}`\n : `Pairing key copied: ${copied}${url === '' ? '' : `\\nMobile browser link: ${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":";;;;;;;;EA+BA,MAAM,cAAc;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;EAsEpB,SAAS,eAAe,UAA2B;GACjD,OAAO,aAAa,eAAe,aAAa,eAAe,aAAa,WAAW,aAAa;EACtG;EAEA,SAAS,QAA+C,MAAS,WAA8C;GAC7G,MAAM,OAAO,SAAS,cAAc,IAAI;GACxC,IAAI,cAAc,KAAA,GAAW,KAAK,YAAY;GAC9C,OAAO;EACT;EAEA,eAAe,YAAY,MAAc,MAAsD;GAC7F,MAAM,WAAW,MAAM,MAAM,MAAM;IACjC,GAAG;IACH,SAAS;KAAE,gBAAgB;KAAoB,GAAG,MAAM;IAAQ;GAClE,CAAC;GACD,MAAM,OAAO,MAAM,SAAS,KAAK;GACjC,IAAI,CAAC,SAAS,IAAI;IAChB,MAAM,OAAO,OAAO,KAAK,UAAU,WAAW,KAAK,QAAQ,QAAQ,OAAO,SAAS,MAAM;IACzF,MAAM,IAAI,MAAM,oBAAoB,IAAI,CAAC;GAC3C;GACA,OAAO;EACT;EAEA,SAAS,oBAAoB,MAAsB;GACjD,MAAM,KAAK,UAAU,SAAS,YAAY,CAAC,CAAC,WAAW,IAAI;GAM3D,MAAM,UAAU;IAJd,yBAAyB,CAAC,yBAAyB,qDAAqD;IACxG,+BAA+B,CAAC,6BAA6B,sEAAsE;IACnI,oBAAoB,CAAC,eAAe,2CAA2C;GAE1D,EAAE;GACzB,OAAO,YAAY,KAAA,IAAY,OAAO,QAAQ,KAAK,IAAI;EACzD;EAEA,SAAS,uBAAuB,MAAc,OAAoB,CAAC,GAAsB;GACvF,MAAM,SAAS,IAAI,IAAI,MAAM,SAAS,IAAI;GAC1C,IAAI,OAAO,WAAW,SAAS,QAAQ,MAAM,IAAI,UAAU,uDAAuD;GAClH,MAAM,UAAU,IAAI,QAAQ,KAAK,OAAO;GACxC,MAAM,UAAU,KAAK,UAAU,MAAA,CAAO,YAAY;GAClD,IAAI,WAAW,SAAS,WAAW,UAAU,CAAC,QAAQ,IAAI,mBAAmB,GAAG;IAC9E,MAAM,OAAO,SAAS,OAAO,MAAM,GAAG,CAAC,CAAC,KAAI,UAAS,MAAM,KAAK,CAAC,CAAC,CAC/D,MAAK,UAAS,MAAM,WAAW,cAAc,CAAC,CAAC,EAAE,MAAM,EAAqB;IAC/E,IAAI,SAAS,KAAA,GAAW,QAAQ,IAAI,qBAAqB,IAAI;GAC/D;GACA,OAAO,MAAM,QAAQ;IACnB,GAAG;IACH,OAAO;IACP,aAAa;IACb;IACA,UAAU;GACZ,CAAC;EACH;EAEA,SAAS,iBAA6D;GACpE,MAAM,KAAK,UAAU,SAAS,YAAY,CAAC,CAAC,WAAW,IAAI;GAC3D,MAAM,OAAO,QAAQ,OAAO,oBAAoB;GAChD,MAAM,QAAQ,QAAQ,WAAW,2BAA2B;GAC5D,MAAM,SAAS;GACf,MAAM,QAAQ,QAAQ,MAAM,2BAA2B;GACvD,MAAM,cAAc,KAAK,SAAS;GAClC,MAAM,SAAS,QAAQ,KAAK,4BAA4B;GACxD,OAAO,cAAc,KAAK,YAAY;GACtC,MAAM,UAAU,QAAQ,OAAO,6BAA6B;GAC5D,MAAM,SAAS,QAAQ,QAAQ;GAC/B,OAAO,QAAQ,UAAU;GACzB,MAAM,OAAO,QAAQ,QAAQ;GAC7B,KAAK,cAAc,KAAK,WAAW;GACnC,QAAQ,OAAO,QAAQ,IAAI;GAC3B,MAAM,OAAO,OAAO,QAAQ,OAAO;GACnC,KAAK,OAAO,KAAK;GACjB,SAAS,KAAK,OAAO,IAAI;GAEzB,IAAI,UAAU;GACd,MAAM,UAAU,SAAwC;IACtD,UAAU,KAAK,YAAY;IAC3B,MAAM,SAAS,OAAO,KAAK,WAAW,WAAW,KAAK,SAAS,KAAA;IAC/D,OAAO,cAAc,UAChB,KAAK,OAAO,UAAU,OAAO,YAAY,UAAU,OACnD,KAAK,qBAAqB;IAC/B,OAAO,cAAc,UAAW,KAAK,OAAO,SAAW,KAAK,OAAO;IACnE,KAAK,WAAW,CAAC;GACnB;GACA,MAAM,UAAU,YAA2B;IACzC,IAAI;KAAE,OAAO,MAAM,YAAY,4BAA4B,CAAC;IAAE,SACvD,OAAO;KAAE,OAAO,cAAc,iBAAiB,QAAQ,MAAM,UAAU,OAAO,KAAK;IAAE;GAC9F;GACA,OAAO,iBAAiB,eAAe;IACrC,OAAO,WAAW;IAClB,YAAiB,8BAA8B;KAC7C,QAAQ;KACR,MAAM,KAAK,UAAU,EAAE,SAAS,CAAC,QAAQ,CAAC;IAC5C,CAAC,CAAC,CAAC,KAAK,SAAS,UAAmB;KAClC,OAAO,cAAc,iBAAiB,QAAQ,MAAM,UAAU,OAAO,KAAK;IAC5E,CAAC,CAAC,CAAC,cAAc;KAAE,OAAO,WAAW;IAAM,CAAC;GAC9C,CAAC;GACD,KAAK,iBAAiB,eAAe;IACnC,KAAK,WAAW;IAChB,YAAiB,mCAAmC;KAClD,QAAQ;KACR,MAAM;IACR,CAAC,CAAC,CAAC,KAAK,OAAO,SAAS;KACtB,MAAM,MAAM,OAAO,KAAK,YAAY,WAAW,KAAK,UAAU;KAC9D,MAAM,QAAQ,OAAO,KAAK,UAAU,WAAW,KAAK,QAAQ;KAC5D,MAAM,SAAS,UAAU,KAAK,QAAQ;KACtC,IAAI,WAAW,MAAM,UAAU,cAAc,KAAA,GAAW,MAAM,UAAU,UAAU,UAAU,MAAM;KAClG,OAAO,cAAc,WAAW,KAAM,KAAK,WAAW,yBACjD,KACG,WAAW,SAAS,QAAQ,KAAK,KAAK,aAAa,UACnD,uBAAuB,SAAS,QAAQ,KAAK,KAAK,0BAA0B;IACtF,IAAI,UAAmB;KACrB,OAAO,cAAc,iBAAiB,QAAQ,MAAM,UAAU,OAAO,KAAK;IAC5E,CAAC,CAAC,CAAC,cAAc;KAAE,KAAK,WAAW,CAAC;IAAQ,CAAC;GAC/C,CAAC;GACD,QAAa;GACb,OAAO;IACL,cAAc;KAAE,KAAK,OAAO;IAAE;IAC9B,cAAc;KAAE,MAAM,SAAS,CAAC,MAAM;IAAO;GAC/C;EACF;EAEA,SAAS,yBAAqC;GAC5C,IAAI;GACJ,IAAI;GACJ,IAAI,iBAAiB;GACrB,IAAI,UAAU;GACd,IAAI;GACJ,MAAM,mBAAmB,OAAO;GAChC,MAAM,WAAoC,OAAO,OAAO,EACtD,WAAW,UAAsC;IAC/C,IAAI,OAAO,UAAU,YAAY,MAAM,IAAI,UAAU,8CAA8C;IACnG,eAAe;GACjB,EACF,CAAC;GACD,MAAM,wBAA0D;GAChE,OAAO,YAAY;GAEnB,MAAM,UAAU,YAA2B;IACzC,IAAI,WAAW,SAAS,oBAAoB,UAAU;IACtD,UAAU;IACV,IAAI;KACF,MAAM,WAAW,MAAM,MAAM,4BAA4B;MACvD,OAAO;MACP,aAAa;MACb,UAAU;KACZ,CAAC;KACD,IAAI,CAAC,SAAS,MAAM,CAAC,SAAS,QAAQ,IAAI,cAAc,CAAC,EAAE,WAAW,iBAAiB,GAAG;KAC1F,MAAM,SAAS,MAAM,SAAS,KAAK;KACnC,IAAI,WAAW,gBAAgB;KAC/B,iBAAiB;KACjB,eAAe,KAAA;KACf,MAAM,SAAS,SAAS,cAAc,QAAQ;KAC9C,OAAO,cAAc,GAAG,OAAO;KAC/B,SAAS,KAAK,OAAO,MAAM;KAC3B,OAAO,OAAO;KACd,MAAM,QAAQ,gBAAgB;KAC9B,IAAI,UAAU,KAAA,GAAW;KAEzB,MAAM,WAAW,SAAS,cAAc,KAAK;KAC7C,SAAS,QAAQ,qBAAqB;KACtC,SAAS,KAAK,OAAO,QAAQ;KAC7B,IAAI;MACF,MAAM,cAAc,MAAM,OAAO,OAAO;OACtC;OACA,SAAS;OACT,MAAM;OACN;MACF,CAAC,CAAC;MACF,IAAI,gBAAgB,KAAA,KAAa,OAAO,gBAAgB,YACtD,MAAM,IAAI,UAAU,yEAAyE;MAE/F,gBAAgB;MAChB,YAAY,OAAO;MACnB,aAAa;MACb,gBAAgB,OAAO,gBAAgB,aAAa,cAAc,KAAA;KACpE,SAAS,OAAO;MACd,SAAS,OAAO;MAChB,QAAQ,MAAM,+CAA+C,KAAK;KACpE;IACF,QAAQ,CAER,UAAU;KACR,UAAU;IACZ;GACF;GACA,QAAa;GACb,MAAM,QAAQ,kBAAkB;IAAE,QAAa;GAAE,GAAG,GAAK;GACzD,aAAa;IACX,cAAc,KAAK;IACnB,gBAAgB;IAChB,YAAY,OAAO;IACnB,IAAI,qBAAqB,KAAA,GAAW,OAAO,OAAO;SAC7C,OAAO,YAAY;GAC1B;EACF;;EAGA,SAAgB,MAAM,KAA0B;GAC9C,IAAI,aAAa;IACf,MAAM,QAAQ,SAAS,cAAc,OAAO;IAC5C,MAAM,QAAQ,SAAS;IACvB,MAAM,cAAc;IACpB,SAAS,KAAK,OAAO,KAAK;IAE1B,MAAM,WAAW,eAAe,SAAS,QAAQ;IACjD,MAAM,SAAS,WAAW,KAAA,IAAY,SAAS,cAAc,OAAO;IACpE,IAAI;IACJ,IAAI,gBAAgB;IACpB,IAAI,WAAW,KAAA,GAAW;KACxB,OAAO,QAAQ,SAAS;KACxB,SAAS,KAAK,OAAO,MAAM;KAC3B,MAAM,mBAAmB,YAA2B;MAClD,IAAI,iBAAiB,SAAS,oBAAoB,UAAU;MAC5D,gBAAgB;MAChB,IAAI;OACF,MAAM,WAAW,MAAM,MAAM,6BAA6B;QACxD,OAAO;QACP,aAAa;QACb,UAAU;OACZ,CAAC;OACD,IAAI,CAAC,SAAS,MAAM,CAAC,SAAS,QAAQ,IAAI,cAAc,CAAC,EAAE,WAAW,UAAU,GAAG;OACnF,MAAM,MAAM,MAAM,SAAS,KAAK;OAChC,IAAI,OAAO,gBAAgB,KAAK,OAAO,cAAc;MACvD,QAAQ,CAER,UAAU;OACR,gBAAgB;MAClB;KACF;KACA,iBAAsB;KACtB,gBAAgB,kBAAkB;MAAE,iBAAsB;KAAE,GAAG,GAAK;IACtE;IACA,MAAM,wBAAwB,WAAW,KAAA,IAAY,uBAAuB;IAC5E,MAAM,UAAU,WAAW,eAAe,IAAI,KAAA;IAC9C,MAAM,cAAc,YAAY,KAAA,IAAY,KAAA,IAAY,IAAI,MAAM,OAAO,+BAA+B,IAAI,MAAM,SAAS;KACzH,MAAM;KACN,IAAI;IACN,IAAI,EAAE,YAAA,GAAWA,MAAAA,cAAAA,CAAc,UAAU;KACvC,WAAW;KACX,MAAM;KACN,OAAO,aAAa;KACpB,SAAS,QAAQ;IACnB,GAAG,OAAO,aAAa,IAAI,GAAG,CAAC,CAAC;IAChC,aAAa;KACX,IAAI,kBAAkB,KAAA,GAAW,cAAc,aAAa;KAC5D,cAAc;KACd,SAAS,OAAO;KAChB,wBAAwB;KACxB,QAAQ,OAAO;KACf,MAAM,OAAO;IACf;GACF,GAAG,6CAA6C;EAClD;;EAGA,MAAa,SAA4B,CAAC,OAAO;EAEjD,SAAS,eAAuB;GAC9B,OAAO,UAAU,SAAS,YAAY,CAAC,CAAC,WAAW,IAAI,IAAI,QAAQ;EACrE"}
package/lib/index.d.mts CHANGED
@@ -239,6 +239,8 @@ interface PluginConfig {
239
239
  customCssFile?: string;
240
240
  /** Optional user script that mounts authenticated mobile-only Web features. */
241
241
  customScriptFile?: string;
242
+ /** Stable public discovery identifier; it is not an authentication secret. */
243
+ instanceId?: string;
242
244
  /** First-run state used only while the control file does not exist. */
243
245
  initiallyEnabled: boolean;
244
246
  tls?: {
@@ -271,6 +273,7 @@ interface ResolvedGatewayConfig {
271
273
  readonly stateFile: string;
272
274
  readonly customCssFile: string;
273
275
  readonly customScriptFile: string;
276
+ readonly instanceId: string;
274
277
  readonly tls: TlsConfig;
275
278
  readonly pairingTtlMs: number;
276
279
  readonly deviceTtlMs: number;
@@ -375,6 +378,8 @@ declare class MobileAccessGateway {
375
378
  private setSessionCookies;
376
379
  private handlePair;
377
380
  private handleRenew;
381
+ private handleNativePair;
382
+ private handleNativeRenew;
378
383
  private handleLogout;
379
384
  private handleExternalRequest;
380
385
  private allocateRequest;
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 {
@@ -541,6 +543,7 @@ const Config = z.object({
541
543
  controlFile: z.string().hidden().required(),
542
544
  customCssFile: z.string().hidden(),
543
545
  customScriptFile: z.string().hidden(),
546
+ instanceId: z.string().hidden(),
544
547
  initiallyEnabled: z.boolean().hidden().required(),
545
548
  tls: z.object({
546
549
  mode: z.union([z.const("provided"), z.const("disabled")]),
@@ -662,6 +665,9 @@ function parseGatewayConfig(raw) {
662
665
  stateFile: absoluteFile(value.stateFile, "stateFile"),
663
666
  customCssFile: value.customCssFile === void 0 ? join(dirname(absoluteFile(value.stateFile, "stateFile")), "mobile.css") : absoluteFile(value.customCssFile, "customCssFile"),
664
667
  customScriptFile: value.customScriptFile === void 0 ? join(dirname(absoluteFile(value.stateFile, "stateFile")), "mobile.js") : absoluteFile(value.customScriptFile, "customScriptFile"),
668
+ instanceId: value.instanceId === void 0 ? createHash("sha256").update(absoluteFile(value.stateFile, "stateFile")).digest("hex") : /^[a-f\d]{64}$/u.test(value.instanceId) ? value.instanceId : (() => {
669
+ throw new Error("instanceId must be a lowercase SHA-256 value");
670
+ })(),
665
671
  tls,
666
672
  pairingTtlMs: integer(value.pairingTtlMs, "pairingTtlMs", 12e4, 1e4, 6e5),
667
673
  deviceTtlMs,
@@ -1467,6 +1473,33 @@ var MobileAccessGateway = class {
1467
1473
  sessionExpiresAt: result.sessionExpiresAt
1468
1474
  }, this.tlsEnabled);
1469
1475
  }
1476
+ async handleNativePair(request, response) {
1477
+ const body = await readJsonObject(request, MAX_CONTROL_BODY_BYTES);
1478
+ if (typeof body.token !== "string" || body.label !== void 0 && typeof body.label !== "string") throw new HttpError(400, "bad_request");
1479
+ const result = await this.access.pair(request.socket.remoteAddress ?? "unknown", body.token, body.label);
1480
+ sendJson(response, 201, {
1481
+ instanceId: this.config.instanceId,
1482
+ deviceId: result.deviceId,
1483
+ deviceToken: result.deviceToken,
1484
+ deviceExpiresAt: result.deviceExpiresAt,
1485
+ sessionToken: result.sessionToken,
1486
+ csrfToken: result.csrfToken,
1487
+ sessionExpiresAt: result.sessionExpiresAt
1488
+ }, this.tlsEnabled);
1489
+ }
1490
+ async handleNativeRenew(request, response) {
1491
+ if (!this.renewLimiter.take(request.socket.remoteAddress ?? "unknown", Date.now())) throw new HttpError(429, "rate_limited");
1492
+ const body = await readJsonObject(request, MAX_CONTROL_BODY_BYTES);
1493
+ if (typeof body.deviceToken !== "string") throw new HttpError(400, "bad_request");
1494
+ const result = await this.access.renew(body.deviceToken);
1495
+ sendJson(response, 200, {
1496
+ instanceId: this.config.instanceId,
1497
+ deviceId: result.deviceId,
1498
+ sessionToken: result.sessionToken,
1499
+ csrfToken: result.csrfToken,
1500
+ sessionExpiresAt: result.sessionExpiresAt
1501
+ }, this.tlsEnabled);
1502
+ }
1470
1503
  async handleLogout(request, response) {
1471
1504
  await readJsonObject(request, MAX_CONTROL_BODY_BYTES);
1472
1505
  const authorization = this.authorize(request);
@@ -1494,6 +1527,14 @@ var MobileAccessGateway = class {
1494
1527
  sendJson(response, 200, { ok: true }, this.tlsEnabled);
1495
1528
  return;
1496
1529
  }
1530
+ if (target.search === "" && request.method === "GET" && target.decodedPathname === `/mobile-access/discovery`) {
1531
+ sendJson(response, 200, {
1532
+ protocol: 1,
1533
+ instanceId: this.config.instanceId,
1534
+ port: this.address().port
1535
+ }, this.tlsEnabled);
1536
+ return;
1537
+ }
1497
1538
  if (target.search === "" && request.method === "GET" && (target.decodedPathname === `/mobile-access/pair` || target.decodedPathname === `/mobile-access/pair.js`)) {
1498
1539
  if (!this.access.pairingStatus().open) throw new HttpError(404, "not_found");
1499
1540
  setSecurityHeaders(response, this.tlsEnabled);
@@ -1524,6 +1565,14 @@ var MobileAccessGateway = class {
1524
1565
  await this.handleRenew(request, response);
1525
1566
  return;
1526
1567
  }
1568
+ if (target.search === "" && request.method === "POST" && target.decodedPathname === `/mobile-access/auth/native-pair`) {
1569
+ await this.handleNativePair(request, response);
1570
+ return;
1571
+ }
1572
+ if (target.search === "" && request.method === "POST" && target.decodedPathname === `/mobile-access/auth/native-renew`) {
1573
+ await this.handleNativeRenew(request, response);
1574
+ return;
1575
+ }
1527
1576
  if (target.search === "" && request.method === "POST" && target.decodedPathname === `/mobile-access/auth/logout`) {
1528
1577
  await this.handleLogout(request, response);
1529
1578
  return;
@@ -2016,13 +2065,190 @@ var MemoryDeviceStore = class {
2016
2065
  }
2017
2066
  };
2018
2067
  //#endregion
2068
+ //#region src/managed-setup.ts
2069
+ function requiredString(value, name) {
2070
+ if (typeof value !== "string" || value.length === 0) throw new Error(`${name} must be a non-empty string`);
2071
+ return value;
2072
+ }
2073
+ /** Validate the durable managed setup before it controls network and filesystem operations. */
2074
+ function parseManagedSetup(value) {
2075
+ if (typeof value !== "object" || value === null || Array.isArray(value)) throw new Error("mobile setup file must be an object");
2076
+ const record = value;
2077
+ if (record.version !== 2 || Reflect.ownKeys(record).some((key) => typeof key !== "string" || ![
2078
+ "version",
2079
+ "networkInterface",
2080
+ "listenPort",
2081
+ "upstreamOrigin",
2082
+ "tls"
2083
+ ].includes(key))) throw new Error("mobile setup file has an unsupported format");
2084
+ if (!Number.isSafeInteger(record.listenPort) || record.listenPort < 1024 || record.listenPort > 65535) throw new Error("mobile setup listenPort must be from 1024 through 65535");
2085
+ if (typeof record.tls !== "object" || record.tls === null || Array.isArray(record.tls)) throw new Error("mobile setup tls must be an object");
2086
+ const tls = record.tls;
2087
+ if (tls.mode !== "managed" || Reflect.ownKeys(tls).some((key) => typeof key !== "string" || ![
2088
+ "mode",
2089
+ "caCertFile",
2090
+ "caKeyFile",
2091
+ "certFile",
2092
+ "keyFile"
2093
+ ].includes(key))) throw new Error("mobile setup tls has an unsupported format");
2094
+ return Object.freeze({
2095
+ version: 2,
2096
+ networkInterface: requiredString(record.networkInterface, "mobile setup networkInterface"),
2097
+ listenPort: record.listenPort,
2098
+ upstreamOrigin: requiredString(record.upstreamOrigin, "mobile setup upstreamOrigin"),
2099
+ tls: Object.freeze({
2100
+ mode: "managed",
2101
+ caCertFile: requiredString(tls.caCertFile, "mobile setup tls.caCertFile"),
2102
+ caKeyFile: requiredString(tls.caKeyFile, "mobile setup tls.caKeyFile"),
2103
+ certFile: requiredString(tls.certFile, "mobile setup tls.certFile"),
2104
+ keyFile: requiredString(tls.keyFile, "mobile setup tls.keyFile")
2105
+ })
2106
+ });
2107
+ }
2108
+ function privateIpv4(value) {
2109
+ const parts = value.split(".").map(Number);
2110
+ 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);
2111
+ }
2112
+ function networkCidr(address, cidr) {
2113
+ const prefix = Number(cidr.slice(cidr.lastIndexOf("/") + 1));
2114
+ const network = (address.split(".").reduce((total, part) => (total << 8 | Number(part)) >>> 0, 0) & (prefix === 0 ? 0 : 4294967295 << 32 - prefix >>> 0)) >>> 0;
2115
+ return `${[
2116
+ 24,
2117
+ 16,
2118
+ 8,
2119
+ 0
2120
+ ].map((shift) => network >>> shift & 255).join(".")}/${String(prefix)}`;
2121
+ }
2122
+ /** List current private IPv4 candidates with their interface identity. */
2123
+ function availableLanNetworks(table = networkInterfaces()) {
2124
+ const candidates = Object.entries(table).flatMap(([name, entries]) => (entries ?? []).filter((entry) => entry.family === "IPv4" && !entry.internal && privateIpv4(entry.address) && entry.cidr !== null).map((entry) => ({
2125
+ name,
2126
+ address: entry.address,
2127
+ cidr: networkCidr(entry.address, entry.cidr)
2128
+ })));
2129
+ return [...new Map(candidates.map((entry) => [`${entry.name}\0${entry.address}`, entry])).values()];
2130
+ }
2131
+ /** Select an active LAN, optionally by address or by a previously saved interface name. */
2132
+ function selectLanNetwork(requestedAddress, requestedInterface, table) {
2133
+ const candidates = availableLanNetworks(table);
2134
+ if (requestedAddress !== void 0) {
2135
+ const match = candidates.find((candidate) => candidate.address === requestedAddress);
2136
+ if (match === void 0) throw new Error(`--address ${requestedAddress} is not an active private LAN address`);
2137
+ return match;
2138
+ }
2139
+ if (requestedInterface !== void 0) {
2140
+ const matches = candidates.filter((candidate) => candidate.name === requestedInterface);
2141
+ if (matches.length === 1) return matches[0];
2142
+ if (matches.length === 0) throw new Error(`saved LAN interface ${JSON.stringify(requestedInterface)} is not connected`);
2143
+ throw new Error(`saved LAN interface ${JSON.stringify(requestedInterface)} has more than one private IPv4 address`);
2144
+ }
2145
+ if (candidates.length === 1) return candidates[0];
2146
+ if (candidates.length === 0) throw new Error("no active private LAN address was found; connect to Wi-Fi or Ethernet");
2147
+ throw new Error(`more than one LAN address is active; rerun with --address and one of: ${candidates.map((entry) => entry.address).join(", ")}`);
2148
+ }
2149
+ function assertMatchingCa(certPem, keyPem) {
2150
+ const certificate = new X509Certificate(certPem);
2151
+ if (!certificate.ca || certificate.subject !== certificate.issuer || !certificate.verify(certificate.publicKey)) throw new Error("managed TLS CA must be a self-signed CA certificate");
2152
+ const privatePublic = createPublicKey(createPrivateKey(keyPem)).export({
2153
+ format: "der",
2154
+ type: "spki"
2155
+ });
2156
+ const certificatePublic = certificate.publicKey.export({
2157
+ format: "der",
2158
+ type: "spki"
2159
+ });
2160
+ if (!privatePublic.equals(certificatePublic)) throw new Error("managed TLS CA certificate and key do not match");
2161
+ if (Date.parse(certificate.validFrom) > Date.now() || Date.parse(certificate.validTo) <= Date.now()) throw new Error("managed TLS CA certificate is not currently valid");
2162
+ return certificate;
2163
+ }
2164
+ async function atomicWrite(file, contents) {
2165
+ const directory = dirname(file);
2166
+ await mkdir(directory, {
2167
+ recursive: true,
2168
+ mode: 448
2169
+ });
2170
+ const temporary = join(directory, `.${basename(file)}.${process.pid}.tmp`);
2171
+ await writeFile(temporary, contents, { mode: 384 });
2172
+ await chmod(temporary, 384);
2173
+ await rename(temporary, file);
2174
+ }
2175
+ /** Sign and atomically install a server leaf for the interface's current address. */
2176
+ async function refreshManagedServerCertificate(setup, address) {
2177
+ const [caCert, caKey] = await Promise.all([readFile(setup.tls.caCertFile, "utf8"), readFile(setup.tls.caKeyFile, "utf8")]);
2178
+ assertMatchingCa(caCert, caKey);
2179
+ const now = /* @__PURE__ */ new Date();
2180
+ const notAfter = new Date(now);
2181
+ notAfter.setDate(notAfter.getDate() + 397);
2182
+ const server = await generate([{
2183
+ name: "commonName",
2184
+ value: "DeepSeek Harness Mobile"
2185
+ }], {
2186
+ keyType: "ec",
2187
+ curve: "P-256",
2188
+ algorithm: "sha256",
2189
+ notBeforeDate: /* @__PURE__ */ new Date(now.getTime() - 3e5),
2190
+ notAfterDate: notAfter,
2191
+ ca: {
2192
+ cert: caCert,
2193
+ key: caKey
2194
+ },
2195
+ extensions: [
2196
+ {
2197
+ name: "basicConstraints",
2198
+ cA: false,
2199
+ critical: true
2200
+ },
2201
+ {
2202
+ name: "keyUsage",
2203
+ digitalSignature: true,
2204
+ critical: true
2205
+ },
2206
+ {
2207
+ name: "extKeyUsage",
2208
+ serverAuth: true
2209
+ },
2210
+ {
2211
+ name: "subjectAltName",
2212
+ altNames: [{
2213
+ type: 7,
2214
+ ip: address
2215
+ }]
2216
+ }
2217
+ ]
2218
+ });
2219
+ await Promise.all([atomicWrite(setup.tls.certFile, server.cert), atomicWrite(setup.tls.keyFile, server.private)]);
2220
+ }
2221
+ /** Resolve the saved interface to the ordinary gateway config consumed by the Host plugin. */
2222
+ async function materializeManagedSetup(setup, table) {
2223
+ const network = selectLanNetwork(void 0, setup.networkInterface, table);
2224
+ await refreshManagedServerCertificate(setup, network.address);
2225
+ const ca = new X509Certificate(await readFile(setup.tls.caCertFile, "utf8"));
2226
+ return {
2227
+ publicOrigin: `https://${network.address}:${String(setup.listenPort)}`,
2228
+ listenHost: network.address,
2229
+ upstreamOrigin: setup.upstreamOrigin,
2230
+ allowedCidrs: [network.cidr],
2231
+ instanceId: ca.fingerprint256.replaceAll(":", "").toLowerCase(),
2232
+ tls: {
2233
+ mode: "provided",
2234
+ certFile: setup.tls.certFile,
2235
+ keyFile: setup.tls.keyFile
2236
+ }
2237
+ };
2238
+ }
2239
+ //#endregion
2019
2240
  //#region src/plugin.ts
2020
2241
  /** Stable Cordis plugin name. */
2021
2242
  const name = "dsh-mobile";
2022
2243
  /** The stock WebServer is the only DSH Host service this plugin requires. */
2023
2244
  const inject = ["webServer"];
2024
2245
  function mapAdminError(error) {
2025
- return error instanceof HttpError ? error : new HttpError(500, "internal_error");
2246
+ if (error instanceof HttpError) return error;
2247
+ const code = error.code;
2248
+ if (code === "EADDRNOTAVAIL") return new HttpError(409, "network_address_changed");
2249
+ if (code === "EADDRINUSE") return new HttpError(409, "listen_port_in_use");
2250
+ if (error instanceof Error && error.message.startsWith("saved LAN interface ")) return new HttpError(409, "network_interface_unavailable");
2251
+ return new HttpError(500, "internal_error");
2026
2252
  }
2027
2253
  const SETUP_KEYS = /* @__PURE__ */ new Set([
2028
2254
  "version",
@@ -2032,6 +2258,7 @@ const SETUP_KEYS = /* @__PURE__ */ new Set([
2032
2258
  "upstreamOrigin",
2033
2259
  "publicAuthorities",
2034
2260
  "allowedCidrs",
2261
+ "instanceId",
2035
2262
  "tls"
2036
2263
  ]);
2037
2264
  async function loadSetup(config) {
@@ -2052,6 +2279,15 @@ async function loadSetup(config) {
2052
2279
  }
2053
2280
  if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) throw new Error("mobile setup file must be an object");
2054
2281
  const record = parsed;
2282
+ if (record.version === 2) {
2283
+ const setup = await materializeManagedSetup(parseManagedSetup(record));
2284
+ const merged = { ...config };
2285
+ for (const key of SETUP_KEYS) if (key !== "version") delete merged[key];
2286
+ return {
2287
+ ...merged,
2288
+ ...setup
2289
+ };
2290
+ }
2055
2291
  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
2292
  const { version: _version, ...setup } = record;
2057
2293
  const merged = { ...config };
package/package.json CHANGED
@@ -1,11 +1,11 @@
1
1
  {
2
2
  "name": "dsh-mobile",
3
- "version": "0.1.0-alpha.2",
3
+ "version": "0.1.0-alpha.4",
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",