From ced05e06a34787590c69ad22a3b9c6e12ecf08f3 Mon Sep 17 00:00:00 2001 From: Kaxi <1042864399@qq.com> Date: Sun, 27 Sep 2026 03:49:12 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20dsh-lan-access=20=E2=80=94=20=E5=B1=80?= =?UTF-8?q?=E5=9F=9F=E7=BD=91=E8=AE=BF=E9=97=AE=20dsh=20Web=20UI?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 用独立反向代理把 dsh Web UI 发布到局域网。官方 CLI 主动拒绝 `dsh web --host 0.0.0.0`(会把宿主机远程代码执行暴露到网络), 因此本插件让官方服务器保持只绑回环,另开监听并转发。 宿主端(index.js) - 在 0.0.0.0:3082 监听,把 HTTP 与 WebSocket 转发到 127.0.0.1: - 上游 Host/Origin 重写为回环,使官方 /api 信任围栏「通过」而不是被绕过 - 不改动 Set-Cookie:浏览器按请求 URL 建立 host-only cookie,转发层无需干预 - 监听端 DNS 重绑定防护;targetHost 仅接受回环地址(不会变成开放代理) - /api/dsh-lan-access/summary 复用官方 connection.admit() 做围栏与浏览器认证 - 启动横幅打印带 launch token 的局域网链接 - 端口被占用时只告警不抛出,并在面板上报 listening:false,不展示不可用的二维码 - ownsHostCompat(默认关):仅为非回环页面声明 ownsHost,恢复官方设置界面 客户端(src/ 经 scripts/build-client.mjs 生成 client.js) - Settings → 局域网访问:局域网地址列表、复制/打开、选中地址的二维码 - 零依赖二维码编码器(byte 模式 / ECC M / version 1–10) 组合层 - 只 insert 一行,不覆盖任何 shipped row:停用本插件只会移除局域网监听, 回环 Web UI 不受影响 验证 - 宿主转发契约 28/28;客户端契约 25/25 - 二维码编码器与 npm qrcode 参考实现在全版本 × 全 8 掩码下逐模块比对 240/240 一致 - 端到端:局域网 token 首访 303 并铸 cookie → 应用 200;无凭证 401; WebSocket 升级 101,且与直连回环逐项行为一致 --- .gitignore | 4 + LICENSE | 21 + README.en.md | 119 ++++++ README.md | 123 ++++++ client.js | 877 +++++++++++++++++++++++++++++++++++++++ cordis.patch.yml | 25 ++ icon.svg | 14 + index.js | 431 +++++++++++++++++++ locale/en.json | 6 + locale/zh.json | 6 + package.json | 71 ++++ scripts/build-client.mjs | 60 +++ scripts/smoke.mjs | 256 ++++++++++++ scripts/test-client.mjs | 166 ++++++++ scripts/test-qr.mjs | 140 +++++++ src/panel.js | 362 ++++++++++++++++ src/qrcode.js | 493 ++++++++++++++++++++++ 17 files changed, 3174 insertions(+) create mode 100644 .gitignore create mode 100644 LICENSE create mode 100644 README.en.md create mode 100644 README.md create mode 100644 client.js create mode 100644 cordis.patch.yml create mode 100644 icon.svg create mode 100644 index.js create mode 100644 locale/en.json create mode 100644 locale/zh.json create mode 100644 package.json create mode 100644 scripts/build-client.mjs create mode 100644 scripts/smoke.mjs create mode 100644 scripts/test-client.mjs create mode 100644 scripts/test-qr.mjs create mode 100644 src/panel.js create mode 100644 src/qrcode.js diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ce20120 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +# `client.js` is generated from `src/` by `scripts/build-client.mjs`; no ignore. +node_modules/ +*.log +.DS_Store diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..d0bb7f8 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 sutong + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.en.md b/README.en.md new file mode 100644 index 0000000..f7ba2c1 --- /dev/null +++ b/README.en.md @@ -0,0 +1,119 @@ +# @sutong/dsh-lan-access + +Publish the dsh Web UI on your local network with a **standalone reverse proxy**: it listens on `0.0.0.0:`, forwards HTTP and WebSocket to the loopback web server, and **keeps the official launch-token login**. + +[简体中文](README.md) | **English** + +## Why a plugin is needed + +`dsh web --host 0.0.0.0` is refused by the shipped CLI on purpose (`@deepseek-ai/dsh-web-app/lib/startup.js`: it would expose this machine's remote code execution to the network). The official web server therefore always binds loopback, so LAN access has to be a second listener that forwards to it. + +## How it differs from dsh-lan-proxy + +| | This plugin | dsh-lan-proxy | +|---|---|---| +| Default login | **the launch token is required** (the link from the banner or the QR code) | `injectToken: true` — anyone who can reach the port gets in untokened | +| Upstream `Host` | rewritten to loopback, so the official fence passes | rewritten to loopback | +| `Set-Cookie` | left untouched (a browser binds a host-only cookie to the **request URL**, so the forwarder needs no rewrite) | paired with token injection and a 401 replay | +| WebSocket | handshake forwarded byte-for-byte; `permessage-deflate` negotiates end to end and **the proxy decompresses nothing** | compression bridge (adds a decompression surface) | +| Shipped rows | **overrides none**, so a failure or a manual disable cannot affect the loopback UI | inserts its own row plus a compatibility injection | + +> **Port collision**: dsh-lan-proxy claims `3081` by default, so this plugin defaults to `3082`. They can coexist, but running both exposes the UI twice — keep one. + +## Install + +```sh +dsh plugin --profile web add /path/to/dsh-lan-access +``` + +Then restart `dsh web` once. The terminal prints the tokenized LAN links. + +## Configuration + +Set the row's `config` (Host Profile → Plugins → this plugin → Configure, or edit `cordis.patch.yml`). + +| Key | Default | Meaning | +|---|---|---| +| `enabled` | `true` | Master switch; off means no listener | +| `host` | `0.0.0.0` | Listen address | +| `port` | `3082` | Listen port (avoids dsh-lan-proxy's 3081) | +| `targetHost` | `127.0.0.1` | Upstream host. **Loopback only** — anything else is refused at startup, so this can never become an open proxy | +| `targetPort` | `0` | Upstream port; `0` follows the live `webServer` port | +| `printBanner` | `true` | Print the LAN links at startup | + +## Usage + +1. **Startup banner**: + + ``` + dsh-lan-access: 0.0.0.0:3082 → http://127.0.0.1:3080 (LAN listener) + dsh-lan-access: http://192.168.1.5:3082/?token=XXXXXXXX + ``` + +2. **Web UI panel**: Settings → **LAN Access** (the plugin owns this settings page). It lists every LAN address with copy/open actions and renders a **QR code** for the address you select, so a phone can scan straight in. + +The `token` is used only on the first visit: the server mints a 30-day cookie and 302s to the clean `./`, so **the token never stays in the address bar or history**. That device then enters by cookie. + +## Security model + +- **Authentication is not relaxed.** No token injection, no fence bypass: a LAN device must hold the link from the banner or the QR code. That is stricter than trusting the whole network. +- **The official fence stays in force.** `/api` accepts only loopback or declared authorities, and `Origin` must match `Host`; the proxy rewrites the upstream `Host`/`Origin` to loopback so those checks *pass* rather than being bypassed. +- **DNS-rebinding guard**: the listener accepts only `localhost`, loopback, the LAN IP literals it publishes, or its own listen address. Hostnames are dropped. +- **Upstream locked to loopback**, so it cannot be turned into an open forward proxy. +- **No extra decompression surface**: no response or frame compression in the middle. +- **The panel's data route** (`/api/dsh-lan-access/summary`) is mounted on the official web server and **reuses the official `connection.admit()`** — the same fence and browser authentication — so only an authenticated page can read the token. +- **⚠️ Blast radius**: a device holding that link has **full dsh control, including command execution on this host**. Enable it only on a trusted network, and disable or uninstall it when you are done. Every link in the list is a key. + +## Settings screens on a LAN page (`ownsHostCompat`) + +DSH restricts the durable settings surface to loopback pages: the client derives `isLoopback` +from `location.hostname` (`dsh-client-connection/lib/client.js`), so a non-loopback page gets +`persistence = "memory"` (`dsh-client-ui-settings/lib/client.js`), the settings mirror never +answers with a `view`, and the Models/provider page reports +**settings are unavailable in this browser** — while the General page's "open config file" +action disappears. + +**This is unrelated to forwarding correctness**: every LAN exposure method hits it, and +`127.0.0.1:3080` is unaffected. There is no matching server-side gate (the `/api` fence plus the +launch token and cookie are the whole authorization surface), so this plugin exposes a switch: + +| Key | Default | Meaning | +|---|---|---| +| `ownsHostCompat` | `false` | Declare `ownsHost` for **non-loopback pages**, restoring the official settings screens (a loopback page is untouched) | + +This profile sets it to `true`. The tradeoff: that page can then read and write server settings, +credentials included. Since a device holding the tokenized link already has full control +(command execution on this host), this is not a new class of capability — but it does step past +the shipped loopback isolation. Set it to `false` to restore that posture and change settings +locally through `127.0.0.1:3080` (or an `ssh -L 3080:127.0.0.1:3080 ` tunnel). + +## Troubleshooting + +| Symptom | Cause and fix | +|---|---| +| `LAN listener unavailable ... EADDRINUSE` | The port is taken (commonly dsh-lan-proxy on 3081). Change `port`, or disable the other plugin. The panel shows the same error instead of a dead QR code. | +| A LAN device gets 401 | It opened the bare address without a token and has no cookie yet. Use the `?token=` link from the banner or the panel. | +| A bookmark stops working after a restart | The launch token changes on every boot. Devices that already logged in keep their cookie; otherwise use a fresh link. | +| The panel reports no non-internal IPv4 address | This machine currently has no usable LAN IPv4, only loopback. | +| Scanning does nothing | Some cameras read dark-background codes poorly; the panel always draws the code on a white card. Raise the screen brightness. | + +## Development + +```sh +node scripts/build-client.mjs # generates client.js from src/panel.js + src/qrcode.js +node scripts/smoke.mjs # host forwarding contract (HTTP/WS/Host/Origin/cookie/bind failure) +node scripts/test-qr.mjs # module-by-module comparison against the npm qrcode reference +node scripts/test-client.mjs # browser contract (slot registration, render, route agreement) +``` + +`client.js` is generated: edit `src/` and rebuild. + +The QR encoder (`src/qrcode.js`) is a dependency-free implementation (byte mode, ECC M, versions 1–10) verified module-by-module against the npm `qrcode` reference across every version and every mask (240/240 identical). + +## Rollback + +Disable or uninstall the plugin from the Plugins page. Because it **overrides no shipped row**, disabling it only removes the LAN listener; loopback `dsh web` is unaffected. + +## License + +MIT diff --git a/README.md b/README.md new file mode 100644 index 0000000..15320e5 --- /dev/null +++ b/README.md @@ -0,0 +1,123 @@ +# @sutong/dsh-lan-access + +局域网访问 dsh Web UI:一个**独立反向代理**,在 `0.0.0.0:` 监听,把 HTTP 与 WebSocket 转发到回环 web 服务器,并**保留官方 launch token 登录**。 + +**简体中文** | [English](README.en.md) + +## 为什么需要插件 + +`dsh web --host 0.0.0.0` 被官方 CLI 主动拒绝(`@deepseek-ai/dsh-web-app/lib/startup.js`:会把这台机器的远程代码执行能力暴露到网络)。所以官方 web 服务器始终只绑回环,局域网访问必须由插件在**另一个端口**上另开监听并转发。 + +## 与 dsh-lan-proxy 的区别 + +社区插件 `@wingsky-1/dsh-lan-proxy` 也做转发,但设计取向不同: + +| | 本插件 | dsh-lan-proxy | +|---|---|---| +| 默认登录 | **必须持有 launch token**(横幅/二维码里的链接) | `injectToken: true`,局域网内任何设备免 token 直入 | +| 上游 `Host` | 重写为回环(官方围栏通过) | 重写为回环 | +| `Set-Cookie` | 不改动(浏览器按**请求 URL** 建立 host-only cookie,转发侧无需干预) | 需配合 token 注入与 401 重放 | +| WebSocket | 握手按字节转发,`permessage-deflate` 端到端协商,**代理不解压任何帧** | 压缩桥接(存在解压放大面) | +| 官方行覆盖 | **不覆盖任何 shipped row**,失败/禁用不会影响回环 UI | 插入自身行 + 兼容注入 | + +> **端口冲突**:dsh-lan-proxy 默认占用 `3081`,因此本插件默认用 `3082`。两者可共存,但同时启用等于把 UI 暴露两次;建议只留一个。 + +## 安装 + +```sh +dsh plugin --profile web add /path/to/dsh-lan-access +``` + +安装后重启一次 `dsh web`: + +```sh +# 重启后终端会打印带 token 的局域网链接 +``` + +或在本仓库内用本地目录安装(Profile → Plugins → 本地安装)。 + +## 配置 + +配置写在插件行的 `config` 里(Host Profile → Plugins → 本插件 → Configure,或直接改 `cordis.patch.yml`)。 + +| 键 | 默认 | 说明 | +|---|---|---| +| `enabled` | `true` | 总开关;关闭后不再监听 | +| `host` | `0.0.0.0` | 监听地址 | +| `port` | `3082` | 监听端口(避开 dsh-lan-proxy 的 3081) | +| `targetHost` | `127.0.0.1` | 上游主机。**仅接受回环地址**,否则插件拒绝启动(防止变成开放转发/SSRF) | +| `targetPort` | `0` | 上游端口;`0` = 跟随 `webServer` 实际端口 | +| `printBanner` | `true` | 启动时打印局域网链接 | + +## 使用 + +1. **终端横幅**(启动时): + + ``` + dsh-lan-access: 0.0.0.0:3082 → http://127.0.0.1:3080 (LAN listener) + dsh-lan-access: http://192.168.1.5:3082/?token=XXXXXXXX + ``` + +2. **Web UI 面板**:Settings → **局域网访问**(插件自带的设置页)。列出全部局域网地址、可复制、可直接打开,并为你选中的地址渲染**二维码**,手机扫一下即可进入。 + +链接里的 `token` 只在首次访问用到:服务器校验后会铸一个 30 天有效的 cookie 并 302 跳到干净的 `./`,**token 不会留在地址栏或历史记录里**。之后该设备靠 cookie 直接进入。 + +## 安全模型 + +- **认证不放松**:不注入 token、不绕过围栏。局域网设备必须拿到横幅或二维码里的链接。这比"信任整个局域网"更保守。 +- **官方围栏照旧生效**:`/api` 只接受回环或已声明的 authority,且 `Origin` 必须与 `Host` 同源;本插件把上游 `Host`/`Origin` 重写为回环,因此这些检查全部通过而不是被绕过。 +- **DNS 重绑定防护**:监听器只接受 `localhost`、回环地址、本插件公布的局域网 IP 字面量,或它自己的监听地址;域名一律断开。 +- **上游锁定回环**:`targetHost` 非回环直接拒绝启动,避免开放代理。 +- **无额外解压面**:中间不做响应/帧压缩,也不解压 WebSocket 帧。 +- **面板数据路由**:`/api/dsh-lan-access/summary` 挂在官方 web 服务器上,并**复用官方 `connection.admit()`**(同一套围栏 + 浏览器认证)——只有已登录页面能读到 token。 +- **⚠️ 风险面**:拿到该链接的设备拥有**完整 dsh 控制权,包括在宿主机执行命令**。请只在可信局域网启用;不使用时卸载或关闭插件。列表里的每一条链接都是一把钥匙。 + +## 局域网页面上的设置界面(ownsHostCompat) + +DSH 官方把「持久化设置」限制在回环页面:客户端由 `location.hostname` 推导 `isLoopback` +(`dsh-client-connection/lib/client.js`),非回环页面得到 `persistence = "memory"` +(`dsh-client-ui-settings/lib/client.js`),于是设置镜像永远没有 `view`,模型/提供商页会报 +**settings are unavailable in this browser**,General 页的「打开配置文件」也会消失。 + +**这与转发是否正确无关**:任何局域网暴露方式都会遇到,用 `127.0.0.1:3080` 打开则一切正常。 +服务端也**没有**对应的回环门禁(`/api` 围栏 + token/cookie 才是授权面),所以本插件提供一个开关: + +| 键 | 默认 | 说明 | +|---|---|---| +| `ownsHostCompat` | `false` | 为**非回环页面**声明 `ownsHost`,恢复官方设置界面(回环页面不受影响) | + +本 profile 已设为 `true`。取舍:该页面因此可读写服务器设置(含凭据)。考虑到持有带 token +链接的设备本就拥有完整控制权(含在宿主机执行命令),这不算新增一类能力,但确实绕过了官方的 +回环隔离 —— 设为 `false` 即回到官方姿态,设置请在本机通过 `127.0.0.1:3080`(或 +`ssh -L 3080:127.0.0.1:3080 <主机>` 隧道)修改。 + +## 排障 + +| 现象 | 原因与处理 | +|---|---| +| 终端出现 `LAN listener unavailable ... EADDRINUSE` | 端口被占用(常见:dsh-lan-proxy 占着 3081)。改 `port`,或停用另一个插件。面板会同时显示该错误,不会展示不可用的二维码。 | +| 局域网设备打开后 401 | 用的是不带 token 的裸地址,且该设备还没有 cookie。改用横幅/面板里带 `?token=` 的链接。 | +| 重启后旧书签失效 | launch token 每次启动都会变。已登录过的设备靠 cookie 仍然有效;否则重新用新链接进入。 | +| 面板显示"未找到非内部 IPv4 地址" | 这台机器当前没有可用的局域网 IPv4(只有回环)。 | +| 扫码无反应 | 部分机型对深色背景上的二维码识别差;面板里的二维码固定渲染在白底卡片上。适当调高屏幕亮度。 | + +## 开发 + +```sh +node scripts/build-client.mjs # 由 src/panel.js + src/qrcode.js 生成 client.js +node scripts/smoke.mjs # 宿主端转发契约(HTTP/WS/Host/Origin/cookie/绑定失败) +node scripts/test-qr.mjs # 与 npm qrcode 参考实现逐模块比对 +node scripts/test-client.mjs # 浏览器端契约(槽位注册、渲染、路由一致) +``` + +`client.js` 是生成产物,请改 `src/` 后重新构建。 + +二维码编码器(`src/qrcode.js`)是零依赖自实现(byte 模式 / ECC M / version 1–10),与 npm `qrcode` 参考实现做了全版本 × 全掩码的逐模块比对(240/240 一致)。 + +## 回退 + +在 Plugins 页停用或卸载本插件即可;因为本插件**不覆盖任何官方行**,停用只会让局域网监听消失,回环 `dsh web` 不受影响。 + +## 许可 + +MIT diff --git a/client.js b/client.js new file mode 100644 index 0000000..15349c8 --- /dev/null +++ b/client.js @@ -0,0 +1,877 @@ +/** + * @sutong/dsh-lan-access — browser half (generated by scripts/build-client.mjs). + * + * Do not edit this file: change src/panel.js or src/qrcode.js and rebuild. + */ +window.__ModuleLoader__.load({ + id: "@sutong/dsh-lan-access", + factory: function (require) { + "use strict"; + var React = require("react"); + + // ── inlined src/qrcode.js ──────────────────────────────────────────────── + var __qrcode = (function () { + /** + * A dependency-free QR Code encoder (byte mode, error-correction level M, + * versions 1–10) used to render a scannable link to the LAN address. + * + * This is a from-scratch implementation of ISO/IEC 18004 for the one mode the + * panel needs; it carries no runtime dependency into the browser artifact. + * Level M recovers about 15% of the symbol, which is the usual choice for a + * code read off a screen. + * + * Verified against the reference `qrcode` npm package: `node scripts/test-qr.mjs` + * compares every module of every version and mask. + * + * @module @sutong/dsh-lan-access/qrcode + */ + + /** Error-correction level indicator bits (`M` = 0b00) and the format-info prefix. */ + const EC_LEVEL_BITS = 0b00 + + /** Reed-Solomon block layout per version at level M: [ecCodewordsPerBlock, [[blocks, dataCodewords], …]]. */ + const BLOCKS_M = { + 1: [10, [[1, 16]]], + 2: [16, [[1, 28]]], + 3: [26, [[1, 44]]], + 4: [18, [[2, 32]]], + 5: [24, [[2, 43]]], + 6: [16, [[4, 27]]], + 7: [18, [[4, 31]]], + 8: [22, [[2, 38], [2, 39]]], + 9: [22, [[3, 36], [2, 37]]], + 10: [26, [[4, 43], [1, 44]]], + } + + /** Alignment-pattern centre coordinates per version. */ + const ALIGN_CENTERS = { + 1: [], + 2: [6, 18], + 3: [6, 22], + 4: [6, 26], + 5: [6, 30], + 6: [6, 34], + 7: [6, 22, 38], + 8: [6, 24, 42], + 9: [6, 26, 46], + 10: [6, 28, 50], + } + + /** Largest version this encoder supports. */ + const MAX_VERSION = 10 + + // ── GF(256) arithmetic ────────────────────────────────────────────────────── + + const GF_EXP = new Uint8Array(512) + const GF_LOG = new Uint8Array(256) + { + let x = 1 + for (let i = 0; i < 255; i += 1) { + GF_EXP[i] = x + GF_LOG[x] = i + x <<= 1 + if (x & 0x100) x ^= 0x11d + } + for (let i = 255; i < 512; i += 1) GF_EXP[i] = GF_EXP[i - 255] + } + + /** + * Multiply two field elements. + * @param a - first element. + * @param b - second element. + * @returns the product in GF(256). + */ + function gfMul(a, b) { + if (a === 0 || b === 0) return 0 + return GF_EXP[GF_LOG[a] + GF_LOG[b]] + } + + /** + * Build the Reed-Solomon generator polynomial for `degree` error codewords. + * @param degree - number of error-correction codewords. + * @returns coefficients, highest power first, leading coefficient 1. + */ + function rsGenerator(degree) { + let poly = [1] + for (let i = 0; i < degree; i += 1) { + const factor = GF_EXP[i] + const next = new Array(poly.length + 1).fill(0) + for (let j = 0; j < poly.length; j += 1) { + next[j] ^= poly[j] + next[j + 1] ^= gfMul(poly[j], factor) + } + poly = next + } + return poly + } + + /** + * Compute the error-correction codewords for one data block. + * @param data - data codewords. + * @param ecCount - number of error codewords to append. + * @returns the error codewords. + */ + function rsEncode(data, ecCount) { + const generator = rsGenerator(ecCount) + const buffer = new Uint8Array(data.length + ecCount) + buffer.set(data, 0) + for (let i = 0; i < data.length; i += 1) { + const coefficient = buffer[i] + if (coefficient === 0) continue + for (let j = 1; j < generator.length; j += 1) { + buffer[i + j] ^= gfMul(generator[j], coefficient) + } + } + return buffer.slice(data.length) + } + + // ── bit stream ────────────────────────────────────────────────────────────── + + /** Append the low `count` bits of `value`, most significant first. */ + function appendBits(bits, value, count) { + for (let i = count - 1; i >= 0; i -= 1) bits.push((value >>> i) & 1) + } + + /** + * The smallest version whose data capacity holds `byteLength`. + * @param byteLength - payload length in bytes. + * @returns the version, or `undefined` when the payload is too large. + */ + function versionFor(byteLength) { + for (let version = 1; version <= MAX_VERSION; version += 1) { + const [, groups] = BLOCKS_M[version] + const dataCodewords = groups.reduce((sum, [blocks, per]) => sum + blocks * per, 0) + const lengthBits = version >= 10 ? 16 : 8 + const capacity = Math.floor((dataCodewords * 8 - 4 - lengthBits) / 8) + if (byteLength <= capacity) return version + } + return undefined + } + + /** + * Encode the payload into the final interleaved codeword sequence. + * @param bytes - UTF-8 payload bytes. + * @param version - resolved symbol version. + * @returns data codewords followed by error codewords, interleaved per block. + */ + function codewordsFor(bytes, version) { + const [ecPerBlock, groups] = BLOCKS_M[version] + const dataCodewords = groups.reduce((sum, [blocks, per]) => sum + blocks * per, 0) + + const bits = [] + appendBits(bits, 0b0100, 4) + appendBits(bits, bytes.length, version >= 10 ? 16 : 8) + for (const byte of bytes) appendBits(bits, byte, 8) + // Terminator, then pad to a codeword boundary. + const capacityBits = dataCodewords * 8 + const terminator = Math.min(4, capacityBits - bits.length) + appendBits(bits, 0, terminator) + while (bits.length % 8 !== 0) bits.push(0) + + const stream = new Uint8Array(dataCodewords) + for (let i = 0; i < bits.length; i += 8) { + let byte = 0 + for (let j = 0; j < 8; j += 1) byte = (byte << 1) | bits[i + j] + stream[i / 8] = byte + } + // Alternating pad codewords fill the remainder. + const PAD = [0xec, 0x11] + for (let i = bits.length / 8, k = 0; i < dataCodewords; i += 1, k += 1) stream[i] = PAD[k % 2] + + // Split into blocks, append each block's error codewords, then interleave. + const dataBlocks = [] + const ecBlocks = [] + let offset = 0 + for (const [blocks, per] of groups) { + for (let b = 0; b < blocks; b += 1) { + const chunk = stream.slice(offset, offset + per) + offset += per + dataBlocks.push(chunk) + ecBlocks.push(rsEncode(chunk, ecPerBlock)) + } + } + + const out = [] + const maxData = Math.max(...dataBlocks.map((block) => block.length)) + for (let i = 0; i < maxData; i += 1) { + for (const block of dataBlocks) if (i < block.length) out.push(block[i]) + } + for (let i = 0; i < ecPerBlock; i += 1) { + for (const block of ecBlocks) out.push(block[i]) + } + return Uint8Array.from(out) + } + + // ── matrix construction and masking ───────────────────────────────────────── + + /** The eight data-mask predicates; index is the mask pattern. */ + const MASKS = [ + (row, col) => (row + col) % 2 === 0, + (row) => row % 2 === 0, + (_row, col) => col % 3 === 0, + (row, col) => (row + col) % 3 === 0, + (row, col) => (Math.floor(row / 2) + Math.floor(col / 3)) % 2 === 0, + (row, col) => ((row * col) % 2) + ((row * col) % 3) === 0, + (row, col) => (((row * col) % 2) + ((row * col) % 3)) % 2 === 0, + (row, col) => (((row * col) % 3) + ((row + col) % 2)) % 2 === 0, + ] + + /** BCH(15,5) generator used by the format information. */ + const FORMAT_GENERATOR = 0x537 + + /** BCH(18,6) generator used by the version information. */ + const VERSION_GENERATOR = 0x1f25 + + /** + * Compute the masked format-information word. + * @param mask - mask pattern index. + * @returns the 15-bit word, already XORed with `0x5412`. + */ + function formatBits(mask) { + const data = (EC_LEVEL_BITS << 3) | mask + let value = data << 10 + for (let i = 14; i >= 10; i -= 1) { + if ((value >>> i) & 1) value ^= FORMAT_GENERATOR << (i - 10) + } + return (((data << 10) | value) ^ 0x5412) & 0x7fff + } + + /** + * Compute the version-information word for versions 7 and above. + * @param version - symbol version. + * @returns the 18-bit word. + */ + function versionBits(version) { + let value = version << 12 + for (let i = 17; i >= 12; i -= 1) { + if ((value >>> i) & 1) value ^= VERSION_GENERATOR << (i - 12) + } + return ((version << 12) | value) & 0x3ffff + } + + /** + * Draw the fixed function patterns and reserve their modules. + * @param size - symbol side length in modules. + * @param version - symbol version. + * @returns `{ modules, reserved }` as row-major byte grids. + */ + function newMatrix(size, version) { + const modules = new Uint8Array(size * size) + const reserved = new Uint8Array(size * size) + const set = (row, col, dark) => { + modules[row * size + col] = dark ? 1 : 0 + reserved[row * size + col] = 1 + } + + // Finder patterns with separators, at three corners. + for (const [top, left] of [[0, 0], [0, size - 7], [size - 7, 0]]) { + for (let r = -1; r <= 7; r += 1) { + for (let c = -1; c <= 7; c += 1) { + const row = top + r + const col = left + c + if (row < 0 || row >= size || col < 0 || col >= size) continue + const inRing = r >= 0 && r <= 6 && c >= 0 && c <= 6 + const dark = inRing && (r === 0 || r === 6 || c === 0 || c === 6 || (r >= 2 && r <= 4 && c >= 2 && c <= 4)) + set(row, col, dark) + } + } + } + + // Timing patterns: dark on even coordinates, alternating with the finders. + for (let i = 8; i < size - 8; i += 1) { + set(6, i, i % 2 === 0) + set(i, 6, i % 2 === 0) + } + + // Alignment patterns, skipping the three finder corners. + const centers = ALIGN_CENTERS[version] + for (const row of centers) { + for (const col of centers) { + const nearFinder = + (row <= 8 && col <= 8) || (row <= 8 && col >= size - 9) || (row >= size - 9 && col <= 8) + if (nearFinder) continue + for (let r = -2; r <= 2; r += 1) { + for (let c = -2; c <= 2; c += 1) { + const dark = Math.max(Math.abs(r), Math.abs(c)) !== 1 + set(row + r, col + c, dark) + } + } + } + } + + // The always-dark module beside the lower-left finder. + set(size - 8, 8, true) + + // Reserve the format-information areas (values are written after masking). + for (let i = 0; i < 9; i += 1) { + if (i !== 6) { + set(8, i, false) + set(i, 8, false) + } + } + // The horizontal copy carries 8 modules; the vertical copy carries 7, because + // the eighth slot of column 8 is the always-dark module below. + for (let i = 0; i < 8; i += 1) set(8, size - 1 - i, false) + for (let i = 0; i < 7; i += 1) set(size - 1 - i, 8, false) + + // Reserve the version-information areas for versions 7 and above. + if (version >= 7) { + for (let i = 0; i < 18; i += 1) { + set(Math.floor(i / 3), (i % 3) + size - 11, false) + set((i % 3) + size - 11, Math.floor(i / 3), false) + } + } + return { size, modules, reserved } + } + + /** + * Write the data and error codewords into the matrix in the zigzag order. + * @param matrix - `{ size, modules, reserved }`. + * @param codewords - interleaved codewords. + * @param mask - mask pattern index. + */ + function placeData(matrix, codewords, mask) { + const { size, modules, reserved } = matrix + const maskFn = MASKS[mask] + let bitIndex = 7 + let byteIndex = 0 + let row = size - 1 + let direction = -1 + + for (let col = size - 1; col > 0; col -= 2) { + if (col === 6) col -= 1 + for (;;) { + for (let c = 0; c < 2; c += 1) { + const at = row * size + (col - c) + if (reserved[at]) continue + let dark = false + if (byteIndex < codewords.length) dark = ((codewords[byteIndex] >>> bitIndex) & 1) === 1 + if (maskFn(row, col - c)) dark = !dark + modules[at] = dark ? 1 : 0 + reserved[at] = 1 + bitIndex -= 1 + if (bitIndex === -1) { + byteIndex += 1 + bitIndex = 7 + } + } + row += direction + if (row < 0 || row >= size) { + row -= direction + direction = -direction + break + } + } + } + } + + /** + * Measure one masked matrix with the four standard penalty rules. + * @param size - symbol side length. + * @param modules - row-major module grid. + * @returns the penalty score; lower is better. + */ + function penalty(size, modules) { + const at = (row, col) => modules[row * size + col] + let score = 0 + + // Rule 1: runs of five or more same-coloured modules in a line. + for (let i = 0; i < size; i += 1) { + for (const line of [0, 1]) { + let run = 1 + let previous = line === 0 ? at(i, 0) : at(0, i) + for (let j = 1; j < size; j += 1) { + const current = line === 0 ? at(i, j) : at(j, i) + if (current === previous) { + run += 1 + } else { + if (run >= 5) score += 3 + (run - 5) + previous = current + run = 1 + } + } + if (run >= 5) score += 3 + (run - 5) + } + } + + // Rule 2: every 2x2 block of one colour. + for (let row = 0; row < size - 1; row += 1) { + for (let col = 0; col < size - 1; col += 1) { + const value = at(row, col) + if (value === at(row, col + 1) && value === at(row + 1, col) && value === at(row + 1, col + 1)) score += 3 + } + } + + // Rule 3: the finder-like 1:1:3:1:1 patterns with a four-module quiet side. + const first = [1, 0, 1, 1, 1, 0, 1, 0, 0, 0, 0] + const second = [0, 0, 0, 0, 1, 0, 1, 1, 1, 0, 1] + const matches = (get, start) => { + let one = true + let two = true + for (let k = 0; k < 11; k += 1) { + const value = get(start + k) + if (value !== first[k]) one = false + if (value !== second[k]) two = false + } + return (one ? 1 : 0) + (two ? 1 : 0) + } + for (let i = 0; i < size; i += 1) { + for (let j = 0; j + 11 <= size; j += 1) { + score += 40 * matches((k) => at(i, k), j) + score += 40 * matches((k) => at(k, i), j) + } + } + + // Rule 4: deviation of the dark-module proportion from 50%. + let dark = 0 + for (let i = 0; i < size; i += 1) dark += modules[i] + const percent = (dark * 100) / (size * size) + score += Math.floor(Math.abs(percent - 50) / 5) * 10 + return score + } + + /** + * Write the format information (and version information for v7+) around the + * fixed patterns. + * @param matrix - `{ size, modules }`. + * @param version - symbol version. + * @param mask - chosen mask pattern index. + */ + function placeHeaders(matrix, version, mask) { + const { size, modules } = matrix + const set = (row, col, dark) => { + modules[row * size + col] = dark ? 1 : 0 + } + + const format = formatBits(mask) + for (let i = 0; i < 15; i += 1) { + const dark = ((format >>> i) & 1) === 1 + if (i < 6) set(i, 8, dark) + else if (i < 8) set(i + 1, 8, dark) + else set(size - 15 + i, 8, dark) + + if (i < 8) set(8, size - 1 - i, dark) + else if (i < 9) set(8, 15 - i, dark) + else set(8, 15 - i - 1, dark) + } + + if (version >= 7) { + const bits = versionBits(version) + for (let i = 0; i < 18; i += 1) { + const dark = ((bits >>> i) & 1) === 1 + set(Math.floor(i / 3), (i % 3) + size - 11, dark) + set((i % 3) + size - 11, Math.floor(i / 3), dark) + } + } + } + + /** + * Encode text as a QR Code symbol. + * @param text - the payload; UTF-8 encoded. + * @param options - optional `version` and `mask` overrides (mainly for tests). + * @returns `{ size, version, mask, modules }` where `modules` is row-major, 1 = dark. + * @throws {RangeError} when the payload exceeds version 10 at level M. + */ + function encodeQR(text, options = {}) { + const bytes = new TextEncoder().encode(String(text)) + const version = options.version ?? versionFor(bytes.length) + if (version === undefined || version > MAX_VERSION) { + throw new RangeError(`qrcode: payload of ${bytes.length} bytes exceeds version ${MAX_VERSION} at level M`) + } + + const codewords = codewordsFor(bytes, version) + const size = version * 4 + 17 + + let best + if (options.mask !== undefined) { + best = options.mask + } else { + let bestScore = Number.POSITIVE_INFINITY + for (let mask = 0; mask < 8; mask += 1) { + const matrix = newMatrix(size, version) + placeData(matrix, codewords, mask) + placeHeaders(matrix, version, mask) + const score = penalty(size, matrix.modules) + if (score < bestScore) { + bestScore = score + best = mask + } + } + } + + const matrix = newMatrix(size, version) + placeData(matrix, codewords, best) + placeHeaders(matrix, version, best) + return { size, version, mask: best, modules: matrix.modules } + } + return { encodeQR: encodeQR, MAX_VERSION: MAX_VERSION }; + })(); + var encodeQR = __qrcode.encodeQR; + + // ── inlined src/panel.js ───────────────────────────────────────────────── + /** + * Client half of @sutong/dsh-lan-access: a Plugins-settings tab that shows the + * LAN addresses the host listener publishes, with a scannable QR code for the + * address you pick. + * + * This file is bundled into the single browser artifact `client.js` by + * `scripts/build-client.mjs`; it is never loaded directly by the browser. + * + * @module @sutong/dsh-lan-access/panel + */ + + + + /** Locale namespace for this plugin's visible text. */ + const NS = 'dsh-lan-access' + + /** Host route (behind the official `/api` fence and browser authentication). */ + const SUMMARY_URL = '/api/dsh-lan-access/summary' + + /** + * The seat this panel occupies: its own entry in the Settings navigation. + * Registering here (rather than inside the Plugins section's tab strip) makes + * the page reachable directly from Settings; `dsh-opencode-go` sets the same + * precedent for a plugin-owned settings page. + */ + const SLOT = 'settings.section' + + /** Nav/section key for this panel. */ + const SECTION_ID = 'lan-access' + + /** Simplified-Chinese dictionary; the key source. */ + const zh = { + title: '局域网访问', + description: '通过独立反向代理把本机 dsh Web UI 发布到局域网,保留官方 launch token 登录。', + loading: '正在读取局域网地址…', + failed: '无法读取局域网信息:{message}', + forward: '转发到', + addresses: '局域网地址', + noAddresses: '未找到非内部 IPv4 地址,局域网内暂时无法访问。', + scanHint: '用另一台设备扫描二维码即可打开(链接已带登录令牌)。', + copy: '复制', + copied: '已复制', + open: '打开', + warning: '该链接携带 launch token,等同于完整 dsh 控制权(含终端命令执行)。请只在可信局域网内分享。', + disabled: '该 LAN 监听器在此 profile 中已被配置为关闭。', + notListening: '监听器未能绑定 {target}:{message}', + qr: '{address} 的二维码', + } + + /** English dictionary; must cover every key above. */ + const en = { + title: 'LAN Access', + description: 'Publishes this machine’s dsh Web UI on the LAN through a standalone reverse proxy while keeping the official launch-token login.', + loading: 'Reading LAN addresses…', + failed: 'Could not read LAN information: {message}', + forward: 'forwards to', + addresses: 'LAN addresses', + noAddresses: 'No non-internal IPv4 address was found, so nothing is reachable from the LAN yet.', + scanHint: 'Scan the QR code with another device to open it (the link already carries the login token).', + copy: 'Copy', + copied: 'Copied', + open: 'Open', + warning: 'That link carries the launch token and grants full dsh control, shell included. Share it only on a trusted network.', + disabled: 'The LAN listener is configured off in this profile.', + notListening: 'The listener could not bind {target}: {message}', + qr: 'QR code for {address}', + } + + /** Substitute `{name}` placeholders. */ + function interpolate(template, params) { + if (params === undefined) return template + return template.replaceAll(/\{(\w+)\}/g, (match, key) => (key in params ? String(params[key]) : match)) + } + + /** + * Bind the locale service, falling back to the Chinese dictionary when the + * service is absent or its contract drifted. + * @param locale - the live `locale` service, when present. + * @returns a translate function taking a key and optional `{name}` parameters. + */ + function makeTranslator(locale) { + if (locale !== undefined && typeof locale.bind === 'function') { + try { + const bound = locale.bind(NS) + if (typeof bound === 'function') return (key, params) => interpolate(bound(key), params) + } catch { + /* fall through to the local dictionary */ + } + } + return (key, params) => interpolate(zh[key] ?? key, params) + } + + /** + * The active translator. `apply` replaces it once the locale service is bound + * and re-binds it on every locale change, so components read it at render time. + */ + let translate = (key, params) => interpolate(zh[key] ?? key, params) + + /** Stylesheet, scoped under `dla-` and expressed only in theme tokens. */ + const CSS = ` + .dla-root { display: flex; flex-direction: column; gap: 16px; padding: 4px 2px 24px; color: var(--dsw-alias-label-primary); } + .dla-head { display: flex; flex-direction: column; gap: 6px; } + .dla-title { font-size: 15px; font-weight: 600; } + .dla-desc { font-size: 13px; line-height: 1.6; color: var(--dsw-alias-label-secondary); } + .dla-facts { display: flex; flex-wrap: wrap; gap: 6px 18px; font-size: 12px; color: var(--dsw-alias-label-secondary); } + .dla-fact b { color: var(--dsw-alias-label-primary); font-weight: 500; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; } + .dla-body { display: flex; flex-wrap: wrap; gap: 20px; align-items: flex-start; } + .dla-list { display: flex; flex-direction: column; gap: 8px; flex: 1 1 320px; min-width: 280px; } + .dla-section { font-size: 12px; font-weight: 600; letter-spacing: .02em; text-transform: uppercase; color: var(--dsw-alias-label-secondary); } + .dla-row { display: flex; align-items: center; gap: 8px; border: 1px solid var(--dsw-alias-border-l1); border-radius: 8px; padding: 8px 10px; background: var(--dsw-alias-bg-layer-1); } + .dla-row[data-selected="true"] { border-color: var(--dsw-alias-brand-primary); } + .dla-row-main { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 2px; } + .dla-ip { font-size: 12px; color: var(--dsw-alias-label-secondary); } + .dla-url { font-size: 12px; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } + .dla-btn { flex: none; font: inherit; font-size: 12px; line-height: 1; padding: 6px 10px; border-radius: 6px; border: 1px solid var(--dsw-alias-border-l1); background: var(--dsw-alias-bg-layer-2); color: var(--dsw-alias-label-primary); cursor: pointer; } + .dla-btn:hover { border-color: var(--dsw-alias-border-l2); } + .dla-btn[data-primary="true"] { border-color: var(--dsw-alias-brand-primary); color: var(--dsw-alias-brand-primary); } + .dla-qr { display: flex; flex-direction: column; gap: 8px; align-items: center; } + .dla-qr-card { padding: 10px; border-radius: 10px; background: #ffffff; border: 1px solid var(--dsw-alias-border-l1); } + .dla-qr-cap { font-size: 12px; color: var(--dsw-alias-label-secondary); max-width: 200px; text-align: center; } + .dla-hint { font-size: 12px; line-height: 1.6; color: var(--dsw-alias-label-secondary); } + .dla-warn { font-size: 12px; line-height: 1.6; color: var(--dsw-alias-state-warn-primary); } + .dla-error { font-size: 12px; color: var(--dsw-alias-state-error-primary); } + ` + + /** + * Render a QR matrix as a crisp SVG. + * @param symbol - `{ size, modules }` from the encoder. + * @param label - accessible label. + * @returns a React element. + */ + function qrElement(React, symbol, label, caption) { + const h = React.createElement + const { size, modules } = symbol + let path = '' + for (let row = 0; row < size; row += 1) { + for (let col = 0; col < size; col += 1) { + if (modules[row * size + col]) path += `M${col} ${row}h1v1h-1z` + } + } + return h( + 'div', + { className: 'dla-qr' }, + h( + 'div', + { className: 'dla-qr-card' }, + h( + 'svg', + { + width: 168, + height: 168, + viewBox: `0 0 ${size} ${size}`, + role: 'img', + 'aria-label': label, + shapeRendering: 'crispEdges', + }, + h('rect', { width: size, height: size, fill: '#ffffff' }), + h('path', { d: path, fill: '#000000' }), + ), + ), + caption === undefined ? null : h('div', { className: 'dla-qr-cap' }, caption), + ) + } + + /** + * The Plugins-settings tab: read the host summary and present the LAN links. + * @param props - slot props; this seat supplies none. + * @returns the panel element. + */ + function Panel() { + const h = React.createElement + const [state, setState] = React.useState({ status: 'loading' }) + const [selected, setSelected] = React.useState(0) + const [copied, setCopied] = React.useState('') + + React.useEffect(() => { + let live = true + fetch(SUMMARY_URL, { headers: { accept: 'application/json' } }) + .then(async (response) => { + if (!response.ok) throw new Error(`HTTP ${String(response.status)}`) + return response.json() + }) + .then((data) => { + if (live) setState({ status: 'ready', data }) + }) + .catch((error) => { + if (live) setState({ status: 'error', message: String(error?.message ?? error) }) + }) + return () => { + live = false + } + }, []) + + const t = translate + const children = [h('style', { key: 'style' }, CSS)] + + if (state.status === 'loading') { + children.push(h('div', { className: 'dla-hint', key: 'loading' }, t('loading'))) + return h('div', { className: 'dla-root' }, children) + } + if (state.status === 'error') { + children.push(h('div', { className: 'dla-error', key: 'error' }, t('failed', { message: state.message }))) + return h('div', { className: 'dla-root' }, children) + } + + const { data } = state + const lan = Array.isArray(data.lan) ? data.lan : [] + const active = lan[Math.min(selected, Math.max(lan.length - 1, 0))] + + children.push( + h( + 'div', + { className: 'dla-head', key: 'head' }, + h('div', { className: 'dla-title' }, t('title')), + h('div', { className: 'dla-desc' }, t('description')), + h( + 'div', + { className: 'dla-facts' }, + h('span', null, `${data.listen.host}:${String(data.listen.port)}`, ' ', t('forward'), ' ', h('b', null, `${data.target.host}:${String(data.target.port)}`)), + ), + ), + ) + + if (data.enabled === false) { + children.push(h('div', { className: 'dla-warn', key: 'disabled' }, t('disabled'))) + } + if (data.listening === false) { + // Never advertise an address that is not actually bound. + children.push( + h( + 'div', + { className: 'dla-error', key: 'not-listening' }, + t('notListening', { + target: `${data.listen.host}:${String(data.listen.port)}`, + message: data.bindError ?? 'unknown error', + }), + ), + ) + } + + if (lan.length === 0) { + children.push(h('div', { className: 'dla-hint', key: 'none' }, t('noAddresses'))) + return h('div', { className: 'dla-root' }, children) + } + + const copy = (url) => { + const done = () => { + setCopied(url) + globalThis.setTimeout(() => setCopied(''), 1500) + } + if (globalThis.navigator?.clipboard?.writeText !== undefined) { + globalThis.navigator.clipboard.writeText(url).then(done, done) + } else { + done() + } + } + + const rows = lan.map((entry, index) => + h( + 'div', + { + className: 'dla-row', + key: entry.address, + 'data-selected': String(index === selected), + onClick: () => setSelected(index), + }, + h( + 'div', + { className: 'dla-row-main' }, + h('div', { className: 'dla-ip' }, entry.address), + h('div', { className: 'dla-url', title: entry.tokenUrl }, entry.tokenUrl), + ), + h( + 'button', + { + type: 'button', + className: 'dla-btn', + onClick: (event) => { + event.stopPropagation() + copy(entry.tokenUrl) + }, + }, + copied === entry.tokenUrl ? t('copied') : t('copy'), + ), + h( + 'a', + { className: 'dla-btn', href: entry.tokenUrl, target: '_blank', rel: 'noreferrer', onClick: (event) => event.stopPropagation() }, + t('open'), + ), + ), + ) + + let symbol + try { + symbol = encodeQR(active.tokenUrl) + } catch { + symbol = undefined + } + + children.push( + h( + 'div', + { className: 'dla-body', key: 'body' }, + h( + 'div', + { className: 'dla-list' }, + h('div', { className: 'dla-section' }, t('addresses')), + ...rows, + h('div', { className: 'dla-hint' }, t('scanHint')), + ), + symbol === undefined ? null : qrElement(React, symbol, t('qr', { address: active.address }), active.address), + ), + h('div', { className: 'dla-warn', key: 'warn' }, t('warning')), + ) + return h('div', { className: 'dla-root' }, children) + } + + /** Services this client half reads. */ + const inject = ['slots', 'locale'] + + /** + * Register the locale dictionaries and the Plugins-settings tab. + * @param ctx - the restricted client context. + */ + function apply(ctx) { + const locale = ctx.get('locale') + if (locale !== undefined) { + if (typeof locale.register === 'function') { + try { + const dispose = locale.register(NS, { zh, en }) + if (typeof dispose === 'function') ctx.effect(() => dispose, 'dsh-lan-access: locale dictionaries') + } catch (error) { + console.error('dsh-lan-access: locale registration failed', error) + } + } + translate = makeTranslator(locale) + if (typeof locale.subscribe === 'function') { + const unsubscribe = locale.subscribe(() => { + translate = makeTranslator(locale) + }) + if (typeof unsubscribe === 'function') ctx.effect(() => unsubscribe, 'dsh-lan-access: locale subscription') + } + } + + const slots = ctx.get('slots') + if (slots === undefined) { + console.error('dsh-lan-access: the slots service is unavailable; the LAN panel is not mounted') + return + } + const t = makeTranslator(locale) + slots.inject(SLOT, () => + slots.register( + { + name: SLOT, + id: SECTION_ID, + order: 50, + label: () => t('title'), + }, + Panel, + ), + ) + } + + return { inject: inject, apply: apply }; + }, +}); diff --git a/cordis.patch.yml b/cordis.patch.yml new file mode 100644 index 0000000..991e626 --- /dev/null +++ b/cordis.patch.yml @@ -0,0 +1,25 @@ +# @sutong/dsh-lan-access — bundle patch. +# +# This patch only INSERTS one row and overrides no shipped row. That is a +# deliberate design property: the LAN listener is a separate socket that +# forwards to the loopback web server, so nothing in the official composition +# (webserver bind, connection trust fence, launch token) has to change, and a +# failure or a manual disable of this row cannot break the loopback Web UI. +# +# The row is an ordinary Cordis plugin activated by package name. +- insert: + - id: dsh-lan-access + name: '@sutong/dsh-lan-access' + config: + # The official client derives its `isLoopback` topology fact from + # `location.hostname`, and the durable settings surface is loopback-only + # by that rule: on a LAN page the settings mirror stays in `memory` mode, + # so the Models/provider pages fail with "settings are unavailable in + # this browser" and the config-file action disappears. + # + # Declaring `ownsHost` for non-loopback pages restores those screens. + # A loopback page is left untouched, and the Host/Origin fence plus the + # launch-token login are unchanged, so this does not widen server-side + # authorization — but that settings UI reads and writes server settings, + # credentials included. Set this to false to keep the shipped posture. + ownsHostCompat: true diff --git a/icon.svg b/icon.svg new file mode 100644 index 0000000..a08face --- /dev/null +++ b/icon.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/index.js b/index.js new file mode 100644 index 0000000..f46d250 --- /dev/null +++ b/index.js @@ -0,0 +1,431 @@ +/** + * @sutong/dsh-lan-access — host half. + * + * Publishes the loopback dsh Web UI on the LAN through a standalone reverse + * proxy. `dsh web --host 0.0.0.0` is refused by the shipped CLI on purpose + * (it would expose remote code execution to the network), so this plugin keeps + * the official server on loopback and adds a second listener that forwards to + * it. The official browser-trust fence and the launch-token login stay in + * force; nothing about the composed application is overridden. + * + * @module @sutong/dsh-lan-access + */ + +import { createServer, request as httpRequest } from 'node:http' +import { connect as netConnect, isIPv4 } from 'node:net' +import { networkInterfaces } from 'node:os' +import z from '@deepseek-ai/schemastery' + +/** Stable Cordis plugin name. */ +export const name = 'dsh-lan-access' + +/** Services required before the LAN listener resolves its upstream. */ +export const inject = ['webServer'] + +/** + * Configuration for the LAN listener. Every value also lives in the profile's + * `cordis.patch.yml` row config, so it survives plugin upgrades. + */ +export const Config = z.object({ + /** Whether the LAN listener runs at all. @default true */ + enabled: z.boolean().default(true), + /** Listener address; `0.0.0.0` publishes on every interface. @default '0.0.0.0' */ + host: z.string().default('0.0.0.0'), + /** + * Listener port. The default avoids 3081, which the community + * `dsh-lan-proxy` plugin also claims by default, so the two can coexist. + * @default 3082 + */ + port: z.number().default(3082), + /** + * Upstream host. Only a loopback authority is accepted, so the plugin can + * never become an open forward proxy (SSRF guard). @default '127.0.0.1' + */ + targetHost: z.string().default('127.0.0.1'), + /** + * Upstream port, or 0 to follow the live `webServer` port. @default 0 + */ + targetPort: z.number().default(0), + /** Print the LAN URLs (with launch token) at startup. @default true */ + printBanner: z.boolean().default(true), + /** + * Declare the page-side topology fact `ownsHost` for non-loopback pages. + * + * The official client derives `isLoopback` from `location.hostname`, and the + * durable settings surface is loopback-only by that rule: on a LAN page the + * settings mirror stays in `memory` mode, so the Models/provider pages fail + * with "settings are unavailable in this browser" and the config-file action + * disappears. The Host/Origin fence and the launch-token login are unaffected + * by this fact, so enabling it restores the settings UI rather than granting + * new server-side authority — but that UI reads and writes server settings, + * credentials included. Keep it off unless every device on this network is + * trusted. @default false + */ + ownsHostCompat: z.boolean().default(false), +}) + +/** Exact route the Web UI panel reads its LAN facts from. */ +export const SUMMARY_PATH = '/api/dsh-lan-access/summary' + +/** Connection-owned hop-by-hop headers this proxy never forwards. */ +const HOP_BY_HOP = new Set([ + 'connection', + 'keep-alive', + 'proxy-authenticate', + 'proxy-authorization', + 'te', + 'trailer', + 'transfer-encoding', + 'upgrade', +]) + +/** Names that always mean the local machine. */ +const LOOPBACK_NAMES = new Set(['localhost', '::1', '0:0:0:0:0:0:0:1']) + +/** + * Whether a host names the local loopback authority. + * @param host - a bare hostname or IP literal, without a port. + * @returns true for localhost, IPv6 loopback, or any 127/8 address. + */ +function isLoopbackHost(host) { + if (typeof host !== 'string' || host === '') return false + const bare = host.toLowerCase().replaceAll('[', '').replaceAll(']', '') + if (LOOPBACK_NAMES.has(bare)) return true + return isIPv4(bare) && bare.startsWith('127.') +} + +/** + * Strip the port from an HTTP `Host` / authority value. + * @param authority - `host`, `host:port`, or `[v6]:port`. + * @returns the bare host, or the input when it cannot be split. + */ +function hostnameOf(authority) { + if (typeof authority !== 'string') return '' + const value = authority.trim() + if (value.startsWith('[')) { + const end = value.indexOf(']') + return end === -1 ? value : value.slice(1, end) + } + const colon = value.indexOf(':') + return colon === -1 ? value : value.slice(0, colon) +} + +/** + * Every non-internal IPv4 address this machine currently holds. These are the + * addresses a peer on the same network can reach. + * @returns addresses in interface order, link-local autoconf excluded. + */ +function lanIPv4Addresses() { + const found = [] + for (const entries of Object.values(networkInterfaces())) { + for (const entry of entries ?? []) { + if (entry.family !== 'IPv4' || entry.internal) continue + if (entry.address.startsWith('169.254.')) continue + found.push(entry.address) + } + } + return found +} + +/** + * Rewrite one outbound request's headers for the loopback upstream. + * + * `Host` must name a loopback authority because the official `/api` fence + * accepts only loopback or declared authorities, and the browser cookie is + * authority-bound. `Origin` must then match the rewritten `Host`, or the fence + * rejects an otherwise same-origin request. The browser keeps its own cookie + * jar keyed by the address it actually requested, so cookies need no rewriting + * on the way back. + * @param headers - incoming request headers. + * @param authority - the upstream `host:port` to present. + * @param preserve - lowercase names to keep even though they are hop-by-hop (the WebSocket handshake). + * @returns a new header record safe to send upstream. + */ +function upstreamHeaders(headers, authority, preserve = EMPTY_PRESERVE) { + const out = {} + for (const [key, value] of Object.entries(headers)) { + if (value === undefined) continue + const lower = key.toLowerCase() + if (HOP_BY_HOP.has(lower) && !preserve.has(lower)) continue + out[key] = value + } + out.host = authority + if (typeof out.origin === 'string') out.origin = `http://${authority}` + return out +} + +/** + * Hop-by-hop names the WebSocket handshake itself must keep: stripping them + * would downgrade the forwarded request to a plain GET, so the upstream would + * answer 200 with a body instead of switching protocols. + */ +const UPGRADE_PRESERVE = new Set(['connection', 'upgrade']) + +/** Shared empty set, so the common path allocates nothing. */ +const EMPTY_PRESERVE = new Set() + +/** + * Forward one upgraded (WebSocket) socket to the upstream server. + * + * The handshake is re-emitted byte-for-byte with only `Host`/`Origin` + * rewritten, so `permessage-deflate` and every other extension negotiate + * end-to-end between the browser and the upstream — this proxy never + * decompresses a frame. + * @param req - the incoming upgrade request. + * @param clientSocket - the browser-side socket, owned on return. + * @param head - bytes already read past the handshake. + * @param options - `authority` plus the upstream `host`/`port` to dial. + */ +function forwardUpgrade(req, clientSocket, head, options) { + const { authority, host, port } = options + const headers = upstreamHeaders(req.headers, authority, UPGRADE_PRESERVE) + let raw = `${req.method ?? 'GET'} ${req.url ?? '/'} HTTP/1.1\r\n` + for (const [key, value] of Object.entries(headers)) { + for (const item of Array.isArray(value) ? value : [value]) raw += `${key}: ${item}\r\n` + } + raw += '\r\n' + + const upstream = netConnect(port, host, () => { + upstream.write(raw) + if (head !== undefined && head.length > 0) upstream.write(head) + upstream.pipe(clientSocket) + clientSocket.pipe(upstream) + }) + const bail = () => { + upstream.destroy() + clientSocket.destroy() + } + upstream.on('error', bail) + clientSocket.on('error', bail) +} + +/** + * Head script that declares `ownsHost` for a non-loopback page only. + * + * It stays inert on a loopback page, where the official client already treats + * the browser as local, and mutates nothing but the one topology flag. + */ +export const OWNS_HOST_COMPAT_SCRIPT = [ + '(function () {', + ' try {', + ' var host = globalThis.location && globalThis.location.hostname;', + ' if (!host) return;', + ' if (host === "localhost" || host === "[::1]" || host.indexOf("127.") === 0) return;', + ' var transport = globalThis.__DSH_TRANSPORT__ || {};', + ' transport.ownsHost = true;', + ' globalThis.__DSH_TRANSPORT__ = transport;', + ' } catch (error) { /* leave the page topology untouched */ }', + '})();', +].join('\n') + +/** + * Insert an inline classic script immediately before ``, so it runs + * before the deferred module bundle reads `__DSH_TRANSPORT__`. + * @param html - the raw index document. + * @param text - script body; must not contain `${text}` + const at = html.indexOf('') + return at === -1 ? `${tag}${html}` : `${html.slice(0, at)}${tag}${html.slice(at)}` +} + +/** + * Mount the LAN listener, the panel's data route, and the startup banner. + * @param ctx - plugin context carrying the web server and connection services. + * @param config - validated {@link Config}. + */ +export function apply(ctx, config) { + const settings = { ...config } + + if (settings.enabled === false) return + if (!isLoopbackHost(settings.targetHost)) { + console.warn( + `dsh-lan-access: refusing targetHost ${JSON.stringify(settings.targetHost)}; only a loopback upstream is allowed (this plugin is not an open proxy)`, + ) + return + } + if (!Number.isInteger(settings.port) || settings.port <= 0 || settings.port > 65535) { + console.warn(`dsh-lan-access: refusing invalid port ${String(settings.port)}`) + return + } + + /** Resolve the live upstream port; `targetPort: 0` follows the web server. */ + const upstreamPort = () => { + if (Number.isInteger(settings.targetPort) && settings.targetPort > 0) return settings.targetPort + const live = ctx.get('webServer')?.port + return typeof live === 'number' && live > 0 ? live : 3080 + } + const upstreamAuthority = () => `${settings.targetHost}:${String(upstreamPort())}` + + /** LAN facts for the banner and the Web UI panel. */ + const summary = () => { + const connection = ctx.get('connection') + const port = settings.port + return { + enabled: true, + listening: listened, + ...(bindError === undefined ? {} : { bindError }), + listen: { host: settings.host, port }, + target: { host: settings.targetHost, port: upstreamPort() }, + lan: lanIPv4Addresses().map((address) => { + const url = `http://${address}:${String(port)}/` + return { + address, + url, + tokenUrl: connection === undefined ? url : connection.authenticatedUrl(url), + } + }), + } + } + + let listened = false + let announced = false + /** Set when the listener could not bind; surfaced to the panel. */ + let bindError + const announce = (connection) => { + if (announced || !listened || settings.printBanner === false) return + announced = true + console.log( + `dsh-lan-access: 0.0.0.0:${String(settings.port)} → http://${upstreamAuthority()} (LAN listener)`, + ) + const addresses = lanIPv4Addresses() + if (addresses.length === 0) { + console.log('dsh-lan-access: no non-internal IPv4 address found; no LAN URL to print') + } + for (const address of addresses) { + const url = `http://${address}:${String(settings.port)}/` + const tokenUrl = connection === undefined ? url : connection.authenticatedUrl(url) + console.log(`dsh-lan-access: ${tokenUrl}`) + } + console.log( + 'dsh-lan-access: that link carries the launch token and grants full dsh control (shell included) — keep it on a trusted network', + ) + } + + // The browser URL the panel is served from may be the loopback one, so the + // data route is reachable both directly and through the listener. + ctx.inject(['webServer', 'connection'], (routeCtx) => { + routeCtx.effect( + () => + routeCtx.webServer.register({ + kind: 'exact', + path: SUMMARY_PATH, + handler: (req, res) => { + const admission = routeCtx.connection.admit(req) + if ('rejection' in admission) { + res.writeHead(admission.rejection, { 'content-type': 'text/plain; charset=utf-8' }) + res.end(admission.rejection === 401 ? 'unauthorized' : 'forbidden') + return + } + const body = JSON.stringify(summary()) + res.writeHead(200, { + 'content-type': 'application/json; charset=utf-8', + 'cache-control': 'no-store', + 'content-length': Buffer.byteLength(body), + }) + res.end(body) + }, + }), + 'dsh-lan-access: LAN summary route', + ) + }) + + if (settings.ownsHostCompat) { + ctx.inject(['webServer'], (compatCtx) => { + compatCtx.effect( + () => compatCtx.webServer.tapIndex((html) => withHeadScript(html, OWNS_HOST_COMPAT_SCRIPT)), + 'dsh-lan-access: ownsHost compat index tap', + ) + }) + } + + const server = createServer((req, res) => { + const authority = upstreamAuthority() + const upstream = httpRequest( + { + host: settings.targetHost, + port: upstreamPort(), + method: req.method, + path: req.url, + headers: upstreamHeaders(req.headers, authority), + }, + (up) => { + const out = {} + for (const [key, value] of Object.entries(up.headers)) { + if (value === undefined) continue + if (HOP_BY_HOP.has(key.toLowerCase())) continue + out[key] = value + } + res.writeHead(up.statusCode ?? 502, out) + up.pipe(res) + }, + ) + upstream.on('error', (error) => { + if (!res.headersSent) { + res.writeHead(502, { 'content-type': 'text/plain; charset=utf-8' }) + } + res.end(`dsh-lan-access: upstream error: ${error.message}`) + }) + req.pipe(upstream) + }) + + server.on('upgrade', (req, socket, head) => { + const authority = upstreamAuthority() + const host = hostnameOf(req.headers.host) + const isLocal = + host === '' || + host === settings.host || + isLoopbackHost(host) || + lanIPv4Addresses().includes(host) + if (!isLocal) { + // DNS-rebinding guard: only address-literal/localhost authorities may + // reach the upstream, so a hostile name cannot point here. + socket.destroy() + return + } + forwardUpgrade(req, socket, head, { + authority, + host: settings.targetHost, + port: upstreamPort(), + }) + }) + + server.on('error', (error) => { + bindError = error.message + const hint = + error.code === 'EADDRINUSE' + ? ' — another process or plugin already holds that port (the community dsh-lan-proxy plugin defaults to 3081)' + : '' + console.warn( + `dsh-lan-access: LAN listener unavailable on ${settings.host}:${String(settings.port)} because ${error.message}${hint}`, + ) + }) + + ctx.effect( + () => () => { + server.closeAllConnections?.() + server.close() + }, + 'dsh-lan-access: LAN listener', + ) + + server.listen(settings.port, settings.host, () => { + listened = true + announce(ctx.get('connection')) + }) + + ctx.inject(['connection'], (connectionCtx) => { + announce(connectionCtx.connection) + }) +} + +/** Test hooks; production never mutates them. */ +export const internals = { + isLoopbackHost, + hostnameOf, + lanIPv4Addresses, + upstreamHeaders, + withHeadScript, +} diff --git a/locale/en.json b/locale/en.json new file mode 100644 index 0000000..54cbb84 --- /dev/null +++ b/locale/en.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "LAN Access", + "description": "Publish the dsh Web UI on your local network with a standalone reverse proxy while keeping the official launch-token login." + } +} diff --git a/locale/zh.json b/locale/zh.json new file mode 100644 index 0000000..d3491b8 --- /dev/null +++ b/locale/zh.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "局域网访问", + "description": "用独立反向代理把 dsh Web UI 发布到局域网,同时保留官方 launch token 登录。" + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..cc78cf9 --- /dev/null +++ b/package.json @@ -0,0 +1,71 @@ +{ + "name": "@sutong/dsh-lan-access", + "version": "0.1.0", + "description": "Expose the dsh Web UI on the LAN through a standalone reverse proxy that preserves the official launch token — HTTP and WebSocket forwarding, no Host rewrite of your trust boundary.", + "type": "module", + "main": "index.js", + "exports": { + ".": "./index.js", + "./client": "./client.js", + "./package.json": "./package.json", + "./locale/*.json": "./locale/*.json" + }, + "icon": "./icon.svg", + "dsh": { + "bundle": { + "patch": "./cordis.patch.yml" + }, + "catalog": { + "category": "integration", + "summary": { + "en": "Access the dsh web UI over the LAN: a standalone reverse proxy forwards HTTP and WebSocket to the loopback server while keeping the official launch-token login.", + "zh": "局域网访问 dsh web UI:独立反向代理转发 HTTP 与 WebSocket 到回环服务,保留官方 launch token 登录" + }, + "capabilities": [ + "connection", + "ui-slots" + ] + }, + "client": { + "platform": "web" + } + }, + "peerDependencies": { + "@deepseek-ai/schemastery": "~3.18.4" + }, + "peerDependenciesMeta": { + "@deepseek-ai/schemastery": { + "optional": true + } + }, + "files": [ + "index.js", + "client.js", + "cordis.patch.yml", + "src", + "scripts", + "locale/*.json", + "icon.svg", + "README.md", + "README.en.md", + "LICENSE" + ], + "engines": { + "node": ">=20.0.0" + }, + "scripts": { + "build:client": "node scripts/build-client.mjs", + "test": "node scripts/smoke.mjs && node scripts/test-qr.mjs && node scripts/test-client.mjs" + }, + "keywords": [ + "dsh", + "deepseek-harness", + "dsh-plugin", + "plugin", + "lan", + "proxy", + "reverse-proxy", + "qr" + ], + "license": "MIT" +} diff --git a/scripts/build-client.mjs b/scripts/build-client.mjs new file mode 100644 index 0000000..6edfdb1 --- /dev/null +++ b/scripts/build-client.mjs @@ -0,0 +1,60 @@ +/** + * Emit the single browser artifact `client.js` from `src/panel.js` and + * `src/qrcode.js`. + * + * A bundle's client half is served as one self-contained module; it cannot + * import a sibling file or a package dependency at runtime. This script inlines + * both sources into the `window.__ModuleLoader__.load` shell, which is the same + * shape the reference plugins ship. + * + * Run: `node scripts/build-client.mjs` + */ + +import { readFileSync, writeFileSync } from 'node:fs' +import { dirname, join } from 'node:path' +import { fileURLToPath } from 'node:url' + +const here = dirname(fileURLToPath(import.meta.url)) +const root = join(here, '..') + +/** Read a source file and turn its ESM exports into plain declarations. */ +function inline(relativePath) { + const source = readFileSync(join(root, relativePath), 'utf8') + return source + .replace(/^import .*$/gm, '') + .replace(/^export (?=(?:function|const|let|class)\b)/gm, '') + .trim() +} + +const qrcode = inline('src/qrcode.js') +const panel = inline('src/panel.js') + +const artifact = `/** + * @sutong/dsh-lan-access — browser half (generated by scripts/build-client.mjs). + * + * Do not edit this file: change src/panel.js or src/qrcode.js and rebuild. + */ +window.__ModuleLoader__.load({ + id: "@sutong/dsh-lan-access", + factory: function (require) { + "use strict"; + var React = require("react"); + + // ── inlined src/qrcode.js ──────────────────────────────────────────────── + var __qrcode = (function () { +${qrcode.replace(/^/gm, ' ')} + return { encodeQR: encodeQR, MAX_VERSION: MAX_VERSION }; + })(); + var encodeQR = __qrcode.encodeQR; + + // ── inlined src/panel.js ───────────────────────────────────────────────── +${panel.replace(/^/gm, ' ')} + + return { inject: inject, apply: apply }; + }, +}); +` + +const out = join(root, 'client.js') +writeFileSync(out, artifact) +console.log(`wrote ${out} (${artifact.length} bytes)`) diff --git a/scripts/smoke.mjs b/scripts/smoke.mjs new file mode 100644 index 0000000..94870ab --- /dev/null +++ b/scripts/smoke.mjs @@ -0,0 +1,256 @@ +/** + * Standalone smoke test for the LAN listener. It mounts the plugin against a + * fake Cordis context, drives a real upstream HTTP + WebSocket server, and + * asserts the forwarding contract. Run: `node scripts/smoke.mjs`. + */ + +import { createServer } from 'node:http' +import { connect as netConnect } from 'node:net' +import vm from 'node:vm' +import { apply, internals, OWNS_HOST_COMPAT_SCRIPT } from '../index.js' + +const results = [] +function check(label, condition, detail) { + results.push({ label, ok: Boolean(condition), detail }) + console.log(`${condition ? 'PASS' : 'FAIL'} ${label}${detail === undefined || condition ? '' : ` — ${detail}`}`) +} + +/** Bind a throwaway server on an OS-assigned loopback port. */ +function listen(server, host = '127.0.0.1') { + return new Promise((resolve) => { + server.listen(0, host, () => resolve(server.address().port)) + }) +} + +const seen = [] +const upstream = createServer((req, res) => { + seen.push({ url: req.url, host: req.headers.host, origin: req.headers.origin, cookie: req.headers.cookie }) + res.writeHead(200, { + 'content-type': 'application/json', + 'set-cookie': 'dsh-auth-test=abc123; Max-Age=60; Path=/; HttpOnly; SameSite=Strict', + 'x-upstream': 'yes', + }) + res.end(JSON.stringify({ url: req.url, host: req.headers.host, origin: req.headers.origin ?? null })) +}) + +// DSH serves HTTP and WebSocket on the same port, so the upstream carries both. +upstream.on('upgrade', (req, socket) => { + seen.push({ upgrade: true, url: req.url, host: req.headers.host, origin: req.headers.origin }) + socket.write('HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\n\r\n') + socket.write('hello-from-upstream') +}) + +const upstreamPort = await listen(upstream) + +// ── fake Cordis context ───────────────────────────────────────────────────── +// Cordis exposes injected services both as `ctx.` and through `ctx.get`. +const routes = [] +let admitted = 0 +const connection = { + admit: () => { + admitted += 1 + return { peer: {} } + }, + authenticatedUrl: (base) => `${base}?token=TESTTOKEN`, +} +const webServer = { + port: upstreamPort, + register: (route) => { + routes.push(route) + return () => {} + }, +} +const ctx = { + webServer, + connection, + get(name) { + if (name === 'connection') return connection + if (name === 'webServer') return webServer + return undefined + }, + inject(deps, callback) { + if (deps.every((d) => ctx.get(d) !== undefined)) callback(ctx) + return () => {} + }, + effect(callback) { + callback() + return () => {} + }, +} + +const LAN_PORT = 39_991 +apply(ctx, { + enabled: true, + host: '127.0.0.1', + port: LAN_PORT, + targetHost: '127.0.0.1', + targetPort: upstreamPort, + printBanner: false, +}) + +// Wait for the listener to bind. +await new Promise((resolve) => setTimeout(resolve, 250)) + +/** Issue a request through the LAN listener. */ +function viaLan(path, headers = {}) { + return new Promise((resolve, reject) => { + const req = netConnect(LAN_PORT, '127.0.0.1', () => { + req.write(`GET ${path} HTTP/1.1\r\nHost: 127.0.0.1:${LAN_PORT}\r\nOrigin: http://127.0.0.1:${LAN_PORT}\r\nCookie: dsh-auth-test=abc123\r\nConnection: close\r\n\r\n`) + }) + let raw = '' + req.on('data', (chunk) => { raw += chunk }) + req.on('end', () => resolve(raw)) + req.on('error', reject) + }) +} + +const response = await viaLan('/api/remote.mux') +check('LAN listener returns the upstream status', response.startsWith('HTTP/1.1 200'), response.split('\r\n')[0]) +check('upstream sees a loopback Host', seen[0]?.host === `127.0.0.1:${upstreamPort}`, seen[0]?.host) +check('upstream sees a matching loopback Origin', seen[0]?.origin === `http://127.0.0.1:${upstreamPort}`, seen[0]?.origin) +check('upstream receives the browser cookie', seen[0]?.cookie === 'dsh-auth-test=abc123', seen[0]?.cookie) +check('Set-Cookie is forwarded untouched', /set-cookie: dsh-auth-test=abc123/i.test(response), 'no set-cookie') +check('custom upstream header survives', /x-upstream: yes/i.test(response)) + +// ── WebSocket upgrade ─────────────────────────────────────────────────────── +const wsRaw = await new Promise((resolve, reject) => { + const socket = netConnect(LAN_PORT, '127.0.0.1', () => { + socket.write( + `GET /api/remote.mux HTTP/1.1\r\nHost: 127.0.0.1:${LAN_PORT}\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\r\nSec-WebSocket-Version: 13\r\nOrigin: http://127.0.0.1:${LAN_PORT}\r\n\r\n`, + ) + }) + let raw = '' + socket.on('data', (chunk) => { raw += chunk }) + socket.on('close', () => resolve(raw)) + socket.on('error', reject) + setTimeout(() => { socket.destroy(); resolve(raw) }, 900) +}) +const upgrade = seen.find((entry) => entry.upgrade) +check('WebSocket upgrade reached the upstream', upgrade !== undefined) +check('upgraded request kept a loopback Host', upgrade?.host === `127.0.0.1:${upstreamPort}`, upgrade?.host) +check('upgrade response flows back to the client', /hello-from-upstream/.test(wsRaw)) + +// ── the panel data route ──────────────────────────────────────────────────── +const route = routes.find((entry) => entry.path === '/api/dsh-lan-access/summary') +check('summary route registered as exact', route?.kind === 'exact', route?.kind) +let body = '' +const fakeRes = { + writeHead(status, headers) { this.status = status; this.headers = headers }, + end(value) { body = value ?? '' }, +} +route?.handler({ headers: { host: `127.0.0.1:${upstreamPort}` } }, fakeRes) +check('summary route requires admission', admitted === 1, `admit calls: ${admitted}`) +check('summary reports the listen port', JSON.parse(body).listen.port === LAN_PORT, body) +check('summary carries a tokenized LAN url', /token=TESTTOKEN/.test(body), body) + +// ── pure helpers ──────────────────────────────────────────────────────────── +check('isLoopbackHost accepts 127.0.0.1', internals.isLoopbackHost('127.0.0.1') === true) +check('isLoopbackHost accepts ::1', internals.isLoopbackHost('::1') === true) +check('isLoopbackHost rejects 192.168.1.5', internals.isLoopbackHost('192.168.1.5') === false) +check('hostnameOf strips the port', internals.hostnameOf('192.168.1.5:3081') === '192.168.1.5') +check('upstreamHeaders drops hop-by-hop', internals.upstreamHeaders({ connection: 'keep-alive', 'x-a': '1' }, 'h:1').connection === undefined) + +// ── bind failure is reported, never thrown ────────────────────────────────── +const blocker = createServer(() => {}) +const blockerPort = await listen(blocker) +const routes2 = [] +const connection2 = { admit: () => ({ peer: {} }), authenticatedUrl: (base) => `${base}?token=T` } +const webServer2 = { port: upstreamPort, register: (route) => { routes2.push(route); return () => {} } } +const ctx2 = { + webServer: webServer2, + connection: connection2, + get: (name) => (name === 'connection' ? connection2 : name === 'webServer' ? webServer2 : undefined), + inject: (deps, callback) => { + if (deps.every((d) => ctx2.get(d) !== undefined)) callback(ctx2) + return () => {} + }, + effect: (callback) => { callback(); return () => {} }, +} +apply(ctx2, { + enabled: true, + host: '127.0.0.1', + port: blockerPort, + targetHost: '127.0.0.1', + targetPort: upstreamPort, + printBanner: false, +}) +await new Promise((resolve) => setTimeout(resolve, 250)) +const route2 = routes2.find((entry) => entry.path === '/api/dsh-lan-access/summary') +let body2 = '' +route2?.handler({ headers: { host: `127.0.0.1:${upstreamPort}` } }, { + writeHead(status) { this.status = status }, + end(value) { body2 = value ?? '' }, +}) +const payload2 = JSON.parse(body2) +check('taken port is reported as listening:false', payload2.listening === false, body2) +check('taken port carries the bind error', String(payload2.bindError ?? '').includes('EADDRINUSE'), payload2.bindError) +blocker.close() + +// ── ownsHost compat is opt-in and page-conditional ────────────────────────── +/** + * Mount the plugin with a given config and return the registered index taps. + * @param config - extra config over merged onto a loopback test binding. + * @returns the tap functions the plugin registered. + */ +let compatPort = 39_990 +async function tapsFor(config) { + compatPort += 1 + const taps = [] + const records = [] + const c = { + get: () => undefined, + inject: (deps, callback) => { + callback({ + webServer: { + port: upstreamPort, + register: (route) => { records.push(route); return () => {} }, + tapIndex: (transform) => { taps.push(transform); return () => {} }, + }, + effect: (callback2) => { callback2(); return () => {} }, + }) + return () => {} + }, + effect: (callback2) => { callback2(); return () => {} }, + } + apply(c, { + enabled: true, + host: '127.0.0.1', + port: compatPort, + targetHost: '127.0.0.1', + targetPort: upstreamPort, + printBanner: false, + ...config, + }) + await new Promise((resolve) => setTimeout(resolve, 120)) + return taps +} + +const offTaps = await tapsFor({ ownsHostCompat: false }) +check('ownsHost compat is off by default', offTaps.length === 0, `taps: ${offTaps.length}`) + +const onTaps = await tapsFor({ ownsHostCompat: true }) +check('ownsHost compat registers one index tap', onTaps.length === 1, `taps: ${onTaps.length}`) +const html = 't' +const tapped = onTaps[0]?.(html) ?? html +const scriptAt = tapped.indexOf('ownsHost = true') +const bundleAt = tapped.indexOf('x.js') +check('compat script lands in the head', scriptAt !== -1 && tapped.indexOf('') > scriptAt, tapped.slice(0, 80)) +check('compat script precedes the app bundle', scriptAt !== -1 && scriptAt < bundleAt) + +/** Run the compat script against a stubbed page location. */ +function runCompat(hostname) { + const sandbox = { globalThis: undefined, location: hostname === undefined ? undefined : { hostname } } + sandbox.globalThis = sandbox + vm.createContext(sandbox) + vm.runInContext(OWNS_HOST_COMPAT_SCRIPT, sandbox) + return sandbox.__DSH_TRANSPORT__ +} +check('compat forges ownsHost on a LAN page', runCompat('192.168.1.5')?.ownsHost === true) +check('compat leaves a loopback page untouched', runCompat('127.0.0.1') === undefined) +check('compat leaves localhost untouched', runCompat('localhost') === undefined) +check('compat survives a missing location', runCompat(undefined) === undefined) + +upstream.close() +const failed = results.filter((entry) => !entry.ok) +console.log(`\n${results.length - failed.length}/${results.length} checks passed`) +process.exit(failed.length === 0 ? 0 : 1) diff --git a/scripts/test-client.mjs b/scripts/test-client.mjs new file mode 100644 index 0000000..9c0c0fd --- /dev/null +++ b/scripts/test-client.mjs @@ -0,0 +1,166 @@ +/** + * Contract test for the generated `client.js`. + * + * Loads the artifact in a sandbox with a stub `window.__ModuleLoader__` and a + * stub React, then checks the slot registration, the localization wiring, the + * loading render, the ready render, and that the route the panel fetches is the + * one the host half exports. + * + * Run: `node scripts/test-client.mjs` + */ + +import { readFileSync } from 'node:fs' +import { dirname, join } from 'node:path' +import { fileURLToPath } from 'node:url' +import vm from 'node:vm' +import { SUMMARY_PATH } from '../index.js' + +const root = join(dirname(fileURLToPath(import.meta.url)), '..') +const source = readFileSync(join(root, 'client.js'), 'utf8') + +const results = [] +function check(label, condition, detail) { + results.push({ label, ok: Boolean(condition) }) + console.log(`${condition ? 'PASS' : 'FAIL'} ${label}${condition || detail === undefined ? '' : ` — ${detail}`}`) +} + +// ── load the artifact the way the browser kernel does ─────────────────────── +let captured +const sandbox = { + window: { __ModuleLoader__: { load: (options) => { captured = options } } }, + console, + TextEncoder, + setTimeout, + navigator: {}, +} +vm.createContext(sandbox) +vm.runInContext(source, sandbox) + +check('artifact registers exactly once', captured !== undefined) +check('module id equals the package name', captured?.id === '@sutong/dsh-lan-access', captured?.id) +check('factory is a function', typeof captured?.factory === 'function') + +// ── stub React ────────────────────────────────────────────────────────────── +let useStateCalls = 0 +let stateFixture +const React = { + createElement: (type, props, ...children) => ({ type, props: props ?? {}, children }), + useState: (initial) => { + useStateCalls += 1 + if (useStateCalls === 1 && stateFixture !== undefined) return [stateFixture, () => {}] + return [typeof initial === 'function' ? initial() : initial, () => {}] + }, + useEffect: () => {}, +} +const requireStub = (name) => { + if (name === 'react') return React + throw new Error(`unexpected require(${JSON.stringify(name)})`) +} + +const plugin = captured.factory(requireStub) +check('plugin exposes an inject list', Array.isArray(plugin?.inject), JSON.stringify(plugin?.inject)) +check('plugin injects slots and locale', plugin.inject?.includes('slots') && plugin.inject?.includes('locale')) +check('plugin exposes apply', typeof plugin?.apply === 'function') + +// ── drive apply against a fake client context ─────────────────────────────── +let dictionaries +const registrations = [] +let injectedOwner +const slots = { + inject(owner, callback) { + injectedOwner = owner + callback() + return () => {} + }, + register(options, component) { + registrations.push({ options, component }) + return () => {} + }, +} +const locale = { + register(ns, dicts) { + dictionaries = { ns, dicts } + return () => {} + }, + bind: () => (key) => dictionaries?.dicts.zh?.[key] ?? key, + subscribe: () => () => {}, +} +const effects = [] +const ctx = { + get: (name) => (name === 'slots' ? slots : name === 'locale' ? locale : undefined), + effect: (callback, label) => { + effects.push(label) + const disposer = callback() + return () => { + if (typeof disposer === 'function') disposer() + } + }, +} +plugin.apply(ctx) + +check('locale namespace registered', dictionaries?.ns === 'dsh-lan-access', dictionaries?.ns) +check('locale carries zh and en', dictionaries?.dicts?.zh !== undefined && dictionaries?.dicts?.en !== undefined) +check( + 'zh and en cover the same keys', + JSON.stringify(Object.keys(dictionaries.dicts.zh).sort()) === JSON.stringify(Object.keys(dictionaries.dicts.en).sort()), + 'dictionary key sets differ', +) +check('registered into the Settings nav seat', injectedOwner === 'settings.section', injectedOwner) +check('exactly one slot registration', registrations.length === 1, `count ${registrations.length}`) +check('registration id is the section key', registrations[0]?.options?.id === 'lan-access', registrations[0]?.options?.id) +check('registration names its slot', registrations[0]?.options?.name === 'settings.section') +check('label thunk projects localized text', registrations[0]?.options?.label?.() === '局域网访问', registrations[0]?.options?.label?.()) + +// ── loading render ────────────────────────────────────────────────────────── +useStateCalls = 0 +const loading = registrations[0].component({}) +check('loading render returns an element', loading?.type === 'div' && loading.props.className === 'dla-root') +check('loading render mentions reading', JSON.stringify(loading.children).includes('正在读取')) + +// ── ready render with the host's payload shape ────────────────────────────── +stateFixture = { + status: 'ready', + data: { + enabled: true, + listen: { host: '0.0.0.0', port: 3081 }, + target: { host: '127.0.0.1', port: 3080 }, + lan: [ + { address: '192.168.1.5', url: 'http://192.168.1.5:3081/', tokenUrl: 'http://192.168.1.5:3081/?token=abc' }, + { address: '10.0.0.7', url: 'http://10.0.0.7:3081/', tokenUrl: 'http://10.0.0.7:3081/?token=abc' }, + ], + }, +} +useStateCalls = 0 +const ready = registrations[0].component({}) +const readyJson = JSON.stringify(ready) +check('ready render returns the panel', ready?.type === 'div' && ready.props.className === 'dla-root') +check('ready render lists both addresses', readyJson.includes('192.168.1.5') && readyJson.includes('10.0.0.7')) +check('ready render embeds a QR svg', readyJson.includes('"svg"')) +check('ready render shows the token url', readyJson.includes('token=abc')) +check('ready render warns about the token', readyJson.includes('launch token')) +check('qr caption targets the selected address', readyJson.includes('192.168.1.5 的二维码')) + +// ── a failed bind must be visible, not dressed up as a live QR ────────────── +stateFixture = { + status: 'ready', + data: { + enabled: true, + listening: false, + bindError: 'listen EADDRINUSE: address already in use 0.0.0.0:3081', + listen: { host: '0.0.0.0', port: 3081 }, + target: { host: '127.0.0.1', port: 3080 }, + lan: [{ address: '192.168.1.5', url: 'http://192.168.1.5:3081/', tokenUrl: 'http://192.168.1.5:3081/?token=abc' }], + }, +} +useStateCalls = 0 +const failed = registrations[0].component({}) +const failedJson = JSON.stringify(failed) +check('bind failure is surfaced in the panel', failedJson.includes('EADDRINUSE')) +check('bind failure names the target', failedJson.includes('0.0.0.0:3081')) + +// ── the two halves agree on the route ─────────────────────────────────────── +check('client fetches the host route', source.includes(SUMMARY_PATH), SUMMARY_PATH) + +const failures = results.filter((entry) => !entry.ok) +console.log(`\n${results.length - failures.length}/${results.length} checks passed`) +process.exit(failures.length === 0 ? 0 : 1) diff --git a/scripts/test-qr.mjs b/scripts/test-qr.mjs new file mode 100644 index 0000000..d8bede5 --- /dev/null +++ b/scripts/test-qr.mjs @@ -0,0 +1,140 @@ +/** + * Cross-check `src/qrcode.js` against the reference `qrcode` npm package. + * + * The reference is only a test oracle — it is never a runtime dependency. Run + * `node scripts/test-qr.mjs`; set `QR_ORACLE_DIR` to the directory holding the + * installed `qrcode` package (default `/tmp/qr-oracle`). + */ + +import { createRequire } from 'node:module' +import { encodeQR } from '../src/qrcode.js' + +const oracleDir = process.env.QR_ORACLE_DIR ?? '/tmp/qr-oracle' +const requireFromOracle = createRequire(`${oracleDir}/package.json`) +let oracle +try { + oracle = requireFromOracle('qrcode') +} catch (error) { + console.error(`cannot load the qrcode oracle from ${oracleDir}: ${error.message}`) + process.exit(2) +} + +/** Byte-mode capacity at error-correction level M, independent of the encoder under test. */ +const CAPACITY_M = { 1: 14, 2: 26, 3: 42, 4: 62, 5: 84, 6: 106, 7: 122, 8: 152, 9: 180, 10: 213 } + +/** Deterministic pseudo-random byte source. */ +function makeRandom(seed) { + let state = seed >>> 0 + return () => { + state = (state * 1_664_525 + 1_013_904_223) >>> 0 + return state + } +} + +/** + * Deterministic payload of exactly `length` bytes. + * + * A lowercase letter is always present on purpose: the reference implementation + * auto-selects the most compact mode, and QR alphanumeric mode covers only + * digits, uppercase, space and `$%*+-./:`. This encoder implements byte mode + * (what a URL needs), so the payload must rule numeric and alphanumeric modes + * out to make the two comparable. + */ +function payload(length, seed) { + const alphabet = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-._~:/?#[]@!$&()*+,;=%' + const random = makeRandom(seed) + let text = '' + while (text.length < length) text += alphabet[random() % alphabet.length] + text = text.slice(0, length) + if (!/[a-z]/.test(text)) text = `${text.slice(0, -1)}a` + return text +} + +let comparisons = 0 +let failures = 0 +const failureSamples = [] + +for (let version = 1; version <= 10; version += 1) { + for (const length of [1, Math.floor(CAPACITY_M[version] / 2), CAPACITY_M[version]]) { + const text = payload(length, version * 1000 + length) + for (let mask = 0; mask < 8; mask += 1) { + const mine = encodeQR(text, { version, mask }) + const reference = oracle.create(text, { + errorCorrectionLevel: 'M', + version, + maskPattern: mask, + }) + comparisons += 1 + + const size = reference.modules.size + const expected = reference.modules.data + let mismatch = -1 + if (mine.size !== size) { + mismatch = -2 + } else { + for (let i = 0; i < expected.length; i += 1) { + if ((mine.modules[i] ? 1 : 0) !== (expected[i] ? 1 : 0)) { + mismatch = i + break + } + } + } + if (mismatch !== -1) { + failures += 1 + if (failureSamples.length < 5) { + failureSamples.push({ version, length, mask, mismatch }) + } + } + } + } +} + +console.log(`forced version+mask comparisons: ${comparisons - failures}/${comparisons} identical`) + +// The format-information and mask choice must match too: compare the chosen +// mask for payloads where both implementations score penalties the same way. +let maskAgreements = 0 +let maskCases = 0 +for (let version = 1; version <= 10; version += 1) { + const text = payload(Math.min(20, CAPACITY_M[version]), version * 7 + 3) + const mine = encodeQR(text, { version }) + const reference = oracle.create(text, { errorCorrectionLevel: 'M', version }) + maskCases += 1 + if (mine.mask === reference.maskPattern) maskAgreements += 1 + else console.log(` mask differs at v${version}: mine=${mine.mask} reference=${reference.maskPattern}`) +} +console.log(`auto-mask agreement: ${maskAgreements}/${maskCases}`) + +// UTF-8. The reference re-segments mixed text into Byte+Alphanumeric runs for +// compactness; this encoder deliberately emits one Byte segment (there is no +// correctness difference, only size). Compare strictly against a payload the +// reference also encodes as a single Byte segment. +const byteOnly = '局域网_def~令牌~abc' +const byteOnlyAuto = encodeQR(byteOnly) +const byteOnlyReference = oracle.create(byteOnly, { + errorCorrectionLevel: 'M', + version: byteOnlyAuto.version, + maskPattern: byteOnlyAuto.mask, +}) +const byteOnlySame = + byteOnlyAuto.size === byteOnlyReference.modules.size && + byteOnlyAuto.modules.every((value, index) => (value ? 1 : 0) === (byteOnlyReference.modules.data[index] ? 1 : 0)) +console.log( + `utf8 single-byte-segment (v${byteOnlyAuto.version}, mask ${byteOnlyAuto.mask}) byte-identical: ${byteOnlySame}`, +) +if (byteOnlyReference.segments.length !== 1) console.log(' note: reference did not emit one segment') + +// A realistic URL is where the reference re-segments. Report the divergence +// rather than treating a valid-but-larger symbol as a failure. +const url = 'http://192.168.1.5:3081/?token=aBcD1234EfGh5678' +const urlSymbol = encodeQR(url) +const urlReference = oracle.create(url, { errorCorrectionLevel: 'M' }) +console.log( + `realistic URL fits v${urlSymbol.version} (size ${urlSymbol.size}); reference used v${urlReference.version} in ${urlReference.segments.length} segment(s)`, +) +const urlStructurallySound = urlSymbol.size === urlSymbol.version * 4 + 17 && urlSymbol.modules.length === urlSymbol.size * urlSymbol.size + +const ok = failures === 0 && byteOnlySame && urlStructurallySound +if (failureSamples.length > 0) console.log('failure samples:', JSON.stringify(failureSamples)) +console.log(ok ? '\nQR encoder matches the reference implementation' : '\nQR encoder MISMATCH') +process.exit(ok ? 0 : 1) diff --git a/src/panel.js b/src/panel.js new file mode 100644 index 0000000..e84b2d8 --- /dev/null +++ b/src/panel.js @@ -0,0 +1,362 @@ +/** + * Client half of @sutong/dsh-lan-access: a Plugins-settings tab that shows the + * LAN addresses the host listener publishes, with a scannable QR code for the + * address you pick. + * + * This file is bundled into the single browser artifact `client.js` by + * `scripts/build-client.mjs`; it is never loaded directly by the browser. + * + * @module @sutong/dsh-lan-access/panel + */ + +import { encodeQR } from './qrcode.js' + +/** Locale namespace for this plugin's visible text. */ +const NS = 'dsh-lan-access' + +/** Host route (behind the official `/api` fence and browser authentication). */ +const SUMMARY_URL = '/api/dsh-lan-access/summary' + +/** + * The seat this panel occupies: its own entry in the Settings navigation. + * Registering here (rather than inside the Plugins section's tab strip) makes + * the page reachable directly from Settings; `dsh-opencode-go` sets the same + * precedent for a plugin-owned settings page. + */ +const SLOT = 'settings.section' + +/** Nav/section key for this panel. */ +const SECTION_ID = 'lan-access' + +/** Simplified-Chinese dictionary; the key source. */ +const zh = { + title: '局域网访问', + description: '通过独立反向代理把本机 dsh Web UI 发布到局域网,保留官方 launch token 登录。', + loading: '正在读取局域网地址…', + failed: '无法读取局域网信息:{message}', + forward: '转发到', + addresses: '局域网地址', + noAddresses: '未找到非内部 IPv4 地址,局域网内暂时无法访问。', + scanHint: '用另一台设备扫描二维码即可打开(链接已带登录令牌)。', + copy: '复制', + copied: '已复制', + open: '打开', + warning: '该链接携带 launch token,等同于完整 dsh 控制权(含终端命令执行)。请只在可信局域网内分享。', + disabled: '该 LAN 监听器在此 profile 中已被配置为关闭。', + notListening: '监听器未能绑定 {target}:{message}', + qr: '{address} 的二维码', +} + +/** English dictionary; must cover every key above. */ +const en = { + title: 'LAN Access', + description: 'Publishes this machine’s dsh Web UI on the LAN through a standalone reverse proxy while keeping the official launch-token login.', + loading: 'Reading LAN addresses…', + failed: 'Could not read LAN information: {message}', + forward: 'forwards to', + addresses: 'LAN addresses', + noAddresses: 'No non-internal IPv4 address was found, so nothing is reachable from the LAN yet.', + scanHint: 'Scan the QR code with another device to open it (the link already carries the login token).', + copy: 'Copy', + copied: 'Copied', + open: 'Open', + warning: 'That link carries the launch token and grants full dsh control, shell included. Share it only on a trusted network.', + disabled: 'The LAN listener is configured off in this profile.', + notListening: 'The listener could not bind {target}: {message}', + qr: 'QR code for {address}', +} + +/** Substitute `{name}` placeholders. */ +function interpolate(template, params) { + if (params === undefined) return template + return template.replaceAll(/\{(\w+)\}/g, (match, key) => (key in params ? String(params[key]) : match)) +} + +/** + * Bind the locale service, falling back to the Chinese dictionary when the + * service is absent or its contract drifted. + * @param locale - the live `locale` service, when present. + * @returns a translate function taking a key and optional `{name}` parameters. + */ +function makeTranslator(locale) { + if (locale !== undefined && typeof locale.bind === 'function') { + try { + const bound = locale.bind(NS) + if (typeof bound === 'function') return (key, params) => interpolate(bound(key), params) + } catch { + /* fall through to the local dictionary */ + } + } + return (key, params) => interpolate(zh[key] ?? key, params) +} + +/** + * The active translator. `apply` replaces it once the locale service is bound + * and re-binds it on every locale change, so components read it at render time. + */ +let translate = (key, params) => interpolate(zh[key] ?? key, params) + +/** Stylesheet, scoped under `dla-` and expressed only in theme tokens. */ +const CSS = ` +.dla-root { display: flex; flex-direction: column; gap: 16px; padding: 4px 2px 24px; color: var(--dsw-alias-label-primary); } +.dla-head { display: flex; flex-direction: column; gap: 6px; } +.dla-title { font-size: 15px; font-weight: 600; } +.dla-desc { font-size: 13px; line-height: 1.6; color: var(--dsw-alias-label-secondary); } +.dla-facts { display: flex; flex-wrap: wrap; gap: 6px 18px; font-size: 12px; color: var(--dsw-alias-label-secondary); } +.dla-fact b { color: var(--dsw-alias-label-primary); font-weight: 500; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; } +.dla-body { display: flex; flex-wrap: wrap; gap: 20px; align-items: flex-start; } +.dla-list { display: flex; flex-direction: column; gap: 8px; flex: 1 1 320px; min-width: 280px; } +.dla-section { font-size: 12px; font-weight: 600; letter-spacing: .02em; text-transform: uppercase; color: var(--dsw-alias-label-secondary); } +.dla-row { display: flex; align-items: center; gap: 8px; border: 1px solid var(--dsw-alias-border-l1); border-radius: 8px; padding: 8px 10px; background: var(--dsw-alias-bg-layer-1); } +.dla-row[data-selected="true"] { border-color: var(--dsw-alias-brand-primary); } +.dla-row-main { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 2px; } +.dla-ip { font-size: 12px; color: var(--dsw-alias-label-secondary); } +.dla-url { font-size: 12px; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.dla-btn { flex: none; font: inherit; font-size: 12px; line-height: 1; padding: 6px 10px; border-radius: 6px; border: 1px solid var(--dsw-alias-border-l1); background: var(--dsw-alias-bg-layer-2); color: var(--dsw-alias-label-primary); cursor: pointer; } +.dla-btn:hover { border-color: var(--dsw-alias-border-l2); } +.dla-btn[data-primary="true"] { border-color: var(--dsw-alias-brand-primary); color: var(--dsw-alias-brand-primary); } +.dla-qr { display: flex; flex-direction: column; gap: 8px; align-items: center; } +.dla-qr-card { padding: 10px; border-radius: 10px; background: #ffffff; border: 1px solid var(--dsw-alias-border-l1); } +.dla-qr-cap { font-size: 12px; color: var(--dsw-alias-label-secondary); max-width: 200px; text-align: center; } +.dla-hint { font-size: 12px; line-height: 1.6; color: var(--dsw-alias-label-secondary); } +.dla-warn { font-size: 12px; line-height: 1.6; color: var(--dsw-alias-state-warn-primary); } +.dla-error { font-size: 12px; color: var(--dsw-alias-state-error-primary); } +` + +/** + * Render a QR matrix as a crisp SVG. + * @param symbol - `{ size, modules }` from the encoder. + * @param label - accessible label. + * @returns a React element. + */ +function qrElement(React, symbol, label, caption) { + const h = React.createElement + const { size, modules } = symbol + let path = '' + for (let row = 0; row < size; row += 1) { + for (let col = 0; col < size; col += 1) { + if (modules[row * size + col]) path += `M${col} ${row}h1v1h-1z` + } + } + return h( + 'div', + { className: 'dla-qr' }, + h( + 'div', + { className: 'dla-qr-card' }, + h( + 'svg', + { + width: 168, + height: 168, + viewBox: `0 0 ${size} ${size}`, + role: 'img', + 'aria-label': label, + shapeRendering: 'crispEdges', + }, + h('rect', { width: size, height: size, fill: '#ffffff' }), + h('path', { d: path, fill: '#000000' }), + ), + ), + caption === undefined ? null : h('div', { className: 'dla-qr-cap' }, caption), + ) +} + +/** + * The Plugins-settings tab: read the host summary and present the LAN links. + * @param props - slot props; this seat supplies none. + * @returns the panel element. + */ +function Panel() { + const h = React.createElement + const [state, setState] = React.useState({ status: 'loading' }) + const [selected, setSelected] = React.useState(0) + const [copied, setCopied] = React.useState('') + + React.useEffect(() => { + let live = true + fetch(SUMMARY_URL, { headers: { accept: 'application/json' } }) + .then(async (response) => { + if (!response.ok) throw new Error(`HTTP ${String(response.status)}`) + return response.json() + }) + .then((data) => { + if (live) setState({ status: 'ready', data }) + }) + .catch((error) => { + if (live) setState({ status: 'error', message: String(error?.message ?? error) }) + }) + return () => { + live = false + } + }, []) + + const t = translate + const children = [h('style', { key: 'style' }, CSS)] + + if (state.status === 'loading') { + children.push(h('div', { className: 'dla-hint', key: 'loading' }, t('loading'))) + return h('div', { className: 'dla-root' }, children) + } + if (state.status === 'error') { + children.push(h('div', { className: 'dla-error', key: 'error' }, t('failed', { message: state.message }))) + return h('div', { className: 'dla-root' }, children) + } + + const { data } = state + const lan = Array.isArray(data.lan) ? data.lan : [] + const active = lan[Math.min(selected, Math.max(lan.length - 1, 0))] + + children.push( + h( + 'div', + { className: 'dla-head', key: 'head' }, + h('div', { className: 'dla-title' }, t('title')), + h('div', { className: 'dla-desc' }, t('description')), + h( + 'div', + { className: 'dla-facts' }, + h('span', null, `${data.listen.host}:${String(data.listen.port)}`, ' ', t('forward'), ' ', h('b', null, `${data.target.host}:${String(data.target.port)}`)), + ), + ), + ) + + if (data.enabled === false) { + children.push(h('div', { className: 'dla-warn', key: 'disabled' }, t('disabled'))) + } + if (data.listening === false) { + // Never advertise an address that is not actually bound. + children.push( + h( + 'div', + { className: 'dla-error', key: 'not-listening' }, + t('notListening', { + target: `${data.listen.host}:${String(data.listen.port)}`, + message: data.bindError ?? 'unknown error', + }), + ), + ) + } + + if (lan.length === 0) { + children.push(h('div', { className: 'dla-hint', key: 'none' }, t('noAddresses'))) + return h('div', { className: 'dla-root' }, children) + } + + const copy = (url) => { + const done = () => { + setCopied(url) + globalThis.setTimeout(() => setCopied(''), 1500) + } + if (globalThis.navigator?.clipboard?.writeText !== undefined) { + globalThis.navigator.clipboard.writeText(url).then(done, done) + } else { + done() + } + } + + const rows = lan.map((entry, index) => + h( + 'div', + { + className: 'dla-row', + key: entry.address, + 'data-selected': String(index === selected), + onClick: () => setSelected(index), + }, + h( + 'div', + { className: 'dla-row-main' }, + h('div', { className: 'dla-ip' }, entry.address), + h('div', { className: 'dla-url', title: entry.tokenUrl }, entry.tokenUrl), + ), + h( + 'button', + { + type: 'button', + className: 'dla-btn', + onClick: (event) => { + event.stopPropagation() + copy(entry.tokenUrl) + }, + }, + copied === entry.tokenUrl ? t('copied') : t('copy'), + ), + h( + 'a', + { className: 'dla-btn', href: entry.tokenUrl, target: '_blank', rel: 'noreferrer', onClick: (event) => event.stopPropagation() }, + t('open'), + ), + ), + ) + + let symbol + try { + symbol = encodeQR(active.tokenUrl) + } catch { + symbol = undefined + } + + children.push( + h( + 'div', + { className: 'dla-body', key: 'body' }, + h( + 'div', + { className: 'dla-list' }, + h('div', { className: 'dla-section' }, t('addresses')), + ...rows, + h('div', { className: 'dla-hint' }, t('scanHint')), + ), + symbol === undefined ? null : qrElement(React, symbol, t('qr', { address: active.address }), active.address), + ), + h('div', { className: 'dla-warn', key: 'warn' }, t('warning')), + ) + return h('div', { className: 'dla-root' }, children) +} + +/** Services this client half reads. */ +export const inject = ['slots', 'locale'] + +/** + * Register the locale dictionaries and the Plugins-settings tab. + * @param ctx - the restricted client context. + */ +export function apply(ctx) { + const locale = ctx.get('locale') + if (locale !== undefined) { + if (typeof locale.register === 'function') { + try { + const dispose = locale.register(NS, { zh, en }) + if (typeof dispose === 'function') ctx.effect(() => dispose, 'dsh-lan-access: locale dictionaries') + } catch (error) { + console.error('dsh-lan-access: locale registration failed', error) + } + } + translate = makeTranslator(locale) + if (typeof locale.subscribe === 'function') { + const unsubscribe = locale.subscribe(() => { + translate = makeTranslator(locale) + }) + if (typeof unsubscribe === 'function') ctx.effect(() => unsubscribe, 'dsh-lan-access: locale subscription') + } + } + + const slots = ctx.get('slots') + if (slots === undefined) { + console.error('dsh-lan-access: the slots service is unavailable; the LAN panel is not mounted') + return + } + const t = makeTranslator(locale) + slots.inject(SLOT, () => + slots.register( + { + name: SLOT, + id: SECTION_ID, + order: 50, + label: () => t('title'), + }, + Panel, + ), + ) +} diff --git a/src/qrcode.js b/src/qrcode.js new file mode 100644 index 0000000..28734de --- /dev/null +++ b/src/qrcode.js @@ -0,0 +1,493 @@ +/** + * A dependency-free QR Code encoder (byte mode, error-correction level M, + * versions 1–10) used to render a scannable link to the LAN address. + * + * This is a from-scratch implementation of ISO/IEC 18004 for the one mode the + * panel needs; it carries no runtime dependency into the browser artifact. + * Level M recovers about 15% of the symbol, which is the usual choice for a + * code read off a screen. + * + * Verified against the reference `qrcode` npm package: `node scripts/test-qr.mjs` + * compares every module of every version and mask. + * + * @module @sutong/dsh-lan-access/qrcode + */ + +/** Error-correction level indicator bits (`M` = 0b00) and the format-info prefix. */ +const EC_LEVEL_BITS = 0b00 + +/** Reed-Solomon block layout per version at level M: [ecCodewordsPerBlock, [[blocks, dataCodewords], …]]. */ +const BLOCKS_M = { + 1: [10, [[1, 16]]], + 2: [16, [[1, 28]]], + 3: [26, [[1, 44]]], + 4: [18, [[2, 32]]], + 5: [24, [[2, 43]]], + 6: [16, [[4, 27]]], + 7: [18, [[4, 31]]], + 8: [22, [[2, 38], [2, 39]]], + 9: [22, [[3, 36], [2, 37]]], + 10: [26, [[4, 43], [1, 44]]], +} + +/** Alignment-pattern centre coordinates per version. */ +const ALIGN_CENTERS = { + 1: [], + 2: [6, 18], + 3: [6, 22], + 4: [6, 26], + 5: [6, 30], + 6: [6, 34], + 7: [6, 22, 38], + 8: [6, 24, 42], + 9: [6, 26, 46], + 10: [6, 28, 50], +} + +/** Largest version this encoder supports. */ +export const MAX_VERSION = 10 + +// ── GF(256) arithmetic ────────────────────────────────────────────────────── + +const GF_EXP = new Uint8Array(512) +const GF_LOG = new Uint8Array(256) +{ + let x = 1 + for (let i = 0; i < 255; i += 1) { + GF_EXP[i] = x + GF_LOG[x] = i + x <<= 1 + if (x & 0x100) x ^= 0x11d + } + for (let i = 255; i < 512; i += 1) GF_EXP[i] = GF_EXP[i - 255] +} + +/** + * Multiply two field elements. + * @param a - first element. + * @param b - second element. + * @returns the product in GF(256). + */ +function gfMul(a, b) { + if (a === 0 || b === 0) return 0 + return GF_EXP[GF_LOG[a] + GF_LOG[b]] +} + +/** + * Build the Reed-Solomon generator polynomial for `degree` error codewords. + * @param degree - number of error-correction codewords. + * @returns coefficients, highest power first, leading coefficient 1. + */ +function rsGenerator(degree) { + let poly = [1] + for (let i = 0; i < degree; i += 1) { + const factor = GF_EXP[i] + const next = new Array(poly.length + 1).fill(0) + for (let j = 0; j < poly.length; j += 1) { + next[j] ^= poly[j] + next[j + 1] ^= gfMul(poly[j], factor) + } + poly = next + } + return poly +} + +/** + * Compute the error-correction codewords for one data block. + * @param data - data codewords. + * @param ecCount - number of error codewords to append. + * @returns the error codewords. + */ +function rsEncode(data, ecCount) { + const generator = rsGenerator(ecCount) + const buffer = new Uint8Array(data.length + ecCount) + buffer.set(data, 0) + for (let i = 0; i < data.length; i += 1) { + const coefficient = buffer[i] + if (coefficient === 0) continue + for (let j = 1; j < generator.length; j += 1) { + buffer[i + j] ^= gfMul(generator[j], coefficient) + } + } + return buffer.slice(data.length) +} + +// ── bit stream ────────────────────────────────────────────────────────────── + +/** Append the low `count` bits of `value`, most significant first. */ +function appendBits(bits, value, count) { + for (let i = count - 1; i >= 0; i -= 1) bits.push((value >>> i) & 1) +} + +/** + * The smallest version whose data capacity holds `byteLength`. + * @param byteLength - payload length in bytes. + * @returns the version, or `undefined` when the payload is too large. + */ +function versionFor(byteLength) { + for (let version = 1; version <= MAX_VERSION; version += 1) { + const [, groups] = BLOCKS_M[version] + const dataCodewords = groups.reduce((sum, [blocks, per]) => sum + blocks * per, 0) + const lengthBits = version >= 10 ? 16 : 8 + const capacity = Math.floor((dataCodewords * 8 - 4 - lengthBits) / 8) + if (byteLength <= capacity) return version + } + return undefined +} + +/** + * Encode the payload into the final interleaved codeword sequence. + * @param bytes - UTF-8 payload bytes. + * @param version - resolved symbol version. + * @returns data codewords followed by error codewords, interleaved per block. + */ +function codewordsFor(bytes, version) { + const [ecPerBlock, groups] = BLOCKS_M[version] + const dataCodewords = groups.reduce((sum, [blocks, per]) => sum + blocks * per, 0) + + const bits = [] + appendBits(bits, 0b0100, 4) + appendBits(bits, bytes.length, version >= 10 ? 16 : 8) + for (const byte of bytes) appendBits(bits, byte, 8) + // Terminator, then pad to a codeword boundary. + const capacityBits = dataCodewords * 8 + const terminator = Math.min(4, capacityBits - bits.length) + appendBits(bits, 0, terminator) + while (bits.length % 8 !== 0) bits.push(0) + + const stream = new Uint8Array(dataCodewords) + for (let i = 0; i < bits.length; i += 8) { + let byte = 0 + for (let j = 0; j < 8; j += 1) byte = (byte << 1) | bits[i + j] + stream[i / 8] = byte + } + // Alternating pad codewords fill the remainder. + const PAD = [0xec, 0x11] + for (let i = bits.length / 8, k = 0; i < dataCodewords; i += 1, k += 1) stream[i] = PAD[k % 2] + + // Split into blocks, append each block's error codewords, then interleave. + const dataBlocks = [] + const ecBlocks = [] + let offset = 0 + for (const [blocks, per] of groups) { + for (let b = 0; b < blocks; b += 1) { + const chunk = stream.slice(offset, offset + per) + offset += per + dataBlocks.push(chunk) + ecBlocks.push(rsEncode(chunk, ecPerBlock)) + } + } + + const out = [] + const maxData = Math.max(...dataBlocks.map((block) => block.length)) + for (let i = 0; i < maxData; i += 1) { + for (const block of dataBlocks) if (i < block.length) out.push(block[i]) + } + for (let i = 0; i < ecPerBlock; i += 1) { + for (const block of ecBlocks) out.push(block[i]) + } + return Uint8Array.from(out) +} + +// ── matrix construction and masking ───────────────────────────────────────── + +/** The eight data-mask predicates; index is the mask pattern. */ +const MASKS = [ + (row, col) => (row + col) % 2 === 0, + (row) => row % 2 === 0, + (_row, col) => col % 3 === 0, + (row, col) => (row + col) % 3 === 0, + (row, col) => (Math.floor(row / 2) + Math.floor(col / 3)) % 2 === 0, + (row, col) => ((row * col) % 2) + ((row * col) % 3) === 0, + (row, col) => (((row * col) % 2) + ((row * col) % 3)) % 2 === 0, + (row, col) => (((row * col) % 3) + ((row + col) % 2)) % 2 === 0, +] + +/** BCH(15,5) generator used by the format information. */ +const FORMAT_GENERATOR = 0x537 + +/** BCH(18,6) generator used by the version information. */ +const VERSION_GENERATOR = 0x1f25 + +/** + * Compute the masked format-information word. + * @param mask - mask pattern index. + * @returns the 15-bit word, already XORed with `0x5412`. + */ +function formatBits(mask) { + const data = (EC_LEVEL_BITS << 3) | mask + let value = data << 10 + for (let i = 14; i >= 10; i -= 1) { + if ((value >>> i) & 1) value ^= FORMAT_GENERATOR << (i - 10) + } + return (((data << 10) | value) ^ 0x5412) & 0x7fff +} + +/** + * Compute the version-information word for versions 7 and above. + * @param version - symbol version. + * @returns the 18-bit word. + */ +function versionBits(version) { + let value = version << 12 + for (let i = 17; i >= 12; i -= 1) { + if ((value >>> i) & 1) value ^= VERSION_GENERATOR << (i - 12) + } + return ((version << 12) | value) & 0x3ffff +} + +/** + * Draw the fixed function patterns and reserve their modules. + * @param size - symbol side length in modules. + * @param version - symbol version. + * @returns `{ modules, reserved }` as row-major byte grids. + */ +function newMatrix(size, version) { + const modules = new Uint8Array(size * size) + const reserved = new Uint8Array(size * size) + const set = (row, col, dark) => { + modules[row * size + col] = dark ? 1 : 0 + reserved[row * size + col] = 1 + } + + // Finder patterns with separators, at three corners. + for (const [top, left] of [[0, 0], [0, size - 7], [size - 7, 0]]) { + for (let r = -1; r <= 7; r += 1) { + for (let c = -1; c <= 7; c += 1) { + const row = top + r + const col = left + c + if (row < 0 || row >= size || col < 0 || col >= size) continue + const inRing = r >= 0 && r <= 6 && c >= 0 && c <= 6 + const dark = inRing && (r === 0 || r === 6 || c === 0 || c === 6 || (r >= 2 && r <= 4 && c >= 2 && c <= 4)) + set(row, col, dark) + } + } + } + + // Timing patterns: dark on even coordinates, alternating with the finders. + for (let i = 8; i < size - 8; i += 1) { + set(6, i, i % 2 === 0) + set(i, 6, i % 2 === 0) + } + + // Alignment patterns, skipping the three finder corners. + const centers = ALIGN_CENTERS[version] + for (const row of centers) { + for (const col of centers) { + const nearFinder = + (row <= 8 && col <= 8) || (row <= 8 && col >= size - 9) || (row >= size - 9 && col <= 8) + if (nearFinder) continue + for (let r = -2; r <= 2; r += 1) { + for (let c = -2; c <= 2; c += 1) { + const dark = Math.max(Math.abs(r), Math.abs(c)) !== 1 + set(row + r, col + c, dark) + } + } + } + } + + // The always-dark module beside the lower-left finder. + set(size - 8, 8, true) + + // Reserve the format-information areas (values are written after masking). + for (let i = 0; i < 9; i += 1) { + if (i !== 6) { + set(8, i, false) + set(i, 8, false) + } + } + // The horizontal copy carries 8 modules; the vertical copy carries 7, because + // the eighth slot of column 8 is the always-dark module below. + for (let i = 0; i < 8; i += 1) set(8, size - 1 - i, false) + for (let i = 0; i < 7; i += 1) set(size - 1 - i, 8, false) + + // Reserve the version-information areas for versions 7 and above. + if (version >= 7) { + for (let i = 0; i < 18; i += 1) { + set(Math.floor(i / 3), (i % 3) + size - 11, false) + set((i % 3) + size - 11, Math.floor(i / 3), false) + } + } + return { size, modules, reserved } +} + +/** + * Write the data and error codewords into the matrix in the zigzag order. + * @param matrix - `{ size, modules, reserved }`. + * @param codewords - interleaved codewords. + * @param mask - mask pattern index. + */ +function placeData(matrix, codewords, mask) { + const { size, modules, reserved } = matrix + const maskFn = MASKS[mask] + let bitIndex = 7 + let byteIndex = 0 + let row = size - 1 + let direction = -1 + + for (let col = size - 1; col > 0; col -= 2) { + if (col === 6) col -= 1 + for (;;) { + for (let c = 0; c < 2; c += 1) { + const at = row * size + (col - c) + if (reserved[at]) continue + let dark = false + if (byteIndex < codewords.length) dark = ((codewords[byteIndex] >>> bitIndex) & 1) === 1 + if (maskFn(row, col - c)) dark = !dark + modules[at] = dark ? 1 : 0 + reserved[at] = 1 + bitIndex -= 1 + if (bitIndex === -1) { + byteIndex += 1 + bitIndex = 7 + } + } + row += direction + if (row < 0 || row >= size) { + row -= direction + direction = -direction + break + } + } + } +} + +/** + * Measure one masked matrix with the four standard penalty rules. + * @param size - symbol side length. + * @param modules - row-major module grid. + * @returns the penalty score; lower is better. + */ +function penalty(size, modules) { + const at = (row, col) => modules[row * size + col] + let score = 0 + + // Rule 1: runs of five or more same-coloured modules in a line. + for (let i = 0; i < size; i += 1) { + for (const line of [0, 1]) { + let run = 1 + let previous = line === 0 ? at(i, 0) : at(0, i) + for (let j = 1; j < size; j += 1) { + const current = line === 0 ? at(i, j) : at(j, i) + if (current === previous) { + run += 1 + } else { + if (run >= 5) score += 3 + (run - 5) + previous = current + run = 1 + } + } + if (run >= 5) score += 3 + (run - 5) + } + } + + // Rule 2: every 2x2 block of one colour. + for (let row = 0; row < size - 1; row += 1) { + for (let col = 0; col < size - 1; col += 1) { + const value = at(row, col) + if (value === at(row, col + 1) && value === at(row + 1, col) && value === at(row + 1, col + 1)) score += 3 + } + } + + // Rule 3: the finder-like 1:1:3:1:1 patterns with a four-module quiet side. + const first = [1, 0, 1, 1, 1, 0, 1, 0, 0, 0, 0] + const second = [0, 0, 0, 0, 1, 0, 1, 1, 1, 0, 1] + const matches = (get, start) => { + let one = true + let two = true + for (let k = 0; k < 11; k += 1) { + const value = get(start + k) + if (value !== first[k]) one = false + if (value !== second[k]) two = false + } + return (one ? 1 : 0) + (two ? 1 : 0) + } + for (let i = 0; i < size; i += 1) { + for (let j = 0; j + 11 <= size; j += 1) { + score += 40 * matches((k) => at(i, k), j) + score += 40 * matches((k) => at(k, i), j) + } + } + + // Rule 4: deviation of the dark-module proportion from 50%. + let dark = 0 + for (let i = 0; i < size; i += 1) dark += modules[i] + const percent = (dark * 100) / (size * size) + score += Math.floor(Math.abs(percent - 50) / 5) * 10 + return score +} + +/** + * Write the format information (and version information for v7+) around the + * fixed patterns. + * @param matrix - `{ size, modules }`. + * @param version - symbol version. + * @param mask - chosen mask pattern index. + */ +function placeHeaders(matrix, version, mask) { + const { size, modules } = matrix + const set = (row, col, dark) => { + modules[row * size + col] = dark ? 1 : 0 + } + + const format = formatBits(mask) + for (let i = 0; i < 15; i += 1) { + const dark = ((format >>> i) & 1) === 1 + if (i < 6) set(i, 8, dark) + else if (i < 8) set(i + 1, 8, dark) + else set(size - 15 + i, 8, dark) + + if (i < 8) set(8, size - 1 - i, dark) + else if (i < 9) set(8, 15 - i, dark) + else set(8, 15 - i - 1, dark) + } + + if (version >= 7) { + const bits = versionBits(version) + for (let i = 0; i < 18; i += 1) { + const dark = ((bits >>> i) & 1) === 1 + set(Math.floor(i / 3), (i % 3) + size - 11, dark) + set((i % 3) + size - 11, Math.floor(i / 3), dark) + } + } +} + +/** + * Encode text as a QR Code symbol. + * @param text - the payload; UTF-8 encoded. + * @param options - optional `version` and `mask` overrides (mainly for tests). + * @returns `{ size, version, mask, modules }` where `modules` is row-major, 1 = dark. + * @throws {RangeError} when the payload exceeds version 10 at level M. + */ +export function encodeQR(text, options = {}) { + const bytes = new TextEncoder().encode(String(text)) + const version = options.version ?? versionFor(bytes.length) + if (version === undefined || version > MAX_VERSION) { + throw new RangeError(`qrcode: payload of ${bytes.length} bytes exceeds version ${MAX_VERSION} at level M`) + } + + const codewords = codewordsFor(bytes, version) + const size = version * 4 + 17 + + let best + if (options.mask !== undefined) { + best = options.mask + } else { + let bestScore = Number.POSITIVE_INFINITY + for (let mask = 0; mask < 8; mask += 1) { + const matrix = newMatrix(size, version) + placeData(matrix, codewords, mask) + placeHeaders(matrix, version, mask) + const score = penalty(size, matrix.modules) + if (score < bestScore) { + bestScore = score + best = mask + } + } + } + + const matrix = newMatrix(size, version) + placeData(matrix, codewords, best) + placeHeaders(matrix, version, best) + return { size, version, mask: best, modules: matrix.modules } +}