release: v2.1.17

Render upload progress from fractional transfer values while keeping the visible percentage rounded. Move the green fill with a linear compositor transform and cover the 92-to-93 percent transition with monotonic frame-level Electron assertions.

Keep Electron UI verification listeners loopback-only so development validation no longer raises a Windows Firewall prompt under electron.exe. Extend the public source allowlist with the focused network safety regression coverage.
This commit is contained in:
Sucukdeluxe
2026-08-13 04:37:18 +02:00
parent a299fc204b
commit b7754f355b
10 changed files with 137 additions and 32 deletions
+2 -2
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). 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 ## 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. - Filter the workspace by all, active, queued, completed, or failed jobs.
- Search and filter queue entries by file name, host, and status. - 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. - 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. - 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. - 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. - Reorder selected jobs, start selected jobs, retry finished jobs, or stop active work.
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "multi-hoster-uploader", "name": "multi-hoster-uploader",
"version": "2.1.16", "version": "2.1.17",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "multi-hoster-uploader", "name": "multi-hoster-uploader",
"version": "2.1.16", "version": "2.1.17",
"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.1.16", "version": "2.1.17",
"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": {
+7 -5
View File
@@ -1820,7 +1820,8 @@ function buildRowHtml(job) {
const elapsed = formatTime(job.elapsed); const elapsed = formatTime(job.elapsed);
const remaining = formatTime(job.remaining); const remaining = formatTime(job.remaining);
const speed = job.speedKbs > 0 ? `${formatSpeed(job.speedKbs)}` : ''; 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 || '') : ''; const link = job.result ? (job.result.download_url || job.result.embed_url || '') : '';
return `<tr class="${rowClass}" data-job-id="${job.id}" data-link="${escapeAttr(link)}" tabindex="0" aria-selected="${selectedJobIds.has(job.id)}" style="height:${VIRTUAL_ROW_HEIGHT}px"> return `<tr class="${rowClass}" data-job-id="${job.id}" data-link="${escapeAttr(link)}" tabindex="0" aria-selected="${selectedJobIds.has(job.id)}" style="height:${VIRTUAL_ROW_HEIGHT}px">
@@ -1834,7 +1835,7 @@ function buildRowHtml(job) {
<td class="col-progress"> <td class="col-progress">
<div class="progress-cell"> <div class="progress-cell">
<div class="progress-bar-bg"> <div class="progress-bar-bg">
<div class="progress-bar-fill ${statusClass}" style="width:${pct}%"></div> <div class="progress-bar-fill ${statusClass}" style="transform:scaleX(${progress})"></div>
</div> </div>
<span class="progress-pct">${job.status === 'preview' ? '' : pct + '%'}</span> <span class="progress-pct">${job.status === 'preview' ? '' : pct + '%'}</span>
</div> </div>
@@ -1850,7 +1851,8 @@ function _updateRowInPlace(tr, job) {
const elapsed = formatTime(job.elapsed); const elapsed = formatTime(job.elapsed);
const remaining = formatTime(job.remaining); const remaining = formatTime(job.remaining);
const speed = job.speedKbs > 0 ? `${formatSpeed(job.speedKbs)}` : ''; 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 || '') : ''; 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 // 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'); const fill = cells[7].querySelector('.progress-bar-fill');
if (fill) { if (fill) {
const pctStr = pct + '%'; const progressTransform = `scaleX(${progress})`;
if (fill.style.width !== pctStr) fill.style.width = pctStr; if (fill.style.transform !== progressTransform) fill.style.transform = progressTransform;
const fillClass = `progress-bar-fill ${statusClass}`; const fillClass = `progress-bar-fill ${statusClass}`;
if (fill.className !== fillClass) fill.className = fillClass; if (fill.className !== fillClass) fill.className = fillClass;
} }
+5 -3
View File
@@ -518,10 +518,12 @@ body.col-resizing, body.col-resizing * { cursor: col-resize !important; user-sel
overflow: hidden; overflow: hidden;
} }
.progress-bar-fill { .progress-bar-fill {
width: 100%;
height: 100%; height: 100%;
border-radius: 2px; border-radius: 2px;
will-change: width; transform-origin: left center;
transition: width 220ms ease-out; 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-uploading { background: linear-gradient(90deg, var(--success), var(--success-end)); }
.progress-bar-fill.status-getting-server { background: var(--accent); } .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 { .progress-bar-fill {
transition-duration: 220ms !important; transition-duration: 360ms !important;
} }
.menu-opening { .menu-opening {
+2
View File
@@ -138,10 +138,12 @@ const sourceFiles = [
'tests/startup-renderer.test.js', 'tests/startup-renderer.test.js',
'tests/stats.test.js', 'tests/stats.test.js',
'tests/support-bundle.test.js', 'tests/support-bundle.test.js',
'tests/support/ui-network-safety.js',
'tests/suspect-reject-alternates.test.js', 'tests/suspect-reject-alternates.test.js',
'tests/throttle-timer.test.js', 'tests/throttle-timer.test.js',
'tests/throttle.test.js', 'tests/throttle.test.js',
'tests/throttled-cache.test.js', 'tests/throttled-cache.test.js',
'tests/ui-network-safety.test.js',
'tests/ui-smoke.js', 'tests/ui-smoke.js',
'tests/updater-version.test.js', 'tests/updater-version.test.js',
'tests/upload-log.test.js', 'tests/upload-log.test.js',
+1
View File
@@ -27,6 +27,7 @@ describe('RemoteServer', () => {
await server.start({ await server.start({
port: 0, // random available port port: 0, // random available port
host: '127.0.0.1',
token: 'test-token-123', token: 'test-token-123',
allowInput: true, allowInput: true,
mainWindow: mockMainWindow, mainWindow: mockMainWindow,
+28
View File
@@ -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 };
+47
View File
@@ -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();
}
});
+42 -19
View File
@@ -31,7 +31,11 @@ const fs = require('fs');
const net = require('net'); const net = require('net');
const path = require('path'); const path = require('path');
const ConfigStore = require(path.join(process.cwd(), 'lib', 'config-store')); 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 updaterModule = require(path.join(process.cwd(), 'lib', 'updater'));
const uiRemoteBindAddresses = [];
installLoopbackRemoteServerGuard(RemoteServer, address => uiRemoteBindAddresses.push(address));
let preparedUpdateMockCalls = 0; let preparedUpdateMockCalls = 0;
let launchedUpdateMockCalls = 0; let launchedUpdateMockCalls = 0;
let updateCheckMockCalls = 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'); check('Upload sidebar drops hidden selections when changing filters', uploadFilterState.active.selected.join('|') === 'ui-active-z');
const uploadProgressMotion = await wc.executeJavaScript(\`(async () => { const uploadProgressMotion = await wc.executeJavaScript(\`(async () => {
const track = document.createElement('div'); const table = document.createElement('table');
track.className = 'progress-bar-bg'; table.style.cssText = 'position:fixed;left:20px;top:20px;width:700px;';
track.style.cssText = 'position:fixed;left:20px;top:20px;width:300px;'; const tbody = document.createElement('tbody');
const fill = document.createElement('div'); 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 };
fill.className = 'progress-bar-fill status-uploading'; tbody.innerHTML = buildRowHtml(job);
fill.style.width = '10%'; table.append(tbody);
track.append(fill); document.body.append(table);
document.body.append(track); 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 ratio = () => fill.getBoundingClientRect().width / track.getBoundingClientRect().width;
const background = getComputedStyle(fill).backgroundImage; const background = getComputedStyle(fill).backgroundImage;
await new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve))); await new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve)));
const start = ratio(); const start = ratio();
fill.style.width = '80%'; job.progress = .924;
await new Promise(resolve => setTimeout(resolve, 70)); job.bytesUploaded = 924;
const middle = ratio(); _updateRowInPlace(row, job);
await new Promise(resolve => setTimeout(resolve, 260)); 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(); const end = ratio();
track.remove(); table.remove();
return { background, start, middle, end }; 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 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 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 }); 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 {} try { fs.unlinkSync(importPersistFailurePath); } catch {}
const occupiedRemotePortServer = net.createServer(); const occupiedRemotePortServer = net.createServer();
await new Promise((resolve, reject) => { await listenOnLoopback(occupiedRemotePortServer);
occupiedRemotePortServer.once('error', reject); const occupiedRemoteBindAddress = occupiedRemotePortServer.address().address;
occupiedRemotePortServer.listen(0, resolve);
});
const occupiedRemotePort = occupiedRemotePortServer.address().port; const occupiedRemotePort = occupiedRemotePortServer.address().port;
const configBeforeRemoteFailure = JSON.parse(fs.readFileSync(activeConfigStore.filePath, 'utf-8')); const configBeforeRemoteFailure = JSON.parse(fs.readFileSync(activeConfigStore.filePath, 'utf-8'));
const alwaysOnTopAfterRemoteFailure = !Boolean(configBeforeRemoteFailure.globalSettings.alwaysOnTop); 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 generatedRemoteSettings = await wc.executeJavaScript('saveRemoteSettingsTracked(' + JSON.stringify({ ...restoredRemoteSettings, enabled: true, token: '' }) + ')');
const generatedRemoteToken = generatedRemoteSettings?.settings?.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 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')); 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); check('Full settings save preserves the canonical remote token', generatedRemoteToken.length > 0 && canonicalRemoteAfterFullSave === generatedRemoteToken && configAfterGeneratedTokenSave.globalSettings.remote?.token === generatedRemoteToken);