Files
dsh-lan-access/client.js
T
sutong dbfdfda60d refactor: 包名定为 @sutong/dsh-lan-access,元数据指向自建 Gitea
主办从 GitHub 迁到自建 Gitea(git.nixus.top/sutong/dsh-lan-access),
包名随之从 @wishesl 改回 @sutong,与仓库归属一致。
repository/bugs/homepage 指向 git.nixus.top;LICENSE 版权主体同步。
因包名已做成单一来源,本次只需改 package.json 并重建。
2026-09-27 04:18:46 +08:00

878 lines
34 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* @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: its own Settings page showing 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 };
},
});