/** * 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 */ import { encodeQR } from './qrcode.js' /** Locale namespace for this plugin's visible text. */ const NS = 'dsh-lan-access' /** Host route (behind the official `/api` fence and browser authentication). */ const SUMMARY_URL = '/api/dsh-lan-access/summary' /** * The seat this panel occupies: its own entry in the Settings navigation. * Registering here (rather than inside the Plugins section's tab strip) makes * the page reachable directly from Settings; `dsh-opencode-go` sets the same * precedent for a plugin-owned settings page. */ const SLOT = 'settings.section' /** Nav/section key for this panel. */ const SECTION_ID = 'lan-access' /** Simplified-Chinese dictionary; the key source. */ const zh = { title: '局域网访问', description: '通过独立反向代理把本机 dsh Web UI 发布到局域网,保留官方 launch token 登录。', loading: '正在读取局域网地址…', failed: '无法读取局域网信息:{message}', forward: '转发到', addresses: '局域网地址', noAddresses: '未找到非内部 IPv4 地址,局域网内暂时无法访问。', scanHint: '用另一台设备扫描二维码即可打开(链接已带登录令牌)。', copy: '复制', copied: '已复制', open: '打开', warning: '该链接携带 launch token,等同于完整 dsh 控制权(含终端命令执行)。请只在可信局域网内分享。', disabled: '该 LAN 监听器在此 profile 中已被配置为关闭。', notListening: '监听器未能绑定 {target}:{message}', qr: '{address} 的二维码', } /** English dictionary; must cover every key above. */ const en = { title: 'LAN Access', description: 'Publishes this machine’s dsh Web UI on the LAN through a standalone reverse proxy while keeping the official launch-token login.', loading: 'Reading LAN addresses…', failed: 'Could not read LAN information: {message}', forward: 'forwards to', addresses: 'LAN addresses', noAddresses: 'No non-internal IPv4 address was found, so nothing is reachable from the LAN yet.', scanHint: 'Scan the QR code with another device to open it (the link already carries the login token).', copy: 'Copy', copied: 'Copied', open: 'Open', warning: 'That link carries the launch token and grants full dsh control, shell included. Share it only on a trusted network.', disabled: 'The LAN listener is configured off in this profile.', notListening: 'The listener could not bind {target}: {message}', qr: 'QR code for {address}', } /** Substitute `{name}` placeholders. */ function interpolate(template, params) { if (params === undefined) return template return template.replaceAll(/\{(\w+)\}/g, (match, key) => (key in params ? String(params[key]) : match)) } /** * Bind the locale service, falling back to the Chinese dictionary when the * service is absent or its contract drifted. * @param locale - the live `locale` service, when present. * @returns a translate function taking a key and optional `{name}` parameters. */ function makeTranslator(locale) { if (locale !== undefined && typeof locale.bind === 'function') { try { const bound = locale.bind(NS) if (typeof bound === 'function') return (key, params) => interpolate(bound(key), params) } catch { /* fall through to the local dictionary */ } } return (key, params) => interpolate(zh[key] ?? key, params) } /** * The active translator. `apply` replaces it once the locale service is bound * and re-binds it on every locale change, so components read it at render time. */ let translate = (key, params) => interpolate(zh[key] ?? key, params) /** Stylesheet, scoped under `dla-` and expressed only in theme tokens. */ const CSS = ` .dla-root { display: flex; flex-direction: column; gap: 16px; padding: 4px 2px 24px; color: var(--dsw-alias-label-primary); } .dla-head { display: flex; flex-direction: column; gap: 6px; } .dla-title { font-size: 15px; font-weight: 600; } .dla-desc { font-size: 13px; line-height: 1.6; color: var(--dsw-alias-label-secondary); } .dla-facts { display: flex; flex-wrap: wrap; gap: 6px 18px; font-size: 12px; color: var(--dsw-alias-label-secondary); } .dla-fact b { color: var(--dsw-alias-label-primary); font-weight: 500; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; } .dla-body { display: flex; flex-wrap: wrap; gap: 20px; align-items: flex-start; } .dla-list { display: flex; flex-direction: column; gap: 8px; flex: 1 1 320px; min-width: 280px; } .dla-section { font-size: 12px; font-weight: 600; letter-spacing: .02em; text-transform: uppercase; color: var(--dsw-alias-label-secondary); } .dla-row { display: flex; align-items: center; gap: 8px; border: 1px solid var(--dsw-alias-border-l1); border-radius: 8px; padding: 8px 10px; background: var(--dsw-alias-bg-layer-1); } .dla-row[data-selected="true"] { border-color: var(--dsw-alias-brand-primary); } .dla-row-main { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 2px; } .dla-ip { font-size: 12px; color: var(--dsw-alias-label-secondary); } .dla-url { font-size: 12px; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .dla-btn { flex: none; font: inherit; font-size: 12px; line-height: 1; padding: 6px 10px; border-radius: 6px; border: 1px solid var(--dsw-alias-border-l1); background: var(--dsw-alias-bg-layer-2); color: var(--dsw-alias-label-primary); cursor: pointer; } .dla-btn:hover { border-color: var(--dsw-alias-border-l2); } .dla-btn[data-primary="true"] { border-color: var(--dsw-alias-brand-primary); color: var(--dsw-alias-brand-primary); } .dla-qr { display: flex; flex-direction: column; gap: 8px; align-items: center; } .dla-qr-card { padding: 10px; border-radius: 10px; background: #ffffff; border: 1px solid var(--dsw-alias-border-l1); } .dla-qr-cap { font-size: 12px; color: var(--dsw-alias-label-secondary); max-width: 200px; text-align: center; } .dla-hint { font-size: 12px; line-height: 1.6; color: var(--dsw-alias-label-secondary); } .dla-warn { font-size: 12px; line-height: 1.6; color: var(--dsw-alias-state-warn-primary); } .dla-error { font-size: 12px; color: var(--dsw-alias-state-error-primary); } ` /** * Render a QR matrix as a crisp SVG. * @param symbol - `{ size, modules }` from the encoder. * @param label - accessible label. * @returns a React element. */ function qrElement(React, symbol, label, caption) { const h = React.createElement const { size, modules } = symbol let path = '' for (let row = 0; row < size; row += 1) { for (let col = 0; col < size; col += 1) { if (modules[row * size + col]) path += `M${col} ${row}h1v1h-1z` } } return h( 'div', { className: 'dla-qr' }, h( 'div', { className: 'dla-qr-card' }, h( 'svg', { width: 168, height: 168, viewBox: `0 0 ${size} ${size}`, role: 'img', 'aria-label': label, shapeRendering: 'crispEdges', }, h('rect', { width: size, height: size, fill: '#ffffff' }), h('path', { d: path, fill: '#000000' }), ), ), caption === undefined ? null : h('div', { className: 'dla-qr-cap' }, caption), ) } /** * The Plugins-settings tab: read the host summary and present the LAN links. * @param props - slot props; this seat supplies none. * @returns the panel element. */ function Panel() { const h = React.createElement const [state, setState] = React.useState({ status: 'loading' }) const [selected, setSelected] = React.useState(0) const [copied, setCopied] = React.useState('') React.useEffect(() => { let live = true fetch(SUMMARY_URL, { headers: { accept: 'application/json' } }) .then(async (response) => { if (!response.ok) throw new Error(`HTTP ${String(response.status)}`) return response.json() }) .then((data) => { if (live) setState({ status: 'ready', data }) }) .catch((error) => { if (live) setState({ status: 'error', message: String(error?.message ?? error) }) }) return () => { live = false } }, []) const t = translate const children = [h('style', { key: 'style' }, CSS)] if (state.status === 'loading') { children.push(h('div', { className: 'dla-hint', key: 'loading' }, t('loading'))) return h('div', { className: 'dla-root' }, children) } if (state.status === 'error') { children.push(h('div', { className: 'dla-error', key: 'error' }, t('failed', { message: state.message }))) return h('div', { className: 'dla-root' }, children) } const { data } = state const lan = Array.isArray(data.lan) ? data.lan : [] const active = lan[Math.min(selected, Math.max(lan.length - 1, 0))] children.push( h( 'div', { className: 'dla-head', key: 'head' }, h('div', { className: 'dla-title' }, t('title')), h('div', { className: 'dla-desc' }, t('description')), h( 'div', { className: 'dla-facts' }, h('span', null, `${data.listen.host}:${String(data.listen.port)}`, ' ', t('forward'), ' ', h('b', null, `${data.target.host}:${String(data.target.port)}`)), ), ), ) if (data.enabled === false) { children.push(h('div', { className: 'dla-warn', key: 'disabled' }, t('disabled'))) } if (data.listening === false) { // Never advertise an address that is not actually bound. children.push( h( 'div', { className: 'dla-error', key: 'not-listening' }, t('notListening', { target: `${data.listen.host}:${String(data.listen.port)}`, message: data.bindError ?? 'unknown error', }), ), ) } if (lan.length === 0) { children.push(h('div', { className: 'dla-hint', key: 'none' }, t('noAddresses'))) return h('div', { className: 'dla-root' }, children) } const copy = (url) => { const done = () => { setCopied(url) globalThis.setTimeout(() => setCopied(''), 1500) } if (globalThis.navigator?.clipboard?.writeText !== undefined) { globalThis.navigator.clipboard.writeText(url).then(done, done) } else { done() } } const rows = lan.map((entry, index) => h( 'div', { className: 'dla-row', key: entry.address, 'data-selected': String(index === selected), onClick: () => setSelected(index), }, h( 'div', { className: 'dla-row-main' }, h('div', { className: 'dla-ip' }, entry.address), h('div', { className: 'dla-url', title: entry.tokenUrl }, entry.tokenUrl), ), h( 'button', { type: 'button', className: 'dla-btn', onClick: (event) => { event.stopPropagation() copy(entry.tokenUrl) }, }, copied === entry.tokenUrl ? t('copied') : t('copy'), ), h( 'a', { className: 'dla-btn', href: entry.tokenUrl, target: '_blank', rel: 'noreferrer', onClick: (event) => event.stopPropagation() }, t('open'), ), ), ) let symbol try { symbol = encodeQR(active.tokenUrl) } catch { symbol = undefined } children.push( h( 'div', { className: 'dla-body', key: 'body' }, h( 'div', { className: 'dla-list' }, h('div', { className: 'dla-section' }, t('addresses')), ...rows, h('div', { className: 'dla-hint' }, t('scanHint')), ), symbol === undefined ? null : qrElement(React, symbol, t('qr', { address: active.address }), active.address), ), h('div', { className: 'dla-warn', key: 'warn' }, t('warning')), ) return h('div', { className: 'dla-root' }, children) } /** Services this client half reads. */ export const inject = ['slots', 'locale'] /** * Register the locale dictionaries and the Plugins-settings tab. * @param ctx - the restricted client context. */ export function apply(ctx) { const locale = ctx.get('locale') if (locale !== undefined) { if (typeof locale.register === 'function') { try { const dispose = locale.register(NS, { zh, en }) if (typeof dispose === 'function') ctx.effect(() => dispose, 'dsh-lan-access: locale dictionaries') } catch (error) { console.error('dsh-lan-access: locale registration failed', error) } } translate = makeTranslator(locale) if (typeof locale.subscribe === 'function') { const unsubscribe = locale.subscribe(() => { translate = makeTranslator(locale) }) if (typeof unsubscribe === 'function') ctx.effect(() => unsubscribe, 'dsh-lan-access: locale subscription') } } const slots = ctx.get('slots') if (slots === undefined) { console.error('dsh-lan-access: the slots service is unavailable; the LAN panel is not mounted') return } const t = makeTranslator(locale) slots.inject(SLOT, () => slots.register( { name: SLOT, id: SECTION_ID, order: 50, label: () => t('title'), }, Panel, ), ) }