a5f51a029f
与 GitHub 仓库归属 wishedl 对齐(原 @sutong 不是发布身份,npm 发布会 403)。 同时把包名改为单一来源:scripts/build-client.mjs 与 scripts/test-client.mjs 都从 package.json 读取,避免产物 id 与清单漂移。LICENSE 版权主体同步为 wishesl。 顺带修正 src/panel.js 里「Plugins 内页」的过时注释(面板早已是 Settings 顶层入口)。
363 lines
13 KiB
JavaScript
363 lines
13 KiB
JavaScript
/**
|
||
* 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,
|
||
),
|
||
)
|
||
}
|