From b472cda010620a06adaa53a6b4e3db63dede90af Mon Sep 17 00:00:00 2001 From: Sarto Date: Sun, 3 May 2026 17:36:25 +0330 Subject: [PATCH] fix download from github on some networks --- .github/workflows/build.yml | 6 +- internal/web/static/index.html | 260 +++++++++++++++------------------ 2 files changed, 122 insertions(+), 144 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index ab69830..057388a 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -143,10 +143,10 @@ jobs: - name: Compress with UPX if: matrix.goos == 'linux' || matrix.goos == 'windows' run: | - # -9 alone (no --lzma) is ~4× faster, only ~5% larger. - # xargs -P fans out across all CPUs. + # Keep best/lzma for small binaries; xargs -P does them + # in parallel across CPUs so wall time stays low. find build -maxdepth 1 -type f -print0 \ - | xargs -0 -n1 -P "$(nproc)" -I{} sh -c 'upx -9 "$1" || true' _ {} + | xargs -0 -n1 -P "$(nproc)" -I{} sh -c 'upx --best --lzma "$1" || true' _ {} - name: Upload artifacts uses: actions/upload-artifact@v4 diff --git a/internal/web/static/index.html b/internal/web/static/index.html index 460fe18..a736293 100644 --- a/internal/web/static/index.html +++ b/internal/web/static/index.html @@ -5585,7 +5585,11 @@ var ok = await showConfirmDialog(t('cancel_media_msg') || 'Cancel this download?', t('yes') || 'Yes', t('no') || 'No'); if (!ok) return; if (!mediaInflight[msgID]) return; - try { mediaInflight[msgID].xhr.abort(); } catch (e) { } + try { + var inf = mediaInflight[msgID]; + if (inf.ctrl) inf.ctrl.abort(); + else if (inf.xhr) inf.xhr.abort(); + } catch (e) { } delete mediaInflight[msgID]; mediaResetCard(card); return; @@ -5657,18 +5661,12 @@ var fname = card.getAttribute('data-fname') || ''; var ghAvail = card.getAttribute('data-gh') === '1'; var dnsAvail = card.getAttribute('data-dns') === '1'; - // forceSource is set when restartWith() retries after a fallback prompt - // — in that case the user has already consented and we skip the - // slow-only confirmation. var source = opts.forceSource || (ghAvail ? 'fast' : 'slow'); if (!opts.forceSource && source === 'slow' && dnsAvail) { var ok = await showConfirmDialog( t('media_slow_only') || 'GitHub relay is unavailable for this file. The DNS path is very slow. Download anyway?', t('yes') || 'Yes', t('no') || 'No'); - if (!ok) { - mediaPumpQueue(); - return; - } + if (!ok) { mediaPumpQueue(); return; } } var baseUrl = '/api/media/get?ch=' + encodeURIComponent(ch) + '&blk=' + encodeURIComponent(blk) @@ -5680,121 +5678,37 @@ var attempt = 0; debugLog('media: download msg=' + msgID + ' source=' + source + ' size=' + size); - // restartWith re-runs the download with a forced source. Pass it - // through so the prompt isn't shown again — user consent already - // happened in the fallback dialog. + var ctrl = new AbortController(); + var pollTimer = null; + var progressShownAt = 0; + var MIN_PROGRESS_VISIBLE_MS = 350; + + function stopPoll() { if (pollTimer) { clearInterval(pollTimer); pollTimer = null; } } + function finishSlot() { + mediaActiveCount = Math.max(0, mediaActiveCount - 1); + mediaPumpQueue(); + } function restartWith(newSource) { - try { stopPoll(); } catch (e) { } - try { xhr.abort(); } catch (e) { } + stopPoll(); + try { ctrl.abort(); } catch (e) { } delete mediaInflight[msgID]; mediaActiveCount = Math.max(0, mediaActiveCount - 1); delete mediaProgressState[msgID]; mediaRunDownload(domID, { forceSource: newSource }); } - - var xhr = new XMLHttpRequest(); - xhr.responseType = 'blob'; - xhr.open('GET', url); - xhr.onprogress = function (ev) { - var total = ev.total || parseInt(size, 10) || 0; - var loaded = ev.loaded || 0; - mediaUpdateProgress(card, loaded, total); - }; - var progressShownAt = 0; - var MIN_PROGRESS_VISIBLE_MS = 350; - async function deliverBlob() { - if (xhr.status >= 200 && xhr.status < 300) { - var expectedCRC = parseInt(crc, 16); - var expectedSize = parseInt(size, 10); - // Hash + size verification — required by spec for any relay. - if (xhr.response && !isNaN(expectedSize) && expectedSize > 0 && xhr.response.size !== expectedSize) { - await mediaForgetCache(card); - if (source === 'fast') { handleFastFailure(t('media_size_mismatch') || 'Size mismatch'); return; } - mediaShowError(card, t('media_size_mismatch') || 'Size mismatch'); - return; - } - if (!isNaN(expectedCRC) && expectedCRC > 0) { - try { - var got = await blobCRC32(xhr.response); - if (got !== expectedCRC) { - await mediaForgetCache(card); - if (source === 'fast') { handleFastFailure(t('media_hash_mismatch') || 'Content hash mismatch'); return; } - mediaShowError(card, t('media_hash_mismatch') || 'Content hash mismatch'); - return; - } - } catch (e) { } - } - var blobURL = URL.createObjectURL(xhr.response); - mediaBlobURLs[msgID] = blobURL; - mediaBlobs[msgID] = { blob: xhr.response, url: blobURL, mime: xhr.getResponseHeader('Content-Type') || '' }; - mediaShowBlob(card, blobURL); - mediaPersistBlob(msgID, card, xhr.response, mediaBlobs[msgID].mime); - } else { - // GitHub rate limit gets its own popup with the reset time so - // the user knows it's temporary and how long to wait. - if (source === 'fast' && xhr.status === 429) { - var resetMin = parseInt(xhr.getResponseHeader('X-Relay-Reset-Min') || '0', 10); - await handleRateLimit(resetMin); - return; - } - if (source === 'fast') { handleFastFailure(xhr.statusText || ('HTTP ' + xhr.status), xhr.status); return; } - mediaShowError(card, xhr.statusText || ('HTTP ' + xhr.status)); - } - } async function handleRateLimit(resetMin) { var minutes = (resetMin && resetMin > 0) ? resetMin : '?'; - var msg = (t('media_rate_limited') || 'GitHub rate limit hit. Reset in {n} min — using slow path.').replace('{n}', minutes); - showToast(msg); - // Always try DNS as the fallback — even if the manifest - // didn't advertise it (the file might still be reachable - // via DNS even when the flag says no). If DNS truly can't - // serve it, the slow path will surface its own error. + showToast((t('media_rate_limited') || 'GitHub rate limit hit. Reset in {n} min — using slow path.').replace('{n}', minutes)); restartWith('slow'); } - function finishSlot() { - mediaActiveCount = Math.max(0, mediaActiveCount - 1); - mediaPumpQueue(); - } - xhr.onload = function () { - delete mediaInflight[msgID]; - if (xhr.status >= 200 && xhr.status < 300) { - var totalSize = (xhr.response && xhr.response.size) ? xhr.response.size - : (parseInt(card.getAttribute('data-size'), 10) || 0); - if (totalSize > 0) mediaUpdateProgress(card, totalSize, totalSize); - debugLog('media: ok msg=' + msgID + ' source=' + source + ' served-by=' + (xhr.getResponseHeader('X-Cache') || '?')); - } - var elapsed = Date.now() - progressShownAt; - if (progressShownAt > 0 && elapsed < MIN_PROGRESS_VISIBLE_MS) { - setTimeout(function () { deliverBlob().finally(finishSlot); }, MIN_PROGRESS_VISIBLE_MS - elapsed); - } else { - deliverBlob().finally(finishSlot); - } - }; async function handleFastFailure(reason, status) { attempt++; - // 404 = not in repo, 429 = rate-limited. Both are terminal - // for the fast path — retrying GitHub won't help, and we - // don't want to burn the 60/h API budget. Skip retries and - // fall straight through to the DNS path if available. var skipRetry = (status === 404 || status === 429); if (!skipRetry && attempt < GH_MAX_RETRIES) { await new Promise(function (r) { setTimeout(r, GH_RETRY_DELAY_MS); }); - var retry = new XMLHttpRequest(); - retry.responseType = 'blob'; - retry.open('GET', url); - retry.onprogress = xhr.onprogress; - retry.onload = xhr.onload; - retry.onerror = xhr.onerror; - retry.onabort = xhr.onabort; - xhr = retry; - mediaInflight[msgID] = { xhr: xhr }; - xhr.send(); + runOnce(); return; } - // 404 / 429: auto-fallback to DNS without a prompt — the - // fast path is terminal here, asking "do you want slow?" - // adds friction. Try DNS regardless of dnsAvail flag, since - // the file might still come through that path. if (skipRetry && source === 'fast') { var note = (status === 404) ? (t('media_relay_404_fallback') || 'Not in fast relay yet — switching to DNS') @@ -5803,33 +5717,109 @@ restartWith('slow'); return; } - // Other failures — ask the user before going to the slow path. if (source === 'fast' && dnsAvail) { - var ok = await showConfirmDialog( + var ok2 = await showConfirmDialog( t('media_relay_fallback') || "Fast relay failed. Try the slow DNS path? Note: this can be very slow.", t('yes') || 'Yes', t('no') || 'No'); - if (ok) { - restartWith('slow'); - return; - } + if (ok2) { restartWith('slow'); return; } } mediaShowError(card, reason || (t('media_failed') || 'Download failed')); finishSlot(); } - xhr.onerror = function () { - delete mediaInflight[msgID]; - if (source === 'fast') { handleFastFailure('network error'); return; } - mediaShowError(card, 'network error'); - finishSlot(); - }; - xhr.onabort = function () { - delete mediaInflight[msgID]; - finishSlot(); - }; - // Reset progress state for this download — both byte and block - // counters live in mediaProgressState[msgID] and only ever go up. - // For the fast (relay) path the block counter is meaningless, so - // blocks=0 and the text omits the K/N suffix. + + async function deliverBlob(blob, headers) { + var expectedCRC = parseInt(crc, 16); + var expectedSize = parseInt(size, 10); + if (blob && !isNaN(expectedSize) && expectedSize > 0 && blob.size !== expectedSize) { + await mediaForgetCache(card); + if (source === 'fast') { handleFastFailure(t('media_size_mismatch') || 'Size mismatch'); return; } + mediaShowError(card, t('media_size_mismatch') || 'Size mismatch'); + return; + } + if (!isNaN(expectedCRC) && expectedCRC > 0) { + try { + var got = await blobCRC32(blob); + if (got !== expectedCRC) { + await mediaForgetCache(card); + if (source === 'fast') { handleFastFailure(t('media_hash_mismatch') || 'Content hash mismatch'); return; } + mediaShowError(card, t('media_hash_mismatch') || 'Content hash mismatch'); + return; + } + } catch (e) { } + } + var mime = (headers && headers.get('Content-Type')) || ''; + var blobURL = URL.createObjectURL(blob); + mediaBlobURLs[msgID] = blobURL; + mediaBlobs[msgID] = { blob: blob, url: blobURL, mime: mime }; + mediaShowBlob(card, blobURL); + mediaPersistBlob(msgID, card, blob, mime); + } + + // fetch + manual reader so we can finalise the moment we have + // Content-Length bytes — some censoring proxies hold the + // connection open after sending the body, which makes XHR / + // resp.blob() hang at 100 %. Cancelling the reader breaks us + // out of that wait. + async function runOnce() { + try { + var resp = await fetch(url, { signal: ctrl.signal, cache: 'no-store' }); + if (!resp.ok) { + stopPoll(); + delete mediaInflight[msgID]; + if (source === 'fast' && resp.status === 429) { + var resetMin = parseInt(resp.headers.get('X-Relay-Reset-Min') || '0', 10); + await handleRateLimit(resetMin); + return; + } + if (source === 'fast') { handleFastFailure(resp.statusText || ('HTTP ' + resp.status), resp.status); return; } + mediaShowError(card, resp.statusText || ('HTTP ' + resp.status)); + finishSlot(); + return; + } + var total = parseInt(resp.headers.get('Content-Length') || '0', 10) || (parseInt(size, 10) || 0); + var blob; + if (!resp.body || !resp.body.getReader) { + // Old WebView fallback — accept the original hang risk. + blob = await resp.blob(); + } else { + var reader = resp.body.getReader(); + var chunks = []; + var received = 0; + while (true) { + var step = await reader.read(); + if (step.done) break; + chunks.push(step.value); + received += step.value.byteLength; + mediaUpdateProgress(card, received, total); + if (total > 0 && received >= total) { + try { await reader.cancel(); } catch (e) { } + break; + } + } + blob = new Blob(chunks, { type: resp.headers.get('Content-Type') || 'application/octet-stream' }); + } + stopPoll(); + delete mediaInflight[msgID]; + var totalSize = blob.size || (parseInt(card.getAttribute('data-size'), 10) || 0); + if (totalSize > 0) mediaUpdateProgress(card, totalSize, totalSize); + debugLog('media: ok msg=' + msgID + ' source=' + source + ' served-by=' + (resp.headers.get('X-Cache') || '?')); + var elapsed = Date.now() - progressShownAt; + var run = function () { deliverBlob(blob, resp.headers).finally(finishSlot); }; + if (progressShownAt > 0 && elapsed < MIN_PROGRESS_VISIBLE_MS) { + setTimeout(run, MIN_PROGRESS_VISIBLE_MS - elapsed); + } else { + run(); + } + } catch (e) { + stopPoll(); + delete mediaInflight[msgID]; + if (e && e.name === 'AbortError') { finishSlot(); return; } + if (source === 'fast') { handleFastFailure((e && e.message) || 'network error'); return; } + mediaShowError(card, (e && e.message) || 'network error'); + finishSlot(); + } + } + mediaProgressState[msgID] = { loaded: 0, total: parseInt(size, 10) || 0, @@ -5837,35 +5827,23 @@ blocks: source === 'slow' ? (parseInt(blk, 10) || 0) : 0 }; - // Poll the server's per-download block counter so the user sees real - // block-level progress (the Go side fetches one DNS block at a time - // but io.Copy buffers those into bigger HTTP chunks, hence the - // "static then jumps" feel without polling). var pollUrl = '/api/media/progress?ch=' + encodeURIComponent(ch) + '&blk=' + encodeURIComponent(blk) + '&crc=' + encodeURIComponent(crc); - var pollTimer = setInterval(async function () { + pollTimer = setInterval(async function () { try { var pr = await fetch(pollUrl); if (!pr.ok) return; var pj = await pr.json(); - // Ignore inactive/zero responses — happens before the server - // registers the download or after it finishes. Don't let those - // overwrite the live counter and cause the UI to bounce back. if (pj.active === false) return; mediaApplyBlockProgress(card, pj.completed | 0, pj.total | 0); } catch (e) { } }, 500); - function stopPoll() { if (pollTimer) { clearInterval(pollTimer); pollTimer = null; } } - var origOnload = xhr.onload, origOnerror = xhr.onerror, origOnabort = xhr.onabort; - xhr.onload = function () { stopPoll(); origOnload && origOnload.apply(this, arguments); }; - xhr.onerror = function () { stopPoll(); origOnerror && origOnerror.apply(this, arguments); }; - xhr.onabort = function () { stopPoll(); origOnabort && origOnabort.apply(this, arguments); }; - mediaInflight[msgID] = { xhr: xhr }; + mediaInflight[msgID] = { ctrl: ctrl }; mediaShowProgress(card); progressShownAt = Date.now(); - requestAnimationFrame(function () { xhr.send(); }); + runOnce(); } // mediaProgressState is the per-download counter. Both xhr.onprogress