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
+104 -14
View File
@@ -551,6 +551,8 @@ window.__ModuleLoader__.load({
scanHint: '用另一台设备扫描二维码即可打开(链接已带登录令牌)。',
copy: '复制',
copied: '已复制',
selected: '已选中',
manualHint: '此浏览器不允许页面自动写入剪贴板(明文 HTTP 下 clipboard API 不可用),已为你选中链接,请按 Ctrl/⌘+C 复制。',
open: '打开',
warning: '该链接携带 launch token,等同于完整 dsh 控制权(含终端命令执行)。请只在可信局域网内分享。',
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).',
copy: 'Copy',
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',
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.',
@@ -622,7 +626,8 @@ window.__ModuleLoader__.load({
.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-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: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); }
@@ -634,6 +639,71 @@ window.__ModuleLoader__.load({
.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.
* @param symbol - `{ size, modules }` from the encoder.
@@ -682,7 +752,9 @@ window.__ModuleLoader__.load({
const h = React.createElement
const [state, setState] = React.useState({ status: 'loading' })
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(() => {
let live = true
@@ -754,16 +826,13 @@ window.__ModuleLoader__.load({
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 copy = (entry) => {
const input = inputRefs.current.get(entry.address)
const settle = (status) => {
setCopyState({ url: entry.tokenUrl, status })
globalThis.setTimeout(() => setCopyState({ url: '', status: 'idle' }), 2500)
}
copyText(entry.tokenUrl, input).then(settle, () => settle('manual'))
}
const rows = lan.map((entry, index) =>
@@ -779,7 +848,23 @@ window.__ModuleLoader__.load({
'div',
{ className: 'dla-row-main' },
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(
'button',
@@ -788,10 +873,14 @@ window.__ModuleLoader__.load({
className: 'dla-btn',
onClick: (event) => {
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(
'a',
@@ -818,6 +907,7 @@ window.__ModuleLoader__.load({
h('div', { className: 'dla-section' }, t('addresses')),
...rows,
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),
),