dsh-pocket 1.12.2 → 1.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.en.md CHANGED
@@ -41,7 +41,7 @@ What it looks like — the phone shows the exact same UI as your computer, live:
41
41
 
42
42
  | Feature | Description |
43
43
  |---|---|
44
- | 📶 LAN QR access | Works out of the box: Settings → Phone access — scan the LAN QR on the same Wi-Fi |
44
+ | 📶 LAN QR access | Works out of the box: Settings → Phone access — scan the LAN QR on the same Wi-Fi (auto-detects the LAN IP; **under WSL it picks the Windows host's physical NIC IP**) |
45
45
  | 🌐 Public QR (from anywhere) | Click "Enable anywhere" → cloudflared tunnel → scan the public QR over 4G / any network |
46
46
  | 🔐 Access PIN | Public links require an **8-digit PIN** (rotated on every tunnel start by default; **customizable to a fixed PIN** — custom PINs are not rotated); LAN has its own separate **8-digit PIN** (on by default; switchable off in Settings — then LAN scans connect directly) |
47
47
  | 🔑 Custom PINs | Both the public and LAN PINs can be **set to your own fixed 8-digit number** in Settings (custom PINs are never auto-rotated) |
@@ -82,6 +82,8 @@ Settings → **Phone access** → scan the "📶 LAN" QR code → enter the **LA
82
82
  > The LAN PIN is **on by default** (security-first). If you're the only user and find typing it every time annoying, flip "LAN access PIN" to **Off** in the LAN block — LAN scans then connect directly with no PIN (LAN-only devices; the **public tunnel always requires a PIN**, unaffected).
83
83
  >
84
84
  > After logging in once, the phone **won't ask again**: as long as the computer's dsh web keeps running, reopening the phone needs no PIN (**a dsh web restart/update asks for it once more**).
85
+ >
86
+ > Advanced option: auto-detection may not pick a reachable address for Tailscale/VPN setups. You can select a detected IP from the "LAN address" dropdown; normally no change is needed.
85
87
 
86
88
  ### Public (from anywhere)
87
89
 
package/README.md CHANGED
@@ -41,7 +41,7 @@ DSH Pocket 就是干这个的:**装上它,手机扫个码,就能实时看
41
41
 
42
42
  | 特性 | 说明 |
43
43
  |---|---|
44
- | 📶 局域网扫码 | 装好即用:设置 → 手机访问,打开就有局域网二维码,手机连同一 WiFi 扫码即开 |
44
+ | 📶 局域网扫码 | 装好即用:设置 → 手机访问,打开就有局域网二维码,手机连同一 WiFi 扫码即开(自动识别本机局域网 IP,**WSL 环境自动取 Windows 物理网卡 IP**) |
45
45
  | 🌐 公网扫码(人在外面) | 点「开启公网访问」→ cloudflared 隧道 → 出公网二维码,4G/任何网络都能访问 |
46
46
  | 🔐 访问密码 | 公网链接需输入 **8 位数字密码**(默认每次开启公网自动换新;**可自定义固定密码**——自定义后不再换新);局域网有独立 **8 位数字密码**(默认开启,设置页可**一键关闭**——关闭后局域网扫码直连) |
47
47
  | 🔑 自定义密码 | 公网/局域网密码都可在设置页**设成自己固定的 8 位数字**(自定义后公网不再自动换新) |
@@ -82,6 +82,8 @@ npx @deepseek-ai/dsh web
82
82
  > 局域网密码**默认开启**(安全优先)。如果只有自己用、嫌每次输密码麻烦,可在设置页局域网区块把「局域网访问密码」切到**关**——之后局域网扫码直连、无需密码(仅同一局域网设备可访问;**公网始终要密码**,不受影响)。
83
83
  >
84
84
  > 手机登录一次后**长期免输**:只要电脑上的 dsh web 不重启,再次打开手机不用再输入(**dsh web 重启/更新后需重新输入一次**)。
85
+ >
86
+ > 高级选项:自动识别在 Tailscale/VPN 等场景下可能选不到可达地址。可在「局域网地址」下拉框手动选择已检测到的 IP;一般不需要修改。
85
87
 
86
88
  ### 公网(人在外面)
87
89
 
package/client/api.js CHANGED
@@ -10,6 +10,7 @@ export const POCKET_ENDPOINTS = Object.freeze({
10
10
  restart: 'pocket.restart',
11
11
  lanTokenRefresh: 'token.lanRefresh',
12
12
  lanAuthSetEnabled: 'lanAuth.setEnabled',
13
+ lanSetOverride: 'lan.setOverride',
13
14
  pinSetCustom: 'pin.setCustom',
14
15
  });
15
16
 
@@ -54,6 +55,8 @@ export function redactStatus(s) {
54
55
  proxyPort: s?.proxyPort ?? null,
55
56
  lanUrl: s?.lanUrl ?? null,
56
57
  lanQr: s?.lanQr ?? null,
58
+ lanCandidates: Array.isArray(s?.lanCandidates) ? s.lanCandidates : [],
59
+ lanIpOverride: s?.lanIpOverride ?? '',
57
60
  tunnelRunning: s?.tunnelRunning === true,
58
61
  tunnelUrl: s?.tunnelUrl ?? null,
59
62
  tunnelQr: s?.tunnelQr ?? null,
package/client/client.js CHANGED
@@ -50,6 +50,7 @@ var POCKET_ENDPOINTS = Object.freeze({
50
50
  restart: "pocket.restart",
51
51
  lanTokenRefresh: "token.lanRefresh",
52
52
  lanAuthSetEnabled: "lanAuth.setEnabled",
53
+ lanSetOverride: "lan.setOverride",
53
54
  pinSetCustom: "pin.setCustom"
54
55
  });
55
56
  function compareVersions(a, b) {
@@ -87,6 +88,8 @@ function redactStatus(s) {
87
88
  proxyPort: s?.proxyPort ?? null,
88
89
  lanUrl: s?.lanUrl ?? null,
89
90
  lanQr: s?.lanQr ?? null,
91
+ lanCandidates: Array.isArray(s?.lanCandidates) ? s.lanCandidates : [],
92
+ lanIpOverride: s?.lanIpOverride ?? "",
90
93
  tunnelRunning: s?.tunnelRunning === true,
91
94
  tunnelUrl: s?.tunnelUrl ?? null,
92
95
  tunnelQr: s?.tunnelQr ?? null,
@@ -1337,6 +1340,9 @@ var zh2 = {
1337
1340
  "versionRange": "\u5F53\u524D v{cur} \u2192 \u6700\u65B0 v{latest}",
1338
1341
  "lanTitle": "\u{1F4F6} \u5C40\u57DF\u7F51\uFF08\u540C\u4E00 WiFi\uFF09",
1339
1342
  "lanHint": "\u624B\u673A\u8FDE\u63A5\u540C\u4E00 WiFi \u540E\u626B\u7801\u5373\u53EF\u6253\u5F00",
1343
+ "lanAddress": "\u5C40\u57DF\u7F51\u5730\u5740",
1344
+ "lanAddressAuto": "\u81EA\u52A8\uFF08\u63A8\u8350\uFF09",
1345
+ "lanAddressHint": "\u9AD8\u7EA7\u9009\u9879\uFF1A\u4E00\u822C\u4E0D\u9700\u8981\u4FEE\u6539\uFF1B\u4F7F\u7528 Tailscale/VPN \u7B49\u8FDC\u7A0B\u8BBF\u95EE\u65F6\u53EF\u624B\u52A8\u9009\u62E9",
1340
1346
  "lanPin": "\u5C40\u57DF\u7F51\u8BBF\u95EE\u5BC6\u7801",
1341
1347
  "on": "\u5F00",
1342
1348
  "off": "\u5173",
@@ -1396,6 +1402,9 @@ var en2 = {
1396
1402
  "versionRange": "Current v{cur} \u2192 latest v{latest}",
1397
1403
  "lanTitle": "\u{1F4F6} LAN (same Wi-Fi)",
1398
1404
  "lanHint": "Scan to open once your phone is on the same Wi-Fi",
1405
+ "lanAddress": "LAN address",
1406
+ "lanAddressAuto": "Auto (recommended)",
1407
+ "lanAddressHint": "Advanced option: usually no change needed; select manually when accessing through Tailscale/VPN",
1399
1408
  "lanPin": "LAN access PIN",
1400
1409
  "on": "On",
1401
1410
  "off": "Off",
@@ -1604,6 +1613,13 @@ function PocketSettingsTab({ rpcCall, t }) {
1604
1613
  } catch {
1605
1614
  }
1606
1615
  };
1616
+ const setLanAddress = async (ip) => {
1617
+ try {
1618
+ setStatus(await call(POCKET_ENDPOINTS.lanSetOverride, { ip }));
1619
+ } catch (err) {
1620
+ setError(err.message);
1621
+ }
1622
+ };
1607
1623
  const [customPin, setCustomPin] = (0, import_react2.useState)(null);
1608
1624
  const saveCustomPin = async (which) => {
1609
1625
  try {
@@ -1717,6 +1733,22 @@ function PocketSettingsTab({ rpcCall, t }) {
1717
1733
  (0, import_react2.createElement)("img", { src: status.lanQr, alt: "LAN QR", style: styles.qr }),
1718
1734
  (0, import_react2.createElement)("div", { style: styles.code }, lanUrl),
1719
1735
  (0, import_react2.createElement)("div", { style: styles.muted }, t("lanHint")),
1736
+ (0, import_react2.createElement)(
1737
+ "label",
1738
+ { style: { display: "flex", alignItems: "center", gap: 8, marginTop: 10, fontSize: 12, color: "var(--dsw-alias-label-secondary,#6b7280)" } },
1739
+ t("lanAddress"),
1740
+ (0, import_react2.createElement)(
1741
+ "select",
1742
+ {
1743
+ value: status?.lanIpOverride || "",
1744
+ onChange: (e) => setLanAddress(e.target.value),
1745
+ style: { font: "inherit", height: 30, padding: "0 8px", borderRadius: 8, border: "1px solid var(--dsw-alias-border-l2,#d1d5db)", background: "var(--dsw-alias-bg-layer-1,#fff)", color: "var(--dsw-alias-label-primary,inherit)" }
1746
+ },
1747
+ (0, import_react2.createElement)("option", { value: "" }, t("lanAddressAuto")),
1748
+ (status?.lanCandidates || []).map((ip) => (0, import_react2.createElement)("option", { key: ip, value: ip }, ip))
1749
+ )
1750
+ ),
1751
+ (0, import_react2.createElement)("div", { style: { ...styles.muted, marginTop: 2 } }, t("lanAddressHint")),
1720
1752
  // 访问密码开关(issue #24):默认开启;关闭后扫码直连(仅同一局域网设备可访问)
1721
1753
  (0, import_react2.createElement)(
1722
1754
  "div",
package/client/index.jsx CHANGED
@@ -211,6 +211,15 @@ function PocketSettingsTab({ rpcCall, t }) {
211
211
  } catch { /* 忽略 */ }
212
212
  };
213
213
 
214
+ // 局域网地址手动覆盖(Tailscale/VPN 等远程访问场景):空值恢复自动选择
215
+ const setLanAddress = async (ip) => {
216
+ try {
217
+ setStatus(await call(POCKET_ENDPOINTS.lanSetOverride, { ip }));
218
+ } catch (err) {
219
+ setError(err.message);
220
+ }
221
+ };
222
+
214
223
  // 自定义访问密码(issue #33):公网/局域网各自设固定 8 位数字;自定义后公网不再自动轮换。
215
224
  // customPin: { which: 'public'|'lan', value, err } | null —— 正在输入自定义密码的区块
216
225
  const [customPin, setCustomPin] = useState(null);
@@ -317,6 +326,18 @@ function PocketSettingsTab({ rpcCall, t }) {
317
326
  h('img', { src: status.lanQr, alt: 'LAN QR', style: styles.qr }),
318
327
  h('div', { style: styles.code }, lanUrl),
319
328
  h('div', { style: styles.muted }, t('lanHint')),
329
+ h('label', { style: { display: 'flex', alignItems: 'center', gap: 8, marginTop: 10, fontSize: 12, color: 'var(--dsw-alias-label-secondary,#6b7280)' } },
330
+ t('lanAddress'),
331
+ h('select', {
332
+ value: status?.lanIpOverride || '',
333
+ onChange: (e) => setLanAddress(e.target.value),
334
+ style: { font: 'inherit', height: 30, padding: '0 8px', borderRadius: 8, border: '1px solid var(--dsw-alias-border-l2,#d1d5db)', background: 'var(--dsw-alias-bg-layer-1,#fff)', color: 'var(--dsw-alias-label-primary,inherit)' },
335
+ },
336
+ h('option', { value: '' }, t('lanAddressAuto')),
337
+ (status?.lanCandidates || []).map((ip) => h('option', { key: ip, value: ip }, ip)),
338
+ ),
339
+ ),
340
+ h('div', { style: { ...styles.muted, marginTop: 2 } }, t('lanAddressHint')),
320
341
  // 访问密码开关(issue #24):默认开启;关闭后扫码直连(仅同一局域网设备可访问)
321
342
  h('div', { style: { display: 'flex', alignItems: 'center', gap: 8, marginTop: 8 } },
322
343
  h('span', { style: { fontSize: 12, color: 'var(--dsw-alias-label-secondary,#6b7280)' } }, t('lanPin')),
@@ -30,6 +30,9 @@ export const zh = {
30
30
  'versionRange': '当前 v{cur} → 最新 v{latest}',
31
31
  'lanTitle': '📶 局域网(同一 WiFi)',
32
32
  'lanHint': '手机连接同一 WiFi 后扫码即可打开',
33
+ 'lanAddress': '局域网地址',
34
+ 'lanAddressAuto': '自动(推荐)',
35
+ 'lanAddressHint': '高级选项:一般不需要修改;使用 Tailscale/VPN 等远程访问时可手动选择',
33
36
  'lanPin': '局域网访问密码',
34
37
  'on': '开',
35
38
  'off': '关',
@@ -91,6 +94,9 @@ export const en = {
91
94
  'versionRange': 'Current v{cur} → latest v{latest}',
92
95
  'lanTitle': '📶 LAN (same Wi-Fi)',
93
96
  'lanHint': 'Scan to open once your phone is on the same Wi-Fi',
97
+ 'lanAddress': 'LAN address',
98
+ 'lanAddressAuto': 'Auto (recommended)',
99
+ 'lanAddressHint': 'Advanced option: usually no change needed; select manually when accessing through Tailscale/VPN',
94
100
  'lanPin': 'LAN access PIN',
95
101
  'on': 'On',
96
102
  'off': 'Off',
package/lib/index.js CHANGED
@@ -21,7 +21,7 @@ import { createPocketService } from './service.mjs';
21
21
  import { installPocketRpc } from './web-rpc.js';
22
22
  import { restartHost } from './restart.js';
23
23
  import { desktopEnvPatchScript, advancedNoticeScript, DEFAULT_INJECT } from './proxy.mjs';
24
- import { lanAuthEnabled, setLanAuthEnabled, pinCustom, setPinCustom } from './settings.mjs';
24
+ import { lanAuthEnabled, setLanAuthEnabled, lanIpOverride, setLanIpOverride, pinCustom, setPinCustom } from './settings.mjs';
25
25
 
26
26
  const name = 'dsh-pocket';
27
27
  const inject = ['connection', 'webServer'];
@@ -222,6 +222,7 @@ export function apply(ctx, config = {}, internals = {}) {
222
222
  port: internals.port ?? config.port ?? 3081,
223
223
  home: internals.home,
224
224
  internals,
225
+ getLanIpOverride: () => lanIpOverride(),
225
226
  // 桌面端:手机扫码访问的页面缺 dsh-desktop-mode/platform 参数会让 dsh-plugin-desktop
226
227
  // client 崩溃(issue #3/#4)。给代理注入「桌面参数补丁」(history.replaceState 补齐参数,
227
228
  // 无跳转;compatibility 模式不套桌面布局,避免移动端按钮叠加)。保留默认 polyfill。
@@ -255,6 +256,8 @@ export function apply(ctx, config = {}, internals = {}) {
255
256
  refreshLanToken: () => refreshLanToken(),
256
257
  getLanAuthEnabled: () => lanAuthEnabled(),
257
258
  setLanAuthEnabled: (on) => setLanAuthEnabled(on),
259
+ getLanIpOverride: () => lanIpOverride(),
260
+ setLanIpOverride: (ip) => setLanIpOverride(ip),
258
261
  getPinCustom: (which) => pinCustom(which),
259
262
  setCustomPin: (which, value) => setCustomPin(which, value),
260
263
  runUpdate: internals.runUpdate ?? { currentVersion, perform: performUpdate, loadedVersion: () => loadedVersion },
package/lib/ip.mjs ADDED
@@ -0,0 +1,11 @@
1
+ // Small shared helpers for the optional LAN IP override.
2
+
3
+ /** @param {unknown} value
4
+ * @returns {boolean} */
5
+ export function isValidIpv4(value) {
6
+ const m = String(value ?? '').match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/);
7
+ return m !== null && m.slice(1).every((part) => {
8
+ const n = Number(part);
9
+ return n >= 0 && n <= 255;
10
+ });
11
+ }
package/lib/service.mjs CHANGED
@@ -8,10 +8,13 @@
8
8
 
9
9
  import { networkInterfaces } from 'node:os';
10
10
  import { createRequire } from 'node:module';
11
+ import { readFileSync } from 'node:fs';
12
+ import { execFile } from 'node:child_process';
11
13
  import { mkdir, readFile, rm, writeFile } from 'node:fs/promises';
12
14
  import { join, dirname } from 'node:path';
13
15
  import { createPocketProxy } from './proxy.mjs';
14
16
  import { startQuickTunnel } from './tunnel.mjs';
17
+ import { isValidIpv4 } from './ip.mjs';
15
18
 
16
19
  const require = createRequire(import.meta.url);
17
20
 
@@ -67,10 +70,93 @@ export function selectLanIPv4(interfaces) {
67
70
  return candidates[0]?.ip ?? null;
68
71
  }
69
72
 
70
- function lanIPv4() {
73
+ // ---------- WSL 局域网 IP(issue #39) ----------
74
+ // WSL2 是 NAT 模式:WSL 内部 os.networkInterfaces() 只能看到自己的虚拟网卡
75
+ // (172.x.x.x),看不到 Windows 宿主机的物理网卡 IP(192.168.x.x)——手机在
76
+ // 同一 WiFi 下访问的是 Windows 宿主机,拿 WSL 的 IP 生成的二维码必然打不开。
77
+ // 解法:检测到 WSL 时,通过 WSL interop 直接执行 Windows 的 ipconfig.exe,
78
+ // 解析出 Windows 侧非虚拟网卡的 IPv4 作为局域网地址;失败回退本机探测。
79
+
80
+ /** WSL 检测:/proc/version 含 microsoft/wsl,或 WSL 专属环境变量存在。 */
81
+ export function detectWsl() {
82
+ try {
83
+ const v = readFileSync('/proc/version', 'utf8').toLowerCase();
84
+ if (v.includes('microsoft') || v.includes('wsl')) return true;
85
+ } catch { /* 非 Linux:无 /proc/version */ }
86
+ return Boolean(process.env.WSL_DISTRO_NAME || process.env.WSL_INTEROP || process.env.WSLENV);
87
+ }
88
+
89
+ /**
90
+ * 解析 ipconfig.exe 输出,默认取非虚拟网卡块的 IPv4 地址(保持输出顺序)。
91
+ * 支持中文(`IPv4 地址 . . . :`)与英文(`IPv4 Address. . . :`)两种格式。
92
+ * @param {string} text ipconfig.exe 的完整输出
93
+ * @param {{ includeVpn?: boolean }} [opts] 传 includeVpn 时保留 Tailscale/VPN 等候选
94
+ * @returns {string[]} 候选 IPv4 列表
95
+ */
96
+ export function parseIpconfig(text, { includeVpn = false } = {}) {
97
+ const out = [];
98
+ // 网卡块:块标题行顶格(行首无缩进),其后内容行带缩进
99
+ const blocks = String(text).split(/\r?\n(?=\S)/);
100
+ const ipRe = /IPv4[^0-9]{0,40}((?:25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)\.){3}(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)/;
101
+ for (const block of blocks) {
102
+ const title = String(block.split(/\r?\n/)[0] ?? '');
103
+ // 跳过虚拟网卡块(vEthernet (WSL)、Docker、VirtualBox、VPN 等)
104
+ if (!includeVpn && VPN_IFACE_RE.test(title)) continue;
105
+ const m = block.match(ipRe);
106
+ if (m) out.push(m[0].replace(/^IPv4[^0-9]*/i, ''));
107
+ }
108
+ return out;
109
+ }
110
+
111
+ function runIpconfig() {
112
+ // WSL 内 PATH 可能不含 Windows System32;用绝对路径兜底
113
+ const candidates = ['ipconfig.exe', '/mnt/c/Windows/System32/ipconfig.exe'];
114
+ return new Promise((resolve) => {
115
+ const tryNext = (i) => {
116
+ if (i >= candidates.length) return resolve(null);
117
+ execFile(candidates[i], [], { timeout: 5000, windowsHide: true }, (err, stdout) => {
118
+ if (err || !stdout) return tryNext(i + 1);
119
+ resolve(String(stdout));
120
+ });
121
+ };
122
+ tryNext(0);
123
+ });
124
+ }
125
+
126
+ async function lanIPv4() {
127
+ // WSL:优先 Windows 物理网卡 IP(手机可达);超时/失败回退本机探测
128
+ if (detectWsl()) {
129
+ try {
130
+ const out = await runIpconfig();
131
+ const candidates = parseIpconfig(out ?? '');
132
+ const ip = candidates.find((c) => PRIVATE_IPV4_RE.test(c)) ?? candidates[0];
133
+ if (ip) return ip;
134
+ } catch { /* 回退 */ }
135
+ }
71
136
  return selectLanIPv4(networkInterfaces());
72
137
  }
73
138
 
139
+ /** 收集所有可手动选择的局域网/Tailnet 候选 IP(WSL 下以 Windows ipconfig 为准)。 */
140
+ async function listLanCandidates() {
141
+ if (detectWsl()) {
142
+ try {
143
+ const out = await runIpconfig();
144
+ const ips = parseIpconfig(out ?? '', { includeVpn: true });
145
+ if (ips.length) return [...new Set(ips)];
146
+ } catch { /* 回退 */ }
147
+ }
148
+ const ips = [];
149
+ for (const [name, addrs] of Object.entries(networkInterfaces())) {
150
+ for (const addr of addrs ?? []) {
151
+ if (addr.family !== 'IPv4' || addr.internal) continue;
152
+ const ip = addr.address;
153
+ if (!ip || ip.startsWith('127.') || ip.startsWith('169.254.')) continue;
154
+ if (!ips.includes(ip)) ips.push(ip);
155
+ }
156
+ }
157
+ return ips;
158
+ }
159
+
74
160
  /**
75
161
  * 创建 Pocket 服务。
76
162
  * @param {object} opts
@@ -84,6 +170,8 @@ export function createPocketService({
84
170
  port = 3081,
85
171
  home,
86
172
  internals = {},
173
+ /** 局域网地址手动覆盖:返回 IPv4 字符串;空值表示自动选择 */
174
+ getLanIpOverride,
87
175
  /** 代理注入 HTML 的内容(桌面端补丁等由 lib/index.js 传入;默认 randomUUID polyfill) */
88
176
  injectHtml,
89
177
  /** 访问令牌认证配置(issue #13):{ getToken, isProtected },传给代理 */
@@ -93,7 +181,20 @@ export function createPocketService({
93
181
  } = {}) {
94
182
  const createProxy = internals.createProxy ?? createPocketProxy;
95
183
  const startTunnel = internals.startTunnel ?? startQuickTunnel;
96
- const getLan = internals.lanIPv4 ?? lanIPv4;
184
+ const getLanOverride = () => {
185
+ const value = String(getLanIpOverride?.() ?? '').trim();
186
+ return isValidIpv4(value) ? value : '';
187
+ };
188
+ const getLan = async () => getLanOverride() || (internals.lanIPv4 ? internals.lanIPv4() : lanIPv4());
189
+ let lanCandidateCache = null;
190
+ const getLanCandidates = async () => {
191
+ if (internals.lanCandidates) return internals.lanCandidates();
192
+ const now = Date.now();
193
+ if (!lanCandidateCache || now - lanCandidateCache.at > 15000) {
194
+ lanCandidateCache = { at: now, ips: await listLanCandidates() };
195
+ }
196
+ return lanCandidateCache.ips;
197
+ };
97
198
 
98
199
  let proxy = null;
99
200
  let tunnel = null;
@@ -244,14 +345,19 @@ export function createPocketService({
244
345
 
245
346
  /** 状态快照(RPC 返回,不含敏感信息;二维码 data URL 本地生成 + 缓存)。 */
246
347
  async status() {
247
- const lan = getLan();
348
+ const lan = await getLan();
248
349
  const proxyPort = proxy?.port ?? null;
249
350
  const lanUrl = lan && proxyPort ? `http://${lan}:${proxyPort}` : null;
351
+ const lanIpOverride = getLanOverride();
352
+ const lanCandidates = [...new Set(await getLanCandidates())];
353
+ if (lanIpOverride && !lanCandidates.includes(lanIpOverride)) lanCandidates.push(lanIpOverride);
250
354
  return {
251
355
  proxyRunning: proxy !== null,
252
356
  proxyPort,
253
357
  lanUrl,
254
358
  lanQr: await qrCached(lanUrl),
359
+ lanCandidates,
360
+ lanIpOverride,
255
361
  tunnelRunning: tunnel !== null,
256
362
  tunnelUrl: tunnel?.url ?? null,
257
363
  tunnelQr: await qrCached(tunnel?.url ?? null),
package/lib/settings.mjs CHANGED
@@ -10,6 +10,7 @@
10
10
  import { readFileSync, writeFileSync, mkdirSync } from 'node:fs';
11
11
  import { join, dirname } from 'node:path';
12
12
  import { homedir } from 'node:os';
13
+ import { isValidIpv4 } from './ip.mjs';
13
14
 
14
15
  const settingsRel = join('dsh-pocket', 'settings.json');
15
16
  export function settingsPath() {
@@ -45,6 +46,24 @@ export function setLanAuthEnabled(on) {
45
46
  return s.lanAuthEnabled;
46
47
  }
47
48
 
49
+ /** 局域网地址手动覆盖:默认空字符串 = 自动选择。 */
50
+ export function lanIpOverride() {
51
+ return readSettings().lanIpOverride ?? '';
52
+ }
53
+
54
+ /** 设置局域网地址覆盖;空字符串清除覆盖,恢复自动选择。非法 IPv4 抛错。 */
55
+ export function setLanIpOverride(value) {
56
+ const ip = String(value ?? '').trim();
57
+ if (ip && !isValidIpv4(ip)) {
58
+ throw new Error('局域网地址必须是 IPv4 地址 | LAN address must be an IPv4 address');
59
+ }
60
+ const s = readSettings();
61
+ if (ip) s.lanIpOverride = ip;
62
+ else delete s.lanIpOverride;
63
+ writeSettings(s);
64
+ return ip;
65
+ }
66
+
48
67
  // ---------- 访问密码「自定义」标记(issue #33) ----------
49
68
  // 用户可把公网/局域网密码设成自己固定的 8 位数字(自定义后不再自动轮换)。
50
69
  // 标记存 settings.json:publicPinCustom / lanPinCustom。
package/lib/web-rpc.js CHANGED
@@ -25,7 +25,7 @@ export function killHint(port) {
25
25
  }
26
26
 
27
27
  /** 注册 /dsh-pocket 逻辑通道(仅本机 loopback 可调)。 */
28
- export function installPocketRpc(ctx, { service, log = console, desktop = false, runUpdate = null, restart = null, restartNotice = null, getToken = null, getLanToken = null, refreshLanToken = null, getLanAuthEnabled = null, setLanAuthEnabled = null, getPinCustom = null, setCustomPin = null }) {
28
+ export function installPocketRpc(ctx, { service, log = console, desktop = false, runUpdate = null, restart = null, restartNotice = null, getToken = null, getLanToken = null, refreshLanToken = null, getLanAuthEnabled = null, setLanAuthEnabled = null, getLanIpOverride = null, setLanIpOverride = null, getPinCustom = null, setCustomPin = null }) {
29
29
  if (!ctx?.connection?.rpc?.handle) {
30
30
  log.warn?.('dsh-pocket: DSH Host Connection RPC unavailable — settings tab disabled | 无 Connection RPC,设置页不可用');
31
31
  return () => {};
@@ -65,6 +65,18 @@ export function installPocketRpc(ctx, { service, log = console, desktop = false,
65
65
  if (enabled === undefined) return fail('bad-request', '局域网密码开关不可用 | LAN PIN switch unavailable');
66
66
  return ok({ lanAuthEnabled: enabled });
67
67
  }
68
+ if (endpoint === POCKET_ENDPOINTS.lanSetOverride) {
69
+ // 返回完整 status:前端 setStatus(await call(...)) 直接替换 status 对象,
70
+ // 若只返回 { lanIpOverride } 会丢掉 accessToken/lanToken/tunnelUrl 等字段,
71
+ // 且 lanUrl/二维码不会随新 IP 刷新(PR #47 的客户端写法依赖完整 status)。
72
+ try {
73
+ const ip = setLanIpOverride?.(payload?.ip ?? '');
74
+ if (ip === undefined) return fail('bad-request', '局域网地址设置不可用 | LAN address setting unavailable');
75
+ return await statusPayload();
76
+ } catch (err) {
77
+ return fail('bad-request', err?.message ?? String(err));
78
+ }
79
+ }
68
80
  if (endpoint === POCKET_ENDPOINTS.pinSetCustom) {
69
81
  const which = payload?.which === 'public' || payload?.which === 'lan' ? payload.which : null;
70
82
  if (!which) return fail('bad-request', '未知密码类型 | unknown PIN kind');
package/package.json CHANGED
@@ -76,5 +76,5 @@
76
76
  "access": "public",
77
77
  "registry": "https://registry.npmjs.org/"
78
78
  },
79
- "version": "1.12.2"
79
+ "version": "1.13.0"
80
80
  }