feat: dsh-lan-access — 局域网访问 dsh Web UI
用独立反向代理把 dsh Web UI 发布到局域网。官方 CLI 主动拒绝 `dsh web --host 0.0.0.0`(会把宿主机远程代码执行暴露到网络), 因此本插件让官方服务器保持只绑回环,另开监听并转发。 宿主端(index.js) - 在 0.0.0.0:3082 监听,把 HTTP 与 WebSocket 转发到 127.0.0.1:<webServer 端口> - 上游 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,且与直连回环逐项行为一致
This commit is contained in:
@@ -0,0 +1,4 @@
|
||||
# `client.js` is generated from `src/` by `scripts/build-client.mjs`; no ignore.
|
||||
node_modules/
|
||||
*.log
|
||||
.DS_Store
|
||||
@@ -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.
|
||||
+119
@@ -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:<port>`, 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 <host>` 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
|
||||
@@ -0,0 +1,123 @@
|
||||
# @sutong/dsh-lan-access
|
||||
|
||||
局域网访问 dsh Web UI:一个**独立反向代理**,在 `0.0.0.0:<port>` 监听,把 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
|
||||
@@ -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 };
|
||||
},
|
||||
});
|
||||
@@ -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
|
||||
@@ -0,0 +1,14 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64" role="img" aria-label="LAN Access">
|
||||
<g fill="none" stroke="#3f7fd4" stroke-width="3.2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<rect x="6" y="38" width="16" height="16" rx="3" />
|
||||
<rect x="42" y="38" width="16" height="16" rx="3" />
|
||||
<rect x="24" y="10" width="16" height="16" rx="3" />
|
||||
<path d="M32 26v6" />
|
||||
<path d="M14 38v-6h36v6" />
|
||||
</g>
|
||||
<g fill="#3f7fd4">
|
||||
<circle cx="14" cy="46" r="1.6" />
|
||||
<circle cx="50" cy="46" r="1.6" />
|
||||
<circle cx="32" cy="18" r="1.6" />
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 607 B |
@@ -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 `</head>`, so it runs
|
||||
* before the deferred module bundle reads `__DSH_TRANSPORT__`.
|
||||
* @param html - the raw index document.
|
||||
* @param text - script body; must not contain `</script`.
|
||||
* @returns the document with the script inserted.
|
||||
*/
|
||||
function withHeadScript(html, text) {
|
||||
const tag = `<script>${text}</script>`
|
||||
const at = html.indexOf('</head>')
|
||||
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,
|
||||
}
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"meta": {
|
||||
"title": "局域网访问",
|
||||
"description": "用独立反向代理把 dsh Web UI 发布到局域网,同时保留官方 launch token 登录。"
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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)`)
|
||||
@@ -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.<name>` 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 = '<html><head><title>t</title></head><body><script type="module" src="x.js"></script></body></html>'
|
||||
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('</head>') > 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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
+362
@@ -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,
|
||||
),
|
||||
)
|
||||
}
|
||||
+493
@@ -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 }
|
||||
}
|
||||
Reference in New Issue
Block a user