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);
|