Fix copy buttons doing nothing over plain HTTP

navigator.clipboard is undefined in non-secure contexts, so the direct
writeText() call threw synchronously and the execCommand fallback in its
.catch() never ran. _copyText() now feature-detects the API, falls back
to execCommand('copy'), then to a prompt() for manual copying. log.js
reuses the helper; _getShareBaseUrl() caches the LAN-IP lookup so token
Copy buttons stay within the click gesture execCommand requires.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
StyxX65 2026-06-10 15:09:34 +02:00
parent 652031b31d
commit 35e767b506
4 changed files with 36 additions and 15 deletions

View File

@ -9,6 +9,10 @@ Version numbers follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html
## [Unreleased] ## [Unreleased]
### Fixed
- **Copy buttons did nothing over plain HTTP** — the share modal's "Copy" buttons (new link + active links) and the log panel's copy button called `navigator.clipboard.writeText()` directly. The Clipboard API only exists in secure contexts (HTTPS or localhost), so when the scanner is reached at `http://<LAN-IP>:5100` the call threw synchronously and the intended `execCommand` fallback never ran — the button silently did nothing. `_copyText()` in `viewer.js` now feature-detects the API, falls back to `document.execCommand('copy')`, and as a last resort shows the link in a `prompt()` for manual copying; `log.js` reuses the same helper via `window._copyText`. `_getShareBaseUrl()` now caches the LAN-IP lookup so the token-list Copy buttons copy synchronously within the click gesture (required for `execCommand`).
--- ---
## [1.7.1] — 2026-06-10 ## [1.7.1] — 2026-06-10

View File

@ -70,6 +70,8 @@ Never revert to `!!window._googleConnected` / `_fileSources.length > 0` — thos
## Gotchas ## Gotchas
- **`navigator.clipboard` is `undefined` over plain HTTP** — the app is normally reached at `http://<LAN-IP>:5100`, a non-secure context where the Clipboard API does not exist, so calling `navigator.clipboard.writeText(...)` throws synchronously (a `.catch()` on it never runs). Always copy via `window._copyText(text, btn)` (defined in `viewer.js`) — it feature-detects the API and falls back to `document.execCommand('copy')`, then to a `prompt()`. Because `execCommand` needs a user gesture, don't `await` network calls between the click and the copy; `_getShareBaseUrl()` caches its result for this reason.
- **`scheduler.js` strings must use `t()`** — frequency labels, "Next", "Running...", "Disabled", empty-job text, and empty-history text all have translation keys. Do not hard-code English strings in `schedLoad()` or `schedRenderJobs()`. - **`scheduler.js` strings must use `t()`** — frequency labels, "Next", "Running...", "Disabled", empty-job text, and empty-history text all have translation keys. Do not hard-code English strings in `schedLoad()` or `schedRenderJobs()`.
- **Scheduler UI — `schedToggleReportOnly()`** — dims the Profile row, shows/hides `#schedReportOnlyHint`, and forces `#schedAutoEmail` checked. Called from the checkbox `onchange` handler and at the start of `schedAddJob()` / `schedEditJob()`. - **Scheduler UI — `schedToggleReportOnly()`** — dims the Profile row, shows/hides `#schedReportOnlyHint`, and forces `#schedAutoEmail` checked. Called from the checkbox `onchange` handler and at the start of `schedAddJob()` / `schedEditJob()`.
- **Profile editor accounts** — default to unchecked. Only explicitly saved `user_ids` are checked. - **Profile editor accounts** — default to unchecked. Only explicitly saved `user_ids` are checked.

View File

@ -161,10 +161,9 @@ function copyLog() {
document.querySelectorAll('#logPanel .log-line:not(#logLive)').forEach(function(d) { document.querySelectorAll('#logPanel .log-line:not(#logLive)').forEach(function(d) {
lines.push(d.textContent); lines.push(d.textContent);
}); });
navigator.clipboard.writeText(lines.join('\n')).then(function() { const btn = document.querySelector('.log-copy-btn');
const btn = document.querySelector('.log-copy-btn'); // _copyText (viewer.js) handles HTTP contexts where navigator.clipboard is undefined.
if (btn) { btn.textContent = '✓ Copied'; setTimeout(function(){ btn.textContent = '⎘ Copy'; }, 1500); } if (btn) window._copyText(lines.join('\n'), btn);
}).catch(function() {});
} }
function _restoreLog() { function _restoreLog() {

View File

@ -2,18 +2,23 @@
// Share button → modal to create, copy, and revoke read-only viewer links. // Share button → modal to create, copy, and revoke read-only viewer links.
import { S } from './state.js'; import { S } from './state.js';
let _shareBaseUrl = null; // cached so Copy buttons can build the URL synchronously
async function _getShareBaseUrl() { async function _getShareBaseUrl() {
// Use the machine's LAN IP so links work for remote users, not just localhost. // Use the machine's LAN IP so links work for remote users, not just localhost.
if (_shareBaseUrl) return _shareBaseUrl;
try { try {
const r = await fetch('/api/local_ip'); const r = await fetch('/api/local_ip');
if (r.ok) { if (r.ok) {
const d = await r.json(); const d = await r.json();
if (d.ip && d.ip !== '127.0.0.1') { if (d.ip && d.ip !== '127.0.0.1') {
return 'http://' + d.ip + ':' + window.location.port; _shareBaseUrl = 'http://' + d.ip + ':' + window.location.port;
return _shareBaseUrl;
} }
} }
} catch(e) {} } catch(e) {}
return window.location.origin; _shareBaseUrl = window.location.origin;
return _shareBaseUrl;
} }
// ── User autocomplete for Share modal ──────────────────────────────────────── // ── User autocomplete for Share modal ────────────────────────────────────────
@ -269,25 +274,35 @@ async function copyTokenLink(token, btn) {
} }
function _copyText(text, btn) { function _copyText(text, btn) {
navigator.clipboard.writeText(text).then(() => { const done = () => {
const orig = btn.textContent; const orig = btn.textContent;
btn.textContent = t('share_copied', 'Copied!'); btn.textContent = t('share_copied', 'Copied!');
setTimeout(() => { btn.textContent = orig; }, 1800); setTimeout(() => { btn.textContent = orig; }, 1800);
}).catch(() => { };
// Fallback for HTTP contexts // Fallback for HTTP contexts, where navigator.clipboard is undefined
// (the Clipboard API only exists in secure contexts — HTTPS or localhost).
const fallback = () => {
let ok = false;
try { try {
const ta = document.createElement('textarea'); const ta = document.createElement('textarea');
ta.value = text; ta.value = text;
ta.style.position = 'fixed'; ta.style.opacity = '0'; ta.style.position = 'fixed'; ta.style.opacity = '0';
ta.setAttribute('readonly', '');
document.body.appendChild(ta); document.body.appendChild(ta);
ta.focus();
ta.select(); ta.select();
document.execCommand('copy'); ok = document.execCommand('copy');
document.body.removeChild(ta); document.body.removeChild(ta);
const orig = btn.textContent; } catch(_) { ok = false; }
btn.textContent = t('share_copied', 'Copied!'); if (ok) done();
setTimeout(() => { btn.textContent = orig; }, 1800); // Last resort: show the link in a prompt so it can be copied manually.
} catch(_) {} else prompt(t('share_copy_link_prompt', 'Copy link:'), text);
}); };
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(text).then(done).catch(fallback);
} else {
fallback();
}
} }
async function revokeToken(token, rowEl) { async function revokeToken(token, rowEl) {
@ -475,6 +490,7 @@ window.openShareModal = openShareModal;
window.closeShareModal = closeShareModal; window.closeShareModal = closeShareModal;
window.createShareLink = createShareLink; window.createShareLink = createShareLink;
window.copyShareLink = copyShareLink; window.copyShareLink = copyShareLink;
window._copyText = _copyText;
window.copyTokenLink = copyTokenLink; window.copyTokenLink = copyTokenLink;
window.revokeToken = revokeToken; window.revokeToken = revokeToken;
window.stLoadViewerPinStatus = stLoadViewerPinStatus; window.stLoadViewerPinStatus = stLoadViewerPinStatus;