ced05e06a3
用独立反向代理把 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,且与直连回环逐项行为一致
878 lines
34 KiB
JavaScript
878 lines
34 KiB
JavaScript
/**
|
||
* @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 };
|
||
},
|
||
});
|