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:
@@ -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.
|
||||
|
||||
Generated
+2
-2
@@ -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",
|
||||
|
||||
+1
-1
@@ -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": {
|
||||
|
||||
+7
-5
@@ -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 `<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">
|
||||
<div class="progress-cell">
|
||||
<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>
|
||||
<span class="progress-pct">${job.status === 'preview' ? '' : pct + '%'}</span>
|
||||
</div>
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
+5
-3
@@ -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 {
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 };
|
||||
@@ -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
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user