diff --git a/README.md b/README.md index e125156..eb679d1 100644 --- a/README.md +++ b/README.md @@ -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.16. Use the release page for the executables and the full English changelog. +The latest public release is version 2.1.17. Use the release page for the executables and the full English changelog. ## Features @@ -20,7 +20,7 @@ The latest public release is version 2.1.16. Use the release page for the execut - Filter the workspace by all, active, queued, completed, or failed jobs. - 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, progress, transferred size, speed, and the selected host account. +- Track status, smoothly interpolated progress, transferred size, speed, and the selected host account. - 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. diff --git a/package-lock.json b/package-lock.json index 11a5780..c68cd61 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "multi-hoster-uploader", - "version": "2.1.16", + "version": "2.1.17", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "multi-hoster-uploader", - "version": "2.1.16", + "version": "2.1.17", "dependencies": { "chokidar": "^3.6.0", "undici": "^7.29.0", diff --git a/package.json b/package.json index aed8406..8e0ed0c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "multi-hoster-uploader", - "version": "2.1.16", + "version": "2.1.17", "description": "Upload files to doodstream, voe, vidmoly, byse simultaneously", "main": "main.js", "scripts": { diff --git a/renderer/app.js b/renderer/app.js index 872b054..eaa8d01 100644 --- a/renderer/app.js +++ b/renderer/app.js @@ -1820,7 +1820,8 @@ function buildRowHtml(job) { const elapsed = formatTime(job.elapsed); const remaining = formatTime(job.remaining); const speed = job.speedKbs > 0 ? `${formatSpeed(job.speedKbs)}` : ''; - const pct = Math.min(100, Math.round((job.progress || 0) * 100)); + const progress = Math.min(1, Math.max(0, Number(job.progress) || 0)); + const pct = Math.round(progress * 100); const link = job.result ? (job.result.download_url || job.result.embed_url || '') : ''; return ` @@ -1834,7 +1835,7 @@ function buildRowHtml(job) {
-
+
${job.status === 'preview' ? '' : pct + '%'}
@@ -1850,7 +1851,8 @@ function _updateRowInPlace(tr, job) { const elapsed = formatTime(job.elapsed); const remaining = formatTime(job.remaining); const speed = job.speedKbs > 0 ? `${formatSpeed(job.speedKbs)}` : ''; - const pct = Math.min(100, Math.round((job.progress || 0) * 100)); + const progress = Math.min(1, Math.max(0, Number(job.progress) || 0)); + const pct = Math.round(progress * 100); const link = job.result ? (job.result.download_url || job.result.embed_url || '') : ''; // Write DOM only when the target value actually changes — a no-op progress @@ -1881,8 +1883,8 @@ function _updateRowInPlace(tr, job) { const fill = cells[7].querySelector('.progress-bar-fill'); if (fill) { - const pctStr = pct + '%'; - if (fill.style.width !== pctStr) fill.style.width = pctStr; + const progressTransform = `scaleX(${progress})`; + if (fill.style.transform !== progressTransform) fill.style.transform = progressTransform; const fillClass = `progress-bar-fill ${statusClass}`; if (fill.className !== fillClass) fill.className = fillClass; } diff --git a/renderer/styles.css b/renderer/styles.css index d1f45dd..46a7f88 100644 --- a/renderer/styles.css +++ b/renderer/styles.css @@ -518,10 +518,12 @@ body.col-resizing, body.col-resizing * { cursor: col-resize !important; user-sel overflow: hidden; } .progress-bar-fill { + width: 100%; height: 100%; border-radius: 2px; - will-change: width; - transition: width 220ms ease-out; + transform-origin: left center; + will-change: transform; + transition: transform 360ms linear; } .progress-bar-fill.status-uploading { background: linear-gradient(90deg, var(--success), var(--success-end)); } .progress-bar-fill.status-getting-server { background: var(--accent); } @@ -1749,7 +1751,7 @@ select.hs-input { max-width: none; width: auto; min-width: 140px; } } .progress-bar-fill { - transition-duration: 220ms !important; + transition-duration: 360ms !important; } .menu-opening { diff --git a/scripts/verify-public-release.mjs b/scripts/verify-public-release.mjs index 641878d..48b8720 100644 --- a/scripts/verify-public-release.mjs +++ b/scripts/verify-public-release.mjs @@ -138,10 +138,12 @@ const sourceFiles = [ 'tests/startup-renderer.test.js', 'tests/stats.test.js', 'tests/support-bundle.test.js', + 'tests/support/ui-network-safety.js', 'tests/suspect-reject-alternates.test.js', 'tests/throttle-timer.test.js', 'tests/throttle.test.js', 'tests/throttled-cache.test.js', + 'tests/ui-network-safety.test.js', 'tests/ui-smoke.js', 'tests/updater-version.test.js', 'tests/upload-log.test.js', diff --git a/tests/remote-server.test.js b/tests/remote-server.test.js index 75a37a3..a84db1f 100644 --- a/tests/remote-server.test.js +++ b/tests/remote-server.test.js @@ -27,6 +27,7 @@ describe('RemoteServer', () => { await server.start({ port: 0, // random available port + host: '127.0.0.1', token: 'test-token-123', allowInput: true, mainWindow: mockMainWindow, diff --git a/tests/support/ui-network-safety.js b/tests/support/ui-network-safety.js new file mode 100644 index 0000000..161f4ab --- /dev/null +++ b/tests/support/ui-network-safety.js @@ -0,0 +1,28 @@ +const LOOPBACK_HOST = '127.0.0.1'; + +function listenOnLoopback(server, port = 0) { + return new Promise((resolve, reject) => { + const handleError = error => reject(error); + server.once('error', handleError); + server.listen(port, LOOPBACK_HOST, () => { + server.off('error', handleError); + resolve(server.address()); + }); + }); +} + +function installLoopbackRemoteServerGuard(RemoteServer, onListening = () => {}) { + const originalStart = RemoteServer.prototype.start; + const guardedStart = async function (options) { + const result = await originalStart.call(this, { ...options, host: LOOPBACK_HOST }); + const address = this._wss && this._wss.address(); + onListening(address && typeof address === 'object' ? address.address : ''); + return result; + }; + RemoteServer.prototype.start = guardedStart; + return () => { + if (RemoteServer.prototype.start === guardedStart) RemoteServer.prototype.start = originalStart; + }; +} + +module.exports = { listenOnLoopback, installLoopbackRemoteServerGuard }; diff --git a/tests/ui-network-safety.test.js b/tests/ui-network-safety.test.js new file mode 100644 index 0000000..e23919c --- /dev/null +++ b/tests/ui-network-safety.test.js @@ -0,0 +1,47 @@ +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const net = require('node:net'); +const RemoteServer = require('../lib/remote-server'); + +let networkSafety = {}; +try { + networkSafety = require('./support/ui-network-safety'); +} catch {} + +test('Electron UI test listeners bind only to loopback', async () => { + assert.equal(typeof networkSafety.listenOnLoopback, 'function'); + + const server = net.createServer(); + try { + await networkSafety.listenOnLoopback(server); + const address = server.address(); + assert.equal(address.address, '127.0.0.1'); + assert.ok(address.port > 0); + } finally { + if (server.listening) await new Promise(resolve => server.close(resolve)); + } +}); + +test('Electron UI remote server guard keeps the real server on loopback', async () => { + assert.equal(typeof networkSafety.installLoopbackRemoteServerGuard, 'function'); + + const observedAddresses = []; + const restore = networkSafety.installLoopbackRemoteServerGuard(RemoteServer, address => observedAddresses.push(address)); + const server = new RemoteServer(); + + try { + await server.start({ + port: 0, + token: 'ui-network-safety-token', + allowInput: true, + onSignalingToCapture: () => {}, + onCreateCaptureWindow: () => {}, + onDestroyCaptureWindow: () => {} + }); + assert.equal(server._wss.address().address, '127.0.0.1'); + assert.deepEqual(observedAddresses, ['127.0.0.1']); + } finally { + server.stop(); + restore(); + } +}); diff --git a/tests/ui-smoke.js b/tests/ui-smoke.js index 22971b7..6e65833 100644 --- a/tests/ui-smoke.js +++ b/tests/ui-smoke.js @@ -31,7 +31,11 @@ const fs = require('fs'); const net = require('net'); const path = require('path'); const ConfigStore = require(path.join(process.cwd(), 'lib', 'config-store')); +const RemoteServer = require(path.join(process.cwd(), 'lib', 'remote-server')); +const { listenOnLoopback, installLoopbackRemoteServerGuard } = require(path.join(process.cwd(), 'tests', 'support', 'ui-network-safety')); const updaterModule = require(path.join(process.cwd(), 'lib', 'updater')); +const uiRemoteBindAddresses = []; +installLoopbackRemoteServerGuard(RemoteServer, address => uiRemoteBindAddresses.push(address)); let preparedUpdateMockCalls = 0; let launchedUpdateMockCalls = 0; let updateCheckMockCalls = 0; @@ -534,28 +538,48 @@ setTimeout(async () => { check('Upload sidebar drops hidden selections when changing filters', uploadFilterState.active.selected.join('|') === 'ui-active-z'); const uploadProgressMotion = await wc.executeJavaScript(\`(async () => { - const track = document.createElement('div'); - track.className = 'progress-bar-bg'; - track.style.cssText = 'position:fixed;left:20px;top:20px;width:300px;'; - const fill = document.createElement('div'); - fill.className = 'progress-bar-fill status-uploading'; - fill.style.width = '10%'; - track.append(fill); - document.body.append(track); + const table = document.createElement('table'); + table.style.cssText = 'position:fixed;left:20px;top:20px;width:700px;'; + const tbody = document.createElement('tbody'); + const job = { id: 'ui-smooth-progress', file: 'C:/ui/smooth.bin', fileName: 'smooth.bin', hoster: 'byse.sx', status: 'uploading', bytesUploaded: 920, bytesTotal: 1000, speedKbs: 1, elapsed: 1, remaining: 1, progress: .92 }; + tbody.innerHTML = buildRowHtml(job); + table.append(tbody); + document.body.append(table); + const row = tbody.querySelector('.queue-row'); + const track = row.querySelector('.progress-bar-bg'); + const fill = row.querySelector('.progress-bar-fill'); + track.style.cssText = 'flex:none;width:400px;'; const ratio = () => fill.getBoundingClientRect().width / track.getBoundingClientRect().width; const background = getComputedStyle(fill).backgroundImage; await new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve))); const start = ratio(); - fill.style.width = '80%'; - await new Promise(resolve => setTimeout(resolve, 70)); - const middle = ratio(); - await new Promise(resolve => setTimeout(resolve, 260)); + job.progress = .924; + job.bytesUploaded = 924; + _updateRowInPlace(row, job); + await new Promise(resolve => setTimeout(resolve, 80)); + const fractionalMiddle = ratio(); + await new Promise(resolve => setTimeout(resolve, 240)); + const fractionalEnd = ratio(); + const fractionalLabel = row.querySelector('.progress-pct').textContent; + job.progress = .93; + job.bytesUploaded = 930; + _updateRowInPlace(row, job); + const nextFrames = []; + for (let frame = 0; frame < 12; frame++) { + await new Promise(resolve => requestAnimationFrame(resolve)); + nextFrames.push(ratio()); + } + await new Promise(resolve => setTimeout(resolve, 240)); const end = ratio(); - track.remove(); - return { background, start, middle, end }; + table.remove(); + return { background, start, fractionalMiddle, fractionalEnd, fractionalLabel, nextFrames, end }; })()\`); check('Active upload progress uses the green success gradient', uploadProgressMotion.background === 'linear-gradient(90deg, rgb(117, 211, 155), rgb(156, 226, 184))'); - check('Active upload progress visibly interpolates percentage changes', uploadProgressMotion.start > 0.08 && uploadProgressMotion.start < 0.12 && uploadProgressMotion.middle > uploadProgressMotion.start + 0.02 && uploadProgressMotion.middle < 0.78 && uploadProgressMotion.end > 0.78 && uploadProgressMotion.end < 0.82); + check('Active upload progress moves continuously before the rounded percentage changes', uploadProgressMotion.start > .919 && uploadProgressMotion.start < .921 && uploadProgressMotion.fractionalMiddle > uploadProgressMotion.start && uploadProgressMotion.fractionalMiddle < .924 && uploadProgressMotion.fractionalEnd > .923 && uploadProgressMotion.fractionalEnd < .925 && uploadProgressMotion.fractionalLabel === '92%'); + const smoothFrameCount = uploadProgressMotion.nextFrames.slice(1).filter((value, index) => value > uploadProgressMotion.nextFrames[index] + .00001).length; + const monotonicFrames = uploadProgressMotion.nextFrames.every((value, index, values) => index === 0 || value >= values[index - 1] - .00001); + console.log('Upload progress frame trace: ' + [uploadProgressMotion.fractionalEnd, ...uploadProgressMotion.nextFrames, uploadProgressMotion.end].map(value => (value * 100).toFixed(3)).join(' -> ')); + check('Active upload progress glides through the next whole percentage across real frames', uploadProgressMotion.nextFrames.length === 12 && smoothFrameCount >= 8 && monotonicFrames && uploadProgressMotion.nextFrames.at(-1) > uploadProgressMotion.fractionalEnd && uploadProgressMotion.nextFrames.at(-1) < .93 && uploadProgressMotion.end > .929 && uploadProgressMotion.end < .931); const uploadSelectionScope = await wc.executeJavaScript(\`(() => { const makeJob = (id, status) => ({ id, file: 'C:/ui/' + id + '.bin', fileName: id + '.bin', hoster: 'byse.sx', status, bytesUploaded: 0, bytesTotal: 1024, speedKbs: 0, elapsed: 0, remaining: 0, progress: status === 'done' ? 1 : 0 }); @@ -1279,10 +1303,8 @@ setTimeout(async () => { try { fs.unlinkSync(importPersistFailurePath); } catch {} const occupiedRemotePortServer = net.createServer(); - await new Promise((resolve, reject) => { - occupiedRemotePortServer.once('error', reject); - occupiedRemotePortServer.listen(0, resolve); - }); + await listenOnLoopback(occupiedRemotePortServer); + const occupiedRemoteBindAddress = occupiedRemotePortServer.address().address; const occupiedRemotePort = occupiedRemotePortServer.address().port; const configBeforeRemoteFailure = JSON.parse(fs.readFileSync(activeConfigStore.filePath, 'utf-8')); const alwaysOnTopAfterRemoteFailure = !Boolean(configBeforeRemoteFailure.globalSettings.alwaysOnTop); @@ -1309,6 +1331,7 @@ setTimeout(async () => { const generatedRemoteSettings = await wc.executeJavaScript('saveRemoteSettingsTracked(' + JSON.stringify({ ...restoredRemoteSettings, enabled: true, token: '' }) + ')'); const generatedRemoteToken = generatedRemoteSettings?.settings?.token || ''; + check('Electron UI smoke keeps every network listener on loopback', occupiedRemoteBindAddress === '127.0.0.1' && uiRemoteBindAddresses.length > 0 && uiRemoteBindAddresses.every(address => address === '127.0.0.1')); const canonicalRemoteAfterFullSave = await wc.executeJavaScript('(async () => { config.globalSettings = { ...(config.globalSettings || {}), remote: { ...' + JSON.stringify(restoredRemoteSettings) + ', enabled: false, token: "" } }; renderSettings(); const tokenInput = document.getElementById("remoteTokenInput"); if (tokenInput) tokenInput.value = ""; await saveSettings({ feedbackText: "Gespeichert" }); return config.globalSettings.remote?.token || ""; })()'); const configAfterGeneratedTokenSave = JSON.parse(fs.readFileSync(activeConfigStore.filePath, 'utf-8')); check('Full settings save preserves the canonical remote token', generatedRemoteToken.length > 0 && canonicalRemoteAfterFullSave === generatedRemoteToken && configAfterGeneratedTokenSave.globalSettings.remote?.token === generatedRemoteToken);