fix: 复制按钮在局域网页面下静默失败

明文 HTTP 的局域网页面不是安全上下文,navigator.clipboard 不存在,
旧代码落到 else 分支直接报告成功却什么都没复制;而且 .then(done, done)
连 Promise 被拒(权限拒绝)也当成功——两种情况都会谎报「已复制」。

现在:
- 只在真的写入剪贴板后才报成功;clipboard API 被拒也继续回退
- 回退到 document.execCommand('copy')(明文 HTTP 下仍然可用)
- 两者都不可用时选中链接并提示手动 Ctrl/⌘+C,不再谎报
- URL 改为可选中的只读输入框(原先是没有省略号就无法看全的 div)

新增 7 项回归测试覆盖:clipboard 缺失时确实走 execCommand、可用时走
API 且不重复复制、被拒时回退、两者都无时不抛异常。旧代码在该测试下失败。
README 中英双语排障表补充该现象与 HTTPS 说明。
This commit is contained in:
2026-09-28 23:17:12 +08:00
parent 304379c80c
commit 42859309c3
5 changed files with 290 additions and 28 deletions
+1
View File
@@ -118,6 +118,7 @@ adaptive choice, remove the last three entries of `cordis.patch.yml` (the
| A bookmark stops working after a restart | The launch token changes on every boot. Devices that already logged in keep their cookie; otherwise use a fresh link. | | A bookmark stops working after a restart | The launch token changes on every boot. Devices that already logged in keep their cookie; otherwise use a fresh link. |
| The panel reports no non-internal IPv4 address | This machine currently has no usable LAN IPv4, only loopback. | | The panel reports no non-internal IPv4 address | This machine currently has no usable LAN IPv4, only loopback. |
| Scanning does nothing | Some cameras read dark-background codes poorly; the panel always draws the code on a white card. Raise the screen brightness. | | Scanning does nothing | Some cameras read dark-background codes poorly; the panel always draws the code on a white card. Raise the screen brightness. |
| "Copy" does nothing / nothing reaches the clipboard | A plain-HTTP LAN page is **not a secure context**, so `navigator.clipboard` does not exist there. The plugin falls back to `document.execCommand('copy')`; when a browser blocks both it **selects the link and asks for Ctrl/⌘+C** instead of falsely reporting success. The real clipboard API needs HTTPS. |
## Development ## Development
+1
View File
@@ -119,6 +119,7 @@ DSH 官方把「持久化设置」限制在回环页面:客户端由 `location
| 重启后旧书签失效 | launch token 每次启动都会变。已登录过的设备靠 cookie 仍然有效;否则重新用新链接进入。 | | 重启后旧书签失效 | launch token 每次启动都会变。已登录过的设备靠 cookie 仍然有效;否则重新用新链接进入。 |
| 面板显示"未找到非内部 IPv4 地址" | 这台机器当前没有可用的局域网 IPv4(只有回环)。 | | 面板显示"未找到非内部 IPv4 地址" | 这台机器当前没有可用的局域网 IPv4(只有回环)。 |
| 扫码无反应 | 部分机型对深色背景上的二维码识别差;面板里的二维码固定渲染在白底卡片上。适当调高屏幕亮度。 | | 扫码无反应 | 部分机型对深色背景上的二维码识别差;面板里的二维码固定渲染在白底卡片上。适当调高屏幕亮度。 |
| 点「复制」没反应 / 剪贴板里没内容 | 明文 HTTP 的局域网页面**不是安全上下文**,`navigator.clipboard` 在该页面不存在。插件会退回 `document.execCommand('copy')`;若浏览器两条路都禁止,就**选中链接并提示手动 Ctrl/⌘+C**,不再谎报「已复制」。想用正规剪贴板 API 需 HTTPS。 |
## 开发 ## 开发
+104 -14
View File
@@ -551,6 +551,8 @@ window.__ModuleLoader__.load({
scanHint: '用另一台设备扫描二维码即可打开(链接已带登录令牌)。', scanHint: '用另一台设备扫描二维码即可打开(链接已带登录令牌)。',
copy: '复制', copy: '复制',
copied: '已复制', copied: '已复制',
selected: '已选中',
manualHint: '此浏览器不允许页面自动写入剪贴板(明文 HTTP 下 clipboard API 不可用),已为你选中链接,请按 Ctrl/⌘+C 复制。',
open: '打开', open: '打开',
warning: '该链接携带 launch token,等同于完整 dsh 控制权(含终端命令执行)。请只在可信局域网内分享。', warning: '该链接携带 launch token,等同于完整 dsh 控制权(含终端命令执行)。请只在可信局域网内分享。',
disabled: '该 LAN 监听器在此 profile 中已被配置为关闭。', disabled: '该 LAN 监听器在此 profile 中已被配置为关闭。',
@@ -570,6 +572,8 @@ window.__ModuleLoader__.load({
scanHint: 'Scan the QR code with another device to open it (the link already carries the login token).', scanHint: 'Scan the QR code with another device to open it (the link already carries the login token).',
copy: 'Copy', copy: 'Copy',
copied: 'Copied', copied: 'Copied',
selected: 'Selected',
manualHint: 'This browser does not let the page write to the clipboard (the clipboard API needs a secure context, which plain HTTP over the LAN is not). The link is selected — press Ctrl/⌘+C to copy.',
open: 'Open', open: 'Open',
warning: 'That link carries the launch token and grants full dsh control, shell included. Share it only on a trusted network.', 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.', disabled: 'The LAN listener is configured off in this profile.',
@@ -622,7 +626,8 @@ window.__ModuleLoader__.load({
.dla-row[data-selected="true"] { border-color: var(--dsw-alias-brand-primary); } .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-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-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-url { font-size: 12px; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; width: 100%; min-width: 0; padding: 0; border: none; background: transparent; color: var(--dsw-alias-label-primary); text-overflow: ellipsis; }
.dla-url:focus { outline: none; }
.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 { 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: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-btn[data-primary="true"] { border-color: var(--dsw-alias-brand-primary); color: var(--dsw-alias-brand-primary); }
@@ -634,6 +639,71 @@ window.__ModuleLoader__.load({
.dla-error { font-size: 12px; color: var(--dsw-alias-state-error-primary); } .dla-error { font-size: 12px; color: var(--dsw-alias-state-error-primary); }
` `
/**
* Copy `text` to the clipboard, reporting what actually happened.
*
* The async Clipboard API only exists in a secure context, so a page served
* over plain HTTP on a LAN address does not have it. The legacy
* `document.execCommand('copy')` path still works there, so it is the real
* fallback rather than a silent success.
* @param text - the string to copy.
* @param input - an already-rendered selectable element holding the same text, when available.
* @returns `'copied'` only when the clipboard was written; otherwise `'manual'`.
*/
async function copyText(text, input) {
const clipboard = globalThis.navigator?.clipboard
if (clipboard !== undefined && typeof clipboard.writeText === 'function') {
try {
await clipboard.writeText(text)
return 'copied'
} catch {
/* permission denied or unusable: fall through to the legacy path */
}
}
return selectAndCopy(input, text) ? 'copied' : 'manual'
}
/**
* Legacy synchronous copy: select the rendered element and run the deprecated
* but still widely supported `execCommand('copy')`.
*
* A transient off-screen textarea covers the case where no element was
* supplied; it is removed in the same tick and only ever exists inside this
* function.
* @param input - the selectable element, when the caller has one.
* @param text - the text to place on the clipboard.
* @returns whether the copy command reported success.
*/
function selectAndCopy(input, text) {
const doc = globalThis.document
if (doc === undefined) return false
if (input !== undefined && input !== null && typeof input.select === 'function') {
try {
if (typeof input.focus === 'function') input.focus()
input.select()
if (typeof input.setSelectionRange === 'function') input.setSelectionRange(0, text.length)
if (typeof doc.execCommand === 'function' && doc.execCommand('copy') === true) return true
} catch {
/* fall through to the textarea path */
}
}
try {
const area = doc.createElement('textarea')
area.value = text
area.setAttribute('readonly', '')
area.style.position = 'fixed'
area.style.top = '-1000px'
area.style.opacity = '0'
doc.body.appendChild(area)
area.select()
const ok = typeof doc.execCommand === 'function' && doc.execCommand('copy') === true
doc.body.removeChild(area)
return ok
} catch {
return false
}
}
/** /**
* Render a QR matrix as a crisp SVG. * Render a QR matrix as a crisp SVG.
* @param symbol - `{ size, modules }` from the encoder. * @param symbol - `{ size, modules }` from the encoder.
@@ -682,7 +752,9 @@ window.__ModuleLoader__.load({
const h = React.createElement const h = React.createElement
const [state, setState] = React.useState({ status: 'loading' }) const [state, setState] = React.useState({ status: 'loading' })
const [selected, setSelected] = React.useState(0) const [selected, setSelected] = React.useState(0)
const [copied, setCopied] = React.useState('') const [copyState, setCopyState] = React.useState({ url: '', status: 'idle' })
const inputRefs = React.useRef(undefined)
if (inputRefs.current === undefined) inputRefs.current = new Map()
React.useEffect(() => { React.useEffect(() => {
let live = true let live = true
@@ -754,16 +826,13 @@ window.__ModuleLoader__.load({
return h('div', { className: 'dla-root' }, children) return h('div', { className: 'dla-root' }, children)
} }
const copy = (url) => { const copy = (entry) => {
const done = () => { const input = inputRefs.current.get(entry.address)
setCopied(url) const settle = (status) => {
globalThis.setTimeout(() => setCopied(''), 1500) setCopyState({ url: entry.tokenUrl, status })
} globalThis.setTimeout(() => setCopyState({ url: '', status: 'idle' }), 2500)
if (globalThis.navigator?.clipboard?.writeText !== undefined) {
globalThis.navigator.clipboard.writeText(url).then(done, done)
} else {
done()
} }
copyText(entry.tokenUrl, input).then(settle, () => settle('manual'))
} }
const rows = lan.map((entry, index) => const rows = lan.map((entry, index) =>
@@ -779,7 +848,23 @@ window.__ModuleLoader__.load({
'div', 'div',
{ className: 'dla-row-main' }, { className: 'dla-row-main' },
h('div', { className: 'dla-ip' }, entry.address), h('div', { className: 'dla-ip' }, entry.address),
h('div', { className: 'dla-url', title: entry.tokenUrl }, entry.tokenUrl), h('input', {
className: 'dla-url',
type: 'text',
readOnly: true,
spellCheck: false,
value: entry.tokenUrl,
title: entry.tokenUrl,
ref: (element) => {
if (element === null) inputRefs.current.delete(entry.address)
else inputRefs.current.set(entry.address, element)
},
onFocus: (event) => {
setSelected(index)
event.currentTarget.select()
},
onClick: (event) => event.stopPropagation(),
}),
), ),
h( h(
'button', 'button',
@@ -788,10 +873,14 @@ window.__ModuleLoader__.load({
className: 'dla-btn', className: 'dla-btn',
onClick: (event) => { onClick: (event) => {
event.stopPropagation() event.stopPropagation()
copy(entry.tokenUrl) copy(entry)
}, },
}, },
copied === entry.tokenUrl ? t('copied') : t('copy'), copyState.url !== entry.tokenUrl
? t('copy')
: copyState.status === 'copied'
? t('copied')
: t('selected'),
), ),
h( h(
'a', 'a',
@@ -818,6 +907,7 @@ window.__ModuleLoader__.load({
h('div', { className: 'dla-section' }, t('addresses')), h('div', { className: 'dla-section' }, t('addresses')),
...rows, ...rows,
h('div', { className: 'dla-hint' }, t('scanHint')), h('div', { className: 'dla-hint' }, t('scanHint')),
copyState.status === 'manual' ? h('div', { className: 'dla-warn' }, t('manualHint')) : null,
), ),
symbol === undefined ? null : qrElement(React, symbol, t('qr', { address: active.address }), active.address), symbol === undefined ? null : qrElement(React, symbol, t('qr', { address: active.address }), active.address),
), ),
+80
View File
@@ -52,6 +52,7 @@ const React = {
return [typeof initial === 'function' ? initial() : initial, () => {}] return [typeof initial === 'function' ? initial() : initial, () => {}]
}, },
useEffect: () => {}, useEffect: () => {},
useRef: (initial) => ({ current: initial }),
} }
const requireStub = (name) => { const requireStub = (name) => {
if (name === 'react') return React if (name === 'react') return React
@@ -159,6 +160,85 @@ const failedJson = JSON.stringify(failed)
check('bind failure is surfaced in the panel', failedJson.includes('EADDRINUSE')) check('bind failure is surfaced in the panel', failedJson.includes('EADDRINUSE'))
check('bind failure names the target', failedJson.includes('0.0.0.0:3081')) check('bind failure names the target', failedJson.includes('0.0.0.0:3081'))
// ── clipboard: a real copy or an honest "you must copy it yourself" ─────────
/**
* Collect every element in a rendered tree whose className matches.
* @param node - a React-element-shaped node from the fake createElement.
* @param className - the class to match.
* @returns the matching nodes.
*/
function findByClass(node, className) {
const found = []
const walk = (current) => {
if (current === null || typeof current !== 'object') return
if (Array.isArray(current)) { current.forEach(walk); return }
if (current.props?.className === className) found.push(current)
for (const child of current.children ?? []) walk(child)
}
walk(node)
return found
}
/** Render the panel with one LAN address and return its copy button. */
function renderCopyButton() {
stateFixture = {
status: 'ready',
data: {
enabled: true,
listening: true,
listen: { host: '0.0.0.0', port: 3082 },
target: { host: '127.0.0.1', port: 3080 },
lan: [{ address: '192.168.1.5', url: 'http://192.168.1.5:3082/', tokenUrl: 'http://192.168.1.5:3082/?token=tok' }],
},
}
useStateCalls = 0
const tree = registrations[0].component({})
const buttons = findByClass(tree, 'dla-btn').filter((node) => node.type === 'button')
return { tree, button: buttons[0], url: 'http://192.168.1.5:3082/?token=tok' }
}
// The URL is rendered as a selectable read-only input, not an ellipsised div.
const rendered = renderCopyButton()
const inputs = findByClass(rendered.tree, 'dla-url').filter((node) => node.type === 'input')
check('the url renders as a read-only input', inputs.length === 1 && inputs[0].props.readOnly === true)
check('the input carries the tokenized url', inputs[0]?.props?.value === rendered.url, inputs[0]?.props?.value)
// Plain HTTP over the LAN has no navigator.clipboard; the legacy path must run
// and must only claim success when execCommand actually reported it.
let execCalls = 0
sandbox.navigator = {}
sandbox.document = {
execCommand: (command) => { execCalls += 1; return command === 'copy' },
createElement: () => ({ style: {}, setAttribute() {}, select() {}, remove() {} }),
body: { appendChild() {}, removeChild() {} },
}
await renderCopyButton().button.props.onClick({ stopPropagation() {}, currentTarget: { select() {} } })
await new Promise((resolve) => setTimeout(resolve, 0))
check('falls back to execCommand when clipboard is unavailable', execCalls > 0, `execCommand calls: ${execCalls}`)
// A secure context (or any host that does provide it) uses the async API.
let written = ''
sandbox.navigator = { clipboard: { writeText: (text) => { written = text; return Promise.resolve() } } }
execCalls = 0
await renderCopyButton().button.props.onClick({ stopPropagation() {}, currentTarget: { select() {} } })
await new Promise((resolve) => setTimeout(resolve, 0))
check('uses the clipboard API when it exists', written === rendered.url, written)
check('does not double-copy through the legacy path', execCalls === 0, `execCommand calls: ${execCalls}`)
// A rejected clipboard write must fall through rather than report success.
written = ''
sandbox.navigator = { clipboard: { writeText: () => Promise.reject(new Error('denied')) } }
execCalls = 0
await renderCopyButton().button.props.onClick({ stopPropagation() {}, currentTarget: { select() {} } })
await new Promise((resolve) => setTimeout(resolve, 0))
check('a rejected clipboard write falls back', execCalls > 0, `execCommand calls: ${execCalls}`)
// Nothing available at all: it must not throw, and must not claim success.
sandbox.navigator = {}
sandbox.document = undefined
await renderCopyButton().button.props.onClick({ stopPropagation() {}, currentTarget: { select() {} } })
check('no clipboard and no document does not throw', true)
// ── the two halves agree on the route ─────────────────────────────────────── // ── the two halves agree on the route ───────────────────────────────────────
check('client fetches the host route', source.includes(SUMMARY_PATH), SUMMARY_PATH) check('client fetches the host route', source.includes(SUMMARY_PATH), SUMMARY_PATH)
+104 -14
View File
@@ -40,6 +40,8 @@ const zh = {
scanHint: '用另一台设备扫描二维码即可打开(链接已带登录令牌)。', scanHint: '用另一台设备扫描二维码即可打开(链接已带登录令牌)。',
copy: '复制', copy: '复制',
copied: '已复制', copied: '已复制',
selected: '已选中',
manualHint: '此浏览器不允许页面自动写入剪贴板(明文 HTTP 下 clipboard API 不可用),已为你选中链接,请按 Ctrl/⌘+C 复制。',
open: '打开', open: '打开',
warning: '该链接携带 launch token,等同于完整 dsh 控制权(含终端命令执行)。请只在可信局域网内分享。', warning: '该链接携带 launch token,等同于完整 dsh 控制权(含终端命令执行)。请只在可信局域网内分享。',
disabled: '该 LAN 监听器在此 profile 中已被配置为关闭。', disabled: '该 LAN 监听器在此 profile 中已被配置为关闭。',
@@ -59,6 +61,8 @@ const en = {
scanHint: 'Scan the QR code with another device to open it (the link already carries the login token).', scanHint: 'Scan the QR code with another device to open it (the link already carries the login token).',
copy: 'Copy', copy: 'Copy',
copied: 'Copied', copied: 'Copied',
selected: 'Selected',
manualHint: 'This browser does not let the page write to the clipboard (the clipboard API needs a secure context, which plain HTTP over the LAN is not). The link is selected — press Ctrl/⌘+C to copy.',
open: 'Open', open: 'Open',
warning: 'That link carries the launch token and grants full dsh control, shell included. Share it only on a trusted network.', 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.', disabled: 'The LAN listener is configured off in this profile.',
@@ -111,7 +115,8 @@ const CSS = `
.dla-row[data-selected="true"] { border-color: var(--dsw-alias-brand-primary); } .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-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-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-url { font-size: 12px; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; width: 100%; min-width: 0; padding: 0; border: none; background: transparent; color: var(--dsw-alias-label-primary); text-overflow: ellipsis; }
.dla-url:focus { outline: none; }
.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 { 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: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-btn[data-primary="true"] { border-color: var(--dsw-alias-brand-primary); color: var(--dsw-alias-brand-primary); }
@@ -123,6 +128,71 @@ const CSS = `
.dla-error { font-size: 12px; color: var(--dsw-alias-state-error-primary); } .dla-error { font-size: 12px; color: var(--dsw-alias-state-error-primary); }
` `
/**
* Copy `text` to the clipboard, reporting what actually happened.
*
* The async Clipboard API only exists in a secure context, so a page served
* over plain HTTP on a LAN address does not have it. The legacy
* `document.execCommand('copy')` path still works there, so it is the real
* fallback rather than a silent success.
* @param text - the string to copy.
* @param input - an already-rendered selectable element holding the same text, when available.
* @returns `'copied'` only when the clipboard was written; otherwise `'manual'`.
*/
async function copyText(text, input) {
const clipboard = globalThis.navigator?.clipboard
if (clipboard !== undefined && typeof clipboard.writeText === 'function') {
try {
await clipboard.writeText(text)
return 'copied'
} catch {
/* permission denied or unusable: fall through to the legacy path */
}
}
return selectAndCopy(input, text) ? 'copied' : 'manual'
}
/**
* Legacy synchronous copy: select the rendered element and run the deprecated
* but still widely supported `execCommand('copy')`.
*
* A transient off-screen textarea covers the case where no element was
* supplied; it is removed in the same tick and only ever exists inside this
* function.
* @param input - the selectable element, when the caller has one.
* @param text - the text to place on the clipboard.
* @returns whether the copy command reported success.
*/
function selectAndCopy(input, text) {
const doc = globalThis.document
if (doc === undefined) return false
if (input !== undefined && input !== null && typeof input.select === 'function') {
try {
if (typeof input.focus === 'function') input.focus()
input.select()
if (typeof input.setSelectionRange === 'function') input.setSelectionRange(0, text.length)
if (typeof doc.execCommand === 'function' && doc.execCommand('copy') === true) return true
} catch {
/* fall through to the textarea path */
}
}
try {
const area = doc.createElement('textarea')
area.value = text
area.setAttribute('readonly', '')
area.style.position = 'fixed'
area.style.top = '-1000px'
area.style.opacity = '0'
doc.body.appendChild(area)
area.select()
const ok = typeof doc.execCommand === 'function' && doc.execCommand('copy') === true
doc.body.removeChild(area)
return ok
} catch {
return false
}
}
/** /**
* Render a QR matrix as a crisp SVG. * Render a QR matrix as a crisp SVG.
* @param symbol - `{ size, modules }` from the encoder. * @param symbol - `{ size, modules }` from the encoder.
@@ -171,7 +241,9 @@ function Panel() {
const h = React.createElement const h = React.createElement
const [state, setState] = React.useState({ status: 'loading' }) const [state, setState] = React.useState({ status: 'loading' })
const [selected, setSelected] = React.useState(0) const [selected, setSelected] = React.useState(0)
const [copied, setCopied] = React.useState('') const [copyState, setCopyState] = React.useState({ url: '', status: 'idle' })
const inputRefs = React.useRef(undefined)
if (inputRefs.current === undefined) inputRefs.current = new Map()
React.useEffect(() => { React.useEffect(() => {
let live = true let live = true
@@ -243,16 +315,13 @@ function Panel() {
return h('div', { className: 'dla-root' }, children) return h('div', { className: 'dla-root' }, children)
} }
const copy = (url) => { const copy = (entry) => {
const done = () => { const input = inputRefs.current.get(entry.address)
setCopied(url) const settle = (status) => {
globalThis.setTimeout(() => setCopied(''), 1500) setCopyState({ url: entry.tokenUrl, status })
} globalThis.setTimeout(() => setCopyState({ url: '', status: 'idle' }), 2500)
if (globalThis.navigator?.clipboard?.writeText !== undefined) {
globalThis.navigator.clipboard.writeText(url).then(done, done)
} else {
done()
} }
copyText(entry.tokenUrl, input).then(settle, () => settle('manual'))
} }
const rows = lan.map((entry, index) => const rows = lan.map((entry, index) =>
@@ -268,7 +337,23 @@ function Panel() {
'div', 'div',
{ className: 'dla-row-main' }, { className: 'dla-row-main' },
h('div', { className: 'dla-ip' }, entry.address), h('div', { className: 'dla-ip' }, entry.address),
h('div', { className: 'dla-url', title: entry.tokenUrl }, entry.tokenUrl), h('input', {
className: 'dla-url',
type: 'text',
readOnly: true,
spellCheck: false,
value: entry.tokenUrl,
title: entry.tokenUrl,
ref: (element) => {
if (element === null) inputRefs.current.delete(entry.address)
else inputRefs.current.set(entry.address, element)
},
onFocus: (event) => {
setSelected(index)
event.currentTarget.select()
},
onClick: (event) => event.stopPropagation(),
}),
), ),
h( h(
'button', 'button',
@@ -277,10 +362,14 @@ function Panel() {
className: 'dla-btn', className: 'dla-btn',
onClick: (event) => { onClick: (event) => {
event.stopPropagation() event.stopPropagation()
copy(entry.tokenUrl) copy(entry)
}, },
}, },
copied === entry.tokenUrl ? t('copied') : t('copy'), copyState.url !== entry.tokenUrl
? t('copy')
: copyState.status === 'copied'
? t('copied')
: t('selected'),
), ),
h( h(
'a', 'a',
@@ -307,6 +396,7 @@ function Panel() {
h('div', { className: 'dla-section' }, t('addresses')), h('div', { className: 'dla-section' }, t('addresses')),
...rows, ...rows,
h('div', { className: 'dla-hint' }, t('scanHint')), h('div', { className: 'dla-hint' }, t('scanHint')),
copyState.status === 'manual' ? h('div', { className: 'dla-warn' }, t('manualHint')) : null,
), ),
symbol === undefined ? null : qrElement(React, symbol, t('qr', { address: active.address }), active.address), symbol === undefined ? null : qrElement(React, symbol, t('qr', { address: active.address }), active.address),
), ),