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:
2026-09-27 03:49:12 +08:00
commit ced05e06a3
17 changed files with 3174 additions and 0 deletions
+877
View File
@@ -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 };
},
});