fix(updater): show header action only when update is available

Hide the header update action until a newer release is confirmed.
Register update notifications before renderer initialization so startup cannot miss them.
Refresh the public product screenshot and release metadata for v2.0.7.
This commit is contained in:
Sucukdeluxe
2026-08-10 13:10:39 +02:00
parent d86caa0b1e
commit 761b5e27db
10 changed files with 68 additions and 32 deletions
+1 -1
View File
@@ -15,7 +15,7 @@ Multi-Hoster-Upload is a Windows desktop app for managing large file batches acr
- Control per-hoster concurrency, bandwidth limits, retries, folder monitoring, notifications, and completed-item cleanup. - Control per-hoster concurrency, bandwidth limits, retries, folder monitoring, notifications, and completed-item cleanup.
- Keep local upload history and copy completed links in bulk. - Keep local upload history and copy completed links in bulk.
- Transfer accounts and settings with a 75-character encrypted online key while encryption and decryption stay on the client. - Transfer accounts and settings with a 75-character encrypted online key while encryption and decryption stay on the client.
- Check for updates from the header and install available releases from an accessible update dialog. - Check for updates from Settings or Help; when a newer release is available, install it from the header update action and accessible update dialog.
## Supported hosters ## Supported hosters
Binary file not shown.

Before

Width:  |  Height:  |  Size: 51 KiB

After

Width:  |  Height:  |  Size: 46 KiB

+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "multi-hoster-uploader", "name": "multi-hoster-uploader",
"version": "2.0.6", "version": "2.0.7",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "multi-hoster-uploader", "name": "multi-hoster-uploader",
"version": "2.0.6", "version": "2.0.7",
"dependencies": { "dependencies": {
"chokidar": "^3.6.0", "chokidar": "^3.6.0",
"undici": "^7.29.0", "undici": "^7.29.0",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "multi-hoster-uploader", "name": "multi-hoster-uploader",
"version": "2.0.6", "version": "2.0.7",
"description": "Upload files to doodstream, voe, vidmoly, byse simultaneously", "description": "Upload files to doodstream, voe, vidmoly, byse simultaneously",
"main": "main.js", "main": "main.js",
"scripts": { "scripts": {
+3 -4
View File
@@ -369,10 +369,6 @@ async function init() {
if (versionLabel) versionLabel.textContent = `v${version}`; if (versionLabel) versionLabel.textContent = `v${version}`;
} catch {} } catch {}
// Update listeners
window.api.onUpdateAvailable(showUpdateBanner);
window.api.onUpdateProgress(handleUpdateProgress);
window.api.onUploadProgress((data) => { window.api.onUploadProgress((data) => {
handleProgress(data); handleProgress(data);
}); });
@@ -660,6 +656,7 @@ function _syncHeaderUpdateState() {
? `Update v${version || 'unbekannt'} verfügbar. Klicken zum Installieren.` ? `Update v${version || 'unbekannt'} verfügbar. Klicken zum Installieren.`
: 'Nach Aktualisierungen suchen'; : 'Nach Aktualisierungen suchen';
if (button) { if (button) {
button.hidden = !available;
button.classList.toggle('update-available', available); button.classList.toggle('update-available', available);
button.classList.toggle('is-checking', _updateCheckBusy); button.classList.toggle('is-checking', _updateCheckBusy);
button.disabled = _updateCheckBusy; button.disabled = _updateCheckBusy;
@@ -6734,6 +6731,8 @@ function updateStatsPanel() {
} }
// --- Start --- // --- Start ---
window.api.onUpdateAvailable(showUpdateBanner);
window.api.onUpdateProgress(handleUpdateProgress);
window.api.onPrepareClose(prepareForWindowClose); window.api.onPrepareClose(prepareForWindowClose);
init().then(() => { init().then(() => {
window.api.signalCloseHandshakeReady(); window.api.signalCloseHandshakeReady();
+1 -1
View File
@@ -53,7 +53,7 @@
</div> </div>
<div class="header-spacer" aria-hidden="true"></div> <div class="header-spacer" aria-hidden="true"></div>
<div class="header-cluster header-utilities"> <div class="header-cluster header-utilities">
<button class="header-update-button" id="headerUpdateBtn" title="Nach Aktualisierungen suchen" aria-label="Nach Aktualisierungen suchen" data-tooltip="Nach Aktualisierungen suchen"> <button class="header-update-button" id="headerUpdateBtn" title="Nach Aktualisierungen suchen" aria-label="Nach Aktualisierungen suchen" data-tooltip="Nach Aktualisierungen suchen" hidden>
<svg class="header-action-icon" aria-hidden="true"><use href="#icon-download"></use></svg> <svg class="header-action-icon" aria-hidden="true"><use href="#icon-download"></use></svg>
<span class="header-update-label">Update</span> <span class="header-update-label">Update</span>
</button> </button>
+4
View File
@@ -1772,6 +1772,10 @@ select.hs-input { max-width: none; width: auto; min-width: 140px; }
transition: background-color .14s, transform .14s; transition: background-color .14s, transform .14s;
} }
.header-update-button[hidden] {
display: none;
}
.header-update-button::after { .header-update-button::after {
content: attr(data-tooltip); content: attr(data-tooltip);
position: absolute; position: absolute;
+1 -1
View File
@@ -48,7 +48,7 @@ function createStage() {
return stage; return stage;
} }
function verify(stage, version = '2.0.6') { function verify(stage, version = '2.0.7') {
return spawnSync(process.execPath, ['scripts/verify-public-release.mjs', '--source-only', '--version', version], { return spawnSync(process.execPath, ['scripts/verify-public-release.mjs', '--source-only', '--version', version], {
cwd: stage, cwd: stage,
encoding: 'utf8' encoding: 'utf8'
+46 -13
View File
@@ -32,7 +32,13 @@ const ConfigStore = require(path.join(process.cwd(), 'lib', 'config-store'));
const updaterModule = require(path.join(process.cwd(), 'lib', 'updater')); const updaterModule = require(path.join(process.cwd(), 'lib', 'updater'));
let preparedUpdateMockCalls = 0; let preparedUpdateMockCalls = 0;
let launchedUpdateMockCalls = 0; let launchedUpdateMockCalls = 0;
updaterModule.checkForUpdate = async () => ({ available: false }); let updateCheckMockCalls = 0;
updaterModule.checkForUpdate = async () => {
updateCheckMockCalls++;
return updateCheckMockCalls === 1
? { available: true, remoteVersion: '9.9.8' }
: { available: false };
};
updaterModule.prepareUpdate = async (onProgress) => { updaterModule.prepareUpdate = async (onProgress) => {
preparedUpdateMockCalls++; preparedUpdateMockCalls++;
if (onProgress) onProgress({ stage: 'prepared', percent: 100 }); if (onProgress) onProgress({ stage: 'prepared', percent: 100 });
@@ -44,9 +50,20 @@ updaterModule.launchPreparedUpdate = () => {
}; };
const initialIpcHandlers = new Map(); const initialIpcHandlers = new Map();
const registerIpcHandler = ipcMain.handle.bind(ipcMain); const registerIpcHandler = ipcMain.handle.bind(ipcMain);
let initialConfigReadDelayed = false;
ipcMain.handle = (channel, listener) => { ipcMain.handle = (channel, listener) => {
if (!initialIpcHandlers.has(channel)) initialIpcHandlers.set(channel, listener); const registeredListener = channel === 'get-config'
return registerIpcHandler(channel, listener); ? async (...args) => {
const result = await listener(...args);
if (!initialConfigReadDelayed) {
initialConfigReadDelayed = true;
await new Promise(resolve => setTimeout(resolve, 4000));
}
return result;
}
: listener;
if (!initialIpcHandlers.has(channel)) initialIpcHandlers.set(channel, registeredListener);
return registerIpcHandler(channel, registeredListener);
}; };
function restoreInitialIpcHandler(channel) { function restoreInitialIpcHandler(channel) {
ipcMain.removeHandler(channel); ipcMain.removeHandler(channel);
@@ -132,6 +149,10 @@ setTimeout(async () => {
} }
try { try {
const startupUpdateState = await wc.executeJavaScript('(() => { const button = document.getElementById("headerUpdateBtn"); return [_knownUpdateInfo?.remoteVersion, button?.hidden, getComputedStyle(button).display, document.getElementById("updateBanner")?.style.display].join("|"); })()');
check('Startup update survives pending renderer initialization', startupUpdateState === '9.9.8|false|flex|flex');
await wc.executeJavaScript('_knownUpdateInfo = null; closeUpdateDialog(); _syncHeaderUpdateState();');
await wc.executeJavaScript('queueJobs = []; selectedFiles = []; selectedJobIds.clear(); rebuildJobIndex(); setUploadSidebarFilter("all"); updateUploadView(); renderQueueTable(); updateStatusBar();'); await wc.executeJavaScript('queueJobs = []; selectedFiles = []; selectedJobIds.clear(); rebuildJobIndex(); setUploadSidebarFilter("all"); updateUploadView(); renderQueueTable(); updateStatusBar();');
console.log('\\n=== Upload View ==='); console.log('\\n=== Upload View ===');
@@ -150,6 +171,9 @@ setTimeout(async () => {
const headerUpdateButtonExists = await wc.executeJavaScript('Boolean(document.getElementById("headerUpdateBtn"))'); const headerUpdateButtonExists = await wc.executeJavaScript('Boolean(document.getElementById("headerUpdateBtn"))');
check('App header exposes the update action', headerUpdateButtonExists); check('App header exposes the update action', headerUpdateButtonExists);
const initialHeaderUpdateVisibility = await wc.executeJavaScript('(() => { const button = document.getElementById("headerUpdateBtn"); return [button?.hidden, getComputedStyle(button).display].join("|"); })()');
check('Header update action stays hidden until an update is available', initialHeaderUpdateVisibility === 'true|none');
const initialUpdateLabel = await wc.executeJavaScript('document.querySelector("#headerUpdateBtn .header-update-label")?.textContent?.trim()'); const initialUpdateLabel = await wc.executeJavaScript('document.querySelector("#headerUpdateBtn .header-update-label")?.textContent?.trim()');
check('App header uses the compact update label', initialUpdateLabel === 'Update'); check('App header uses the compact update label', initialUpdateLabel === 'Update');
@@ -492,14 +516,21 @@ setTimeout(async () => {
document.getElementById('headerUpdateBtn').click(); document.getElementById('headerUpdateBtn').click();
})()\`); })()\`);
await new Promise(resolve => setTimeout(resolve, 100)); await new Promise(resolve => setTimeout(resolve, 100));
const coordinatedUpdateBusy = await wc.executeJavaScript('(() => { const manual = document.getElementById("manualUpdateCheckBtn"); const header = document.getElementById("headerUpdateBtn"); return [manual?.disabled, manual?.getAttribute("aria-busy"), manual?.textContent?.trim(), header?.disabled, header?.getAttribute("aria-busy")].join("|"); })()'); const coordinatedUpdateBusy = await wc.executeJavaScript('(() => { const manual = document.getElementById("manualUpdateCheckBtn"); const header = document.getElementById("headerUpdateBtn"); return [manual?.disabled, manual?.getAttribute("aria-busy"), manual?.textContent?.trim(), header?.disabled, header?.getAttribute("aria-busy"), header?.hidden].join("|"); })()');
check('All update entry points share one in-flight check', updateCheckCallCount === 1 && coordinatedUpdateBusy === 'true|true|Prüfe…|true|true'); check('All update entry points share one in-flight check', updateCheckCallCount === 1 && coordinatedUpdateBusy === 'true|true|Prüfe…|true|true|true');
updateCheckResolvers.splice(0).forEach(resolve => resolve({ available: false, error: 'Simulierter Netzwerkfehler' })); updateCheckResolvers.splice(0).forEach(resolve => resolve({ available: false, error: 'Simulierter Netzwerkfehler' }));
await new Promise(resolve => setTimeout(resolve, 150)); await new Promise(resolve => setTimeout(resolve, 150));
const coordinatedUpdateError = await wc.executeJavaScript('(() => { const manual = document.getElementById("manualUpdateCheckBtn"); const header = document.getElementById("headerUpdateBtn"); return [manual?.disabled, header?.disabled, manual?.textContent?.trim(), document.getElementById("copyToast")?.textContent?.trim()].join("|"); })()'); const coordinatedUpdateError = await wc.executeJavaScript('(() => { const manual = document.getElementById("manualUpdateCheckBtn"); const header = document.getElementById("headerUpdateBtn"); return [manual?.disabled, header?.disabled, header?.hidden, manual?.textContent?.trim(), document.getElementById("copyToast")?.textContent?.trim()].join("|"); })()');
check('Settings update check uses the shared error contract', coordinatedUpdateError === 'false|false|Nach Updates suchen|Updateprüfung fehlgeschlagen'); check('Settings update check uses the shared error contract', coordinatedUpdateError === 'false|false|true|Nach Updates suchen|Updateprüfung fehlgeschlagen');
await wc.executeJavaScript('document.getElementById("copyToast")?.classList.remove("show")'); await wc.executeJavaScript('document.getElementById("copyToast")?.classList.remove("show")');
await wc.executeJavaScript('requestUpdateCheck(); true');
await new Promise(resolve => setTimeout(resolve, 100));
updateCheckResolvers.splice(0).forEach(resolve => resolve({ available: false }));
await new Promise(resolve => setTimeout(resolve, 150));
const noUpdateHeaderVisibility = await wc.executeJavaScript('(() => { const button = document.getElementById("headerUpdateBtn"); return [button?.hidden, getComputedStyle(button).display].join("|"); })()');
check('Successful no-update result keeps the header action hidden', noUpdateHeaderVisibility === 'true|none');
const settingsNavigation = await wc.executeJavaScript('(() => { const buttons = [...document.querySelectorAll(".settings-nav-button")]; return [buttons.length, buttons.map(button => button.textContent.trim()).join("|"), document.querySelector(".settings-nav-button.active")?.dataset.settingsPage, document.getElementById("settingsSearchInput")?.placeholder].join("::"); })()'); const settingsNavigation = await wc.executeJavaScript('(() => { const buttons = [...document.querySelectorAll(".settings-nav-button")]; return [buttons.length, buttons.map(button => button.textContent.trim()).join("|"), document.querySelector(".settings-nav-button.active")?.dataset.settingsPage, document.getElementById("settingsSearchInput")?.placeholder].join("::"); })()');
check('Settings use the task-based sidebar navigation', settingsNavigation === '8::Allgemein|Uploads|Automatik|Benachrichtigungen|Logs & Support|Fernsteuerung|Diagnose-Zugriff|Backup & Übertragen::allgemein::Einstellungen durchsuchen'); check('Settings use the task-based sidebar navigation', settingsNavigation === '8::Allgemein|Uploads|Automatik|Benachrichtigungen|Logs & Support|Fernsteuerung|Diagnose-Zugriff|Backup & Übertragen::allgemein::Einstellungen durchsuchen');
@@ -1043,8 +1074,8 @@ setTimeout(async () => {
check('Responsive queue keeps the fixed virtual row height', queueProgressVisibility.standard.rowHeight === 28 && queueProgressVisibility.minimum.rowHeight === 28); check('Responsive queue keeps the fixed virtual row height', queueProgressVisibility.standard.rowHeight === 28 && queueProgressVisibility.minimum.rowHeight === 28);
check('Minimum window keeps the settings header compact', compactSettingsHeader <= 58); check('Minimum window keeps the settings header compact', compactSettingsHeader <= 58);
const updateOverlayState = await wc.executeJavaScript('document.getElementById("headerUpdateBtn").focus(); showUpdateBanner({ remoteVersion: "9.9.9" }); (() => { const overlay = document.getElementById("updateBanner"); const dialog = overlay?.querySelector(".update-dialog"); return [overlay?.classList.contains("update-overlay"), overlay?.style.display, dialog?.getAttribute("role"), dialog?.getAttribute("aria-modal")].join("|"); })()'); const updateOverlayState = await wc.executeJavaScript('_knownUpdateInfo = { available: true, remoteVersion: "9.9.9" }; _syncHeaderUpdateState(); document.getElementById("headerUpdateBtn").focus(); showUpdateBanner({ remoteVersion: "9.9.9" }); (() => { const overlay = document.getElementById("updateBanner"); const dialog = overlay?.querySelector(".update-dialog"); const button = document.getElementById("headerUpdateBtn"); return [overlay?.classList.contains("update-overlay"), overlay?.style.display, dialog?.getAttribute("role"), dialog?.getAttribute("aria-modal"), button?.hidden, getComputedStyle(button).display].join("|"); })()');
check('Available update opens an accessible update dialog', updateOverlayState === 'true|flex|dialog|true'); check('Available update opens an accessible update dialog', updateOverlayState === 'true|flex|dialog|true|false|flex');
await new Promise(resolve => setTimeout(resolve, 100)); await new Promise(resolve => setTimeout(resolve, 100));
const updateModalKeyboard = await wc.executeJavaScript(\`(() => { const updateModalKeyboard = await wc.executeJavaScript(\`(() => {
@@ -1086,20 +1117,22 @@ setTimeout(async () => {
overlay.dispatchEvent(new MouseEvent('click', { bubbles: true })); overlay.dispatchEvent(new MouseEvent('click', { bubbles: true }));
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true, cancelable: true })); document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true, cancelable: true }));
const progress = document.getElementById('updateProgressBar'); const progress = document.getElementById('updateProgressBar');
const header = document.getElementById('headerUpdateBtn');
return { return {
display: overlay.style.display, display: overlay.style.display,
hidden: overlay.getAttribute('aria-hidden'), hidden: overlay.getAttribute('aria-hidden'),
closeDisabled: document.getElementById('updateCloseBtn').disabled, closeDisabled: document.getElementById('updateCloseBtn').disabled,
dismissDisabled: document.getElementById('dismissUpdateBtn').disabled, dismissDisabled: document.getElementById('dismissUpdateBtn').disabled,
headerHidden: header.hidden,
progressLabel: progress.getAttribute('aria-label'), progressLabel: progress.getAttribute('aria-label'),
progressText: progress.getAttribute('aria-valuetext') progressText: progress.getAttribute('aria-valuetext')
}; };
})()\`); })()\`);
check('Busy update keeps its progress dialog open', busyUpdateState.display === 'flex' && busyUpdateState.hidden === 'false' && busyUpdateState.closeDisabled === true && busyUpdateState.dismissDisabled === true); check('Busy update keeps its progress dialog open', busyUpdateState.display === 'flex' && busyUpdateState.hidden === 'false' && busyUpdateState.closeDisabled === true && busyUpdateState.dismissDisabled === true && busyUpdateState.headerHidden === false);
check('Update progress exposes an accessible live value', busyUpdateState.progressLabel === 'Update-Fortschritt' && busyUpdateState.progressText === 'Download 50%'); check('Update progress exposes an accessible live value', busyUpdateState.progressLabel === 'Update-Fortschritt' && busyUpdateState.progressText === 'Download 50%');
const updateErrorRecovery = await wc.executeJavaScript('handleUpdateProgress({ stage: "error", error: "Netzwerkfehler" }); document.getElementById("dismissUpdateBtn").click(); document.getElementById("updateBanner").style.display + "|" + document.getElementById("updateCloseBtn").disabled + "|" + document.getElementById("dismissUpdateBtn").disabled'); const updateErrorRecovery = await wc.executeJavaScript('handleUpdateProgress({ stage: "error", error: "Netzwerkfehler" }); document.getElementById("dismissUpdateBtn").click(); document.getElementById("updateBanner").style.display + "|" + document.getElementById("updateCloseBtn").disabled + "|" + document.getElementById("dismissUpdateBtn").disabled + "|" + document.getElementById("headerUpdateBtn").hidden');
check('Update errors restore all close actions', updateErrorRecovery === 'none|false|false'); check('Update errors restore all close actions', updateErrorRecovery === 'none|false|false|false');
const initialInstallUpdateHandler = initialIpcHandlers.get('app:install-update'); const initialInstallUpdateHandler = initialIpcHandlers.get('app:install-update');
const initialUpdateQueueHandler = initialIpcHandlers.get('save-pending-queue'); const initialUpdateQueueHandler = initialIpcHandlers.get('save-pending-queue');
@@ -1267,7 +1300,7 @@ try {
const result = execFileSync( const result = execFileSync(
electronPath, electronPath,
[`--user-data-dir=${userDataPath}`, '--require', injectPath, mainPath], [`--user-data-dir=${userDataPath}`, '--require', injectPath, mainPath],
{ cwd: path.join(__dirname, '..'), timeout: 45000, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] } { cwd: path.join(__dirname, '..'), timeout: 60000, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] }
); );
console.log(result); console.log(result);
const isolatedConfigPath = path.join(userDataPath, 'electron-config.json'); const isolatedConfigPath = path.join(userDataPath, 'electron-config.json');
+9 -9
View File
@@ -83,7 +83,7 @@ test('a prepared installer launches at most once', () => {
test('release plan keeps product artifacts separate from the transport tag', async () => { test('release plan keeps product artifacts separate from the transport tag', async () => {
const { createReleasePlan, parseReleaseArgs, renderLatestYml } = await import(releasePlanUrl); const { createReleasePlan, parseReleaseArgs, renderLatestYml } = await import(releasePlanUrl);
const plan = createReleasePlan(parseReleaseArgs(['2.0.6', '--transport-tag', 'v3.3.114', 'Interface', 'redesign'])); const plan = createReleasePlan(parseReleaseArgs(['2.0.7', '--transport-tag', 'v3.3.115', 'Update', 'visibility']));
const latestYml = renderLatestYml(plan, 'abc123', 456, '2026-08-07T12:00:00.000Z'); const latestYml = renderLatestYml(plan, 'abc123', 456, '2026-08-07T12:00:00.000Z');
assert.deepEqual({ assert.deepEqual({
version: plan.version, version: plan.version,
@@ -93,17 +93,17 @@ test('release plan keeps product artifacts separate from the transport tag', asy
expectedArtifacts: plan.expectedArtifacts, expectedArtifacts: plan.expectedArtifacts,
latestYml latestYml
}, { }, {
version: '2.0.6', version: '2.0.7',
transportTag: 'v3.3.114', transportTag: 'v3.3.115',
releaseTitle: 'Multi-Hoster-Upload v2.0.6', releaseTitle: 'Multi-Hoster-Upload v2.0.7',
releaseBody: 'Interface redesign', releaseBody: 'Update visibility',
expectedArtifacts: [ expectedArtifacts: [
'Multi-Hoster-Upload Setup 2.0.6.exe', 'Multi-Hoster-Upload Setup 2.0.7.exe',
'Multi-Hoster-Upload 2.0.6.exe', 'Multi-Hoster-Upload 2.0.7.exe',
'Multi-Hoster-Upload Setup 2.0.6.exe.blockmap', 'Multi-Hoster-Upload Setup 2.0.7.exe.blockmap',
'latest.yml' 'latest.yml'
], ],
latestYml: "version: 2.0.6\nfiles:\n - url: Multi-Hoster-Upload Setup 2.0.6.exe\n sha512: abc123\n size: 456\npath: Multi-Hoster-Upload Setup 2.0.6.exe\nsha512: abc123\nreleaseDate: '2026-08-07T12:00:00.000Z'\n" latestYml: "version: 2.0.7\nfiles:\n - url: Multi-Hoster-Upload Setup 2.0.7.exe\n sha512: abc123\n size: 456\npath: Multi-Hoster-Upload Setup 2.0.7.exe\nsha512: abc123\nreleaseDate: '2026-08-07T12:00:00.000Z'\n"
}); });
}); });