/** * @wishesl/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: "@wishesl/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 @wishesl/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 @wishesl/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 @wishesl/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 }; }, });