diff --git a/README.md b/README.md index f2142db..e144ad0 100644 --- a/README.md +++ b/README.md @@ -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. - 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. -- 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 diff --git a/assets/product-overview.png b/assets/product-overview.png index 4e7db08..514cdda 100644 Binary files a/assets/product-overview.png and b/assets/product-overview.png differ diff --git a/package-lock.json b/package-lock.json index e77fd5a..b6c33a3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "multi-hoster-uploader", - "version": "2.0.6", + "version": "2.0.7", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "multi-hoster-uploader", - "version": "2.0.6", + "version": "2.0.7", "dependencies": { "chokidar": "^3.6.0", "undici": "^7.29.0", diff --git a/package.json b/package.json index 36ca3d8..9b0f19d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "multi-hoster-uploader", - "version": "2.0.6", + "version": "2.0.7", "description": "Upload files to doodstream, voe, vidmoly, byse simultaneously", "main": "main.js", "scripts": { diff --git a/renderer/app.js b/renderer/app.js index 5b63296..ed77095 100644 --- a/renderer/app.js +++ b/renderer/app.js @@ -369,10 +369,6 @@ async function init() { if (versionLabel) versionLabel.textContent = `v${version}`; } catch {} - // Update listeners - window.api.onUpdateAvailable(showUpdateBanner); - window.api.onUpdateProgress(handleUpdateProgress); - window.api.onUploadProgress((data) => { handleProgress(data); }); @@ -660,6 +656,7 @@ function _syncHeaderUpdateState() { ? `Update v${version || 'unbekannt'} verfügbar. Klicken zum Installieren.` : 'Nach Aktualisierungen suchen'; if (button) { + button.hidden = !available; button.classList.toggle('update-available', available); button.classList.toggle('is-checking', _updateCheckBusy); button.disabled = _updateCheckBusy; @@ -6734,6 +6731,8 @@ function updateStatsPanel() { } // --- Start --- +window.api.onUpdateAvailable(showUpdateBanner); +window.api.onUpdateProgress(handleUpdateProgress); window.api.onPrepareClose(prepareForWindowClose); init().then(() => { window.api.signalCloseHandshakeReady(); diff --git a/renderer/index.html b/renderer/index.html index e0081ca..ab9b3c5 100644 --- a/renderer/index.html +++ b/renderer/index.html @@ -53,7 +53,7 @@
- diff --git a/renderer/styles.css b/renderer/styles.css index 3b1cd6b..2ab002d 100644 --- a/renderer/styles.css +++ b/renderer/styles.css @@ -1772,6 +1772,10 @@ select.hs-input { max-width: none; width: auto; min-width: 140px; } transition: background-color .14s, transform .14s; } +.header-update-button[hidden] { + display: none; +} + .header-update-button::after { content: attr(data-tooltip); position: absolute; diff --git a/tests/public-release-verifier.test.js b/tests/public-release-verifier.test.js index 091e67e..e6ed67c 100644 --- a/tests/public-release-verifier.test.js +++ b/tests/public-release-verifier.test.js @@ -48,7 +48,7 @@ function createStage() { 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], { cwd: stage, encoding: 'utf8' diff --git a/tests/ui-smoke.js b/tests/ui-smoke.js index 125a6d7..72c17a5 100644 --- a/tests/ui-smoke.js +++ b/tests/ui-smoke.js @@ -32,7 +32,13 @@ const ConfigStore = require(path.join(process.cwd(), 'lib', 'config-store')); const updaterModule = require(path.join(process.cwd(), 'lib', 'updater')); let preparedUpdateMockCalls = 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) => { preparedUpdateMockCalls++; if (onProgress) onProgress({ stage: 'prepared', percent: 100 }); @@ -44,9 +50,20 @@ updaterModule.launchPreparedUpdate = () => { }; const initialIpcHandlers = new Map(); const registerIpcHandler = ipcMain.handle.bind(ipcMain); +let initialConfigReadDelayed = false; ipcMain.handle = (channel, listener) => { - if (!initialIpcHandlers.has(channel)) initialIpcHandlers.set(channel, listener); - return registerIpcHandler(channel, listener); + const registeredListener = channel === 'get-config' + ? 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) { ipcMain.removeHandler(channel); @@ -132,6 +149,10 @@ setTimeout(async () => { } 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();'); console.log('\\n=== Upload View ==='); @@ -150,6 +171,9 @@ setTimeout(async () => { const headerUpdateButtonExists = await wc.executeJavaScript('Boolean(document.getElementById("headerUpdateBtn"))'); 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()'); check('App header uses the compact update label', initialUpdateLabel === 'Update'); @@ -492,14 +516,21 @@ setTimeout(async () => { document.getElementById('headerUpdateBtn').click(); })()\`); 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("|"); })()'); - check('All update entry points share one in-flight check', updateCheckCallCount === 1 && coordinatedUpdateBusy === 'true|true|Prüfe…|true|true'); + 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|true'); updateCheckResolvers.splice(0).forEach(resolve => resolve({ available: false, error: 'Simulierter Netzwerkfehler' })); 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("|"); })()'); - check('Settings update check uses the shared error contract', coordinatedUpdateError === 'false|false|Nach Updates suchen|Updateprüfung fehlgeschlagen'); + 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|true|Nach Updates suchen|Updateprüfung fehlgeschlagen'); 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("::"); })()'); 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('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("|"); })()'); - check('Available update opens an accessible update dialog', updateOverlayState === 'true|flex|dialog|true'); + 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|false|flex'); await new Promise(resolve => setTimeout(resolve, 100)); const updateModalKeyboard = await wc.executeJavaScript(\`(() => { @@ -1086,20 +1117,22 @@ setTimeout(async () => { overlay.dispatchEvent(new MouseEvent('click', { bubbles: true })); document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true, cancelable: true })); const progress = document.getElementById('updateProgressBar'); + const header = document.getElementById('headerUpdateBtn'); return { display: overlay.style.display, hidden: overlay.getAttribute('aria-hidden'), closeDisabled: document.getElementById('updateCloseBtn').disabled, dismissDisabled: document.getElementById('dismissUpdateBtn').disabled, + headerHidden: header.hidden, progressLabel: progress.getAttribute('aria-label'), 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%'); - 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'); - check('Update errors restore all close actions', updateErrorRecovery === 'none|false|false'); + 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|false'); const initialInstallUpdateHandler = initialIpcHandlers.get('app:install-update'); const initialUpdateQueueHandler = initialIpcHandlers.get('save-pending-queue'); @@ -1267,7 +1300,7 @@ try { const result = execFileSync( electronPath, [`--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); const isolatedConfigPath = path.join(userDataPath, 'electron-config.json'); diff --git a/tests/updater-version.test.js b/tests/updater-version.test.js index 04ade35..7017239 100644 --- a/tests/updater-version.test.js +++ b/tests/updater-version.test.js @@ -83,7 +83,7 @@ test('a prepared installer launches at most once', () => { test('release plan keeps product artifacts separate from the transport tag', async () => { 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'); assert.deepEqual({ version: plan.version, @@ -93,17 +93,17 @@ test('release plan keeps product artifacts separate from the transport tag', asy expectedArtifacts: plan.expectedArtifacts, latestYml }, { - version: '2.0.6', - transportTag: 'v3.3.114', - releaseTitle: 'Multi-Hoster-Upload v2.0.6', - releaseBody: 'Interface redesign', + version: '2.0.7', + transportTag: 'v3.3.115', + releaseTitle: 'Multi-Hoster-Upload v2.0.7', + releaseBody: 'Update visibility', expectedArtifacts: [ - 'Multi-Hoster-Upload Setup 2.0.6.exe', - 'Multi-Hoster-Upload 2.0.6.exe', - 'Multi-Hoster-Upload Setup 2.0.6.exe.blockmap', + 'Multi-Hoster-Upload Setup 2.0.7.exe', + 'Multi-Hoster-Upload 2.0.7.exe', + 'Multi-Hoster-Upload Setup 2.0.7.exe.blockmap', '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" }); });