From 6932496bfcf3959de65c3e3fe630bb67ae4f2fae Mon Sep 17 00:00:00 2001 From: Sergey Antropoff Date: Thu, 23 Jul 2026 10:34:13 +0300 Subject: [PATCH] Fix lightbox QR download: sync canvas.toDataURL Async fetch/toBlob lost the user-gesture so Safari/Chrome opened the PNG instead of downloading. Use a button and sync data-URL save. --- .../templates/export/global-index.html.j2 | 119 +++++++++--------- .../hysteria2/templates/export/index.html.j2 | 117 +++++++++-------- 2 files changed, 117 insertions(+), 119 deletions(-) diff --git a/roles/hysteria2/templates/export/global-index.html.j2 b/roles/hysteria2/templates/export/global-index.html.j2 index dba17c9..d112506 100644 --- a/roles/hysteria2/templates/export/global-index.html.j2 +++ b/roles/hysteria2/templates/export/global-index.html.j2 @@ -516,8 +516,10 @@ background: var(--bg-input); color: var(--accent); text-decoration: none; + font: inherit; font-size: 0.85rem; font-weight: 650; + cursor: pointer; transition: color 0.15s, border-color 0.15s, background 0.15s; } .lightbox-download:hover { @@ -625,7 +627,7 @@
- {{ server.mode | default('salamander') | e }} + {{ server.mode | default('masquerade') | e }}
@@ -759,10 +761,10 @@
@@ -827,6 +829,16 @@ var downloadBtn = document.getElementById('lightbox-download'); var downloadSrc = ''; + function triggerDataUrlDownload(dataUrl, filename) { + var a = document.createElement('a'); + a.href = dataUrl; + a.download = filename; + a.rel = 'noopener'; + document.body.appendChild(a); + a.click(); + a.remove(); + } + function triggerFileDownload(blob, filename) { var url = URL.createObjectURL(blob); var a = document.createElement('a'); @@ -839,49 +851,36 @@ setTimeout(function () { URL.revokeObjectURL(url); }, 1500); } + function downloadQrFromCanvas(filename) { + if (!img.complete || !img.naturalWidth) return false; + try { + var canvas = document.createElement('canvas'); + canvas.width = img.naturalWidth; + canvas.height = img.naturalHeight; + canvas.getContext('2d').drawImage(img, 0, 0); + // toDataURL sync — download stays in the user-gesture (Safari/Chrome) + triggerDataUrlDownload(canvas.toDataURL('image/png'), filename); + return true; + } catch (err) { + return false; + } + } + function downloadQrImage(e) { e.preventDefault(); e.stopPropagation(); - var filename = downloadBtn.getAttribute('data-filename') || downloadBtn.getAttribute('download') || 'qr.png'; + var filename = downloadBtn.getAttribute('data-filename') || 'qr.png'; + if (downloadQrFromCanvas(filename)) return; + var src = downloadSrc || img.currentSrc || img.src; - - function fromCanvas() { - try { - if (!img.complete || !img.naturalWidth) return false; - var canvas = document.createElement('canvas'); - canvas.width = img.naturalWidth; - canvas.height = img.naturalHeight; - canvas.getContext('2d').drawImage(img, 0, 0); - canvas.toBlob(function (blob) { - if (blob) triggerFileDownload(blob, filename); - }, 'image/png'); - return true; - } catch (err) { - return false; - } - } - - if (src) { - fetch(src) - .then(function (res) { - if (!res.ok) throw new Error('fetch failed'); - return res.blob(); - }) - .then(function (blob) { triggerFileDownload(blob, filename); }) - .catch(function () { - if (!fromCanvas()) { - var a = document.createElement('a'); - a.href = src; - a.download = filename; - a.rel = 'noopener'; - document.body.appendChild(a); - a.click(); - a.remove(); - } - }); - } else { - fromCanvas(); - } + if (!src) return; + fetch(src) + .then(function (res) { + if (!res.ok) throw new Error('fetch failed'); + return res.blob(); + }) + .then(function (blob) { triggerFileDownload(blob, filename); }) + .catch(function () { /* avoid navigating to the image URL */ }); } function openLightbox(src, text, alt, filename) { @@ -890,8 +889,6 @@ img.alt = alt || text || 'QR'; caption.textContent = text || ''; downloadSrc = src; - downloadBtn.href = '#'; - downloadBtn.setAttribute('download', safeName); downloadBtn.setAttribute('data-filename', safeName); downloadBtn.setAttribute('aria-label', 'Скачать ' + safeName); box.classList.add('open'); @@ -938,6 +935,16 @@ })(); (function () { + function triggerDataUrlDownload(dataUrl, filename) { + var a = document.createElement('a'); + a.href = dataUrl; + a.download = filename; + a.rel = 'noopener'; + document.body.appendChild(a); + a.click(); + a.remove(); + } + function triggerFileDownload(blob, filename) { var url = URL.createObjectURL(blob); var a = document.createElement('a'); @@ -953,16 +960,13 @@ function pngFromCard(link, filename) { var card = link.closest('.user-card'); var qrImg = card && card.querySelector('img[data-lightbox-src], .qr-block img'); - if (!qrImg) return false; + if (!qrImg || !qrImg.complete || !qrImg.naturalWidth) return false; try { - if (!qrImg.complete || !qrImg.naturalWidth) return false; var canvas = document.createElement('canvas'); canvas.width = qrImg.naturalWidth; canvas.height = qrImg.naturalHeight; canvas.getContext('2d').drawImage(qrImg, 0, 0); - canvas.toBlob(function (blob) { - if (blob) triggerFileDownload(blob, filename); - }, 'image/png'); + triggerDataUrlDownload(canvas.toDataURL('image/png'), filename); return true; } catch (err) { return false; @@ -978,18 +982,13 @@ var fallbackText = link.getAttribute('data-text'); var isPng = /\.png$/i.test(filename); - function fallback() { - if (fallbackText !== null) { - triggerFileDownload(new Blob([fallbackText], { type: 'text/plain;charset=utf-8' }), filename); - return; - } - if (isPng) pngFromCard(link, filename); - } - - if (!href) { - fallback(); + // Sync paths first — async fetch loses the user-gesture in Safari/Chrome + if (isPng && pngFromCard(link, filename)) return; + if (fallbackText !== null) { + triggerFileDownload(new Blob([fallbackText], { type: 'text/plain;charset=utf-8' }), filename); return; } + if (!href) return; fetch(href) .then(function (res) { @@ -997,7 +996,7 @@ return res.blob(); }) .then(function (blob) { triggerFileDownload(blob, filename); }) - .catch(fallback); + .catch(function () { /* do not navigate to the file URL */ }); }); }); })(); diff --git a/roles/hysteria2/templates/export/index.html.j2 b/roles/hysteria2/templates/export/index.html.j2 index 70713c7..6834a34 100644 --- a/roles/hysteria2/templates/export/index.html.j2 +++ b/roles/hysteria2/templates/export/index.html.j2 @@ -333,8 +333,10 @@ background: var(--bg-input); color: var(--accent); text-decoration: none; + font: inherit; font-size: 0.85rem; font-weight: 650; + cursor: pointer; } .lightbox-download:hover { border-color: var(--accent); @@ -463,10 +465,10 @@ @@ -504,6 +506,16 @@ var downloadBtn = document.getElementById('lightbox-download'); var downloadSrc = ''; + function triggerDataUrlDownload(dataUrl, filename) { + var a = document.createElement('a'); + a.href = dataUrl; + a.download = filename; + a.rel = 'noopener'; + document.body.appendChild(a); + a.click(); + a.remove(); + } + function triggerFileDownload(blob, filename) { var url = URL.createObjectURL(blob); var a = document.createElement('a'); @@ -516,49 +528,36 @@ setTimeout(function () { URL.revokeObjectURL(url); }, 1500); } + function downloadQrFromCanvas(filename) { + if (!img.complete || !img.naturalWidth) return false; + try { + var canvas = document.createElement('canvas'); + canvas.width = img.naturalWidth; + canvas.height = img.naturalHeight; + canvas.getContext('2d').drawImage(img, 0, 0); + // toDataURL sync — download stays in the user-gesture (Safari/Chrome) + triggerDataUrlDownload(canvas.toDataURL('image/png'), filename); + return true; + } catch (err) { + return false; + } + } + function downloadQrImage(e) { e.preventDefault(); e.stopPropagation(); - var filename = downloadBtn.getAttribute('data-filename') || downloadBtn.getAttribute('download') || 'qr.png'; + var filename = downloadBtn.getAttribute('data-filename') || 'qr.png'; + if (downloadQrFromCanvas(filename)) return; + var src = downloadSrc || img.currentSrc || img.src; - - function fromCanvas() { - try { - if (!img.complete || !img.naturalWidth) return false; - var canvas = document.createElement('canvas'); - canvas.width = img.naturalWidth; - canvas.height = img.naturalHeight; - canvas.getContext('2d').drawImage(img, 0, 0); - canvas.toBlob(function (blob) { - if (blob) triggerFileDownload(blob, filename); - }, 'image/png'); - return true; - } catch (err) { - return false; - } - } - - if (src) { - fetch(src) - .then(function (res) { - if (!res.ok) throw new Error('fetch failed'); - return res.blob(); - }) - .then(function (blob) { triggerFileDownload(blob, filename); }) - .catch(function () { - if (!fromCanvas()) { - var a = document.createElement('a'); - a.href = src; - a.download = filename; - a.rel = 'noopener'; - document.body.appendChild(a); - a.click(); - a.remove(); - } - }); - } else { - fromCanvas(); - } + if (!src) return; + fetch(src) + .then(function (res) { + if (!res.ok) throw new Error('fetch failed'); + return res.blob(); + }) + .then(function (blob) { triggerFileDownload(blob, filename); }) + .catch(function () { /* avoid navigating to the image URL */ }); } function openLightbox(src, text, alt, filename) { @@ -567,8 +566,6 @@ img.alt = alt || text || 'QR'; caption.textContent = text || ''; downloadSrc = src; - downloadBtn.href = '#'; - downloadBtn.setAttribute('download', safeName); downloadBtn.setAttribute('data-filename', safeName); downloadBtn.setAttribute('aria-label', 'Скачать ' + safeName); box.classList.add('open'); @@ -615,6 +612,16 @@ })(); (function () { + function triggerDataUrlDownload(dataUrl, filename) { + var a = document.createElement('a'); + a.href = dataUrl; + a.download = filename; + a.rel = 'noopener'; + document.body.appendChild(a); + a.click(); + a.remove(); + } + function triggerFileDownload(blob, filename) { var url = URL.createObjectURL(blob); var a = document.createElement('a'); @@ -630,16 +637,13 @@ function pngFromCard(link, filename) { var card = link.closest('.user-card'); var qrImg = card && card.querySelector('img[data-lightbox-src], .qr-block img'); - if (!qrImg) return false; + if (!qrImg || !qrImg.complete || !qrImg.naturalWidth) return false; try { - if (!qrImg.complete || !qrImg.naturalWidth) return false; var canvas = document.createElement('canvas'); canvas.width = qrImg.naturalWidth; canvas.height = qrImg.naturalHeight; canvas.getContext('2d').drawImage(qrImg, 0, 0); - canvas.toBlob(function (blob) { - if (blob) triggerFileDownload(blob, filename); - }, 'image/png'); + triggerDataUrlDownload(canvas.toDataURL('image/png'), filename); return true; } catch (err) { return false; @@ -655,18 +659,13 @@ var fallbackText = link.getAttribute('data-text'); var isPng = /\.png$/i.test(filename); - function fallback() { - if (fallbackText !== null) { - triggerFileDownload(new Blob([fallbackText], { type: 'text/plain;charset=utf-8' }), filename); - return; - } - if (isPng) pngFromCard(link, filename); - } - - if (!href) { - fallback(); + // Sync paths first — async fetch loses the user-gesture in Safari/Chrome + if (isPng && pngFromCard(link, filename)) return; + if (fallbackText !== null) { + triggerFileDownload(new Blob([fallbackText], { type: 'text/plain;charset=utf-8' }), filename); return; } + if (!href) return; fetch(href) .then(function (res) { @@ -674,7 +673,7 @@ return res.blob(); }) .then(function (blob) { triggerFileDownload(blob, filename); }) - .catch(fallback); + .catch(function () { /* do not navigate to the file URL */ }); }); }); })();