dsh-mobile-access 1.0.3 → 1.1.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/client.js CHANGED
@@ -262,8 +262,13 @@ function statusOf(state, loading, error, t) {
262
262
  return { label: t('statusError'), tone: 'bad' };
263
263
  if (state === null)
264
264
  return { label: loading ? t('statusLoading') : t('statusError'), tone: 'muted' };
265
- if (state.loopback)
265
+ if (state.loopback) {
266
+ if (state.gateway.running)
267
+ return { label: t('statusGateway'), tone: 'ok' };
268
+ if (state.gateway.starting)
269
+ return { label: t('statusGatewayStarting'), tone: 'warn' };
266
270
  return { label: t('statusLoopback'), tone: 'bad' };
271
+ }
267
272
  if (state.locked)
268
273
  return { label: t('statusLocked'), tone: 'bad' };
269
274
  if (state.expired)
@@ -274,8 +279,8 @@ function statusOf(state, loading, error, t) {
274
279
  "use strict";
275
280
  const NS = 'dsh-mobile-access';
276
281
  const PANEL_ID = 'mobile-access';
277
- const ZH = {"panel":"手机访问","title":"手机访问","subtitle":"在同一局域网内,用 iPhone 或 iPad 打开这台 Mac 上的官方 DSH。","statusLoading":"正在读取配对状态","statusReady":"已就绪","statusLoopback":"仅回环地址","statusLocked":"配对码已锁定","statusExpired":"配对码已过期","statusError":"暂时无法读取状态","errorTitle":"无法读取配对状态","retry":"重新加载","urlLabel":"iPhone 打开的地址","urlHint":"在 iPhone 的 Safari 地址栏输入这个地址。","codeLabel":"配对码","codeHint":"在 iPhone 的配对页输入这 8 位数字。","copyLink":"复制地址","copyCode":"复制配对码","copied":"已复制","copyFailed":"复制失败,请手动选择文本","rotate":"刷新配对码","rotating":"正在刷新…","toastRotated":"配对码已刷新","rotateFailed":"刷新失败,请重试","expiry":"有效至","expiresIn":"剩余","expired":"已过期","locked":"已锁定","never":"尚未配对","lastPaired":"最近配对","pairCount":"成功配对次数","stepsTitle":"在 iPhone 上连接","step1":"确认 iPhone 与 Mac 连接同一个 Wi-Fi,且没有开启会隔离设备的访客网络。","step2":"在 iPhone Safari 打开上面的地址,输入配对码并点「连接 DSH」。","step3":"配对成功后会自动进入 DSH;再点 Safari 分享按钮,选择「添加到主屏幕」。","diagnosticsTitle":"连接诊断","bindLabel":"Mac 监听地址","deviceLabel":"当前设备","deviceIOS":"iPhone / iPad","deviceOther":"电脑或其他设备","launchMode":"启动方式","standaloneYes":"已从主屏幕启动","standaloneNo":"浏览器标签页","secureYes":"安全上下文","secureNo":"HTTP 非安全上下文(剪贴板回退已启用)","loopbackTitle":"iPhone 现在无法连接","loopbackBody":"DSH 只监听了回环地址(localhost / 127.0.0.1)。请把 Host 的 webserver.host 改为这台 Mac 的局域网 IPv4 地址,然后由你手动重启 DSH。","httpTitle":"关于 HTTP 访问","httpBody":"当前是局域网 HTTP。DSH 已为 UUID 和多数复制按钮提供兼容实现;本插件会在 iPhone 上补齐直接调用 navigator.clipboard 的旧代码。","selftestTitle":"浏览器能力自检","selftestRun":"运行自检","selftestRunning":"正在自检…","selftestNote":"自检只读取浏览器能力,不修改 DSH 配置,也不影响 Mac。","checkClipboard":"剪贴板写入","checkCrypto":"安全随机数","checkStorage":"本地存储","checkWebSocket":"WebSocket","checkViewport":"移动端视口","checkOnline":"网络状态","checkPass":"通过","checkWarn":"注意","checkFail":"失败","limitsTitle":"平台边界(不是插件缺陷)","limit1":"iOS Safari 不支持桌面式拖放;文件请用系统选择器上传。","limit2":"iOS 会在后台挂起网页;长任务请保持 DSH 页面在前台。","limit3":"下载文件由 Safari 交给「文件」App,落点由 iOS 决定。","zeroImpactTitle":"对 Mac 的零影响设计","zeroImpactBody":"插件只增加三个精确路由、一个左侧栏入口和一个主面板;不修改核心代码、认证、配置、主题、布局或其他插件,也不在后台轮询。","mainAria":"手机访问面板","codeAria":"配对码","reload":"刷新","unknown":"未知"};
278
- const EN = {"panel":"Mobile Access","title":"Mobile Access","subtitle":"Open this Mac’s official DSH from an iPhone or iPad on the same LAN.","statusLoading":"Reading pairing state","statusReady":"Ready","statusLoopback":"Loopback only","statusLocked":"Code locked","statusExpired":"Code expired","statusError":"State unavailable","errorTitle":"Could not read pairing state","retry":"Reload","urlLabel":"Address for iPhone","urlHint":"Type this address into Safari on the iPhone.","codeLabel":"Pairing code","codeHint":"Enter these 8 digits on the iPhone pairing page.","copyLink":"Copy address","copyCode":"Copy code","copied":"Copied","copyFailed":"Copy failed; select the text manually","rotate":"New code","rotating":"Rotating…","toastRotated":"Pairing code rotated","rotateFailed":"Rotation failed; try again","expiry":"Valid until","expiresIn":"Remaining","expired":"Expired","locked":"Locked","never":"Never","lastPaired":"Last paired","pairCount":"Successful pairings","stepsTitle":"Connect from iPhone","step1":"Put the iPhone and the Mac on the same Wi-Fi, without client isolation.","step2":"Open the address in Safari, enter the code, and tap “Connect DSH”.","step3":"After pairing, use Safari Share → “Add to Home Screen”.","diagnosticsTitle":"Connection diagnostics","bindLabel":"Mac listener","deviceLabel":"This device","deviceIOS":"iPhone / iPad","deviceOther":"Computer or other device","launchMode":"Launch mode","standaloneYes":"Launched from Home Screen","standaloneNo":"Browser tab","secureYes":"Secure context","secureNo":"HTTP insecure context (clipboard fallback enabled)","loopbackTitle":"The iPhone cannot connect yet","loopbackBody":"DSH is listening on loopback only. Set Host webserver.host to this Mac’s LAN IPv4 address, then restart DSH manually.","httpTitle":"About HTTP","httpBody":"This is plain-HTTP LAN access. DSH already handles UUIDs and most copy buttons; this plugin adds a fallback for older direct navigator.clipboard calls on iPhone.","selftestTitle":"Browser capability self-test","selftestRun":"Run self-test","selftestRunning":"Testing…","selftestNote":"The self-test only reads browser capabilities; it does not change DSH or affect the Mac.","checkClipboard":"Clipboard write","checkCrypto":"Secure random","checkStorage":"Local storage","checkWebSocket":"WebSocket","checkViewport":"Mobile viewport","checkOnline":"Network status","checkPass":"Pass","checkWarn":"Note","checkFail":"Fail","limitsTitle":"Platform limits (not plugin defects)","limit1":"iOS Safari has no desktop drag-and-drop; use the system file picker.","limit2":"iOS suspends background pages; keep DSH in the foreground for long tasks.","limit3":"Safari hands downloads to the Files app; iOS chooses the location.","zeroImpactTitle":"Zero-impact design for the Mac","zeroImpactBody":"The plugin adds exactly three routes, one sidebar entry, and one main panel. It does not modify core code, auth, config, theme, layout, or other plugins, and it never polls in the background.","mainAria":"Mobile access panel","codeAria":"Pairing code","reload":"Reload","unknown":"Unknown"};
282
+ const ZH = {"panel":"手机访问","title":"手机访问","subtitle":"在同一局域网内,用 iPhone 或 iPad 打开这台 Mac 上的官方 DSH。","statusLoading":"正在读取配对状态","statusReady":"已就绪","statusLoopback":"仅回环地址","statusGateway":"局域网网关已开启","statusGatewayStarting":"正在开启局域网网关","statusLocked":"配对码已锁定","statusExpired":"配对码已过期","statusError":"暂时无法读取状态","errorTitle":"无法读取配对状态","retry":"重新加载","urlLabel":"iPhone 打开的地址","urlHint":"在 iPhone 的 Safari 地址栏输入这个地址。","codeLabel":"配对码","codeHint":"在 iPhone 的配对页输入这 8 位数字。","copyLink":"复制地址","copyCode":"复制配对码","copied":"已复制","copyFailed":"复制失败,请手动选择文本","rotate":"刷新配对码","rotating":"正在刷新…","toastRotated":"配对码已刷新","rotateFailed":"刷新失败,请重试","expiry":"有效至","expiresIn":"剩余","expired":"已过期","locked":"已锁定","never":"尚未配对","lastPaired":"最近配对","pairCount":"成功配对次数","stepsTitle":"在 iPhone 上连接","step1":"确认 iPhone 与 Mac 连接同一个 Wi-Fi,且没有开启会隔离设备的访客网络。","step2":"在 iPhone Safari 打开上面的地址,输入配对码并点「连接 DSH」。","step3":"配对成功后会自动进入 DSH;再点 Safari 分享按钮,选择「添加到主屏幕」。","diagnosticsTitle":"连接诊断","bindLabel":"Mac 监听地址","gatewayLabel":"局域网网关","gatewayOn":"已开启","gatewayOff":"未开启","deviceLabel":"当前设备","deviceIOS":"iPhone / iPad","deviceOther":"电脑或其他设备","launchMode":"启动方式","standaloneYes":"已从主屏幕启动","standaloneNo":"浏览器标签页","secureYes":"安全上下文","secureNo":"HTTP 非安全上下文(剪贴板回退已启用)","loopbackTitle":"iPhone 现在无法连接","loopbackBody":"DSH 只监听回环地址;插件正在尝试开启局域网网关。若失败,诊断信息会显示在这里,Mac 仍可正常使用 127.0.0.1。","gatewayTitle":"局域网网关已开启","gatewayBody":"DSH 仍只监听 127.0.0.1;iPhone 通过下面的局域网地址访问。Mac 继续用原地址,不受影响。","gatewayStartingTitle":"正在开启局域网网关","httpTitle":"关于 HTTP 访问","httpBody":"当前是局域网 HTTP。DSH 已为 UUID 和多数复制按钮提供兼容实现;本插件会在 iPhone 上补齐直接调用 navigator.clipboard 的旧代码。","selftestTitle":"浏览器能力自检","selftestRun":"运行自检","selftestRunning":"正在自检…","selftestNote":"自检只读取浏览器能力,不修改 DSH 配置,也不影响 Mac。","checkClipboard":"剪贴板写入","checkCrypto":"安全随机数","checkStorage":"本地存储","checkWebSocket":"WebSocket","checkViewport":"移动端视口","checkOnline":"网络状态","checkPass":"通过","checkWarn":"注意","checkFail":"失败","limitsTitle":"平台边界(不是插件缺陷)","limit1":"iOS Safari 不支持桌面式拖放;文件请用系统选择器上传。","limit2":"iOS 会在后台挂起网页;长任务请保持 DSH 页面在前台。","limit3":"下载文件由 Safari 交给「文件」App,落点由 iOS 决定。","zeroImpactTitle":"对 Mac 的零影响设计","zeroImpactBody":"插件只增加三个精确路由、一个左侧栏入口和一个主面板;不修改核心代码、认证、配置、主题、布局或其他插件,也不在后台轮询。","mainAria":"手机访问面板","codeAria":"配对码","reload":"刷新","unknown":"未知"};
283
+ const EN = {"panel":"Mobile Access","title":"Mobile Access","subtitle":"Open this Mac’s official DSH from an iPhone or iPad on the same LAN.","statusLoading":"Reading pairing state","statusReady":"Ready","statusLoopback":"Loopback only","statusGateway":"LAN gateway on","statusGatewayStarting":"Starting LAN gateway","statusLocked":"Code locked","statusExpired":"Code expired","statusError":"State unavailable","errorTitle":"Could not read pairing state","retry":"Reload","urlLabel":"Address for iPhone","urlHint":"Type this address into Safari on the iPhone.","codeLabel":"Pairing code","codeHint":"Enter these 8 digits on the iPhone pairing page.","copyLink":"Copy address","copyCode":"Copy code","copied":"Copied","copyFailed":"Copy failed; select the text manually","rotate":"New code","rotating":"Rotating…","toastRotated":"Pairing code rotated","rotateFailed":"Rotation failed; try again","expiry":"Valid until","expiresIn":"Remaining","expired":"Expired","locked":"Locked","never":"Never","lastPaired":"Last paired","pairCount":"Successful pairings","stepsTitle":"Connect from iPhone","step1":"Put the iPhone and the Mac on the same Wi-Fi, without client isolation.","step2":"Open the address in Safari, enter the code, and tap “Connect DSH”.","step3":"After pairing, use Safari Share → “Add to Home Screen”.","diagnosticsTitle":"Connection diagnostics","bindLabel":"Mac listener","gatewayLabel":"LAN gateway","gatewayOn":"On","gatewayOff":"Off","deviceLabel":"This device","deviceIOS":"iPhone / iPad","deviceOther":"Computer or other device","launchMode":"Launch mode","standaloneYes":"Launched from Home Screen","standaloneNo":"Browser tab","secureYes":"Secure context","secureNo":"HTTP insecure context (clipboard fallback enabled)","loopbackTitle":"The iPhone cannot connect yet","loopbackBody":"DSH is listening on loopback only. The plugin is starting a LAN gateway; if it fails, diagnostics appear here. The Mac keeps using 127.0.0.1.","gatewayTitle":"LAN gateway is on","gatewayBody":"DSH still listens only on 127.0.0.1. The iPhone uses the LAN address below; the Mac keeps its original address.","gatewayStartingTitle":"Starting LAN gateway","httpTitle":"About HTTP","httpBody":"This is plain-HTTP LAN access. DSH already handles UUIDs and most copy buttons; this plugin adds a fallback for older direct navigator.clipboard calls on iPhone.","selftestTitle":"Browser capability self-test","selftestRun":"Run self-test","selftestRunning":"Testing…","selftestNote":"The self-test only reads browser capabilities; it does not change DSH or affect the Mac.","checkClipboard":"Clipboard write","checkCrypto":"Secure random","checkStorage":"Local storage","checkWebSocket":"WebSocket","checkViewport":"Mobile viewport","checkOnline":"Network status","checkPass":"Pass","checkWarn":"Note","checkFail":"Fail","limitsTitle":"Platform limits (not plugin defects)","limit1":"iOS Safari has no desktop drag-and-drop; use the system file picker.","limit2":"iOS suspends background pages; keep DSH in the foreground for long tasks.","limit3":"Safari hands downloads to the Files app; iOS chooses the location.","zeroImpactTitle":"Zero-impact design for the Mac","zeroImpactBody":"The plugin adds exactly three routes, one sidebar entry, and one main panel. It does not modify core code, auth, config, theme, layout, or other plugins, and it never polls in the background.","mainAria":"Mobile access panel","codeAria":"Pairing code","reload":"Reload","unknown":"Unknown"};
279
284
  function createPanel(t, locale) {
280
285
  return function MobileAccessPanel() {
281
286
  useLocaleRevision(locale);
@@ -287,6 +292,7 @@ function createPanel(t, locale) {
287
292
  const toastTimer = React.useRef(undefined);
288
293
  const remaining = useRemaining(state?.expiresAt);
289
294
  const status = statusOf(state, loading, error, t);
295
+ const publicReady = state?.gateway.running === true && state.gateway.publicOrigin !== null;
290
296
  const isIOS = isAppleMobile();
291
297
  const standalone = navigator.standalone === true
292
298
  || window.matchMedia('(display-mode: standalone)').matches;
@@ -348,13 +354,17 @@ function createPanel(t, locale) {
348
354
  };
349
355
  const codeBlock = state === null
350
356
  ? null
351
- : h('div', { className: 'dsh-ma-card dsh-ma-pair' }, h('div', { className: 'dsh-ma-row' }, h('div', null, h('p', { className: 'dsh-ma-label' }, t('codeLabel')), h('div', { className: 'dsh-ma-code', 'aria-label': t('codeAria') }, state.displayCode), h('p', { className: 'dsh-ma-hint' }, t('codeHint'))), h('div', { className: `dsh-ma-pill ${state.loopback || state.locked ? 'is-bad' : state.expired ? 'is-warn' : 'is-ok'}` }, h('span', { className: 'dsh-ma-dot' }), status.label)), h('div', { className: 'dsh-ma-actions' }, h('button', { type: 'button', className: 'dsh-ma-button primary', onClick: () => { void copy(state.pairUrl); }, disabled: state.loopback }, t('copyLink')), h('button', { type: 'button', className: 'dsh-ma-button', onClick: () => { void copy(state.displayCode); } }, t('copyCode')), h('button', { type: 'button', className: 'dsh-ma-button', onClick: () => { void rotate(); }, disabled: rotating }, rotating ? t('rotating') : t('rotate'))), h('dl', { className: 'dsh-ma-meta' }, h('div', null, h('dt', null, t('expiry')), h('dd', null, new Date(state.expiresAt).toLocaleTimeString(undefined, { hour12: false }))), h('div', null, h('dt', null, t('expiresIn')), h('dd', { className: remaining === 0 ? 'is-bad' : '' }, remaining === 0 ? t('expired') : formatRemaining(remaining))), h('div', null, h('dt', null, t('lastPaired')), h('dd', null, state.lastPairedAt === null ? t('never') : new Date(state.lastPairedAt).toLocaleString(undefined, { hour12: false }))), h('div', null, h('dt', null, t('pairCount')), h('dd', null, String(state.pairingCount)))));
357
+ : h('div', { className: 'dsh-ma-card dsh-ma-pair' }, h('div', { className: 'dsh-ma-row' }, h('div', null, h('p', { className: 'dsh-ma-label' }, t('codeLabel')), h('div', { className: 'dsh-ma-code', 'aria-label': t('codeAria') }, state.displayCode), h('p', { className: 'dsh-ma-hint' }, t('codeHint'))), h('div', { className: `dsh-ma-pill ${state.loopback || state.locked ? 'is-bad' : state.expired ? 'is-warn' : 'is-ok'}` }, h('span', { className: 'dsh-ma-dot' }), status.label)), h('div', { className: 'dsh-ma-actions' }, h('button', { type: 'button', className: 'dsh-ma-button primary', onClick: () => { void copy(state.pairUrl); }, disabled: state.loopback && !publicReady }, t('copyLink')), h('button', { type: 'button', className: 'dsh-ma-button', onClick: () => { void copy(state.displayCode); } }, t('copyCode')), h('button', { type: 'button', className: 'dsh-ma-button', onClick: () => { void rotate(); }, disabled: rotating }, rotating ? t('rotating') : t('rotate'))), h('dl', { className: 'dsh-ma-meta' }, h('div', null, h('dt', null, t('expiry')), h('dd', null, new Date(state.expiresAt).toLocaleTimeString(undefined, { hour12: false }))), h('div', null, h('dt', null, t('expiresIn')), h('dd', { className: remaining === 0 ? 'is-bad' : '' }, remaining === 0 ? t('expired') : formatRemaining(remaining))), h('div', null, h('dt', null, t('lastPaired')), h('dd', null, state.lastPairedAt === null ? t('never') : new Date(state.lastPairedAt).toLocaleString(undefined, { hour12: false }))), h('div', null, h('dt', null, t('pairCount')), h('dd', null, String(state.pairingCount)))));
352
358
  return h('div', { className: 'dsh-ma', 'data-testid': 'mobile-access-panel', 'data-state': status.tone }, h('style', null, STYLES), h('div', { className: 'dsh-ma-inner', role: 'region', 'aria-label': t('mainAria') }, h('header', { className: 'dsh-ma-head' }, h('div', { className: 'dsh-ma-mark' }, 'D'), h('div', null, h('h1', null, t('title')), h('p', null, t('subtitle')))), h('div', { className: 'dsh-ma-card dsh-ma-url' }, h('p', { className: 'dsh-ma-label' }, t('urlLabel')), state === null
353
359
  ? h('p', { className: 'dsh-ma-hint' }, loading ? t('statusLoading') : t('statusError'))
354
- : h('code', null, state.pairUrl), h('p', { className: 'dsh-ma-hint' }, t('urlHint')), state?.loopback ? h('div', { className: 'dsh-ma-alert' }, h('strong', null, t('loopbackTitle')), h('p', null, t('loopbackBody'))) : null), error !== null ? h('div', { className: 'dsh-ma-alert' }, h('strong', null, t('errorTitle')), h('p', null, error), h('button', { type: 'button', className: 'dsh-ma-button', onClick: () => { void reload(); } }, t('retry'))) : null, state === null && loading ? h('div', { className: 'dsh-ma-card dsh-ma-hint' }, t('statusLoading')) : null, codeBlock, h('div', { className: 'dsh-ma-card' }, h('h2', null, t('stepsTitle')), h('ol', { className: 'dsh-ma-steps' }, h('li', null, t('step1')), h('li', null, t('step2')), h('li', null, t('step3')))), h('div', { className: 'dsh-ma-card' }, h('h2', null, t('diagnosticsTitle')), h('dl', { className: 'dsh-ma-meta' }, h('div', null, h('dt', null, t('bindLabel')), h('dd', null, state === null ? t('unknown') : `${state.bindHost}:${state.port}`)), h('div', null, h('dt', null, t('deviceLabel')), h('dd', null, isIOS ? t('deviceIOS') : t('deviceOther'))), h('div', null, h('dt', null, t('launchMode')), h('dd', null, standalone ? t('standaloneYes') : t('standaloneNo'))), h('div', null, h('dt', null, t('secureYes')), h('dd', null, window.isSecureContext ? t('secureYes') : t('secureNo'))))), h('div', { className: 'dsh-ma-card' }, h('div', { className: 'dsh-ma-row' }, h('div', null, h('h2', null, t('selftestTitle')), h('p', { className: 'dsh-ma-hint' }, t('selftestNote'))), h('button', { type: 'button', className: 'dsh-ma-button', onClick: () => { void selfTest(); }, disabled: testing }, testing ? t('selftestRunning') : t('selftestRun'))), checks === null ? null : h('ul', { className: 'dsh-ma-checks' }, ...checks.map((check, index) => h('li', { key: index, className: `is-${check.status}` }, h('span', { className: 'dsh-ma-dot' }), h('span', { className: 'dsh-ma-check-label' }, labels[check.label] ?? check.label), h('span', { className: 'dsh-ma-check-status' }, statusText[check.status]), h('span', { className: 'dsh-ma-check-detail' }, check.detail))))), h('div', { className: 'dsh-ma-card dsh-ma-note' }, h('h2', null, t('httpTitle')), h('p', null, t('httpBody')), h('h2', null, t('zeroImpactTitle')), h('p', null, t('zeroImpactBody'))), h('details', { className: 'dsh-ma-card dsh-ma-details' }, h('summary', null, t('limitsTitle')), h('ul', null, h('li', null, t('limit1')), h('li', null, t('limit2')), h('li', null, t('limit3')))), h('p', { className: 'dsh-ma-toast', 'aria-live': 'polite' }, toast ?? '')));
360
+ : h('code', null, state.pairUrl), h('p', { className: 'dsh-ma-hint' }, t('urlHint')), state?.loopback
361
+ ? state.gateway.running
362
+ ? h('div', { className: 'dsh-ma-alert is-ok' }, h('strong', null, t('gatewayTitle')), h('p', null, t('gatewayBody')), state.gateway.publicOrigin === null ? null : h('code', null, state.gateway.publicOrigin))
363
+ : h('div', { className: 'dsh-ma-alert' }, h('strong', null, state.gateway.starting ? t('gatewayStartingTitle') : t('loopbackTitle')), h('p', null, state.gateway.error ?? t('loopbackBody')))
364
+ : null), error !== null ? h('div', { className: 'dsh-ma-alert' }, h('strong', null, t('errorTitle')), h('p', null, error), h('button', { type: 'button', className: 'dsh-ma-button', onClick: () => { void reload(); } }, t('retry'))) : null, state === null && loading ? h('div', { className: 'dsh-ma-card dsh-ma-hint' }, t('statusLoading')) : null, codeBlock, h('div', { className: 'dsh-ma-card' }, h('h2', null, t('stepsTitle')), h('ol', { className: 'dsh-ma-steps' }, h('li', null, t('step1')), h('li', null, t('step2')), h('li', null, t('step3')))), h('div', { className: 'dsh-ma-card' }, h('h2', null, t('diagnosticsTitle')), h('dl', { className: 'dsh-ma-meta' }, h('div', null, h('dt', null, t('bindLabel')), h('dd', null, state === null ? t('unknown') : `${state.bindHost}:${state.port}`)), h('div', null, h('dt', null, t('gatewayLabel')), h('dd', null, state === null ? t('unknown') : state.gateway.running ? (state.gateway.publicOrigin ?? t('gatewayOn')) : t('gatewayOff'))), h('div', null, h('dt', null, t('deviceLabel')), h('dd', null, isIOS ? t('deviceIOS') : t('deviceOther'))), h('div', null, h('dt', null, t('launchMode')), h('dd', null, standalone ? t('standaloneYes') : t('standaloneNo'))), h('div', null, h('dt', null, t('secureYes')), h('dd', null, window.isSecureContext ? t('secureYes') : t('secureNo'))))), h('div', { className: 'dsh-ma-card' }, h('div', { className: 'dsh-ma-row' }, h('div', null, h('h2', null, t('selftestTitle')), h('p', { className: 'dsh-ma-hint' }, t('selftestNote'))), h('button', { type: 'button', className: 'dsh-ma-button', onClick: () => { void selfTest(); }, disabled: testing }, testing ? t('selftestRunning') : t('selftestRun'))), checks === null ? null : h('ul', { className: 'dsh-ma-checks' }, ...checks.map((check, index) => h('li', { key: index, className: `is-${check.status}` }, h('span', { className: 'dsh-ma-dot' }), h('span', { className: 'dsh-ma-check-label' }, labels[check.label] ?? check.label), h('span', { className: 'dsh-ma-check-status' }, statusText[check.status]), h('span', { className: 'dsh-ma-check-detail' }, check.detail))))), h('div', { className: 'dsh-ma-card dsh-ma-note' }, h('h2', null, t('httpTitle')), h('p', null, t('httpBody')), h('h2', null, t('zeroImpactTitle')), h('p', null, t('zeroImpactBody'))), h('details', { className: 'dsh-ma-card dsh-ma-details' }, h('summary', null, t('limitsTitle')), h('ul', null, h('li', null, t('limit1')), h('li', null, t('limit2')), h('li', null, t('limit3')))), h('p', { className: 'dsh-ma-toast', 'aria-live': 'polite' }, toast ?? '')));
355
365
  };
356
366
  }
357
- const STYLES = ".dsh-ma{height:100%;min-height:100%;overflow:auto;background:var(--dsw-alias-bg-base,#fff);color:var(--dsw-alias-label-primary,#111827)}\n.dsh-ma *{box-sizing:border-box}\n.dsh-ma-inner{width:100%;max-width:860px;margin:0 auto;padding:clamp(18px,4vw,36px);display:flex;flex-direction:column;gap:14px}\n.dsh-ma-head{display:flex;align-items:center;gap:14px;margin-bottom:4px}\n.dsh-ma-mark{display:grid;place-items:center;flex:0 0 46px;width:46px;height:46px;border-radius:14px;background:var(--dsw-alias-brand-primary,#2f5bea);color:#fff;font:800 21px/1 -apple-system,BlinkMacSystemFont,sans-serif}\n.dsh-ma-head h1{margin:0 0 3px;font-size:24px;line-height:1.2;letter-spacing:-.02em}\n.dsh-ma-head p{margin:0;color:var(--dsw-alias-label-secondary,#5f6b7a);font-size:14px;line-height:1.5}\n.dsh-ma-card{padding:16px;border:1px solid var(--dsw-alias-border-l2,#dfe4ec);border-radius:16px;background:var(--dsw-alias-bg-layer-1,#f8fafc)}\n.dsh-ma-card h2{margin:0 0 8px;font-size:15px;line-height:1.35}\n.dsh-ma-card p{margin:0 0 8px;color:var(--dsw-alias-label-secondary,#5f6b7a);font-size:14px;line-height:1.6}\n.dsh-ma-card p:last-child{margin-bottom:0}\n.dsh-ma-row{display:flex;align-items:flex-start;justify-content:space-between;gap:12px;flex-wrap:wrap}\n.dsh-ma-label{margin:0 0 5px!important;color:var(--dsw-alias-label-tertiary,#6b7280)!important;font-size:12px!important;font-weight:700;letter-spacing:.06em;text-transform:uppercase}\n.dsh-ma-url code{display:block;padding:12px 13px;border:1px solid var(--dsw-alias-border-l2,#dfe4ec);border-radius:11px;background:var(--dsw-alias-bg-base,#fff);color:var(--dsw-alias-label-primary,#111827);font:600 15px/1.45 ui-monospace,\"SF Mono\",Menlo,monospace;word-break:break-all}\n.dsh-ma-hint{color:var(--dsw-alias-label-tertiary,#6b7280)!important;font-size:12px!important}\n.dsh-ma-code{margin:3px 0 6px;font:800 clamp(28px,6vw,40px)/1.1 ui-monospace,\"SF Mono\",Menlo,monospace;font-variant-numeric:tabular-nums;letter-spacing:.1em;color:var(--dsw-alias-brand-primary,#2f5bea)}\n.dsh-ma-pill{display:inline-flex;align-items:center;gap:7px;min-height:28px;padding:4px 10px;border:1px solid var(--dsw-alias-border-l2,#dfe4ec);border-radius:999px;font-size:12px;font-weight:700;white-space:nowrap}\n.dsh-ma-pill.is-ok{color:var(--dsw-alias-state-success-primary,#087443)}\n.dsh-ma-pill.is-warn{color:#b45309}\n.dsh-ma-pill.is-bad{color:var(--dsw-alias-state-error-primary,#b42318)}\n.dsh-ma-dot{display:inline-block;flex:0 0 8px;width:8px;height:8px;border-radius:50%;background:currentColor}\n.dsh-ma-pill.is-ok .dsh-ma-dot{background:var(--dsw-alias-state-success-primary,#087443)}\n.dsh-ma-pill.is-warn .dsh-ma-dot{background:#d97706}\n.dsh-ma-pill.is-bad .dsh-ma-dot{background:var(--dsw-alias-state-error-primary,#b42318)}\n.dsh-ma-actions{display:flex;flex-wrap:wrap;gap:8px;margin:12px 0}\n.dsh-ma-button{display:inline-flex;align-items:center;justify-content:center;min-height:40px;padding:8px 13px;border:1px solid var(--dsw-alias-border-l2,#d5dbe5);border-radius:10px;background:var(--dsw-alias-bg-base,#fff);color:var(--dsw-alias-label-primary,#111827);font:650 13px/1.2 -apple-system,BlinkMacSystemFont,sans-serif;cursor:pointer;-webkit-tap-highlight-color:transparent}\n.dsh-ma-button.primary{border-color:var(--dsw-alias-brand-primary,#2f5bea);background:var(--dsw-alias-brand-primary,#2f5bea);color:#fff}\n.dsh-ma-button:hover:not(:disabled){border-color:var(--dsw-alias-brand-primary,#2f5bea)}\n.dsh-ma-button:disabled{opacity:.55;cursor:not-allowed}\n.dsh-ma-button:focus-visible{outline:2px solid var(--dsw-alias-brand-primary,#2f5bea);outline-offset:2px}\n.dsh-ma-meta{display:grid;grid-template-columns:repeat(auto-fit,minmax(150px,1fr));gap:10px 16px;margin:8px 0 0}\n.dsh-ma-meta div{min-width:0}\n.dsh-ma-meta dt{margin:0 0 2px;color:var(--dsw-alias-label-tertiary,#6b7280);font-size:11px;font-weight:700;letter-spacing:.04em;text-transform:uppercase}\n.dsh-ma-meta dd{margin:0;font-size:13px;line-height:1.45;word-break:break-word}\n.dsh-ma-meta dd.is-bad{color:var(--dsw-alias-state-error-primary,#b42318)}\n.dsh-ma-alert{margin:12px 0 0;padding:12px 13px;border:1px solid color-mix(in srgb,var(--dsw-alias-state-error-primary,#b42318) 35%,transparent);border-radius:12px;background:color-mix(in srgb,var(--dsw-alias-state-error-primary,#b42318) 8%,transparent);color:var(--dsw-alias-state-error-primary,#b42318)}\n.dsh-ma-alert strong{display:block;margin-bottom:4px;font-size:13px}\n.dsh-ma-alert p{color:inherit!important;font-size:13px!important;line-height:1.55!important}\n.dsh-ma-steps{margin:0;padding-left:20px;color:var(--dsw-alias-label-secondary,#5f6b7a);font-size:14px;line-height:1.65}\n.dsh-ma-steps li{margin:0 0 7px;padding-left:2px}\n.dsh-ma-steps li:last-child{margin-bottom:0}\n.dsh-ma-checks{display:flex;flex-direction:column;gap:7px;margin:10px 0 0;padding:0;list-style:none}\n.dsh-ma-checks li{display:flex;align-items:center;gap:8px;flex-wrap:wrap;font-size:13px;color:var(--dsw-alias-label-secondary,#5f6b7a)}\n.dsh-ma-checks li.is-pass .dsh-ma-dot{background:var(--dsw-alias-state-success-primary,#087443)}\n.dsh-ma-checks li.is-warn .dsh-ma-dot{background:#d97706}\n.dsh-ma-checks li.is-fail .dsh-ma-dot{background:var(--dsw-alias-state-error-primary,#b42318)}\n.dsh-ma-check-label{color:var(--dsw-alias-label-primary,#111827);font-weight:650}\n.dsh-ma-check-status{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.05em}\n.dsh-ma-check-detail{margin-left:auto;color:var(--dsw-alias-label-tertiary,#6b7280);font-size:12px;word-break:break-word}\n.dsh-ma-note h2{font-size:13px;margin-top:4px}\n.dsh-ma-details summary{cursor:pointer;font-size:14px;font-weight:700}\n.dsh-ma-details ul{margin:10px 0 0;padding-left:20px;color:var(--dsw-alias-label-secondary,#5f6b7a);font-size:13px;line-height:1.65}\n.dsh-ma-toast{min-height:20px;margin:0!important;text-align:center;color:var(--dsw-alias-brand-primary,#2f5bea)!important;font-size:13px!important;font-weight:650}\n@media (max-width:560px){\n.dsh-ma-inner{padding:max(14px,env(safe-area-inset-top)) max(14px,env(safe-area-inset-right)) max(18px,env(safe-area-inset-bottom)) max(14px,env(safe-area-inset-left));gap:11px}\n.dsh-ma-card{padding:14px;border-radius:14px}\n.dsh-ma-head h1{font-size:21px}\n.dsh-ma-mark{flex-basis:40px;width:40px;height:40px;border-radius:12px;font-size:18px}\n.dsh-ma-actions .dsh-ma-button{flex:1 1 140px;min-height:44px}\n.dsh-ma-check-detail{width:100%;margin-left:16px}\n}\n@media (prefers-reduced-motion:no-preference){\n.dsh-ma-button{transition:border-color .15s ease,background-color .15s ease,opacity .15s ease}\n}";
367
+ const STYLES = ".dsh-ma{height:100%;min-height:100%;overflow:auto;background:var(--dsw-alias-bg-base,#fff);color:var(--dsw-alias-label-primary,#111827)}\n.dsh-ma *{box-sizing:border-box}\n.dsh-ma-inner{width:100%;max-width:860px;margin:0 auto;padding:clamp(18px,4vw,36px);display:flex;flex-direction:column;gap:14px}\n.dsh-ma-head{display:flex;align-items:center;gap:14px;margin-bottom:4px}\n.dsh-ma-mark{display:grid;place-items:center;flex:0 0 46px;width:46px;height:46px;border-radius:14px;background:var(--dsw-alias-brand-primary,#2f5bea);color:#fff;font:800 21px/1 -apple-system,BlinkMacSystemFont,sans-serif}\n.dsh-ma-head h1{margin:0 0 3px;font-size:24px;line-height:1.2;letter-spacing:-.02em}\n.dsh-ma-head p{margin:0;color:var(--dsw-alias-label-secondary,#5f6b7a);font-size:14px;line-height:1.5}\n.dsh-ma-card{padding:16px;border:1px solid var(--dsw-alias-border-l2,#dfe4ec);border-radius:16px;background:var(--dsw-alias-bg-layer-1,#f8fafc)}\n.dsh-ma-card h2{margin:0 0 8px;font-size:15px;line-height:1.35}\n.dsh-ma-card p{margin:0 0 8px;color:var(--dsw-alias-label-secondary,#5f6b7a);font-size:14px;line-height:1.6}\n.dsh-ma-card p:last-child{margin-bottom:0}\n.dsh-ma-row{display:flex;align-items:flex-start;justify-content:space-between;gap:12px;flex-wrap:wrap}\n.dsh-ma-label{margin:0 0 5px!important;color:var(--dsw-alias-label-tertiary,#6b7280)!important;font-size:12px!important;font-weight:700;letter-spacing:.06em;text-transform:uppercase}\n.dsh-ma-url code{display:block;padding:12px 13px;border:1px solid var(--dsw-alias-border-l2,#dfe4ec);border-radius:11px;background:var(--dsw-alias-bg-base,#fff);color:var(--dsw-alias-label-primary,#111827);font:600 15px/1.45 ui-monospace,\"SF Mono\",Menlo,monospace;word-break:break-all}\n.dsh-ma-hint{color:var(--dsw-alias-label-tertiary,#6b7280)!important;font-size:12px!important}\n.dsh-ma-code{margin:3px 0 6px;font:800 clamp(28px,6vw,40px)/1.1 ui-monospace,\"SF Mono\",Menlo,monospace;font-variant-numeric:tabular-nums;letter-spacing:.1em;color:var(--dsw-alias-brand-primary,#2f5bea)}\n.dsh-ma-pill{display:inline-flex;align-items:center;gap:7px;min-height:28px;padding:4px 10px;border:1px solid var(--dsw-alias-border-l2,#dfe4ec);border-radius:999px;font-size:12px;font-weight:700;white-space:nowrap}\n.dsh-ma-pill.is-ok{color:var(--dsw-alias-state-success-primary,#087443)}\n.dsh-ma-pill.is-warn{color:#b45309}\n.dsh-ma-pill.is-bad{color:var(--dsw-alias-state-error-primary,#b42318)}\n.dsh-ma-dot{display:inline-block;flex:0 0 8px;width:8px;height:8px;border-radius:50%;background:currentColor}\n.dsh-ma-pill.is-ok .dsh-ma-dot{background:var(--dsw-alias-state-success-primary,#087443)}\n.dsh-ma-pill.is-warn .dsh-ma-dot{background:#d97706}\n.dsh-ma-pill.is-bad .dsh-ma-dot{background:var(--dsw-alias-state-error-primary,#b42318)}\n.dsh-ma-actions{display:flex;flex-wrap:wrap;gap:8px;margin:12px 0}\n.dsh-ma-button{display:inline-flex;align-items:center;justify-content:center;min-height:40px;padding:8px 13px;border:1px solid var(--dsw-alias-border-l2,#d5dbe5);border-radius:10px;background:var(--dsw-alias-bg-base,#fff);color:var(--dsw-alias-label-primary,#111827);font:650 13px/1.2 -apple-system,BlinkMacSystemFont,sans-serif;cursor:pointer;-webkit-tap-highlight-color:transparent}\n.dsh-ma-button.primary{border-color:var(--dsw-alias-brand-primary,#2f5bea);background:var(--dsw-alias-brand-primary,#2f5bea);color:#fff}\n.dsh-ma-button:hover:not(:disabled){border-color:var(--dsw-alias-brand-primary,#2f5bea)}\n.dsh-ma-button:disabled{opacity:.55;cursor:not-allowed}\n.dsh-ma-button:focus-visible{outline:2px solid var(--dsw-alias-brand-primary,#2f5bea);outline-offset:2px}\n.dsh-ma-meta{display:grid;grid-template-columns:repeat(auto-fit,minmax(150px,1fr));gap:10px 16px;margin:8px 0 0}\n.dsh-ma-meta div{min-width:0}\n.dsh-ma-meta dt{margin:0 0 2px;color:var(--dsw-alias-label-tertiary,#6b7280);font-size:11px;font-weight:700;letter-spacing:.04em;text-transform:uppercase}\n.dsh-ma-meta dd{margin:0;font-size:13px;line-height:1.45;word-break:break-word}\n.dsh-ma-meta dd.is-bad{color:var(--dsw-alias-state-error-primary,#b42318)}\n.dsh-ma-alert{margin:12px 0 0;padding:12px 13px;border:1px solid color-mix(in srgb,var(--dsw-alias-state-error-primary,#b42318) 35%,transparent);border-radius:12px;background:color-mix(in srgb,var(--dsw-alias-state-error-primary,#b42318) 8%,transparent);color:var(--dsw-alias-state-error-primary,#b42318)}\n.dsh-ma-alert strong{display:block;margin-bottom:4px;font-size:13px}\n.dsh-ma-alert p{color:inherit!important;font-size:13px!important;line-height:1.55!important}\n.dsh-ma-alert.is-ok{border-color:color-mix(in srgb,var(--dsw-alias-state-success-primary,#087443) 35%,transparent);background:color-mix(in srgb,var(--dsw-alias-state-success-primary,#087443) 8%,transparent);color:var(--dsw-alias-state-success-primary,#087443)}\n.dsh-ma-steps{margin:0;padding-left:20px;color:var(--dsw-alias-label-secondary,#5f6b7a);font-size:14px;line-height:1.65}\n.dsh-ma-steps li{margin:0 0 7px;padding-left:2px}\n.dsh-ma-steps li:last-child{margin-bottom:0}\n.dsh-ma-checks{display:flex;flex-direction:column;gap:7px;margin:10px 0 0;padding:0;list-style:none}\n.dsh-ma-checks li{display:flex;align-items:center;gap:8px;flex-wrap:wrap;font-size:13px;color:var(--dsw-alias-label-secondary,#5f6b7a)}\n.dsh-ma-checks li.is-pass .dsh-ma-dot{background:var(--dsw-alias-state-success-primary,#087443)}\n.dsh-ma-checks li.is-warn .dsh-ma-dot{background:#d97706}\n.dsh-ma-checks li.is-fail .dsh-ma-dot{background:var(--dsw-alias-state-error-primary,#b42318)}\n.dsh-ma-check-label{color:var(--dsw-alias-label-primary,#111827);font-weight:650}\n.dsh-ma-check-status{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.05em}\n.dsh-ma-check-detail{margin-left:auto;color:var(--dsw-alias-label-tertiary,#6b7280);font-size:12px;word-break:break-word}\n.dsh-ma-note h2{font-size:13px;margin-top:4px}\n.dsh-ma-details summary{cursor:pointer;font-size:14px;font-weight:700}\n.dsh-ma-details ul{margin:10px 0 0;padding-left:20px;color:var(--dsw-alias-label-secondary,#5f6b7a);font-size:13px;line-height:1.65}\n.dsh-ma-toast{min-height:20px;margin:0!important;text-align:center;color:var(--dsw-alias-brand-primary,#2f5bea)!important;font-size:13px!important;font-weight:650}\n@media (max-width:560px){\n.dsh-ma-inner{padding:max(14px,env(safe-area-inset-top)) max(14px,env(safe-area-inset-right)) max(18px,env(safe-area-inset-bottom)) max(14px,env(safe-area-inset-left));gap:11px}\n.dsh-ma-card{padding:14px;border-radius:14px}\n.dsh-ma-head h1{font-size:21px}\n.dsh-ma-mark{flex-basis:40px;width:40px;height:40px;border-radius:12px;font-size:18px}\n.dsh-ma-actions .dsh-ma-button{flex:1 1 140px;min-height:44px}\n.dsh-ma-check-detail{width:100%;margin-left:16px}\n}\n@media (prefers-reduced-motion:no-preference){\n.dsh-ma-button{transition:border-color .15s ease,background-color .15s ease,opacity .15s ease}\n}";
358
368
  function factory(require) {
359
369
  React = require('react');
360
370
  return {
package/gateway.js ADDED
@@ -0,0 +1,368 @@
1
+ /**
2
+ * Loopback-only DSH needs one LAN entry to be reachable from a phone. This
3
+ * gateway is deliberately small and additive: it forwards HTTP and the one
4
+ * DSH WebSocket endpoint to the loopback listener, rewriting Host/Origin so
5
+ * the existing Connection trust fence and cookies keep their normal meaning.
6
+ * The DSH listener itself never changes and the Mac keeps using 127.0.0.1.
7
+ */
8
+ import { createServer, request as httpRequest } from 'node:http';
9
+ import { connect } from 'node:net';
10
+ import { networkInterfaces } from 'node:os';
11
+ import { isLoopbackHost } from './service.js';
12
+ const DEFAULT_PORT = 3081;
13
+ const PORT_ATTEMPTS = 10;
14
+ const UPGRADE_PATH = '/api/remote.mux';
15
+ const HOP_BY_HOP = new Set([
16
+ 'connection',
17
+ 'keep-alive',
18
+ 'proxy-authenticate',
19
+ 'proxy-authorization',
20
+ 'proxy-connection',
21
+ 'te',
22
+ 'trailer',
23
+ 'transfer-encoding',
24
+ 'upgrade',
25
+ ]);
26
+ export const EMPTY_GATEWAY_STATUS = {
27
+ available: false,
28
+ running: false,
29
+ starting: false,
30
+ port: null,
31
+ publicOrigin: null,
32
+ lanAddresses: [],
33
+ error: null,
34
+ };
35
+ function errorText(error) {
36
+ return error instanceof Error ? error.message : String(error);
37
+ }
38
+ function stripBrackets(host) {
39
+ return host.startsWith('[') && host.endsWith(']') ? host.slice(1, -1) : host;
40
+ }
41
+ function isPrivate(address) {
42
+ if (address.startsWith('10.'))
43
+ return true;
44
+ if (address.startsWith('192.168.'))
45
+ return true;
46
+ const parts = address.split('.');
47
+ if (parts.length !== 4)
48
+ return false;
49
+ const second = Number(parts[1]);
50
+ return parts[0] === '172' && second >= 16 && second <= 31;
51
+ }
52
+ function interfaceScore(name, address) {
53
+ const lower = name.toLowerCase();
54
+ if (lower === 'en0' || lower === 'wi-fi' || lower === 'wlan0')
55
+ return 0;
56
+ if (lower === 'en1' || lower === 'ethernet' || lower === 'eth0')
57
+ return 1;
58
+ if (lower.startsWith('utun') || lower.includes('tailscale'))
59
+ return 40;
60
+ return isPrivate(address) ? 10 : 20;
61
+ }
62
+ /** Every usable non-loopback IPv4 address, best candidate first. */
63
+ export function lanIPv4Addresses() {
64
+ const found = [];
65
+ for (const [name, entries] of Object.entries(networkInterfaces())) {
66
+ for (const entry of entries ?? []) {
67
+ if (entry.internal)
68
+ continue;
69
+ if (entry.family !== 'IPv4')
70
+ continue;
71
+ if (entry.address.startsWith('127.') || entry.address.startsWith('169.254.'))
72
+ continue;
73
+ if (entry.address === '0.0.0.0')
74
+ continue;
75
+ found.push({ address: entry.address, score: interfaceScore(name, entry.address) });
76
+ }
77
+ }
78
+ const seen = new Set();
79
+ return found
80
+ .sort((left, right) => left.score - right.score || left.address.localeCompare(right.address))
81
+ .map((entry) => entry.address)
82
+ .filter((address) => {
83
+ if (seen.has(address))
84
+ return false;
85
+ seen.add(address);
86
+ return true;
87
+ });
88
+ }
89
+ function closeServer(server) {
90
+ return new Promise((resolve) => {
91
+ server.close(() => resolve());
92
+ setTimeout(resolve, 500).unref();
93
+ });
94
+ }
95
+ export class LanGateway {
96
+ target;
97
+ targetHost;
98
+ targetPort;
99
+ preferredPort;
100
+ addresses;
101
+ unavailable;
102
+ server;
103
+ port = null;
104
+ running = false;
105
+ starting = false;
106
+ error = null;
107
+ stopped = false;
108
+ sockets = new Set();
109
+ constructor(options) {
110
+ this.target = new URL(options.targetOrigin);
111
+ this.targetHost = stripBrackets(this.target.hostname);
112
+ this.targetPort = Number(this.target.port || (this.target.protocol === 'https:' ? 443 : 80));
113
+ this.preferredPort = options.preferredPort ?? DEFAULT_PORT;
114
+ this.addresses = options.addresses ?? lanIPv4Addresses();
115
+ if (!isLoopbackHost(this.target.hostname)) {
116
+ this.unavailable = null;
117
+ }
118
+ else if (this.target.protocol !== 'http:') {
119
+ this.unavailable = 'HTTPS DSH 监听地址不能由 HTTP 网关代理;请继续使用局域网绑定或受信任证书。';
120
+ }
121
+ else if (this.addresses.length === 0) {
122
+ this.unavailable = '未找到可用的局域网 IPv4 地址;请检查 Mac/Windows 的网络连接。';
123
+ }
124
+ else {
125
+ this.unavailable = null;
126
+ }
127
+ }
128
+ get available() {
129
+ return this.unavailable === null && isLoopbackHost(this.target.hostname);
130
+ }
131
+ status() {
132
+ const publicAddress = this.addresses[0];
133
+ return {
134
+ available: this.available,
135
+ running: this.running,
136
+ starting: this.starting,
137
+ port: this.port,
138
+ publicOrigin: this.running && this.port !== null && publicAddress !== undefined
139
+ ? `http://${publicAddress}:${this.port}`
140
+ : null,
141
+ lanAddresses: this.addresses,
142
+ error: this.error ?? (this.available ? null : this.unavailable),
143
+ };
144
+ }
145
+ async start() {
146
+ if (this.stopped || this.running || this.starting)
147
+ return;
148
+ if (!this.available) {
149
+ this.error = this.unavailable;
150
+ return;
151
+ }
152
+ this.starting = true;
153
+ this.error = null;
154
+ try {
155
+ const server = createServer((request, response) => this.onRequest(request, response));
156
+ server.on('upgrade', (request, socket, head) => this.onUpgrade(request, socket, head));
157
+ server.on('connection', (socket) => this.track(socket));
158
+ server.on('clientError', (_error, socket) => {
159
+ if (socket.writable)
160
+ socket.end('HTTP/1.1 400 Bad Request\r\nConnection: close\r\n\r\n');
161
+ });
162
+ const port = await listenOnFreePort(server, this.preferredPort);
163
+ this.server = server;
164
+ this.port = port;
165
+ this.running = true;
166
+ server.on('error', (caught) => {
167
+ this.error = errorText(caught);
168
+ this.running = false;
169
+ });
170
+ }
171
+ catch (caught) {
172
+ this.error = errorText(caught);
173
+ await this.closeServer();
174
+ }
175
+ finally {
176
+ this.starting = false;
177
+ }
178
+ }
179
+ async stop() {
180
+ this.stopped = true;
181
+ this.running = false;
182
+ this.port = null;
183
+ for (const socket of this.sockets)
184
+ socket.destroy();
185
+ this.sockets.clear();
186
+ await this.closeServer();
187
+ }
188
+ async closeServer() {
189
+ const server = this.server;
190
+ this.server = undefined;
191
+ if (server === undefined)
192
+ return;
193
+ await closeServer(server);
194
+ }
195
+ track(socket) {
196
+ this.sockets.add(socket);
197
+ socket.on('close', () => this.sockets.delete(socket));
198
+ socket.on('error', () => this.sockets.delete(socket));
199
+ }
200
+ get publicOrigin() {
201
+ const address = this.addresses[0];
202
+ return address === undefined || this.port === null ? null : `http://${address}:${this.port}`;
203
+ }
204
+ rewriteOrigin(value) {
205
+ const publicOrigin = this.publicOrigin;
206
+ return publicOrigin !== null && value === publicOrigin ? this.target.origin : value;
207
+ }
208
+ rewriteReferer(value) {
209
+ const publicOrigin = this.publicOrigin;
210
+ if (publicOrigin === null || !value.startsWith(`${publicOrigin}/`))
211
+ return value;
212
+ return `${this.target.origin}${value.slice(publicOrigin.length)}`;
213
+ }
214
+ requestHeaders(request) {
215
+ const headers = {};
216
+ for (const [name, value] of Object.entries(request.headers)) {
217
+ if (value === undefined)
218
+ continue;
219
+ const lower = name.toLowerCase();
220
+ if (HOP_BY_HOP.has(lower) || lower === 'host' || lower.startsWith('x-forwarded-'))
221
+ continue;
222
+ if (lower === 'origin') {
223
+ headers.origin = this.rewriteOrigin(String(value));
224
+ continue;
225
+ }
226
+ if (lower === 'referer') {
227
+ headers.referer = this.rewriteReferer(String(value));
228
+ continue;
229
+ }
230
+ headers[lower] = value;
231
+ }
232
+ headers.host = this.target.host;
233
+ const publicOrigin = this.publicOrigin;
234
+ if (publicOrigin !== null) {
235
+ const publicUrl = new URL(publicOrigin);
236
+ headers['x-forwarded-host'] = publicUrl.host;
237
+ headers['x-forwarded-proto'] = 'http';
238
+ }
239
+ return headers;
240
+ }
241
+ responseHeaders(headers) {
242
+ const result = {};
243
+ for (const [name, value] of Object.entries(headers)) {
244
+ if (value === undefined)
245
+ continue;
246
+ const lower = name.toLowerCase();
247
+ if (HOP_BY_HOP.has(lower))
248
+ continue;
249
+ if (lower === 'location' && typeof value === 'string') {
250
+ const publicOrigin = this.publicOrigin;
251
+ result.location = publicOrigin !== null && value.startsWith(`${this.target.origin}/`)
252
+ ? `${publicOrigin}${value.slice(this.target.origin.length)}`
253
+ : value;
254
+ continue;
255
+ }
256
+ if (lower === 'set-cookie') {
257
+ result['set-cookie'] = (Array.isArray(value) ? value : [value])
258
+ .map((cookie) => cookie.replaceAll(/;\s*Domain=[^;]*/gi, ''));
259
+ continue;
260
+ }
261
+ result[lower] = value;
262
+ }
263
+ return result;
264
+ }
265
+ onRequest(request, response) {
266
+ if (!this.running) {
267
+ response.writeHead(503, { 'content-type': 'text/plain; charset=utf-8' });
268
+ response.end('LAN gateway is not running');
269
+ return;
270
+ }
271
+ const upstream = httpRequest({
272
+ protocol: 'http:',
273
+ hostname: this.targetHost,
274
+ port: this.targetPort,
275
+ method: request.method,
276
+ path: request.url,
277
+ headers: this.requestHeaders(request),
278
+ }, (incoming) => {
279
+ response.writeHead(incoming.statusCode ?? 502, this.responseHeaders(incoming.headers));
280
+ incoming.pipe(response);
281
+ });
282
+ upstream.on('error', () => {
283
+ if (!response.headersSent) {
284
+ response.writeHead(502, { 'content-type': 'text/plain; charset=utf-8' });
285
+ response.end('LAN gateway upstream error');
286
+ }
287
+ else {
288
+ response.destroy();
289
+ }
290
+ });
291
+ request.on('error', () => upstream.destroy());
292
+ request.pipe(upstream);
293
+ }
294
+ onUpgrade(request, socket, head) {
295
+ if (!this.running) {
296
+ socket.destroy();
297
+ return;
298
+ }
299
+ let pathname;
300
+ try {
301
+ pathname = new URL(request.url ?? '/', this.target.origin).pathname;
302
+ }
303
+ catch {
304
+ socket.end('HTTP/1.1 400 Bad Request\r\nConnection: close\r\n\r\n');
305
+ return;
306
+ }
307
+ if (pathname !== UPGRADE_PATH) {
308
+ socket.end('HTTP/1.1 404 Not Found\r\nConnection: close\r\n\r\n');
309
+ return;
310
+ }
311
+ const upstream = connect({ host: this.targetHost, port: this.targetPort }, () => {
312
+ const headers = this.requestHeaders(request);
313
+ headers.connection = 'Upgrade';
314
+ headers.upgrade = 'websocket';
315
+ const lines = [`GET ${request.url ?? '/'} HTTP/1.1`];
316
+ for (const [name, value] of Object.entries(headers)) {
317
+ if (value === undefined)
318
+ continue;
319
+ if (Array.isArray(value)) {
320
+ for (const item of value)
321
+ lines.push(`${name}: ${item}`);
322
+ }
323
+ else {
324
+ lines.push(`${name}: ${String(value)}`);
325
+ }
326
+ }
327
+ upstream.write(`${lines.join('\r\n')}\r\n\r\n`);
328
+ if (head.length > 0)
329
+ upstream.write(head);
330
+ socket.pipe(upstream);
331
+ upstream.pipe(socket);
332
+ });
333
+ this.track(upstream);
334
+ upstream.on('error', () => socket.destroy());
335
+ socket.on('error', () => upstream.destroy());
336
+ socket.on('close', () => upstream.destroy());
337
+ }
338
+ }
339
+ async function listenOnFreePort(server, preferredPort) {
340
+ const candidates = Array.from({ length: PORT_ATTEMPTS }, (_, index) => preferredPort + index);
341
+ candidates.push(0);
342
+ let lastError;
343
+ for (const port of candidates) {
344
+ try {
345
+ await new Promise((resolve, reject) => {
346
+ const onError = (caught) => {
347
+ server.off('listening', onListening);
348
+ reject(caught);
349
+ };
350
+ const onListening = () => {
351
+ server.off('error', onError);
352
+ resolve();
353
+ };
354
+ server.once('error', onError);
355
+ server.once('listening', onListening);
356
+ server.listen({ host: '0.0.0.0', port, exclusive: true });
357
+ });
358
+ const address = server.address();
359
+ return typeof address === 'object' && address !== null ? address.port : port;
360
+ }
361
+ catch (caught) {
362
+ lastError = caught;
363
+ if (server.listening)
364
+ await closeServer(server);
365
+ }
366
+ }
367
+ throw lastError instanceof Error ? lastError : new Error('no free LAN gateway port');
368
+ }
package/index.js CHANGED
@@ -1,19 +1,39 @@
1
1
  /** Host half of the independent `dsh-mobile-access` bundle. */
2
+ import { LanGateway } from './gateway.js';
2
3
  import { registerMobileAccess } from './routes.js';
3
4
  import { PairingService } from './service.js';
4
5
  export const name = 'dsh-mobile-access';
5
6
  export const inject = ['webServer', 'connection'];
6
7
  /**
7
- * Register exactly three additive routes. No existing route, auth record,
8
- * profile setting, layout component, or client root is read or replaced.
8
+ * Register the additive routes and, only when the official listener is bound
9
+ * to loopback, start the optional LAN gateway. A DSH already bound to a LAN
10
+ * address never opens another listener.
9
11
  */
10
12
  export function apply(ctx) {
11
13
  const service = new PairingService();
14
+ let gateway;
15
+ try {
16
+ gateway = new LanGateway({ targetOrigin: ctx.webServer.getOrigin() });
17
+ }
18
+ catch (error) {
19
+ ctx.logger?.warn?.(`[mobile-access] gateway disabled: ${error instanceof Error ? error.message : String(error)}`);
20
+ }
12
21
  ctx.effect(() => {
13
- const dispose = registerMobileAccess(ctx, service);
22
+ const dispose = registerMobileAccess(ctx, service, gateway);
23
+ if (gateway !== undefined) {
24
+ void gateway.start().then(() => {
25
+ const status = gateway?.status();
26
+ if (status?.running === true)
27
+ ctx.logger?.info?.(`[mobile-access] LAN gateway on ${status.publicOrigin ?? 'unknown'}`);
28
+ }).catch((error) => {
29
+ ctx.logger?.warn?.(`[mobile-access] gateway failed: ${error instanceof Error ? error.message : String(error)}`);
30
+ });
31
+ }
14
32
  ctx.logger?.info?.('[mobile-access] routes registered');
15
33
  return () => {
16
34
  dispose();
35
+ if (gateway !== undefined)
36
+ void gateway.stop();
17
37
  ctx.logger?.info?.('[mobile-access] routes disposed');
18
38
  };
19
39
  }, 'mobile-access routes');
package/locale/en.json CHANGED
@@ -10,6 +10,8 @@
10
10
  "statusLoading": "Reading pairing state",
11
11
  "statusReady": "Ready",
12
12
  "statusLoopback": "Loopback only",
13
+ "statusGateway": "LAN gateway on",
14
+ "statusGatewayStarting": "Starting LAN gateway",
13
15
  "statusLocked": "Code locked",
14
16
  "statusExpired": "Code expired",
15
17
  "statusError": "State unavailable",
@@ -40,6 +42,9 @@
40
42
  "step3": "After pairing, use Safari Share → “Add to Home Screen”.",
41
43
  "diagnosticsTitle": "Connection diagnostics",
42
44
  "bindLabel": "Mac listener",
45
+ "gatewayLabel": "LAN gateway",
46
+ "gatewayOn": "On",
47
+ "gatewayOff": "Off",
43
48
  "deviceLabel": "This device",
44
49
  "deviceIOS": "iPhone / iPad",
45
50
  "deviceOther": "Computer or other device",
@@ -49,7 +54,10 @@
49
54
  "secureYes": "Secure context",
50
55
  "secureNo": "HTTP insecure context (clipboard fallback enabled)",
51
56
  "loopbackTitle": "The iPhone cannot connect yet",
52
- "loopbackBody": "DSH is listening on loopback only. Set Host webserver.host to this Mac’s LAN IPv4 address, then restart DSH manually.",
57
+ "loopbackBody": "DSH is listening on loopback only. The plugin is starting a LAN gateway; if it fails, diagnostics appear here. The Mac keeps using 127.0.0.1.",
58
+ "gatewayTitle": "LAN gateway is on",
59
+ "gatewayBody": "DSH still listens only on 127.0.0.1. The iPhone uses the LAN address below; the Mac keeps its original address.",
60
+ "gatewayStartingTitle": "Starting LAN gateway",
53
61
  "httpTitle": "About HTTP",
54
62
  "httpBody": "This is plain-HTTP LAN access. DSH already handles UUIDs and most copy buttons; this plugin adds a fallback for older direct navigator.clipboard calls on iPhone.",
55
63
  "selftestTitle": "Browser capability self-test",
package/locale/zh.json CHANGED
@@ -10,6 +10,8 @@
10
10
  "statusLoading": "正在读取配对状态",
11
11
  "statusReady": "已就绪",
12
12
  "statusLoopback": "仅回环地址",
13
+ "statusGateway": "局域网网关已开启",
14
+ "statusGatewayStarting": "正在开启局域网网关",
13
15
  "statusLocked": "配对码已锁定",
14
16
  "statusExpired": "配对码已过期",
15
17
  "statusError": "暂时无法读取状态",
@@ -40,6 +42,9 @@
40
42
  "step3": "配对成功后会自动进入 DSH;再点 Safari 分享按钮,选择「添加到主屏幕」。",
41
43
  "diagnosticsTitle": "连接诊断",
42
44
  "bindLabel": "Mac 监听地址",
45
+ "gatewayLabel": "局域网网关",
46
+ "gatewayOn": "已开启",
47
+ "gatewayOff": "未开启",
43
48
  "deviceLabel": "当前设备",
44
49
  "deviceIOS": "iPhone / iPad",
45
50
  "deviceOther": "电脑或其他设备",
@@ -49,7 +54,10 @@
49
54
  "secureYes": "安全上下文",
50
55
  "secureNo": "HTTP 非安全上下文(剪贴板回退已启用)",
51
56
  "loopbackTitle": "iPhone 现在无法连接",
52
- "loopbackBody": "DSH 只监听了回环地址(localhost / 127.0.0.1)。请把 Host 的 webserver.host 改为这台 Mac 的局域网 IPv4 地址,然后由你手动重启 DSH。",
57
+ "loopbackBody": "DSH 只监听回环地址;插件正在尝试开启局域网网关。若失败,诊断信息会显示在这里,Mac 仍可正常使用 127.0.0.1。",
58
+ "gatewayTitle": "局域网网关已开启",
59
+ "gatewayBody": "DSH 仍只监听 127.0.0.1;iPhone 通过下面的局域网地址访问。Mac 继续用原地址,不受影响。",
60
+ "gatewayStartingTitle": "正在开启局域网网关",
53
61
  "httpTitle": "关于 HTTP 访问",
54
62
  "httpBody": "当前是局域网 HTTP。DSH 已为 UUID 和多数复制按钮提供兼容实现;本插件会在 iPhone 上补齐直接调用 navigator.clipboard 的旧代码。",
55
63
  "selftestTitle": "浏览器能力自检",
package/package.json CHANGED
@@ -1,9 +1,17 @@
1
1
  {
2
2
  "name": "dsh-mobile-access",
3
- "version": "1.0.3",
3
+ "version": "1.1.0",
4
4
  "description": "Independent DSH bundle that pairs iPhones and iPads with the official DeepSeek Harness Web GUI over the local network.",
5
5
  "type": "module",
6
6
  "license": "MIT",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/abbaababa/dsh-mobile-access.git"
10
+ },
11
+ "homepage": "https://github.com/abbaababa/dsh-mobile-access#readme",
12
+ "bugs": {
13
+ "url": "https://github.com/abbaababa/dsh-mobile-access/issues"
14
+ },
7
15
  "publishConfig": {
8
16
  "access": "public"
9
17
  },
@@ -20,6 +28,7 @@
20
28
  "index.js",
21
29
  "service.js",
22
30
  "routes.js",
31
+ "gateway.js",
23
32
  "html.js",
24
33
  "types.js",
25
34
  "client.js",
@@ -48,5 +57,9 @@
48
57
  "@deepseek-ai/dsh-client-ui-sidebar"
49
58
  ]
50
59
  }
60
+ },
61
+ "devDependencies": {
62
+ "@types/node": "^26.6.5",
63
+ "typescript": "6.0.3"
51
64
  }
52
65
  }