feat: improve live update and upload feedback
CI / verify (push) Has been cancelled

Refresh release metadata immediately before downloading an update and keep automatic checks current while the app remains open.

Accept desktop file drops from the first renderer frame, preserve early and rapid drops until initialization completes, and show the live remaining upload size in the sidebar.

Add regression coverage for update freshness, startup drag-and-drop ordering, localization, and remaining-byte retry behavior. Bump the application to v2.1.25.
This commit is contained in:
Sucukdeluxe
2026-08-20 10:40:58 +02:00
parent d7c9f287e4
commit c4c7907686
16 changed files with 351 additions and 82 deletions
+2 -1
View File
@@ -8,7 +8,7 @@ Multi-Hoster-Upload is a Windows desktop application for sending file batches to
Download the current Setup or Portable build from the [latest GitHub release](https://github.com/Sucukdeluxe/Multi-Hoster-Upload/releases/latest).
The latest public release is version 2.1.24. Use the release page for the executables and the full English changelog.
The latest public release is version 2.1.25. Use the release page for the executables and the full English changelog.
## Features
@@ -22,6 +22,7 @@ The latest public release is version 2.1.24. Use the release page for the execut
- Search and filter queue entries by file name, host, and status.
- Open per-upload diagnostics with the selected account, retry count, and safe error details.
- Track status, smoothly interpolated progress, transferred size, speed, and the selected host account.
- Track the remaining upload size as queued and active files progress or return for a retry.
- Read total, remaining, running, completed, and failed upload activity from the persistent sidebar telemetry.
- Follow current upload speed in the sidebar and the synchronized header graph.
- Reorder selected jobs, start selected jobs, retry finished jobs, or stop active work.
+22 -1
View File
@@ -16,7 +16,28 @@
};
}
const api = { createSerializedRunner };
function createReadySerializedRunner(task) {
let ready = false;
let releaseReady;
const readyPromise = new Promise((resolve) => { releaseReady = resolve; });
const runner = createSerializedRunner(async (...args) => {
await readyPromise;
return task(...args);
});
return {
run: (...args) => runner.run(...args),
flush: () => runner.flush(),
ready() {
if (ready) return false;
ready = true;
releaseReady();
return true;
},
get isReady() { return ready; }
};
}
const api = { createSerializedRunner, createReadySerializedRunner };
if (typeof module !== 'undefined' && module.exports) module.exports = api;
else if (root) root.SerializedRunner = api;
})(typeof window !== 'undefined' ? window : this);
+48 -12
View File
@@ -17,6 +17,27 @@ const CACHE_TTL = 10 * 60 * 1000; // 10 min
let activeAbort = null;
const launchedInstallerPaths = new Set();
function createUpdateAnnouncementState() {
let announcedVersion = '';
function versionOf(update) {
return String(update?.remoteVersion || '').replace(/^v/i, '').trim();
}
return Object.freeze({
canAnnounce(update, rendererReady) {
const version = versionOf(update);
return Boolean(rendererReady && update?.available && version && version !== announcedVersion);
},
markAnnounced(update) {
announcedVersion = versionOf(update);
},
reset() {
announcedVersion = '';
}
});
}
function getCurrentVersion() {
return app.getVersion();
}
@@ -61,17 +82,32 @@ function findLatestYml(assets) {
return assets.find(a => /^latest\.yml$/i.test(a.name)) || null;
}
async function fetchJson(url, signal) {
function cacheBustedUrl(url) {
const parsed = new URL(url);
parsed.searchParams.set('_mhu_update', crypto.randomUUID());
return parsed.toString();
}
async function fetchJson(url, signal, options = {}) {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), CHECK_TIMEOUT);
const onAbort = () => controller.abort();
const forceRefresh = options.forceRefresh === true;
const fetchImpl = options.fetchImpl || fetch;
if (signal) signal.addEventListener('abort', onAbort);
try {
const res = await fetch(url, {
const res = await fetchImpl(forceRefresh ? cacheBustedUrl(url) : url, {
method: 'GET',
signal: controller.signal,
redirect: 'follow'
redirect: 'follow',
...(forceRefresh ? {
cache: 'no-store',
headers: {
'Cache-Control': 'no-cache, no-store',
Pragma: 'no-cache'
}
} : {})
});
const text = await res.text();
try {
@@ -112,13 +148,14 @@ async function fetchGithubReleaseNotes(remoteVersion, fallback = '', fetchImpl =
}
}
async function checkForUpdate() {
// Return cached result if fresh
if (cachedCheck && (Date.now() - cachedCheckTs) < CACHE_TTL) {
async function checkForUpdate(options = {}) {
const forceRefresh = options.forceRefresh === true;
const fetchImpl = options.fetchImpl || fetch;
if (!forceRefresh && cachedCheck && (Date.now() - cachedCheckTs) < CACHE_TTL) {
return cachedCheck;
}
const releases = await fetchJson(API_URL);
const releases = await fetchJson(API_URL, undefined, { forceRefresh, fetchImpl });
if (!Array.isArray(releases) || releases.length === 0) {
return { available: false };
@@ -142,7 +179,7 @@ async function checkForUpdate() {
return { available: false, reason: 'Kein Setup-Asset im Release gefunden' };
}
const releaseNotes = await fetchGithubReleaseNotes(remoteVersion, release.body || '');
const releaseNotes = await fetchGithubReleaseNotes(remoteVersion, release.body || '', fetchImpl);
cachedCheck = {
available: true,
currentVersion,
@@ -210,10 +247,9 @@ async function prepareUpdate(onProgress, options = {}) {
// Stage: starting
if (onProgress) onProgress({ stage: 'starting', percent: 0 });
// Check or use cached
let check = options.checkResult || cachedCheck;
let check = options.checkResult || null;
if (!check || !check.available) {
check = await checkForUpdate();
check = await checkForUpdate({ forceRefresh: true, fetchImpl });
}
if (!check || !check.available) {
throw new Error('Kein Update verfügbar');
@@ -353,4 +389,4 @@ function abortUpdate() {
}
}
module.exports = { checkForUpdate, fetchGithubReleaseNotes, prepareUpdate, launchPreparedUpdate, abortUpdate, isNewer, resolveReleaseVersion, pickSetupAsset, parseLatestYml };
module.exports = { checkForUpdate, fetchGithubReleaseNotes, prepareUpdate, launchPreparedUpdate, abortUpdate, isNewer, resolveReleaseVersion, pickSetupAsset, parseLatestYml, createUpdateAnnouncementState };
+36 -18
View File
@@ -17,7 +17,7 @@ const DoodstreamUploader = require('./lib/doodstream-upload');
const { selectUploadAuth } = require('./lib/account-auth');
const { createAccountPicker } = require('./lib/account-rotation');
const ClouddropUploader = require('./lib/clouddrop-upload');
const { checkForUpdate, prepareUpdate, launchPreparedUpdate, abortUpdate } = require('./lib/updater');
const { checkForUpdate, prepareUpdate, launchPreparedUpdate, abortUpdate, createUpdateAnnouncementState } = require('./lib/updater');
const backupCrypto = require('./lib/backup-crypto');
const { createOnlineBackup, downloadOnlineBackup, uploadOnlineBackup } = require('./lib/online-backup');
const { createPortableSettingsSnapshot, prepareImportedSettings } = require('./lib/settings-backup');
@@ -113,6 +113,8 @@ let preparedUpdate = null;
let updatePreparationPromise = null;
let updateQuitPending = false;
let preparedUpdateLaunchStarted = false;
let updateCheckInterval = null;
const updateAnnouncementState = createUpdateAnnouncementState();
let _lastImportPath = null;
let dropTargetWindow = null;
let tray = null;
@@ -1458,6 +1460,7 @@ function createWindow() {
mainWindow.webContents.on('did-start-navigation', (_event, _url, isInPlace, isMainFrame) => {
if (isInPlace || !isMainFrame) return;
closeHandshakeReady = false;
updateAnnouncementState.reset();
restoreClosePreparation(closePreparationAttempt);
});
@@ -1543,6 +1546,25 @@ function updateTrayTooltip(text) {
if (tray && !tray.isDestroyed()) tray.setToolTip(text);
}
function announceAvailableUpdate(result) {
if (!updateAnnouncementState.canAnnounce(result, closeHandshakeReady)) return false;
if (!safeSend('app:update-available', result)) return false;
updateAnnouncementState.markAnnounced(result);
return true;
}
async function runAutomaticUpdateCheck(forceRefresh) {
try {
logInfo('update-check: starting');
const result = await checkForUpdate({ forceRefresh });
logInfo(`update-check: available=${result && result.available}, remote=${result && result.remoteVersion}`);
logDebug(`update-check result: ${JSON.stringify(result)}`);
announceAvailableUpdate(result);
} catch (err) {
logError('update-check failed', err);
}
}
app.whenReady().then(async () => {
if (!_hasSingleInstanceLock) return;
try {
@@ -1630,20 +1652,9 @@ app.whenReady().then(async () => {
}
} catch {}
// Auto-check for updates after 3 seconds
setTimeout(async () => {
try {
logInfo('update-check: starting');
const result = await checkForUpdate();
logInfo(`update-check: available=${result && result.available}, remote=${result && result.remoteVersion}`);
logDebug(`update-check result: ${JSON.stringify(result)}`);
if (result && result.available && mainWindow && !mainWindow.isDestroyed()) {
safeSend('app:update-available', result);
}
} catch (err) {
logError('update-check failed', err);
}
}, 3000);
setTimeout(() => { void runAutomaticUpdateCheck(true); }, 3000);
updateCheckInterval = setInterval(() => { void runAutomaticUpdateCheck(true); }, 5 * 60 * 1000);
updateCheckInterval.unref?.();
});
app.on('window-all-closed', () => {
@@ -1663,6 +1674,8 @@ app.on('before-quit', (event) => {
app.on('will-quit', () => {
if (quitTeardownStarted) return;
quitTeardownStarted = true;
if (updateCheckInterval) clearInterval(updateCheckInterval);
updateCheckInterval = null;
if (preparedUpdate && updateQuitPending && closeFlushApproved && !preparedUpdateLaunchStarted) {
preparedUpdateLaunchStarted = true;
try {
@@ -2856,9 +2869,9 @@ ipcMain.handle('copy-to-clipboard', (_event, text) => {
return true;
});
ipcMain.handle('app:check-updates', async () => {
ipcMain.handle('app:check-updates', async (_event, options) => {
try {
return await checkForUpdate();
return await checkForUpdate({ forceRefresh: options && options.forceRefresh === true });
} catch (err) {
return { available: false, error: err.message };
}
@@ -2912,7 +2925,12 @@ ipcMain.handle('app:quit', () => {
});
ipcMain.on('app:close-handshake-ready', (event) => {
if (mainWindow && !mainWindow.isDestroyed() && event.sender === mainWindow.webContents) closeHandshakeReady = true;
if (mainWindow && !mainWindow.isDestroyed() && event.sender === mainWindow.webContents) {
closeHandshakeReady = true;
void checkForUpdate().then(announceAvailableUpdate).catch((error) => {
logError('update-check failed', error);
});
}
});
ipcMain.on('app:close-preparation-started', (event, attempt) => {
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "multi-hoster-uploader",
"version": "2.1.24",
"version": "2.1.25",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "multi-hoster-uploader",
"version": "2.1.24",
"version": "2.1.25",
"dependencies": {
"chokidar": "^3.6.0",
"undici": "^7.29.0",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "multi-hoster-uploader",
"version": "2.1.24",
"version": "2.1.25",
"description": "Upload files to doodstream, voe, vidmoly, byse simultaneously",
"main": "main.js",
"scripts": {
+1 -1
View File
@@ -63,7 +63,7 @@ contextBridge.exposeInMainWorld('api', {
copyToClipboard: (text) => ipcRenderer.invoke('copy-to-clipboard', text),
// Updates
checkForUpdate: () => ipcRenderer.invoke('app:check-updates'),
checkForUpdate: (options = {}) => ipcRenderer.invoke('app:check-updates', { forceRefresh: options && options.forceRefresh === true }),
installUpdate: () => ipcRenderer.invoke('app:install-update'),
abortUpdate: () => ipcRenderer.invoke('app:abort-update'),
getVersion: () => ipcRenderer.invoke('app:get-version'),
+60 -40
View File
@@ -416,7 +416,7 @@ async function init() {
renderSettings();
renderAccounts();
setupListeners();
setupDragDrop();
importEntryCoordinator.ready();
initUploadSpeedSparkline();
restoreQueueColumnWidths();
loadHistory();
@@ -826,8 +826,8 @@ function _syncHeaderUpdateState() {
}
}
async function requestUpdateCheck() {
if (_knownUpdateInfo && _knownUpdateInfo.available) {
async function requestUpdateCheck({ forceRefresh = true } = {}) {
if (!forceRefresh && _knownUpdateInfo && _knownUpdateInfo.available) {
showUpdateBanner(_knownUpdateInfo);
return _knownUpdateInfo;
}
@@ -836,7 +836,7 @@ async function requestUpdateCheck() {
_syncHeaderUpdateState();
showCopyToast('Suche nach Updates…');
try {
const result = await window.api.checkForUpdate();
const result = await window.api.checkForUpdate({ forceRefresh });
if (result && result.available) {
showUpdateBanner(result);
showCopyToast('Update gefunden!');
@@ -1368,17 +1368,24 @@ function clearPersistedQueueStateSoon() {
}
// --- File selection ---
let dragDropBound = false;
function setupDragDrop() {
if (dragDropBound) return;
dragDropBound = true;
const dropZone = document.getElementById('dropZone');
// Allow drop on the entire upload view
const uploadView = document.getElementById('upload-view');
let _dragCounter = 0;
dropZone.addEventListener('dragover', (e) => { e.preventDefault(); e.stopPropagation(); });
dropZone.addEventListener('dragenter', (e) => { e.preventDefault(); _dragCounter++; dropZone.classList.add('drag-over'); });
dropZone.addEventListener('dragleave', (e) => { e.preventDefault(); _dragCounter--; if (_dragCounter <= 0) { _dragCounter = 0; dropZone.classList.remove('drag-over'); } });
const acceptCopy = (event) => {
event.preventDefault();
event.stopPropagation();
if (event.dataTransfer) event.dataTransfer.dropEffect = 'copy';
};
dropZone.addEventListener('dragover', acceptCopy);
dropZone.addEventListener('dragenter', (e) => { acceptCopy(e); _dragCounter++; dropZone.classList.add('drag-over'); });
dropZone.addEventListener('dragleave', (e) => { e.preventDefault(); e.stopPropagation(); _dragCounter--; if (_dragCounter <= 0) { _dragCounter = 0; dropZone.classList.remove('drag-over'); } });
dropZone.addEventListener('drop', (e) => {
e.preventDefault(); e.stopPropagation(); _dragCounter = 0; dropZone.classList.remove('drag-over');
addDroppedFiles(e.dataTransfer.files).catch(console.error);
enqueueDroppedFiles(e.dataTransfer?.files).catch(console.error);
});
dropZone.addEventListener('click', () => pickFiles());
dropZone.addEventListener('keydown', (event) => {
@@ -1387,12 +1394,12 @@ function setupDragDrop() {
pickFiles();
});
// Also handle drops on queue container
uploadView.addEventListener('dragover', (e) => { e.preventDefault(); });
uploadView.addEventListener('dragover', acceptCopy);
uploadView.addEventListener('dragenter', acceptCopy);
uploadView.addEventListener('drop', (e) => {
e.preventDefault();
if (e.target.closest('.drop-zone')) return; // handled above
addDroppedFiles(e.dataTransfer.files).catch(console.error);
e.preventDefault(); e.stopPropagation();
if (e.target.closest('.drop-zone')) return;
enqueueDroppedFiles(e.dataTransfer?.files).catch(console.error);
});
}
@@ -1401,7 +1408,24 @@ let _pendingImportInspection = null;
let _importCoordination = Promise.resolve();
let _importGeneration = 0;
let _pendingImportInspections = 0;
let _addingDropped = false;
const importEntryCoordinator = window.SerializedRunner.createReadySerializedRunner(async (kind, payload) => {
if (kind === 'drop') return processDroppedFiles(payload);
return addPathsToQueue(payload);
});
function enqueueDroppedFiles(fileList) {
const files = Array.from(fileList || []);
if (files.length === 0) return Promise.resolve(null);
return importEntryCoordinator.run('drop', files);
}
function enqueueImportEntries(entries) {
const snapshot = Array.from(entries || [], entry => (
entry && typeof entry === 'object' ? { ...entry } : entry
));
if (snapshot.length === 0) return Promise.resolve(null);
return importEntryCoordinator.run('entries', snapshot);
}
function existingImportPaths() {
return [...selectedFiles.map(file => file.path), ..._pendingFiles.map(file => file.path), ...queueJobs.map(job => job.file)];
@@ -1522,44 +1546,38 @@ function coordinateImportEntries(entries) {
return pending;
}
async function addDroppedFiles(fileList) {
if (_addingDropped) return;
_addingDropped = true;
try {
const entries = [];
for (const file of Array.from(fileList)) {
let filePath = '';
try { filePath = window.api.getPathForFile(file); } catch { filePath = file.path || ''; }
if (!filePath) continue;
if (file.type === '' && file.size === 0) {
try {
const folderFiles = await window.api.resolveFolderFiles(filePath);
if (folderFiles && folderFiles.length > 0) {
entries.push(...folderFiles);
continue;
}
} catch {}
}
entries.push({ path: filePath, name: file.name || '', size: file.size });
async function processDroppedFiles(files) {
const entries = [];
for (const file of files) {
let filePath = '';
try { filePath = window.api.getPathForFile(file); } catch { filePath = file.path || ''; }
if (!filePath) continue;
if (file.type === '' && file.size === 0) {
try {
const folderFiles = await window.api.resolveFolderFiles(filePath);
if (folderFiles && folderFiles.length > 0) {
entries.push(...folderFiles);
continue;
}
} catch {}
}
await addPathsToQueue(entries);
} finally {
_addingDropped = false;
entries.push({ path: filePath, name: file.name || '', size: file.size });
}
return addPathsToQueue(entries);
}
async function pickFiles() {
const paths = await window.api.selectFiles();
if (!paths) return;
await addPathsToQueue(paths);
await enqueueImportEntries(paths);
}
async function pickFolder() {
const richFiles = window.api.selectFolderWithSizes ? await window.api.selectFolderWithSizes() : null;
if (richFiles && Array.isArray(richFiles)) return addPathsToQueue(richFiles);
if (richFiles && Array.isArray(richFiles)) return enqueueImportEntries(richFiles);
const paths = await window.api.selectFolder();
if (!paths) return;
return addPathsToQueue(paths);
return enqueueImportEntries(paths);
}
function addPathsToQueue(paths) {
@@ -4187,6 +4205,7 @@ function updateStatusBar() {
_setRollingUploadMetric('uploadTelemetryTotal', stats.total);
_setRollingUploadMetric('uploadTelemetryConnections', lastUploadStats.activeJobs || 0);
_setRollingUploadMetric('uploadTelemetryRemaining', stats.remaining);
_setUploadTelemetryText('uploadTelemetryRemainingSize', formatBytes(stats.bytesRemaining));
_setRollingUploadMetric('uploadTelemetryRunning', stats.inProgress);
_setRollingUploadMetric('uploadTelemetryCompleted', _sessionDoneCount);
_setRollingUploadMetric('uploadTelemetryFailed', Math.max(_sessionErrorCount, stats.errors));
@@ -8097,6 +8116,7 @@ function updateStatsPanel() {
window.api.onUpdateAvailable(showUpdateBanner);
window.api.onUpdateProgress(handleUpdateProgress);
window.api.onPrepareClose(prepareForWindowClose);
setupDragDrop();
init().then(() => {
window.api.signalCloseHandshakeReady();
}).catch((err) => {
+1
View File
@@ -82,6 +82,7 @@
['Aktuelle Upload-Geschwindigkeit', 'Current upload speed'],
['Gesamt', 'Total'],
['Verbindungen', 'Connections'],
['Verbleibende Größe', 'Remaining size'],
['Verbleibend', 'Remaining'],
['Läuft', 'Running'],
['Suche Aktualisierungen', 'Check for updates'],
+1
View File
@@ -212,6 +212,7 @@
<div class="upload-telemetry-row"><span class="upload-telemetry-label">Gesamt</span><strong class="upload-telemetry-value upload-rolling-value" id="uploadTelemetryTotal" data-numeric-value="0" aria-label="0"><span>0</span></strong></div>
<div class="upload-telemetry-row"><span class="upload-telemetry-label">Verbindungen</span><strong class="upload-telemetry-value upload-rolling-value" id="uploadTelemetryConnections" data-numeric-value="0" aria-label="0"><span>0</span></strong></div>
<div class="upload-telemetry-row"><span class="upload-telemetry-label">Verbleibend</span><strong class="upload-telemetry-value upload-rolling-value" id="uploadTelemetryRemaining" data-numeric-value="0" aria-label="0"><span>0</span></strong></div>
<div class="upload-telemetry-row"><span class="upload-telemetry-label">Verbleibende Größe</span><strong class="upload-telemetry-value" id="uploadTelemetryRemainingSize" aria-label="0 B">0 B</strong></div>
<div class="upload-telemetry-row"><span class="upload-telemetry-label">Läuft</span><strong class="upload-telemetry-value upload-rolling-value" id="uploadTelemetryRunning" data-numeric-value="0" aria-label="0"><span>0</span></strong></div>
<div class="upload-telemetry-row"><span class="upload-telemetry-label">Fertig</span><strong class="upload-telemetry-value upload-rolling-value" id="uploadTelemetryCompleted" data-numeric-value="0" aria-label="0"><span>0</span></strong></div>
<div class="upload-telemetry-row"><span class="upload-telemetry-label">Fehler</span><strong class="upload-telemetry-value upload-rolling-value" id="uploadTelemetryFailed" data-numeric-value="0" aria-label="0"><span>0</span></strong></div>
+5
View File
@@ -11,6 +11,11 @@ test('translates permanent source deletion controls to English', () => {
assert.equal(translateText('Dauerhaftes Löschen aktivieren', 'en'), 'Enable permanent deletion');
});
test('translates the remaining upload size label', () => {
assert.equal(translateText('Verbleibende Größe', 'en'), 'Remaining size');
assert.equal(translateText('Remaining size', 'de'), 'Verbleibende Größe');
});
test('English is the fallback language and German remains selectable', () => {
assert.equal(normalizeLanguage(), 'en');
assert.equal(normalizeLanguage('fr'), 'en');
+15
View File
@@ -21,3 +21,18 @@ test('queue totals keep skipped and aborted jobs out of remaining work', () => {
});
assert.equal(stats.remainingSize, 160);
});
test('remaining bytes decrease with progress and return when a failed job is queued again', () => {
const jobs = [
{ status: 'queued', bytesTotal: 1024, bytesUploaded: 0 },
{ status: 'uploading', bytesTotal: 2048, bytesUploaded: 512 },
{ status: 'error', bytesTotal: 4096, bytesUploaded: 1024 }
];
assert.equal(calculateQueueStats(jobs).bytesRemaining, 2560);
jobs[1].bytesUploaded = 1536;
assert.equal(calculateQueueStats(jobs).bytesRemaining, 1536);
jobs[2].status = 'queued';
jobs[2].bytesUploaded = 0;
assert.equal(calculateQueueStats(jobs).bytesRemaining, 5632);
});
+21
View File
@@ -29,4 +29,25 @@ describe('serialized runner', () => {
assert.equal(flushed, true);
assert.deepEqual(calls, ['start:first', 'end:first', 'start:second', 'end:second']);
});
it('queues work before readiness and drains every task in arrival order', async () => {
const { createReadySerializedRunner } = require('../lib/serialized-runner');
const calls = [];
const runner = createReadySerializedRunner(async (value) => {
calls.push(value);
return value;
});
const first = runner.run('first');
const second = runner.run('second');
await new Promise((resolve) => setImmediate(resolve));
assert.equal(runner.isReady, false);
assert.deepEqual(calls, []);
assert.equal(runner.ready(), true);
assert.equal(runner.ready(), false);
assert.deepEqual(await Promise.all([first, second]), ['first', 'second']);
assert.deepEqual(calls, ['first', 'second']);
assert.equal(runner.isReady, true);
});
});
+21
View File
@@ -103,3 +103,24 @@ test('startup load forwards navigation options before the renderer becomes visib
assert.deepEqual(startup.window.loadOptions, options);
});
test('desktop drag and drop is accepted before asynchronous renderer initialization', () => {
const projectRoot = path.join(__dirname, '..');
const appSource = fs.readFileSync(path.join(projectRoot, 'renderer', 'app.js'), 'utf8');
const earlyBinding = appSource.lastIndexOf('\nsetupDragDrop();');
const initialization = appSource.lastIndexOf('\ninit().then(');
assert.notEqual(earlyBinding, -1);
assert.notEqual(initialization, -1);
assert.ok(earlyBinding < initialization);
assert.match(appSource, /dataTransfer\.dropEffect\s*=\s*['"]copy['"]/u);
});
test('upload sidebar renders and updates the remaining upload size', () => {
const projectRoot = path.join(__dirname, '..');
const html = fs.readFileSync(path.join(projectRoot, 'renderer', 'index.html'), 'utf8');
const appSource = fs.readFileSync(path.join(projectRoot, 'renderer', 'app.js'), 'utf8');
assert.match(html, /Verbleibende Größe[\s\S]*id="uploadTelemetryRemainingSize"[^>]*>0 B</u);
assert.match(appSource, /_setUploadTelemetryText\(['"]uploadTelemetryRemainingSize['"],\s*formatBytes\(stats\.bytesRemaining\)\)/u);
});
+5 -5
View File
@@ -217,7 +217,7 @@ setTimeout(async () => {
const englishSidebarHeadings = await wc.executeJavaScript('[...document.querySelectorAll("#upload-view, #accounts-view, #history-view")].map(view => [view.querySelector(".view-sidebar-kicker")?.textContent?.trim(), view.querySelector(".view-sidebar-title")?.textContent?.trim()].join("|"))');
check('English sidebar hierarchy uses distinct translated kickers', englishSidebarHeadings.join('::') === 'Workspace|Uploads::Manage accounts|Accounts::Archive|History');
const englishTelemetryLabels = await wc.executeJavaScript('[...document.querySelectorAll("#uploadTelemetry .upload-telemetry-label")].map(el => el.textContent.trim()).join("|")');
check('English upload telemetry is fully localized', englishTelemetryLabels === 'Total|Connections|Remaining|Running|Completed|Failed|Speed|ETA');
check('English upload telemetry is fully localized', englishTelemetryLabels === 'Total|Connections|Remaining|Remaining size|Running|Completed|Failed|Speed|ETA');
const englishLayoutFits = await wc.executeJavaScript('(() => { const states = [...document.querySelectorAll(".tab")].map(tab => { tab.click(); const view = document.querySelector(".view.active"); return view && view.scrollWidth <= view.clientWidth + 1; }); document.querySelector(".tab[data-view=upload]")?.click(); return states.every(Boolean) && document.documentElement.scrollWidth <= document.documentElement.clientWidth + 1; })()');
check('English labels fit every main view without horizontal overflow', englishLayoutFits === true);
const speedSparklineAcrossTabs = await wc.executeJavaScript('(() => [...document.querySelectorAll(".tab")].map(tab => { tab.click(); const widget = document.getElementById("uploadSpeedSparkline"); const rect = widget?.getBoundingClientRect(); const style = widget && getComputedStyle(widget); return Boolean(widget && !widget.classList.contains("is-hidden") && style.visibility === "visible" && style.opacity === "1" && rect.width > 0 && rect.height > 0); }))()');
@@ -447,10 +447,10 @@ setTimeout(async () => {
check('Recent panel labels are consistently German', localizedRecentTabs === 'Dateien|Statistik');
const localizedTelemetry = await wc.executeJavaScript('[...document.querySelectorAll("#uploadTelemetry .upload-telemetry-label")].map(el => el.textContent.trim()).join("|")');
check('Upload telemetry exposes all eight German labels', localizedTelemetry === 'Gesamt|Verbindungen|Verbleibend|Läuft|Fertig|Fehler|Geschwindigkeit|ETA');
check('Upload telemetry exposes all nine German labels', localizedTelemetry === 'Gesamt|Verbindungen|Verbleibend|Verbleibende Größe|Läuft|Fertig|Fehler|Geschwindigkeit|ETA');
const initialTelemetryValues = await wc.executeJavaScript('[...document.querySelectorAll("#uploadTelemetry .upload-telemetry-value")].map(el => el.getAttribute("aria-label") || el.textContent.trim()).join("|")');
check('Upload telemetry starts with stable empty values', initialTelemetryValues === '0|0|0|0|0|0|0 B/s|--:--');
check('Upload telemetry starts with stable empty values', initialTelemetryValues === '0|0|0|0 B|0|0|0|0 B/s|--:--');
const previewQueueCounts = await wc.executeJavaScript(\`(() => {
queueJobs = [{ id: 'existing-done', file: 'C:/ui/existing-done.bin', fileName: 'existing-done.bin', hoster: 'doodstream.com', status: 'done', bytesUploaded: 1024, bytesTotal: 1024, speedKbs: 0, elapsed: 1, remaining: 0, progress: 1 }];
@@ -517,7 +517,7 @@ setTimeout(async () => {
const total = document.getElementById('uploadTelemetryTotal');
const rolling = total?.querySelectorAll(':scope > span').length;
return {
values: ['Total', 'Connections', 'Remaining', 'Running', 'Completed', 'Failed', 'Speed', 'Eta'].map(key => {
values: ['Total', 'Connections', 'Remaining', 'RemainingSize', 'Running', 'Completed', 'Failed', 'Speed', 'Eta'].map(key => {
const element = document.getElementById('uploadTelemetry' + key);
return element?.getAttribute('aria-label') || element?.textContent.trim();
}).join('|'),
@@ -526,7 +526,7 @@ setTimeout(async () => {
speedPair: [document.getElementById('uploadTelemetrySpeed')?.textContent, document.getElementById('uploadSpeedValue')?.textContent].join('|')
};
})()\`);
check('Upload telemetry reflects queue and session activity', telemetryUpdate.values === '4|1|2|1|7|2|2 kB/s|00:03');
check('Upload telemetry reflects queue and session activity', telemetryUpdate.values === '4|1|2|5.00 KB|1|7|2|2 kB/s|00:03');
check('Changing integer telemetry rolls vertically', telemetryUpdate.rolling === 2 && telemetryUpdate.direction === 'up');
check('Header and sidebar speed update synchronously from the same live sample', telemetryUpdate.speedPair === '2 kB/s|2 kB/s');
const secondSynchronizedSpeed = await wc.executeJavaScript('lastUploadStats = { ...lastUploadStats, globalSpeedKbs: 1536 }; updateStatusBar(); [document.getElementById("uploadTelemetrySpeed")?.textContent, document.getElementById("uploadSpeedValue")?.textContent].join("|")');
+110 -1
View File
@@ -4,9 +4,10 @@ const path = require('node:path');
const fs = require('node:fs');
const os = require('node:os');
const crypto = require('node:crypto');
const Module = require('node:module');
const { pathToFileURL } = require('node:url');
const { isNewer, resolveReleaseVersion, fetchGithubReleaseNotes, prepareUpdate, launchPreparedUpdate, pickSetupAsset, parseLatestYml } = require('../lib/updater');
const { isNewer, resolveReleaseVersion, fetchGithubReleaseNotes, prepareUpdate, launchPreparedUpdate, pickSetupAsset, parseLatestYml, createUpdateAnnouncementState } = require('../lib/updater');
const releasePlanUrl = pathToFileURL(path.resolve(__dirname, '../scripts/release-plan.mjs')).href;
test('bridge title resolves product version instead of transport tag', () => {
@@ -86,6 +87,114 @@ test('update preparation writes a verified installer without launching it', asyn
}
});
test('update preparation refreshes a cached release before downloading the installer', async () => {
const updaterPath = require.resolve('../lib/updater');
const originalLoad = Module._load;
const originalFetch = global.fetch;
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'mhu-updater-refresh-test-'));
const installer = Buffer.alloc(128 * 1024, 0);
installer[0] = 0x4d;
installer[1] = 0x5a;
const sha512 = crypto.createHash('sha512').update(installer).digest('base64');
const oldVersion = '2.1.23';
const latestVersion = '2.1.24';
const createRelease = version => ({
name: `Multi-Hoster-Upload v${version}`,
tag_name: `v${version}`,
html_url: `https://update.invalid/releases/${version}`,
body: `Release ${version}`,
assets: [
{
name: `Multi-Hoster-Upload Setup ${version}.exe`,
size: installer.length,
browser_download_url: `https://update.invalid/${version}/setup.exe`
},
{
name: 'latest.yml',
browser_download_url: `https://update.invalid/${version}/latest.yml`
}
]
});
const createResponse = payload => ({
ok: true,
status: 200,
text: async () => JSON.stringify(payload),
json: async () => payload
});
const createInstallerResponse = () => ({
ok: true,
status: 200,
body: {
getReader: () => {
let served = false;
return {
read: async () => {
if (served) return { done: true };
served = true;
return { done: false, value: installer };
}
};
}
}
});
const createManifestResponse = version => ({
ok: true,
status: 200,
text: async () => `version: ${version}\npath: Multi-Hoster-Upload Setup ${version}.exe\nsha512: ${sha512}\nsize: ${installer.length}\n`
});
Module._load = function load(request, parent, isMain) {
if (request === 'electron') return { app: { getVersion: () => '2.1.22', getPath: () => tempDir } };
return originalLoad.call(this, request, parent, isMain);
};
delete require.cache[updaterPath];
const isolatedUpdater = require(updaterPath);
try {
global.fetch = async url => {
const value = String(url);
if (value.includes('/api/v1/repos/')) return createResponse([createRelease(oldVersion)]);
if (value.includes('api.github.com')) return createResponse({ body: `Release ${oldVersion}` });
throw new Error(`Unexpected initial request: ${value}`);
};
const oldCheck = await isolatedUpdater.checkForUpdate();
assert.equal(oldCheck.remoteVersion, oldVersion);
global.fetch = async url => {
const value = String(url);
if (value.includes('/api/v1/repos/')) return createResponse([createRelease(latestVersion)]);
if (value.includes('api.github.com')) return createResponse({ body: `Release ${latestVersion}` });
if (value.endsWith(`${oldVersion}/latest.yml`)) return createManifestResponse(oldVersion);
if (value.endsWith(`${latestVersion}/latest.yml`)) return createManifestResponse(latestVersion);
if (value.endsWith(`${oldVersion}/setup.exe`) || value.endsWith(`${latestVersion}/setup.exe`)) return createInstallerResponse();
throw new Error(`Unexpected refreshed request: ${value}`);
};
const prepared = await isolatedUpdater.prepareUpdate(null, { tempDir });
assert.equal(prepared.remoteVersion, latestVersion);
assert.equal(prepared.assetName, `Multi-Hoster-Upload Setup ${latestVersion}.exe`);
} finally {
global.fetch = originalFetch;
Module._load = originalLoad;
delete require.cache[updaterPath];
fs.rmSync(tempDir, { recursive: true, force: true });
}
});
test('an update found before renderer readiness remains announceable afterwards', () => {
const state = createUpdateAnnouncementState();
const update = { available: true, remoteVersion: '2.1.24' };
assert.equal(state.canAnnounce(update, false), false);
assert.equal(state.canAnnounce(update, true), true);
assert.equal(state.canAnnounce(update, true), true);
state.markAnnounced(update);
assert.equal(state.canAnnounce(update, true), false);
state.reset();
assert.equal(state.canAnnounce(update, true), true);
});
test('update preparation fails closed when checksum metadata is unavailable', async () => {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'mhu-updater-test-'));
try {