Files
dsh-lan-access/src/panel.js
T
sutong a5f51a029f refactor: 包名改为 @wishesl/dsh-lan-access
与 GitHub 仓库归属 wishedl 对齐(原 @sutong 不是发布身份,npm 发布会 403)。
同时把包名改为单一来源:scripts/build-client.mjs 与 scripts/test-client.mjs
都从 package.json 读取,避免产物 id 与清单漂移。LICENSE 版权主体同步为 wishesl。
顺带修正 src/panel.js 里「Plugins 内页」的过时注释(面板早已是 Settings 顶层入口)。
2026-09-27 04:11:15 +08:00

363 lines
13 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.
/**
* 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,
),
)
}