fix download from github on some networks

This commit is contained in:
Sarto
2026-05-03 17:36:25 +03:30
parent 0e8f6d7311
commit b472cda010
2 changed files with 122 additions and 144 deletions
+3 -3
View File
@@ -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
+119 -141
View File
@@ -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