dsh-mobile 0.1.0-alpha.6 → 0.1.0-alpha.8

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
@@ -61,12 +61,12 @@ 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 remembers the selected LAN interface, creates one local CA, stores plugin data under `$DSH_HOME/mobile-access/`, and on Windows requests administrator approval for LAN-only TCP/UDP firewall rules. Use `--no-firewall` only when those rules are managed separately. 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.
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, stores plugin data under `$DSH_HOME/mobile-access/`, and on Windows requests administrator approval once for TCP/UDP rules limited to Windows' dynamic `LocalSubnet`. Use `--no-firewall` only when those rules are managed separately. While DSH is running, the plugin follows DHCP, Wi-Fi, and phone-hotspot address changes on that interface, rebinds the gateway, and signs a matching server certificate from the same CA. Paired devices and the Android CA trust remain valid.
65
65
 
66
- 1. Copy the generated `dsh-mobile-ca.cer` to Android and install it once as a user CA certificate.
67
- 2. Open the Mobile card in the lower-left corner of the desktop DSH UI.
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, select **Scan**, then select the discovered DSH. Paste the key and connect. Alternatively, open the browser link directly on the phone.
66
+ 1. Open the Mobile card in the lower-left corner of the desktop DSH UI.
67
+ 2. Start access and select **Create pairing key**. The fingerprint-bound key is copied to the clipboard; the same card also shows a complete browser link.
68
+ 3. Open the Android app, select **Scan**, then select the discovered DSH. Paste the key and connect.
69
+ 4. On first connection, confirm the fingerprint-verified DSH Mobile CA in Android's system screen. The app continues automatically; no certificate file transfer is required. Alternatively, install the CA manually and open the browser link directly on the phone.
70
70
 
71
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
 
@@ -79,7 +79,9 @@ 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 first sends a UDP discovery query on the default port `3443`; the plugin replies with only its HTTPS origin and stable, non-secret installation identifier. It falls back to HTTPS scanning of visible private Wi-Fi and hotspot `/24` networks. Credentials never appear in discovery responses. The first screen contains only Scan and the discovered DSH list; the pairing-key field appears after selecting one result.
82
+ While Mobile Access is running, DSH publishes a small DNS-SD/mDNS service and a periodic UDP announcement. Each contains only the computer name, HTTPS origin, port, protocol version, and stable installation identifier. Android listens to both, also sends the original UDP query, and keeps HTTPS `/24` probing as a last compatibility fallback. Results are merged by installation identifier, so a new Wi-Fi or hotspot address updates the existing device instead of requiring another pairing. No pairing key, CA, device token, Cookie, or DSH data is discoverable. The first screen contains only Scan and the discovered DSH list; the pairing-key field appears after selecting one result.
83
+
84
+ After the user selects a result and enters its fingerprint-bound pairing key, the app fetches the public CA from that selected HTTPS origin without sending the key or any credential. It installs the CA only if the certificate is self-signed and its SHA-256 fingerprint exactly matches the key and discovered installation identifier. Only then does normal platform-validated HTTPS pairing begin.
83
85
 
84
86
  ## Customize the mobile Web UI
85
87
 
package/README.zh.md CHANGED
@@ -61,12 +61,12 @@ pnpm dsh plugin --profile web exec dsh-mobile setup
61
61
  pnpm dsh --profile web
62
62
  ```
63
63
 
64
- 两种方式都会把公开 npm 包安装到原生 DSH,不修改 DSH 源码。设置向导会记住所选局域网网卡、生成一份稳定的本地 CA,并把插件数据保存到 `$DSH_HOME/mobile-access/`。Windows 会请求管理员批准两条仅限所选局域网的 TCP/UDP 防火墙规则;只有自行管理规则时才使用 `--no-firewall`。DHCP 改变该网卡的 IP 后,插件会在下次启动 DSH 时跟随新地址,并用同一 CA 自动签发匹配的服务器证书。
64
+ 两种方式都会把公开 npm 包安装到原生 DSH,不修改 DSH 源码。设置向导会记住所选局域网网卡、生成一份稳定的本地 CA,并把插件数据保存到 `$DSH_HOME/mobile-access/`。Windows 只在首次设置时请求管理员批准两条受动态 `LocalSubnet` 限制的 TCP/UDP 规则;只有自行管理规则时才使用 `--no-firewall`。DSH 运行期间,如果该网卡因 DHCP、切换 Wi-Fi 或手机热点而改变地址,插件会自动重绑网关,并用同一 CA 签发匹配的新服务器证书;已有设备配对和 Android CA 信任不会失效。
65
65
 
66
- 1. 将向导输出的 `dsh-mobile-ca.cer` 复制到 Android,并在系统设置中一次性安装为用户 CA 证书。
67
- 2. 在电脑 DSH 左下角打开“移动端”卡片。
68
- 3. 确认服务已开启,点击“生成配对密钥”。密钥会复制到剪贴板,同一卡片也会显示完整的浏览器链接。
69
- 4. 打开 Android App,点击“扫描”并选择发现的 DSH,再粘贴密钥连接;也可直接在手机浏览器打开卡片中的链接。
66
+ 1. 在电脑 DSH 左下角打开“移动端”卡片。
67
+ 2. 确认服务已开启,点击“生成配对密钥”。绑定 CA 指纹的密钥会复制到剪贴板,同一卡片也会显示完整的浏览器链接。
68
+ 3. 打开 Android App,点击“扫描”并选择发现的 DSH,再粘贴密钥连接。
69
+ 4. 首次连接时,在 Android 系统页面确认 App 已核对指纹的 DSH Mobile CA;确认后自动继续,无需传输证书文件。手机浏览器访问仍可手动安装 CA 后打开完整链接。
70
70
 
71
71
  密钥和浏览器链接两分钟内有效且只能使用一次。配对后,Android 会用 Android Keystore 加密保存可随时撤销的设备凭据。局域网 IP 变化后,App 能在默认端口重新找到同一个 DSH,并自动续期短期 Web 会话,不需要新密钥。浏览器仍需单独配对。
72
72
 
@@ -79,7 +79,9 @@ pnpm dsh --profile web
79
79
  - Android App:使用系统 WebView 打开同一 DSH 页面,没有浏览器地址栏和标签栏。
80
80
  - 手机浏览器:直接访问向导显示的 `https://局域网地址:端口`,无需安装 App。
81
81
 
82
- Android 会先在默认端口 `3443` 发送 UDP 发现请求,插件只回复 HTTPS 地址和稳定的非机密安装标识;失败时才回退扫描手机可见的私有 Wi-Fi 和热点 `/24` 网段。发现响应不包含任何凭据。首页只有扫描按钮和 DSH 列表,点击其中一项后才会显示配对密钥输入框。
82
+ “移动访问”运行时,DSH 会同时发布小型 DNS-SD/mDNS 服务和周期性 UDP 公告。二者只包含电脑名称、HTTPS 地址、端口、协议版本和稳定安装标识。Android 同时监听两种公告,也保留原有的主动 UDP 查询,最后再以 HTTPS `/24` 网段探测兼容特殊热点。结果按安装标识合并,因此切换 Wi-Fi、热点或 DHCP 地址后只会更新同一设备的 IP,不需要重新配对。发现阶段不会传输配对密钥、CA、设备令牌、Cookie 或任何 DSH 数据。首页仍然只有扫描按钮和设备列表,点击设备后才显示密钥输入框。
83
+
84
+ 用户选中设备并输入带指纹的配对密钥后,App 才从所选 HTTPS 地址获取公开 CA;这个引导请求不会携带密钥或任何凭据。只有证书为自签 CA,且 SHA-256 指纹同时匹配密钥和发现到的安装标识时,App 才会交给 Android 系统确认安装。随后配对请求始终使用 Android 正常校验的 HTTPS。
83
85
 
84
86
  ## 自定义移动端 Web UI
85
87
 
package/SECURITY.md CHANGED
@@ -16,7 +16,8 @@ The maintainer will acknowledge a complete report within seven days. Publication
16
16
 
17
17
  - Keep the ordinary DSH Web listener on loopback.
18
18
  - Expose only the plugin-owned HTTPS listener to the LAN.
19
- - UDP discovery on the same port accepts only the fixed discovery query from the configured LAN CIDR and returns only the public HTTPS origin and non-secret installation identifier. It never returns credentials or configuration.
19
+ - DNS-SD/mDNS, periodic UDP announcements, active UDP query replies, and HTTPS discovery return only the device name, public HTTPS origin, port, protocol version, and stable non-secret installation identifier. Discovery never returns the CA, a pairing key, a device token, Cookies, credentials, or private configuration.
20
+ - Only after a user selects a device and enters the fingerprint-bound pairing key may Android fetch the public CA from that exact HTTPS origin. The bootstrap GET sends no key or credential. Android offers the certificate to the system installer only after validating that it is a self-signed CA whose SHA-256 fingerprint equals both the key and discovered installation identifier.
20
21
  - Use a certificate trusted by every client platform; never instruct a WebView to ignore TLS errors.
21
22
  - Keep pairing closed except during a short local onboarding action.
22
23
  - Revoke a lost device immediately and rotate the device registry if credential theft is suspected.
package/lib/cli.js CHANGED
@@ -229,14 +229,14 @@ async function runElevatedPowerShell(script) {
229
229
  launch
230
230
  ], { windowsHide: true });
231
231
  }
232
- async function configureWindowsFirewall(port, cidr) {
232
+ async function configureWindowsFirewall(port) {
233
233
  if (process.platform !== "win32") return;
234
234
  const script = [
235
235
  "$ErrorActionPreference = 'Stop'",
236
236
  `Get-NetFirewallRule -DisplayName '${FIREWALL_TCP_RULE}' -ErrorAction SilentlyContinue | Remove-NetFirewallRule`,
237
237
  `Get-NetFirewallRule -DisplayName '${FIREWALL_UDP_RULE}' -ErrorAction SilentlyContinue | Remove-NetFirewallRule`,
238
- `New-NetFirewallRule -DisplayName '${FIREWALL_TCP_RULE}' -Direction Inbound -Action Allow -Protocol TCP -LocalPort ${String(port)} -RemoteAddress '${cidr}' -Profile Any | Out-Null`,
239
- `New-NetFirewallRule -DisplayName '${FIREWALL_UDP_RULE}' -Direction Inbound -Action Allow -Protocol UDP -LocalPort ${String(port)} -RemoteAddress '${cidr}' -Profile Any | Out-Null`
238
+ `New-NetFirewallRule -DisplayName '${FIREWALL_TCP_RULE}' -Direction Inbound -Action Allow -Protocol TCP -LocalPort ${String(port)} -RemoteAddress LocalSubnet -Profile Any | Out-Null`,
239
+ `New-NetFirewallRule -DisplayName '${FIREWALL_UDP_RULE}' -Direction Inbound -Action Allow -Protocol UDP -LocalPort ${String(port)} -RemoteAddress LocalSubnet -Profile Any | Out-Null`
240
240
  ].join("; ");
241
241
  console.log("Windows will request administrator approval for two LAN-only firewall rules.");
242
242
  await runElevatedPowerShell(script);
@@ -293,7 +293,7 @@ async function setup(args) {
293
293
  chmod(keyFile, 384),
294
294
  chmod(androidCertificate, 384)
295
295
  ]);
296
- if (options.configureFirewall) await configureWindowsFirewall(options.port, network.cidr);
296
+ if (options.configureFirewall) await configureWindowsFirewall(options.port);
297
297
  const customCss = join(directory, "mobile.css");
298
298
  try {
299
299
  await readFile(customCss);
package/lib/client.js CHANGED
@@ -176,7 +176,8 @@ window.__ModuleLoader__.load({
176
176
  }).then(async (data) => {
177
177
  const url = typeof data.pairUrl === "string" ? data.pairUrl : "";
178
178
  const token = typeof data.token === "string" ? data.token : "";
179
- const copied = token !== "" ? token : url;
179
+ const appKey = typeof data.appKey === "string" ? data.appKey : "";
180
+ const copied = appKey !== "" ? appKey : token !== "" ? token : url;
180
181
  if (copied !== "" && navigator.clipboard !== void 0) await navigator.clipboard.writeText(copied);
181
182
  status.textContent = copied === "" ? zh ? "无法生成密钥" : "Could not create key" : zh ? `配对密钥已复制:${copied}${url === "" ? "" : `\n手机浏览器链接:${url}`}` : `Pairing key copied: ${copied}${url === "" ? "" : `\nMobile browser link: ${url}`}`;
182
183
  }, (error) => {
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) {\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":"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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 appKey = typeof data.appKey === 'string' ? data.appKey : ''\n const copied = appKey !== '' ? appKey : 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":"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"}
package/lib/index.d.mts CHANGED
@@ -241,6 +241,8 @@ interface PluginConfig {
241
241
  customScriptFile?: string;
242
242
  /** Stable public discovery identifier; it is not an authentication secret. */
243
243
  instanceId?: string;
244
+ /** Managed CA certificate offered to the Android installer after fingerprint binding. */
245
+ pairingCaFile?: string;
244
246
  /** First-run state used only while the control file does not exist. */
245
247
  initiallyEnabled: boolean;
246
248
  tls?: {
@@ -274,6 +276,7 @@ interface ResolvedGatewayConfig {
274
276
  readonly customCssFile: string;
275
277
  readonly customScriptFile: string;
276
278
  readonly instanceId: string;
279
+ readonly pairingCaFile?: string;
277
280
  readonly tls: TlsConfig;
278
281
  readonly pairingTtlMs: number;
279
282
  readonly deviceTtlMs: number;
@@ -353,6 +356,9 @@ declare class MobileAccessGateway {
353
356
  private policy;
354
357
  private server;
355
358
  private discoverySocket;
359
+ private discoveryTimer;
360
+ private bonjour;
361
+ private pairingCaCertificate;
356
362
  private listenerPort;
357
363
  private readonly connectedSockets;
358
364
  private readonly activeRequests;
@@ -367,7 +373,9 @@ declare class MobileAccessGateway {
367
373
  /** Initialize durable state, validate TLS, and bind the externally reachable listener. */
368
374
  start(): Promise<void>;
369
375
  private startDiscovery;
376
+ private discoveryAnnouncement;
370
377
  private closeFailedStart;
378
+ private closeBonjour;
371
379
  /** Actual bound address, available after start and safe for loopback status output. */
372
380
  address(): {
373
381
  host: string;
package/lib/index.mjs CHANGED
@@ -4,11 +4,12 @@ import z from "@deepseek-ai/schemastery";
4
4
  import { connect, isIP } from "node:net";
5
5
  import { chmod, lstat, mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
6
6
  import { createSocket } from "node:dgram";
7
+ import { hostname, networkInterfaces } from "node:os";
7
8
  import { createServer, request } from "node:http";
8
9
  import { createServer as createServer$1 } from "node:https";
9
10
  import { Transform } from "node:stream";
10
11
  import { pipeline } from "node:stream/promises";
11
- import { networkInterfaces } from "node:os";
12
+ import Bonjour from "bonjour-service";
12
13
  import { generate } from "selfsigned";
13
14
  //#region src/access.ts
14
15
  /** Stable error categories converted to deliberately terse HTTP responses. */
@@ -545,6 +546,7 @@ const Config = z.object({
545
546
  customCssFile: z.string().hidden(),
546
547
  customScriptFile: z.string().hidden(),
547
548
  instanceId: z.string().hidden(),
549
+ pairingCaFile: z.string().hidden(),
548
550
  initiallyEnabled: z.boolean().hidden().required(),
549
551
  tls: z.object({
550
552
  mode: z.union([z.const("provided"), z.const("disabled")]),
@@ -669,6 +671,7 @@ function parseGatewayConfig(raw) {
669
671
  instanceId: value.instanceId === void 0 ? createHash("sha256").update(absoluteFile(value.stateFile, "stateFile")).digest("hex") : /^[a-f\d]{64}$/u.test(value.instanceId) ? value.instanceId : (() => {
670
672
  throw new Error("instanceId must be a lowercase SHA-256 value");
671
673
  })(),
674
+ ...value.pairingCaFile === void 0 ? {} : { pairingCaFile: absoluteFile(value.pairingCaFile, "pairingCaFile") },
672
675
  tls,
673
676
  pairingTtlMs: integer(value.pairingTtlMs, "pairingTtlMs", 12e4, 1e4, 6e5),
674
677
  deviceTtlMs,
@@ -687,6 +690,60 @@ function parseGatewayConfig(raw) {
687
690
  }
688
691
  //#endregion
689
692
  //#region src/control.ts
693
+ /** Keeps one runtime aligned with a changing network selection. */
694
+ var FollowingMobileAccessRuntime = class {
695
+ select;
696
+ onRefreshError;
697
+ runtime;
698
+ key;
699
+ queue = Promise.resolve();
700
+ closed = false;
701
+ timer;
702
+ constructor(select, onRefreshError) {
703
+ this.select = select;
704
+ this.onRefreshError = onRefreshError;
705
+ }
706
+ /** Start the current selection and optionally poll for later changes. */
707
+ async initialize(refreshIntervalMs) {
708
+ await this.refresh();
709
+ if (refreshIntervalMs === void 0) return;
710
+ this.timer = setInterval(() => {
711
+ this.refresh().catch(this.onRefreshError);
712
+ }, refreshIntervalMs);
713
+ this.timer.unref();
714
+ }
715
+ /** Reconcile the active runtime with the latest selection. */
716
+ refresh() {
717
+ return this.enqueue(async () => {
718
+ if (this.closed) return;
719
+ const selection = await this.select();
720
+ if (this.closed || this.runtime !== void 0 && this.key === selection.key) return;
721
+ const previous = this.runtime;
722
+ this.runtime = void 0;
723
+ this.key = void 0;
724
+ if (previous !== void 0) await previous.close();
725
+ this.runtime = await selection.start();
726
+ this.key = selection.key;
727
+ });
728
+ }
729
+ /** Stop polling and close the most recently selected runtime. */
730
+ close() {
731
+ if (this.closed) return this.queue;
732
+ this.closed = true;
733
+ if (this.timer !== void 0) clearInterval(this.timer);
734
+ return this.enqueue(async () => {
735
+ const runtime = this.runtime;
736
+ this.runtime = void 0;
737
+ this.key = void 0;
738
+ if (runtime !== void 0) await runtime.close();
739
+ });
740
+ }
741
+ enqueue(operation) {
742
+ const run = this.queue.then(operation, operation);
743
+ this.queue = run.then(() => {}, () => {});
744
+ return run;
745
+ }
746
+ };
690
747
  /** Validate control state loaded across the filesystem boundary. */
691
748
  function parseMobileAccessControlState(value) {
692
749
  if (typeof value !== "object" || value === null || Array.isArray(value)) throw new Error("mobile-access control state must be an object");
@@ -1021,6 +1078,9 @@ function assertLocalAdminTrust(request, requireBrowserOrigin) {
1021
1078
  const MAX_CONTROL_BODY_BYTES = 16384;
1022
1079
  const MAX_HEADER_BYTES = 16384;
1023
1080
  const DISCOVERY_QUERY = Buffer.from("DSH_MOBILE_DISCOVER_V1", "ascii");
1081
+ const DISCOVERY_PROTOCOL = 1;
1082
+ const DISCOVERY_INTERVAL_MS = 3e3;
1083
+ const MDNS_SERVICE_TYPE = "dsh-mobile";
1024
1084
  const PAIR_PAGE = `<!doctype html>
1025
1085
  <html lang="en">
1026
1086
  <meta charset="utf-8">
@@ -1287,6 +1347,29 @@ function mapError(error) {
1287
1347
  if (error instanceof AccessError) return new HttpError(error.status, error.code);
1288
1348
  return new HttpError(500, "internal_error");
1289
1349
  }
1350
+ function discoveryDeviceName() {
1351
+ const value = hostname().trim().replaceAll(/[\u0000-\u001f\u007f]/gu, "");
1352
+ return (value === "" ? "DeepSeek Harness" : value).slice(0, 63);
1353
+ }
1354
+ function discoveryMdnsHost(instanceId) {
1355
+ const label = hostname().toLowerCase().replaceAll(/[^a-z0-9-]/gu, "-").replaceAll(/^-+|-+$/gu, "").slice(0, 40);
1356
+ return `${label === "" ? "dsh" : label}-${instanceId.slice(0, 8)}.local`;
1357
+ }
1358
+ function discoveryBroadcastTargets(cidrs) {
1359
+ const targets = /* @__PURE__ */ new Set(["255.255.255.255"]);
1360
+ for (const cidr of cidrs) {
1361
+ if (cidr.bits !== 32 || cidr.prefix >= 32) continue;
1362
+ const hostBits = BigInt(32 - cidr.prefix);
1363
+ const broadcast = cidr.network | (1n << hostBits) - 1n;
1364
+ targets.add([
1365
+ 24n,
1366
+ 16n,
1367
+ 8n,
1368
+ 0n
1369
+ ].map((shift) => Number(broadcast >> shift & 255n)).join("."));
1370
+ }
1371
+ return [...targets];
1372
+ }
1290
1373
  /** Authenticated TLS edge in front of the ordinary loopback-only DSH Web server. */
1291
1374
  var MobileAccessGateway = class {
1292
1375
  config;
@@ -1295,6 +1378,9 @@ var MobileAccessGateway = class {
1295
1378
  policy;
1296
1379
  server;
1297
1380
  discoverySocket;
1381
+ discoveryTimer;
1382
+ bonjour;
1383
+ pairingCaCertificate;
1298
1384
  listenerPort;
1299
1385
  connectedSockets = /* @__PURE__ */ new Set();
1300
1386
  activeRequests = /* @__PURE__ */ new Map();
@@ -1329,6 +1415,12 @@ var MobileAccessGateway = class {
1329
1415
  this.started = true;
1330
1416
  await this.access.initialize();
1331
1417
  try {
1418
+ if (this.config.pairingCaFile !== void 0) {
1419
+ const certificate = new X509Certificate(await readFile(this.config.pairingCaFile));
1420
+ const fingerprint = certificate.fingerprint256.replaceAll(":", "").toLowerCase();
1421
+ if (!certificate.ca || certificate.subject !== certificate.issuer || !certificate.verify(certificate.publicKey) || fingerprint !== this.config.instanceId) throw new Error("pairingCaFile must be the self-signed CA identified by instanceId");
1422
+ this.pairingCaCertificate = certificate.raw.toString("base64");
1423
+ }
1332
1424
  const handler = (request, response) => {
1333
1425
  this.handleExternalRequest(request, response).catch((error) => {
1334
1426
  const mapped = mapError(error);
@@ -1388,14 +1480,10 @@ var MobileAccessGateway = class {
1388
1480
  async startDiscovery(port) {
1389
1481
  const socket = createSocket("udp4");
1390
1482
  this.discoverySocket = socket;
1483
+ const announcement = this.discoveryAnnouncement(port);
1391
1484
  socket.on("message", (message, remote) => {
1392
1485
  if (this.closing || !message.equals(DISCOVERY_QUERY) || !addressAllowed(remote.address, this.config.allowedCidrs)) return;
1393
- const response = Buffer.from(JSON.stringify({
1394
- protocol: 1,
1395
- instanceId: this.config.instanceId,
1396
- origin: this.address().origin
1397
- }), "utf8");
1398
- socket.send(response, remote.port, remote.address, () => void 0);
1486
+ socket.send(announcement, remote.port, remote.address, () => void 0);
1399
1487
  });
1400
1488
  await new Promise((resolve, reject) => {
1401
1489
  const failed = (error) => {
@@ -1404,11 +1492,47 @@ var MobileAccessGateway = class {
1404
1492
  socket.once("error", failed);
1405
1493
  socket.bind(port, "0.0.0.0", () => {
1406
1494
  socket.off("error", failed);
1495
+ socket.setBroadcast(true);
1407
1496
  resolve();
1408
1497
  });
1409
1498
  });
1499
+ const announce = () => {
1500
+ for (const target of discoveryBroadcastTargets(this.config.allowedCidrs)) socket.send(announcement, port, target, () => void 0);
1501
+ };
1502
+ announce();
1503
+ this.discoveryTimer = setInterval(announce, DISCOVERY_INTERVAL_MS);
1504
+ this.discoveryTimer.unref();
1505
+ const deviceName = discoveryDeviceName();
1506
+ const bonjour = new Bonjour({ disableIPv6: true });
1507
+ this.bonjour = bonjour;
1508
+ bonjour.publish({
1509
+ name: `${deviceName} (${this.config.instanceId.slice(0, 8)})`,
1510
+ type: MDNS_SERVICE_TYPE,
1511
+ protocol: "tcp",
1512
+ port,
1513
+ host: discoveryMdnsHost(this.config.instanceId),
1514
+ disableIPv6: true,
1515
+ txt: {
1516
+ deviceName,
1517
+ origin: this.address().origin,
1518
+ instanceId: this.config.instanceId,
1519
+ protocol: String(DISCOVERY_PROTOCOL)
1520
+ }
1521
+ });
1522
+ }
1523
+ discoveryAnnouncement(port) {
1524
+ return Buffer.from(JSON.stringify({
1525
+ deviceName: discoveryDeviceName(),
1526
+ origin: this.address().origin,
1527
+ port,
1528
+ protocol: DISCOVERY_PROTOCOL,
1529
+ instanceId: this.config.instanceId
1530
+ }), "utf8");
1410
1531
  }
1411
1532
  async closeFailedStart() {
1533
+ if (this.discoveryTimer !== void 0) clearInterval(this.discoveryTimer);
1534
+ this.discoveryTimer = void 0;
1535
+ await this.closeBonjour();
1412
1536
  this.discoverySocket?.close();
1413
1537
  this.discoverySocket = void 0;
1414
1538
  for (const socket of this.connectedSockets) socket.destroy();
@@ -1419,6 +1543,16 @@ var MobileAccessGateway = class {
1419
1543
  });
1420
1544
  await this.access.close();
1421
1545
  }
1546
+ async closeBonjour() {
1547
+ const bonjour = this.bonjour;
1548
+ this.bonjour = void 0;
1549
+ if (bonjour === void 0) return;
1550
+ await new Promise((resolve) => {
1551
+ bonjour.unpublishAll(() => {
1552
+ bonjour.destroy(() => resolve());
1553
+ });
1554
+ });
1555
+ }
1422
1556
  /** Actual bound address, available after start and safe for loopback status output. */
1423
1557
  address() {
1424
1558
  if (this.listenerPort === void 0 || this.policy === void 0) throw new Error("gateway is not listening");
@@ -1558,12 +1692,26 @@ var MobileAccessGateway = class {
1558
1692
  }
1559
1693
  if (target.search === "" && request.method === "GET" && target.decodedPathname === `/mobile-access/discovery`) {
1560
1694
  sendJson(response, 200, {
1561
- protocol: 1,
1562
- instanceId: this.config.instanceId,
1563
- port: this.address().port
1695
+ deviceName: discoveryDeviceName(),
1696
+ origin: this.address().origin,
1697
+ port: this.address().port,
1698
+ protocol: DISCOVERY_PROTOCOL,
1699
+ instanceId: this.config.instanceId
1564
1700
  }, this.tlsEnabled);
1565
1701
  return;
1566
1702
  }
1703
+ if (target.search === "" && request.method === "GET" && target.decodedPathname === `/mobile-access/ca.cer`) {
1704
+ if (this.pairingCaCertificate === void 0) throw new HttpError(404, "not_found");
1705
+ const body = Buffer.from(this.pairingCaCertificate, "base64");
1706
+ setSecurityHeaders(response, this.tlsEnabled);
1707
+ response.writeHead(200, {
1708
+ "Content-Type": "application/pkix-cert",
1709
+ "Content-Length": body.length,
1710
+ "Cache-Control": "no-store"
1711
+ });
1712
+ response.end(body);
1713
+ return;
1714
+ }
1567
1715
  if (target.search === "" && request.method === "GET" && (target.decodedPathname === `/mobile-access/pair` || target.decodedPathname === `/mobile-access/pair.js`)) {
1568
1716
  if (!this.access.pairingStatus().open) throw new HttpError(404, "not_found");
1569
1717
  setSecurityHeaders(response, this.tlsEnabled);
@@ -1913,6 +2061,7 @@ var MobileAccessGateway = class {
1913
2061
  const opened = await this.access.openPairing(body.ttlMs);
1914
2062
  sendJson(response, 201, {
1915
2063
  ...opened,
2064
+ appKey: `dsh1.${this.config.instanceId}.${opened.token}`,
1916
2065
  pairUrl: `${this.address().origin}/mobile-access/pair#token=${opened.token}`
1917
2066
  }, false);
1918
2067
  return;
@@ -1955,6 +2104,9 @@ var MobileAccessGateway = class {
1955
2104
  websocket.upstream.destroy();
1956
2105
  }
1957
2106
  for (const socket of this.connectedSockets) socket.destroy();
2107
+ if (this.discoveryTimer !== void 0) clearInterval(this.discoveryTimer);
2108
+ this.discoveryTimer = void 0;
2109
+ await this.closeBonjour();
1958
2110
  const discoverySocket = this.discoverySocket;
1959
2111
  this.discoverySocket = void 0;
1960
2112
  if (discoverySocket !== void 0) await new Promise((resolve) => {
@@ -2263,6 +2415,7 @@ async function materializeManagedSetup(setup, table) {
2263
2415
  upstreamOrigin: setup.upstreamOrigin,
2264
2416
  allowedCidrs: [network.cidr],
2265
2417
  instanceId: ca.fingerprint256.replaceAll(":", "").toLowerCase(),
2418
+ pairingCaFile: setup.tls.caCertFile,
2266
2419
  tls: {
2267
2420
  mode: "provided",
2268
2421
  certFile: setup.tls.certFile,
@@ -2293,16 +2446,28 @@ const SETUP_KEYS = /* @__PURE__ */ new Set([
2293
2446
  "publicAuthorities",
2294
2447
  "allowedCidrs",
2295
2448
  "instanceId",
2449
+ "pairingCaFile",
2296
2450
  "tls"
2297
2451
  ]);
2452
+ function withoutSetupKeys(config) {
2453
+ const merged = { ...config };
2454
+ for (const key of SETUP_KEYS) if (key !== "version") delete merged[key];
2455
+ return merged;
2456
+ }
2298
2457
  async function loadSetup(config) {
2299
- if (config.setupFile === void 0) return config;
2458
+ if (config.setupFile === void 0) return {
2459
+ kind: "fixed",
2460
+ config
2461
+ };
2300
2462
  if (!isAbsolute(config.setupFile)) throw new Error("setupFile must be an absolute file path");
2301
2463
  let source;
2302
2464
  try {
2303
2465
  source = await readFile(resolve(config.setupFile), "utf8");
2304
2466
  } catch (error) {
2305
- if (error.code === "ENOENT") return config;
2467
+ if (error.code === "ENOENT") return {
2468
+ kind: "fixed",
2469
+ config
2470
+ };
2306
2471
  throw error;
2307
2472
  }
2308
2473
  let parsed;
@@ -2313,29 +2478,27 @@ async function loadSetup(config) {
2313
2478
  }
2314
2479
  if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) throw new Error("mobile setup file must be an object");
2315
2480
  const record = parsed;
2316
- if (record.version === 2) {
2317
- const setup = await materializeManagedSetup(parseManagedSetup(record));
2318
- const merged = { ...config };
2319
- for (const key of SETUP_KEYS) if (key !== "version") delete merged[key];
2320
- return {
2321
- ...merged,
2322
- ...setup
2323
- };
2324
- }
2481
+ if (record.version === 2) return {
2482
+ kind: "managed",
2483
+ config: withoutSetupKeys(config),
2484
+ setup: parseManagedSetup(record)
2485
+ };
2325
2486
  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");
2326
2487
  const { version: _version, ...setup } = record;
2327
- const merged = { ...config };
2328
- for (const key of SETUP_KEYS) if (key !== "version") delete merged[key];
2329
2488
  return {
2330
- ...merged,
2331
- ...setup
2489
+ kind: "fixed",
2490
+ config: {
2491
+ ...withoutSetupKeys(config),
2492
+ ...setup
2493
+ }
2332
2494
  };
2333
2495
  }
2334
2496
  /** Mount the resident control route and its optional authenticated LAN gateway. */
2335
2497
  async function apply(ctx, config) {
2336
- const resolved = parseGatewayConfig(await loadSetup(config));
2498
+ const loaded = await loadSetup(config);
2337
2499
  let gateway;
2338
- const controller = new MobileAccessGatewayController(new JsonMobileAccessControlStore(parseControlFile(config.controlFile), config.initiallyEnabled), async () => {
2500
+ const startGateway = async (candidateConfig) => {
2501
+ const resolved = parseGatewayConfig(candidateConfig);
2339
2502
  const candidate = new MobileAccessGateway(resolved, new JsonDeviceStore(resolved.stateFile, resolved.maxDevices));
2340
2503
  await candidate.start();
2341
2504
  gateway = candidate;
@@ -2343,7 +2506,25 @@ async function apply(ctx, config) {
2343
2506
  if (gateway === candidate) gateway = void 0;
2344
2507
  await candidate.close();
2345
2508
  } };
2346
- });
2509
+ };
2510
+ const startRuntime = async () => {
2511
+ if (loaded.kind === "fixed") return startGateway(loaded.config);
2512
+ const following = new FollowingMobileAccessRuntime(async () => {
2513
+ const network = selectLanNetwork(void 0, loaded.setup.networkInterface);
2514
+ return {
2515
+ key: `${network.name}\0${network.address}\0${network.cidr}`,
2516
+ start: async () => startGateway({
2517
+ ...loaded.config,
2518
+ ...await materializeManagedSetup(loaded.setup)
2519
+ })
2520
+ };
2521
+ }, (error) => {
2522
+ process.emitWarning(`DSH Mobile could not follow the current LAN address: ${error instanceof Error ? error.message : String(error)}`, { code: "DSH_MOBILE_NETWORK_REFRESH" });
2523
+ });
2524
+ await following.initialize(2e3);
2525
+ return following;
2526
+ };
2527
+ const controller = new MobileAccessGatewayController(new JsonMobileAccessControlStore(parseControlFile(config.controlFile), config.initiallyEnabled), startRuntime);
2347
2528
  const adminRoute = {
2348
2529
  kind: "prefix",
2349
2530
  path: LOCAL_ADMIN_PREFIX,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-mobile",
3
- "version": "0.1.0-alpha.6",
3
+ "version": "0.1.0-alpha.8",
4
4
  "private": false,
5
5
  "description": "Authenticated Android and mobile-browser access for the stock DeepSeek Harness Web UI",
6
6
  "type": "module",
@@ -100,6 +100,7 @@
100
100
  },
101
101
  "dependencies": {
102
102
  "@deepseek-ai/schemastery": "^3.18.1",
103
+ "bonjour-service": "^1.4.4",
103
104
  "selfsigned": "^5.5.0"
104
105
  },
105
106
  "devDependencies": {