From 42859309c3c1e83049ad8c66627d64860fd28a6f Mon Sep 17 00:00:00 2001 From: Kaxi <1042864399@qq.com> Date: Mon, 28 Sep 2026 23:17:12 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E5=A4=8D=E5=88=B6=E6=8C=89=E9=92=AE?= =?UTF-8?q?=E5=9C=A8=E5=B1=80=E5=9F=9F=E7=BD=91=E9=A1=B5=E9=9D=A2=E4=B8=8B?= =?UTF-8?q?=E9=9D=99=E9=BB=98=E5=A4=B1=E8=B4=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 明文 HTTP 的局域网页面不是安全上下文,navigator.clipboard 不存在, 旧代码落到 else 分支直接报告成功却什么都没复制;而且 .then(done, done) 连 Promise 被拒(权限拒绝)也当成功——两种情况都会谎报「已复制」。 现在: - 只在真的写入剪贴板后才报成功;clipboard API 被拒也继续回退 - 回退到 document.execCommand('copy')(明文 HTTP 下仍然可用) - 两者都不可用时选中链接并提示手动 Ctrl/⌘+C,不再谎报 - URL 改为可选中的只读输入框(原先是没有省略号就无法看全的 div) 新增 7 项回归测试覆盖:clipboard 缺失时确实走 execCommand、可用时走 API 且不重复复制、被拒时回退、两者都无时不抛异常。旧代码在该测试下失败。 README 中英双语排障表补充该现象与 HTTPS 说明。 --- README.en.md | 1 + README.md | 1 + client.js | 118 +++++++++++++++++++++++++++++++++++----- scripts/test-client.mjs | 80 +++++++++++++++++++++++++++ src/panel.js | 118 +++++++++++++++++++++++++++++++++++----- 5 files changed, 290 insertions(+), 28 deletions(-) diff --git a/README.en.md b/README.en.md index 17ed0de..5c9a81b 100644 --- a/README.en.md +++ b/README.en.md @@ -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. | | 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. | +| "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 diff --git a/README.md b/README.md index d621be2..99b6877 100644 --- a/README.md +++ b/README.md @@ -119,6 +119,7 @@ DSH 官方把「持久化设置」限制在回环页面:客户端由 `location | 重启后旧书签失效 | launch token 每次启动都会变。已登录过的设备靠 cookie 仍然有效;否则重新用新链接进入。 | | 面板显示"未找到非内部 IPv4 地址" | 这台机器当前没有可用的局域网 IPv4(只有回环)。 | | 扫码无反应 | 部分机型对深色背景上的二维码识别差;面板里的二维码固定渲染在白底卡片上。适当调高屏幕亮度。 | +| 点「复制」没反应 / 剪贴板里没内容 | 明文 HTTP 的局域网页面**不是安全上下文**,`navigator.clipboard` 在该页面不存在。插件会退回 `document.execCommand('copy')`;若浏览器两条路都禁止,就**选中链接并提示手动 Ctrl/⌘+C**,不再谎报「已复制」。想用正规剪贴板 API 需 HTTPS。 | ## 开发 diff --git a/client.js b/client.js index ef91fa8..e67cc68 100644 --- a/client.js +++ b/client.js @@ -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), ), diff --git a/scripts/test-client.mjs b/scripts/test-client.mjs index 47aa4b2..8a0a731 100644 --- a/scripts/test-client.mjs +++ b/scripts/test-client.mjs @@ -52,6 +52,7 @@ const React = { return [typeof initial === 'function' ? initial() : initial, () => {}] }, useEffect: () => {}, + useRef: (initial) => ({ current: initial }), } const requireStub = (name) => { 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 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 ─────────────────────────────────────── check('client fetches the host route', source.includes(SUMMARY_PATH), SUMMARY_PATH) diff --git a/src/panel.js b/src/panel.js index e9c2475..6fa49af 100644 --- a/src/panel.js +++ b/src/panel.js @@ -40,6 +40,8 @@ const zh = { scanHint: '用另一台设备扫描二维码即可打开(链接已带登录令牌)。', copy: '复制', copied: '已复制', + selected: '已选中', + manualHint: '此浏览器不允许页面自动写入剪贴板(明文 HTTP 下 clipboard API 不可用),已为你选中链接,请按 Ctrl/⌘+C 复制。', open: '打开', warning: '该链接携带 launch token,等同于完整 dsh 控制权(含终端命令执行)。请只在可信局域网内分享。', 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).', 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.', @@ -111,7 +115,8 @@ const CSS = ` .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); } @@ -123,6 +128,71 @@ const CSS = ` .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. @@ -171,7 +241,9 @@ function Panel() { 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 @@ -243,16 +315,13 @@ function Panel() { 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) => @@ -268,7 +337,23 @@ function Panel() { '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', @@ -277,10 +362,14 @@ function Panel() { 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', @@ -307,6 +396,7 @@ function Panel() { 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), ),