Refresh release metadata immediately before downloading an update and keep automatic checks current while the app remains open. Accept desktop file drops from the first renderer frame, preserve early and rapid drops until initialization completes, and show the live remaining upload size in the sidebar. Add regression coverage for update freshness, startup drag-and-drop ordering, localization, and remaining-byte retry behavior. Bump the application to v2.1.25.
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).
|
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.24. Use the release page for the executables and the full English changelog.
|
The latest public release is version 2.1.25. Use the release page for the executables and the full English changelog.
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
@@ -22,6 +22,7 @@ The latest public release is version 2.1.24. Use the release page for the execut
|
|||||||
- 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, smoothly interpolated progress, transferred size, speed, and the selected host account.
|
- Track status, smoothly interpolated progress, transferred size, speed, and the selected host account.
|
||||||
|
- Track the remaining upload size as queued and active files progress or return for a retry.
|
||||||
- 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.
|
||||||
|
|||||||
@@ -16,7 +16,28 @@
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const api = { createSerializedRunner };
|
function createReadySerializedRunner(task) {
|
||||||
|
let ready = false;
|
||||||
|
let releaseReady;
|
||||||
|
const readyPromise = new Promise((resolve) => { releaseReady = resolve; });
|
||||||
|
const runner = createSerializedRunner(async (...args) => {
|
||||||
|
await readyPromise;
|
||||||
|
return task(...args);
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
run: (...args) => runner.run(...args),
|
||||||
|
flush: () => runner.flush(),
|
||||||
|
ready() {
|
||||||
|
if (ready) return false;
|
||||||
|
ready = true;
|
||||||
|
releaseReady();
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
get isReady() { return ready; }
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const api = { createSerializedRunner, createReadySerializedRunner };
|
||||||
if (typeof module !== 'undefined' && module.exports) module.exports = api;
|
if (typeof module !== 'undefined' && module.exports) module.exports = api;
|
||||||
else if (root) root.SerializedRunner = api;
|
else if (root) root.SerializedRunner = api;
|
||||||
})(typeof window !== 'undefined' ? window : this);
|
})(typeof window !== 'undefined' ? window : this);
|
||||||
|
|||||||
+48
-12
@@ -17,6 +17,27 @@ const CACHE_TTL = 10 * 60 * 1000; // 10 min
|
|||||||
let activeAbort = null;
|
let activeAbort = null;
|
||||||
const launchedInstallerPaths = new Set();
|
const launchedInstallerPaths = new Set();
|
||||||
|
|
||||||
|
function createUpdateAnnouncementState() {
|
||||||
|
let announcedVersion = '';
|
||||||
|
|
||||||
|
function versionOf(update) {
|
||||||
|
return String(update?.remoteVersion || '').replace(/^v/i, '').trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
return Object.freeze({
|
||||||
|
canAnnounce(update, rendererReady) {
|
||||||
|
const version = versionOf(update);
|
||||||
|
return Boolean(rendererReady && update?.available && version && version !== announcedVersion);
|
||||||
|
},
|
||||||
|
markAnnounced(update) {
|
||||||
|
announcedVersion = versionOf(update);
|
||||||
|
},
|
||||||
|
reset() {
|
||||||
|
announcedVersion = '';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function getCurrentVersion() {
|
function getCurrentVersion() {
|
||||||
return app.getVersion();
|
return app.getVersion();
|
||||||
}
|
}
|
||||||
@@ -61,17 +82,32 @@ function findLatestYml(assets) {
|
|||||||
return assets.find(a => /^latest\.yml$/i.test(a.name)) || null;
|
return assets.find(a => /^latest\.yml$/i.test(a.name)) || null;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fetchJson(url, signal) {
|
function cacheBustedUrl(url) {
|
||||||
|
const parsed = new URL(url);
|
||||||
|
parsed.searchParams.set('_mhu_update', crypto.randomUUID());
|
||||||
|
return parsed.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchJson(url, signal, options = {}) {
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
const timeout = setTimeout(() => controller.abort(), CHECK_TIMEOUT);
|
const timeout = setTimeout(() => controller.abort(), CHECK_TIMEOUT);
|
||||||
const onAbort = () => controller.abort();
|
const onAbort = () => controller.abort();
|
||||||
|
const forceRefresh = options.forceRefresh === true;
|
||||||
|
const fetchImpl = options.fetchImpl || fetch;
|
||||||
if (signal) signal.addEventListener('abort', onAbort);
|
if (signal) signal.addEventListener('abort', onAbort);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await fetch(url, {
|
const res = await fetchImpl(forceRefresh ? cacheBustedUrl(url) : url, {
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
signal: controller.signal,
|
signal: controller.signal,
|
||||||
redirect: 'follow'
|
redirect: 'follow',
|
||||||
|
...(forceRefresh ? {
|
||||||
|
cache: 'no-store',
|
||||||
|
headers: {
|
||||||
|
'Cache-Control': 'no-cache, no-store',
|
||||||
|
Pragma: 'no-cache'
|
||||||
|
}
|
||||||
|
} : {})
|
||||||
});
|
});
|
||||||
const text = await res.text();
|
const text = await res.text();
|
||||||
try {
|
try {
|
||||||
@@ -112,13 +148,14 @@ async function fetchGithubReleaseNotes(remoteVersion, fallback = '', fetchImpl =
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function checkForUpdate() {
|
async function checkForUpdate(options = {}) {
|
||||||
// Return cached result if fresh
|
const forceRefresh = options.forceRefresh === true;
|
||||||
if (cachedCheck && (Date.now() - cachedCheckTs) < CACHE_TTL) {
|
const fetchImpl = options.fetchImpl || fetch;
|
||||||
|
if (!forceRefresh && cachedCheck && (Date.now() - cachedCheckTs) < CACHE_TTL) {
|
||||||
return cachedCheck;
|
return cachedCheck;
|
||||||
}
|
}
|
||||||
|
|
||||||
const releases = await fetchJson(API_URL);
|
const releases = await fetchJson(API_URL, undefined, { forceRefresh, fetchImpl });
|
||||||
|
|
||||||
if (!Array.isArray(releases) || releases.length === 0) {
|
if (!Array.isArray(releases) || releases.length === 0) {
|
||||||
return { available: false };
|
return { available: false };
|
||||||
@@ -142,7 +179,7 @@ async function checkForUpdate() {
|
|||||||
return { available: false, reason: 'Kein Setup-Asset im Release gefunden' };
|
return { available: false, reason: 'Kein Setup-Asset im Release gefunden' };
|
||||||
}
|
}
|
||||||
|
|
||||||
const releaseNotes = await fetchGithubReleaseNotes(remoteVersion, release.body || '');
|
const releaseNotes = await fetchGithubReleaseNotes(remoteVersion, release.body || '', fetchImpl);
|
||||||
cachedCheck = {
|
cachedCheck = {
|
||||||
available: true,
|
available: true,
|
||||||
currentVersion,
|
currentVersion,
|
||||||
@@ -210,10 +247,9 @@ async function prepareUpdate(onProgress, options = {}) {
|
|||||||
// Stage: starting
|
// Stage: starting
|
||||||
if (onProgress) onProgress({ stage: 'starting', percent: 0 });
|
if (onProgress) onProgress({ stage: 'starting', percent: 0 });
|
||||||
|
|
||||||
// Check or use cached
|
let check = options.checkResult || null;
|
||||||
let check = options.checkResult || cachedCheck;
|
|
||||||
if (!check || !check.available) {
|
if (!check || !check.available) {
|
||||||
check = await checkForUpdate();
|
check = await checkForUpdate({ forceRefresh: true, fetchImpl });
|
||||||
}
|
}
|
||||||
if (!check || !check.available) {
|
if (!check || !check.available) {
|
||||||
throw new Error('Kein Update verfügbar');
|
throw new Error('Kein Update verfügbar');
|
||||||
@@ -353,4 +389,4 @@ function abortUpdate() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { checkForUpdate, fetchGithubReleaseNotes, prepareUpdate, launchPreparedUpdate, abortUpdate, isNewer, resolveReleaseVersion, pickSetupAsset, parseLatestYml };
|
module.exports = { checkForUpdate, fetchGithubReleaseNotes, prepareUpdate, launchPreparedUpdate, abortUpdate, isNewer, resolveReleaseVersion, pickSetupAsset, parseLatestYml, createUpdateAnnouncementState };
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ const DoodstreamUploader = require('./lib/doodstream-upload');
|
|||||||
const { selectUploadAuth } = require('./lib/account-auth');
|
const { selectUploadAuth } = require('./lib/account-auth');
|
||||||
const { createAccountPicker } = require('./lib/account-rotation');
|
const { createAccountPicker } = require('./lib/account-rotation');
|
||||||
const ClouddropUploader = require('./lib/clouddrop-upload');
|
const ClouddropUploader = require('./lib/clouddrop-upload');
|
||||||
const { checkForUpdate, prepareUpdate, launchPreparedUpdate, abortUpdate } = require('./lib/updater');
|
const { checkForUpdate, prepareUpdate, launchPreparedUpdate, abortUpdate, createUpdateAnnouncementState } = require('./lib/updater');
|
||||||
const backupCrypto = require('./lib/backup-crypto');
|
const backupCrypto = require('./lib/backup-crypto');
|
||||||
const { createOnlineBackup, downloadOnlineBackup, uploadOnlineBackup } = require('./lib/online-backup');
|
const { createOnlineBackup, downloadOnlineBackup, uploadOnlineBackup } = require('./lib/online-backup');
|
||||||
const { createPortableSettingsSnapshot, prepareImportedSettings } = require('./lib/settings-backup');
|
const { createPortableSettingsSnapshot, prepareImportedSettings } = require('./lib/settings-backup');
|
||||||
@@ -113,6 +113,8 @@ let preparedUpdate = null;
|
|||||||
let updatePreparationPromise = null;
|
let updatePreparationPromise = null;
|
||||||
let updateQuitPending = false;
|
let updateQuitPending = false;
|
||||||
let preparedUpdateLaunchStarted = false;
|
let preparedUpdateLaunchStarted = false;
|
||||||
|
let updateCheckInterval = null;
|
||||||
|
const updateAnnouncementState = createUpdateAnnouncementState();
|
||||||
let _lastImportPath = null;
|
let _lastImportPath = null;
|
||||||
let dropTargetWindow = null;
|
let dropTargetWindow = null;
|
||||||
let tray = null;
|
let tray = null;
|
||||||
@@ -1458,6 +1460,7 @@ function createWindow() {
|
|||||||
mainWindow.webContents.on('did-start-navigation', (_event, _url, isInPlace, isMainFrame) => {
|
mainWindow.webContents.on('did-start-navigation', (_event, _url, isInPlace, isMainFrame) => {
|
||||||
if (isInPlace || !isMainFrame) return;
|
if (isInPlace || !isMainFrame) return;
|
||||||
closeHandshakeReady = false;
|
closeHandshakeReady = false;
|
||||||
|
updateAnnouncementState.reset();
|
||||||
restoreClosePreparation(closePreparationAttempt);
|
restoreClosePreparation(closePreparationAttempt);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1543,6 +1546,25 @@ function updateTrayTooltip(text) {
|
|||||||
if (tray && !tray.isDestroyed()) tray.setToolTip(text);
|
if (tray && !tray.isDestroyed()) tray.setToolTip(text);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function announceAvailableUpdate(result) {
|
||||||
|
if (!updateAnnouncementState.canAnnounce(result, closeHandshakeReady)) return false;
|
||||||
|
if (!safeSend('app:update-available', result)) return false;
|
||||||
|
updateAnnouncementState.markAnnounced(result);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runAutomaticUpdateCheck(forceRefresh) {
|
||||||
|
try {
|
||||||
|
logInfo('update-check: starting');
|
||||||
|
const result = await checkForUpdate({ forceRefresh });
|
||||||
|
logInfo(`update-check: available=${result && result.available}, remote=${result && result.remoteVersion}`);
|
||||||
|
logDebug(`update-check result: ${JSON.stringify(result)}`);
|
||||||
|
announceAvailableUpdate(result);
|
||||||
|
} catch (err) {
|
||||||
|
logError('update-check failed', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
app.whenReady().then(async () => {
|
app.whenReady().then(async () => {
|
||||||
if (!_hasSingleInstanceLock) return;
|
if (!_hasSingleInstanceLock) return;
|
||||||
try {
|
try {
|
||||||
@@ -1630,20 +1652,9 @@ app.whenReady().then(async () => {
|
|||||||
}
|
}
|
||||||
} catch {}
|
} catch {}
|
||||||
|
|
||||||
// Auto-check for updates after 3 seconds
|
setTimeout(() => { void runAutomaticUpdateCheck(true); }, 3000);
|
||||||
setTimeout(async () => {
|
updateCheckInterval = setInterval(() => { void runAutomaticUpdateCheck(true); }, 5 * 60 * 1000);
|
||||||
try {
|
updateCheckInterval.unref?.();
|
||||||
logInfo('update-check: starting');
|
|
||||||
const result = await checkForUpdate();
|
|
||||||
logInfo(`update-check: available=${result && result.available}, remote=${result && result.remoteVersion}`);
|
|
||||||
logDebug(`update-check result: ${JSON.stringify(result)}`);
|
|
||||||
if (result && result.available && mainWindow && !mainWindow.isDestroyed()) {
|
|
||||||
safeSend('app:update-available', result);
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
logError('update-check failed', err);
|
|
||||||
}
|
|
||||||
}, 3000);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
app.on('window-all-closed', () => {
|
app.on('window-all-closed', () => {
|
||||||
@@ -1663,6 +1674,8 @@ app.on('before-quit', (event) => {
|
|||||||
app.on('will-quit', () => {
|
app.on('will-quit', () => {
|
||||||
if (quitTeardownStarted) return;
|
if (quitTeardownStarted) return;
|
||||||
quitTeardownStarted = true;
|
quitTeardownStarted = true;
|
||||||
|
if (updateCheckInterval) clearInterval(updateCheckInterval);
|
||||||
|
updateCheckInterval = null;
|
||||||
if (preparedUpdate && updateQuitPending && closeFlushApproved && !preparedUpdateLaunchStarted) {
|
if (preparedUpdate && updateQuitPending && closeFlushApproved && !preparedUpdateLaunchStarted) {
|
||||||
preparedUpdateLaunchStarted = true;
|
preparedUpdateLaunchStarted = true;
|
||||||
try {
|
try {
|
||||||
@@ -2856,9 +2869,9 @@ ipcMain.handle('copy-to-clipboard', (_event, text) => {
|
|||||||
return true;
|
return true;
|
||||||
});
|
});
|
||||||
|
|
||||||
ipcMain.handle('app:check-updates', async () => {
|
ipcMain.handle('app:check-updates', async (_event, options) => {
|
||||||
try {
|
try {
|
||||||
return await checkForUpdate();
|
return await checkForUpdate({ forceRefresh: options && options.forceRefresh === true });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
return { available: false, error: err.message };
|
return { available: false, error: err.message };
|
||||||
}
|
}
|
||||||
@@ -2912,7 +2925,12 @@ ipcMain.handle('app:quit', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
ipcMain.on('app:close-handshake-ready', (event) => {
|
ipcMain.on('app:close-handshake-ready', (event) => {
|
||||||
if (mainWindow && !mainWindow.isDestroyed() && event.sender === mainWindow.webContents) closeHandshakeReady = true;
|
if (mainWindow && !mainWindow.isDestroyed() && event.sender === mainWindow.webContents) {
|
||||||
|
closeHandshakeReady = true;
|
||||||
|
void checkForUpdate().then(announceAvailableUpdate).catch((error) => {
|
||||||
|
logError('update-check failed', error);
|
||||||
|
});
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
ipcMain.on('app:close-preparation-started', (event, attempt) => {
|
ipcMain.on('app:close-preparation-started', (event, attempt) => {
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "multi-hoster-uploader",
|
"name": "multi-hoster-uploader",
|
||||||
"version": "2.1.24",
|
"version": "2.1.25",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "multi-hoster-uploader",
|
"name": "multi-hoster-uploader",
|
||||||
"version": "2.1.24",
|
"version": "2.1.25",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"chokidar": "^3.6.0",
|
"chokidar": "^3.6.0",
|
||||||
"undici": "^7.29.0",
|
"undici": "^7.29.0",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "multi-hoster-uploader",
|
"name": "multi-hoster-uploader",
|
||||||
"version": "2.1.24",
|
"version": "2.1.25",
|
||||||
"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": {
|
||||||
|
|||||||
+1
-1
@@ -63,7 +63,7 @@ contextBridge.exposeInMainWorld('api', {
|
|||||||
copyToClipboard: (text) => ipcRenderer.invoke('copy-to-clipboard', text),
|
copyToClipboard: (text) => ipcRenderer.invoke('copy-to-clipboard', text),
|
||||||
|
|
||||||
// Updates
|
// Updates
|
||||||
checkForUpdate: () => ipcRenderer.invoke('app:check-updates'),
|
checkForUpdate: (options = {}) => ipcRenderer.invoke('app:check-updates', { forceRefresh: options && options.forceRefresh === true }),
|
||||||
installUpdate: () => ipcRenderer.invoke('app:install-update'),
|
installUpdate: () => ipcRenderer.invoke('app:install-update'),
|
||||||
abortUpdate: () => ipcRenderer.invoke('app:abort-update'),
|
abortUpdate: () => ipcRenderer.invoke('app:abort-update'),
|
||||||
getVersion: () => ipcRenderer.invoke('app:get-version'),
|
getVersion: () => ipcRenderer.invoke('app:get-version'),
|
||||||
|
|||||||
+60
-40
@@ -416,7 +416,7 @@ async function init() {
|
|||||||
renderSettings();
|
renderSettings();
|
||||||
renderAccounts();
|
renderAccounts();
|
||||||
setupListeners();
|
setupListeners();
|
||||||
setupDragDrop();
|
importEntryCoordinator.ready();
|
||||||
initUploadSpeedSparkline();
|
initUploadSpeedSparkline();
|
||||||
restoreQueueColumnWidths();
|
restoreQueueColumnWidths();
|
||||||
loadHistory();
|
loadHistory();
|
||||||
@@ -826,8 +826,8 @@ function _syncHeaderUpdateState() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function requestUpdateCheck() {
|
async function requestUpdateCheck({ forceRefresh = true } = {}) {
|
||||||
if (_knownUpdateInfo && _knownUpdateInfo.available) {
|
if (!forceRefresh && _knownUpdateInfo && _knownUpdateInfo.available) {
|
||||||
showUpdateBanner(_knownUpdateInfo);
|
showUpdateBanner(_knownUpdateInfo);
|
||||||
return _knownUpdateInfo;
|
return _knownUpdateInfo;
|
||||||
}
|
}
|
||||||
@@ -836,7 +836,7 @@ async function requestUpdateCheck() {
|
|||||||
_syncHeaderUpdateState();
|
_syncHeaderUpdateState();
|
||||||
showCopyToast('Suche nach Updates…');
|
showCopyToast('Suche nach Updates…');
|
||||||
try {
|
try {
|
||||||
const result = await window.api.checkForUpdate();
|
const result = await window.api.checkForUpdate({ forceRefresh });
|
||||||
if (result && result.available) {
|
if (result && result.available) {
|
||||||
showUpdateBanner(result);
|
showUpdateBanner(result);
|
||||||
showCopyToast('Update gefunden!');
|
showCopyToast('Update gefunden!');
|
||||||
@@ -1368,17 +1368,24 @@ function clearPersistedQueueStateSoon() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// --- File selection ---
|
// --- File selection ---
|
||||||
|
let dragDropBound = false;
|
||||||
function setupDragDrop() {
|
function setupDragDrop() {
|
||||||
|
if (dragDropBound) return;
|
||||||
|
dragDropBound = true;
|
||||||
const dropZone = document.getElementById('dropZone');
|
const dropZone = document.getElementById('dropZone');
|
||||||
// Allow drop on the entire upload view
|
|
||||||
const uploadView = document.getElementById('upload-view');
|
const uploadView = document.getElementById('upload-view');
|
||||||
let _dragCounter = 0;
|
let _dragCounter = 0;
|
||||||
dropZone.addEventListener('dragover', (e) => { e.preventDefault(); e.stopPropagation(); });
|
const acceptCopy = (event) => {
|
||||||
dropZone.addEventListener('dragenter', (e) => { e.preventDefault(); _dragCounter++; dropZone.classList.add('drag-over'); });
|
event.preventDefault();
|
||||||
dropZone.addEventListener('dragleave', (e) => { e.preventDefault(); _dragCounter--; if (_dragCounter <= 0) { _dragCounter = 0; dropZone.classList.remove('drag-over'); } });
|
event.stopPropagation();
|
||||||
|
if (event.dataTransfer) event.dataTransfer.dropEffect = 'copy';
|
||||||
|
};
|
||||||
|
dropZone.addEventListener('dragover', acceptCopy);
|
||||||
|
dropZone.addEventListener('dragenter', (e) => { acceptCopy(e); _dragCounter++; dropZone.classList.add('drag-over'); });
|
||||||
|
dropZone.addEventListener('dragleave', (e) => { e.preventDefault(); e.stopPropagation(); _dragCounter--; if (_dragCounter <= 0) { _dragCounter = 0; dropZone.classList.remove('drag-over'); } });
|
||||||
dropZone.addEventListener('drop', (e) => {
|
dropZone.addEventListener('drop', (e) => {
|
||||||
e.preventDefault(); e.stopPropagation(); _dragCounter = 0; dropZone.classList.remove('drag-over');
|
e.preventDefault(); e.stopPropagation(); _dragCounter = 0; dropZone.classList.remove('drag-over');
|
||||||
addDroppedFiles(e.dataTransfer.files).catch(console.error);
|
enqueueDroppedFiles(e.dataTransfer?.files).catch(console.error);
|
||||||
});
|
});
|
||||||
dropZone.addEventListener('click', () => pickFiles());
|
dropZone.addEventListener('click', () => pickFiles());
|
||||||
dropZone.addEventListener('keydown', (event) => {
|
dropZone.addEventListener('keydown', (event) => {
|
||||||
@@ -1387,12 +1394,12 @@ function setupDragDrop() {
|
|||||||
pickFiles();
|
pickFiles();
|
||||||
});
|
});
|
||||||
|
|
||||||
// Also handle drops on queue container
|
uploadView.addEventListener('dragover', acceptCopy);
|
||||||
uploadView.addEventListener('dragover', (e) => { e.preventDefault(); });
|
uploadView.addEventListener('dragenter', acceptCopy);
|
||||||
uploadView.addEventListener('drop', (e) => {
|
uploadView.addEventListener('drop', (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault(); e.stopPropagation();
|
||||||
if (e.target.closest('.drop-zone')) return; // handled above
|
if (e.target.closest('.drop-zone')) return;
|
||||||
addDroppedFiles(e.dataTransfer.files).catch(console.error);
|
enqueueDroppedFiles(e.dataTransfer?.files).catch(console.error);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1401,7 +1408,24 @@ let _pendingImportInspection = null;
|
|||||||
let _importCoordination = Promise.resolve();
|
let _importCoordination = Promise.resolve();
|
||||||
let _importGeneration = 0;
|
let _importGeneration = 0;
|
||||||
let _pendingImportInspections = 0;
|
let _pendingImportInspections = 0;
|
||||||
let _addingDropped = false;
|
const importEntryCoordinator = window.SerializedRunner.createReadySerializedRunner(async (kind, payload) => {
|
||||||
|
if (kind === 'drop') return processDroppedFiles(payload);
|
||||||
|
return addPathsToQueue(payload);
|
||||||
|
});
|
||||||
|
|
||||||
|
function enqueueDroppedFiles(fileList) {
|
||||||
|
const files = Array.from(fileList || []);
|
||||||
|
if (files.length === 0) return Promise.resolve(null);
|
||||||
|
return importEntryCoordinator.run('drop', files);
|
||||||
|
}
|
||||||
|
|
||||||
|
function enqueueImportEntries(entries) {
|
||||||
|
const snapshot = Array.from(entries || [], entry => (
|
||||||
|
entry && typeof entry === 'object' ? { ...entry } : entry
|
||||||
|
));
|
||||||
|
if (snapshot.length === 0) return Promise.resolve(null);
|
||||||
|
return importEntryCoordinator.run('entries', snapshot);
|
||||||
|
}
|
||||||
|
|
||||||
function existingImportPaths() {
|
function existingImportPaths() {
|
||||||
return [...selectedFiles.map(file => file.path), ..._pendingFiles.map(file => file.path), ...queueJobs.map(job => job.file)];
|
return [...selectedFiles.map(file => file.path), ..._pendingFiles.map(file => file.path), ...queueJobs.map(job => job.file)];
|
||||||
@@ -1522,44 +1546,38 @@ function coordinateImportEntries(entries) {
|
|||||||
return pending;
|
return pending;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function addDroppedFiles(fileList) {
|
async function processDroppedFiles(files) {
|
||||||
if (_addingDropped) return;
|
const entries = [];
|
||||||
_addingDropped = true;
|
for (const file of files) {
|
||||||
try {
|
let filePath = '';
|
||||||
const entries = [];
|
try { filePath = window.api.getPathForFile(file); } catch { filePath = file.path || ''; }
|
||||||
for (const file of Array.from(fileList)) {
|
if (!filePath) continue;
|
||||||
let filePath = '';
|
if (file.type === '' && file.size === 0) {
|
||||||
try { filePath = window.api.getPathForFile(file); } catch { filePath = file.path || ''; }
|
try {
|
||||||
if (!filePath) continue;
|
const folderFiles = await window.api.resolveFolderFiles(filePath);
|
||||||
if (file.type === '' && file.size === 0) {
|
if (folderFiles && folderFiles.length > 0) {
|
||||||
try {
|
entries.push(...folderFiles);
|
||||||
const folderFiles = await window.api.resolveFolderFiles(filePath);
|
continue;
|
||||||
if (folderFiles && folderFiles.length > 0) {
|
}
|
||||||
entries.push(...folderFiles);
|
} catch {}
|
||||||
continue;
|
|
||||||
}
|
|
||||||
} catch {}
|
|
||||||
}
|
|
||||||
entries.push({ path: filePath, name: file.name || '', size: file.size });
|
|
||||||
}
|
}
|
||||||
await addPathsToQueue(entries);
|
entries.push({ path: filePath, name: file.name || '', size: file.size });
|
||||||
} finally {
|
|
||||||
_addingDropped = false;
|
|
||||||
}
|
}
|
||||||
|
return addPathsToQueue(entries);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function pickFiles() {
|
async function pickFiles() {
|
||||||
const paths = await window.api.selectFiles();
|
const paths = await window.api.selectFiles();
|
||||||
if (!paths) return;
|
if (!paths) return;
|
||||||
await addPathsToQueue(paths);
|
await enqueueImportEntries(paths);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function pickFolder() {
|
async function pickFolder() {
|
||||||
const richFiles = window.api.selectFolderWithSizes ? await window.api.selectFolderWithSizes() : null;
|
const richFiles = window.api.selectFolderWithSizes ? await window.api.selectFolderWithSizes() : null;
|
||||||
if (richFiles && Array.isArray(richFiles)) return addPathsToQueue(richFiles);
|
if (richFiles && Array.isArray(richFiles)) return enqueueImportEntries(richFiles);
|
||||||
const paths = await window.api.selectFolder();
|
const paths = await window.api.selectFolder();
|
||||||
if (!paths) return;
|
if (!paths) return;
|
||||||
return addPathsToQueue(paths);
|
return enqueueImportEntries(paths);
|
||||||
}
|
}
|
||||||
|
|
||||||
function addPathsToQueue(paths) {
|
function addPathsToQueue(paths) {
|
||||||
@@ -4187,6 +4205,7 @@ function updateStatusBar() {
|
|||||||
_setRollingUploadMetric('uploadTelemetryTotal', stats.total);
|
_setRollingUploadMetric('uploadTelemetryTotal', stats.total);
|
||||||
_setRollingUploadMetric('uploadTelemetryConnections', lastUploadStats.activeJobs || 0);
|
_setRollingUploadMetric('uploadTelemetryConnections', lastUploadStats.activeJobs || 0);
|
||||||
_setRollingUploadMetric('uploadTelemetryRemaining', stats.remaining);
|
_setRollingUploadMetric('uploadTelemetryRemaining', stats.remaining);
|
||||||
|
_setUploadTelemetryText('uploadTelemetryRemainingSize', formatBytes(stats.bytesRemaining));
|
||||||
_setRollingUploadMetric('uploadTelemetryRunning', stats.inProgress);
|
_setRollingUploadMetric('uploadTelemetryRunning', stats.inProgress);
|
||||||
_setRollingUploadMetric('uploadTelemetryCompleted', _sessionDoneCount);
|
_setRollingUploadMetric('uploadTelemetryCompleted', _sessionDoneCount);
|
||||||
_setRollingUploadMetric('uploadTelemetryFailed', Math.max(_sessionErrorCount, stats.errors));
|
_setRollingUploadMetric('uploadTelemetryFailed', Math.max(_sessionErrorCount, stats.errors));
|
||||||
@@ -8097,6 +8116,7 @@ function updateStatsPanel() {
|
|||||||
window.api.onUpdateAvailable(showUpdateBanner);
|
window.api.onUpdateAvailable(showUpdateBanner);
|
||||||
window.api.onUpdateProgress(handleUpdateProgress);
|
window.api.onUpdateProgress(handleUpdateProgress);
|
||||||
window.api.onPrepareClose(prepareForWindowClose);
|
window.api.onPrepareClose(prepareForWindowClose);
|
||||||
|
setupDragDrop();
|
||||||
init().then(() => {
|
init().then(() => {
|
||||||
window.api.signalCloseHandshakeReady();
|
window.api.signalCloseHandshakeReady();
|
||||||
}).catch((err) => {
|
}).catch((err) => {
|
||||||
|
|||||||
@@ -82,6 +82,7 @@
|
|||||||
['Aktuelle Upload-Geschwindigkeit', 'Current upload speed'],
|
['Aktuelle Upload-Geschwindigkeit', 'Current upload speed'],
|
||||||
['Gesamt', 'Total'],
|
['Gesamt', 'Total'],
|
||||||
['Verbindungen', 'Connections'],
|
['Verbindungen', 'Connections'],
|
||||||
|
['Verbleibende Größe', 'Remaining size'],
|
||||||
['Verbleibend', 'Remaining'],
|
['Verbleibend', 'Remaining'],
|
||||||
['Läuft', 'Running'],
|
['Läuft', 'Running'],
|
||||||
['Suche Aktualisierungen', 'Check for updates'],
|
['Suche Aktualisierungen', 'Check for updates'],
|
||||||
|
|||||||
@@ -212,6 +212,7 @@
|
|||||||
<div class="upload-telemetry-row"><span class="upload-telemetry-label">Gesamt</span><strong class="upload-telemetry-value upload-rolling-value" id="uploadTelemetryTotal" data-numeric-value="0" aria-label="0"><span>0</span></strong></div>
|
<div class="upload-telemetry-row"><span class="upload-telemetry-label">Gesamt</span><strong class="upload-telemetry-value upload-rolling-value" id="uploadTelemetryTotal" data-numeric-value="0" aria-label="0"><span>0</span></strong></div>
|
||||||
<div class="upload-telemetry-row"><span class="upload-telemetry-label">Verbindungen</span><strong class="upload-telemetry-value upload-rolling-value" id="uploadTelemetryConnections" data-numeric-value="0" aria-label="0"><span>0</span></strong></div>
|
<div class="upload-telemetry-row"><span class="upload-telemetry-label">Verbindungen</span><strong class="upload-telemetry-value upload-rolling-value" id="uploadTelemetryConnections" data-numeric-value="0" aria-label="0"><span>0</span></strong></div>
|
||||||
<div class="upload-telemetry-row"><span class="upload-telemetry-label">Verbleibend</span><strong class="upload-telemetry-value upload-rolling-value" id="uploadTelemetryRemaining" data-numeric-value="0" aria-label="0"><span>0</span></strong></div>
|
<div class="upload-telemetry-row"><span class="upload-telemetry-label">Verbleibend</span><strong class="upload-telemetry-value upload-rolling-value" id="uploadTelemetryRemaining" data-numeric-value="0" aria-label="0"><span>0</span></strong></div>
|
||||||
|
<div class="upload-telemetry-row"><span class="upload-telemetry-label">Verbleibende Größe</span><strong class="upload-telemetry-value" id="uploadTelemetryRemainingSize" aria-label="0 B">0 B</strong></div>
|
||||||
<div class="upload-telemetry-row"><span class="upload-telemetry-label">Läuft</span><strong class="upload-telemetry-value upload-rolling-value" id="uploadTelemetryRunning" data-numeric-value="0" aria-label="0"><span>0</span></strong></div>
|
<div class="upload-telemetry-row"><span class="upload-telemetry-label">Läuft</span><strong class="upload-telemetry-value upload-rolling-value" id="uploadTelemetryRunning" data-numeric-value="0" aria-label="0"><span>0</span></strong></div>
|
||||||
<div class="upload-telemetry-row"><span class="upload-telemetry-label">Fertig</span><strong class="upload-telemetry-value upload-rolling-value" id="uploadTelemetryCompleted" data-numeric-value="0" aria-label="0"><span>0</span></strong></div>
|
<div class="upload-telemetry-row"><span class="upload-telemetry-label">Fertig</span><strong class="upload-telemetry-value upload-rolling-value" id="uploadTelemetryCompleted" data-numeric-value="0" aria-label="0"><span>0</span></strong></div>
|
||||||
<div class="upload-telemetry-row"><span class="upload-telemetry-label">Fehler</span><strong class="upload-telemetry-value upload-rolling-value" id="uploadTelemetryFailed" data-numeric-value="0" aria-label="0"><span>0</span></strong></div>
|
<div class="upload-telemetry-row"><span class="upload-telemetry-label">Fehler</span><strong class="upload-telemetry-value upload-rolling-value" id="uploadTelemetryFailed" data-numeric-value="0" aria-label="0"><span>0</span></strong></div>
|
||||||
|
|||||||
@@ -11,6 +11,11 @@ test('translates permanent source deletion controls to English', () => {
|
|||||||
assert.equal(translateText('Dauerhaftes Löschen aktivieren', 'en'), 'Enable permanent deletion');
|
assert.equal(translateText('Dauerhaftes Löschen aktivieren', 'en'), 'Enable permanent deletion');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('translates the remaining upload size label', () => {
|
||||||
|
assert.equal(translateText('Verbleibende Größe', 'en'), 'Remaining size');
|
||||||
|
assert.equal(translateText('Remaining size', 'de'), 'Verbleibende Größe');
|
||||||
|
});
|
||||||
|
|
||||||
test('English is the fallback language and German remains selectable', () => {
|
test('English is the fallback language and German remains selectable', () => {
|
||||||
assert.equal(normalizeLanguage(), 'en');
|
assert.equal(normalizeLanguage(), 'en');
|
||||||
assert.equal(normalizeLanguage('fr'), 'en');
|
assert.equal(normalizeLanguage('fr'), 'en');
|
||||||
|
|||||||
@@ -21,3 +21,18 @@ test('queue totals keep skipped and aborted jobs out of remaining work', () => {
|
|||||||
});
|
});
|
||||||
assert.equal(stats.remainingSize, 160);
|
assert.equal(stats.remainingSize, 160);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('remaining bytes decrease with progress and return when a failed job is queued again', () => {
|
||||||
|
const jobs = [
|
||||||
|
{ status: 'queued', bytesTotal: 1024, bytesUploaded: 0 },
|
||||||
|
{ status: 'uploading', bytesTotal: 2048, bytesUploaded: 512 },
|
||||||
|
{ status: 'error', bytesTotal: 4096, bytesUploaded: 1024 }
|
||||||
|
];
|
||||||
|
|
||||||
|
assert.equal(calculateQueueStats(jobs).bytesRemaining, 2560);
|
||||||
|
jobs[1].bytesUploaded = 1536;
|
||||||
|
assert.equal(calculateQueueStats(jobs).bytesRemaining, 1536);
|
||||||
|
jobs[2].status = 'queued';
|
||||||
|
jobs[2].bytesUploaded = 0;
|
||||||
|
assert.equal(calculateQueueStats(jobs).bytesRemaining, 5632);
|
||||||
|
});
|
||||||
|
|||||||
@@ -29,4 +29,25 @@ describe('serialized runner', () => {
|
|||||||
assert.equal(flushed, true);
|
assert.equal(flushed, true);
|
||||||
assert.deepEqual(calls, ['start:first', 'end:first', 'start:second', 'end:second']);
|
assert.deepEqual(calls, ['start:first', 'end:first', 'start:second', 'end:second']);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('queues work before readiness and drains every task in arrival order', async () => {
|
||||||
|
const { createReadySerializedRunner } = require('../lib/serialized-runner');
|
||||||
|
const calls = [];
|
||||||
|
const runner = createReadySerializedRunner(async (value) => {
|
||||||
|
calls.push(value);
|
||||||
|
return value;
|
||||||
|
});
|
||||||
|
|
||||||
|
const first = runner.run('first');
|
||||||
|
const second = runner.run('second');
|
||||||
|
await new Promise((resolve) => setImmediate(resolve));
|
||||||
|
|
||||||
|
assert.equal(runner.isReady, false);
|
||||||
|
assert.deepEqual(calls, []);
|
||||||
|
assert.equal(runner.ready(), true);
|
||||||
|
assert.equal(runner.ready(), false);
|
||||||
|
assert.deepEqual(await Promise.all([first, second]), ['first', 'second']);
|
||||||
|
assert.deepEqual(calls, ['first', 'second']);
|
||||||
|
assert.equal(runner.isReady, true);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -103,3 +103,24 @@ test('startup load forwards navigation options before the renderer becomes visib
|
|||||||
|
|
||||||
assert.deepEqual(startup.window.loadOptions, options);
|
assert.deepEqual(startup.window.loadOptions, options);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('desktop drag and drop is accepted before asynchronous renderer initialization', () => {
|
||||||
|
const projectRoot = path.join(__dirname, '..');
|
||||||
|
const appSource = fs.readFileSync(path.join(projectRoot, 'renderer', 'app.js'), 'utf8');
|
||||||
|
const earlyBinding = appSource.lastIndexOf('\nsetupDragDrop();');
|
||||||
|
const initialization = appSource.lastIndexOf('\ninit().then(');
|
||||||
|
|
||||||
|
assert.notEqual(earlyBinding, -1);
|
||||||
|
assert.notEqual(initialization, -1);
|
||||||
|
assert.ok(earlyBinding < initialization);
|
||||||
|
assert.match(appSource, /dataTransfer\.dropEffect\s*=\s*['"]copy['"]/u);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('upload sidebar renders and updates the remaining upload size', () => {
|
||||||
|
const projectRoot = path.join(__dirname, '..');
|
||||||
|
const html = fs.readFileSync(path.join(projectRoot, 'renderer', 'index.html'), 'utf8');
|
||||||
|
const appSource = fs.readFileSync(path.join(projectRoot, 'renderer', 'app.js'), 'utf8');
|
||||||
|
|
||||||
|
assert.match(html, /Verbleibende Größe[\s\S]*id="uploadTelemetryRemainingSize"[^>]*>0 B</u);
|
||||||
|
assert.match(appSource, /_setUploadTelemetryText\(['"]uploadTelemetryRemainingSize['"],\s*formatBytes\(stats\.bytesRemaining\)\)/u);
|
||||||
|
});
|
||||||
|
|||||||
+5
-5
@@ -217,7 +217,7 @@ setTimeout(async () => {
|
|||||||
const englishSidebarHeadings = await wc.executeJavaScript('[...document.querySelectorAll("#upload-view, #accounts-view, #history-view")].map(view => [view.querySelector(".view-sidebar-kicker")?.textContent?.trim(), view.querySelector(".view-sidebar-title")?.textContent?.trim()].join("|"))');
|
const englishSidebarHeadings = await wc.executeJavaScript('[...document.querySelectorAll("#upload-view, #accounts-view, #history-view")].map(view => [view.querySelector(".view-sidebar-kicker")?.textContent?.trim(), view.querySelector(".view-sidebar-title")?.textContent?.trim()].join("|"))');
|
||||||
check('English sidebar hierarchy uses distinct translated kickers', englishSidebarHeadings.join('::') === 'Workspace|Uploads::Manage accounts|Accounts::Archive|History');
|
check('English sidebar hierarchy uses distinct translated kickers', englishSidebarHeadings.join('::') === 'Workspace|Uploads::Manage accounts|Accounts::Archive|History');
|
||||||
const englishTelemetryLabels = await wc.executeJavaScript('[...document.querySelectorAll("#uploadTelemetry .upload-telemetry-label")].map(el => el.textContent.trim()).join("|")');
|
const englishTelemetryLabels = await wc.executeJavaScript('[...document.querySelectorAll("#uploadTelemetry .upload-telemetry-label")].map(el => el.textContent.trim()).join("|")');
|
||||||
check('English upload telemetry is fully localized', englishTelemetryLabels === 'Total|Connections|Remaining|Running|Completed|Failed|Speed|ETA');
|
check('English upload telemetry is fully localized', englishTelemetryLabels === 'Total|Connections|Remaining|Remaining size|Running|Completed|Failed|Speed|ETA');
|
||||||
const englishLayoutFits = await wc.executeJavaScript('(() => { const states = [...document.querySelectorAll(".tab")].map(tab => { tab.click(); const view = document.querySelector(".view.active"); return view && view.scrollWidth <= view.clientWidth + 1; }); document.querySelector(".tab[data-view=upload]")?.click(); return states.every(Boolean) && document.documentElement.scrollWidth <= document.documentElement.clientWidth + 1; })()');
|
const englishLayoutFits = await wc.executeJavaScript('(() => { const states = [...document.querySelectorAll(".tab")].map(tab => { tab.click(); const view = document.querySelector(".view.active"); return view && view.scrollWidth <= view.clientWidth + 1; }); document.querySelector(".tab[data-view=upload]")?.click(); return states.every(Boolean) && document.documentElement.scrollWidth <= document.documentElement.clientWidth + 1; })()');
|
||||||
check('English labels fit every main view without horizontal overflow', englishLayoutFits === true);
|
check('English labels fit every main view without horizontal overflow', englishLayoutFits === true);
|
||||||
const speedSparklineAcrossTabs = await wc.executeJavaScript('(() => [...document.querySelectorAll(".tab")].map(tab => { tab.click(); const widget = document.getElementById("uploadSpeedSparkline"); const rect = widget?.getBoundingClientRect(); const style = widget && getComputedStyle(widget); return Boolean(widget && !widget.classList.contains("is-hidden") && style.visibility === "visible" && style.opacity === "1" && rect.width > 0 && rect.height > 0); }))()');
|
const speedSparklineAcrossTabs = await wc.executeJavaScript('(() => [...document.querySelectorAll(".tab")].map(tab => { tab.click(); const widget = document.getElementById("uploadSpeedSparkline"); const rect = widget?.getBoundingClientRect(); const style = widget && getComputedStyle(widget); return Boolean(widget && !widget.classList.contains("is-hidden") && style.visibility === "visible" && style.opacity === "1" && rect.width > 0 && rect.height > 0); }))()');
|
||||||
@@ -447,10 +447,10 @@ setTimeout(async () => {
|
|||||||
check('Recent panel labels are consistently German', localizedRecentTabs === 'Dateien|Statistik');
|
check('Recent panel labels are consistently German', localizedRecentTabs === 'Dateien|Statistik');
|
||||||
|
|
||||||
const localizedTelemetry = await wc.executeJavaScript('[...document.querySelectorAll("#uploadTelemetry .upload-telemetry-label")].map(el => el.textContent.trim()).join("|")');
|
const localizedTelemetry = await wc.executeJavaScript('[...document.querySelectorAll("#uploadTelemetry .upload-telemetry-label")].map(el => el.textContent.trim()).join("|")');
|
||||||
check('Upload telemetry exposes all eight German labels', localizedTelemetry === 'Gesamt|Verbindungen|Verbleibend|Läuft|Fertig|Fehler|Geschwindigkeit|ETA');
|
check('Upload telemetry exposes all nine German labels', localizedTelemetry === 'Gesamt|Verbindungen|Verbleibend|Verbleibende Größe|Läuft|Fertig|Fehler|Geschwindigkeit|ETA');
|
||||||
|
|
||||||
const initialTelemetryValues = await wc.executeJavaScript('[...document.querySelectorAll("#uploadTelemetry .upload-telemetry-value")].map(el => el.getAttribute("aria-label") || el.textContent.trim()).join("|")');
|
const initialTelemetryValues = await wc.executeJavaScript('[...document.querySelectorAll("#uploadTelemetry .upload-telemetry-value")].map(el => el.getAttribute("aria-label") || el.textContent.trim()).join("|")');
|
||||||
check('Upload telemetry starts with stable empty values', initialTelemetryValues === '0|0|0|0|0|0|0 B/s|--:--');
|
check('Upload telemetry starts with stable empty values', initialTelemetryValues === '0|0|0|0 B|0|0|0|0 B/s|--:--');
|
||||||
|
|
||||||
const previewQueueCounts = await wc.executeJavaScript(\`(() => {
|
const previewQueueCounts = await wc.executeJavaScript(\`(() => {
|
||||||
queueJobs = [{ id: 'existing-done', file: 'C:/ui/existing-done.bin', fileName: 'existing-done.bin', hoster: 'doodstream.com', status: 'done', bytesUploaded: 1024, bytesTotal: 1024, speedKbs: 0, elapsed: 1, remaining: 0, progress: 1 }];
|
queueJobs = [{ id: 'existing-done', file: 'C:/ui/existing-done.bin', fileName: 'existing-done.bin', hoster: 'doodstream.com', status: 'done', bytesUploaded: 1024, bytesTotal: 1024, speedKbs: 0, elapsed: 1, remaining: 0, progress: 1 }];
|
||||||
@@ -517,7 +517,7 @@ setTimeout(async () => {
|
|||||||
const total = document.getElementById('uploadTelemetryTotal');
|
const total = document.getElementById('uploadTelemetryTotal');
|
||||||
const rolling = total?.querySelectorAll(':scope > span').length;
|
const rolling = total?.querySelectorAll(':scope > span').length;
|
||||||
return {
|
return {
|
||||||
values: ['Total', 'Connections', 'Remaining', 'Running', 'Completed', 'Failed', 'Speed', 'Eta'].map(key => {
|
values: ['Total', 'Connections', 'Remaining', 'RemainingSize', 'Running', 'Completed', 'Failed', 'Speed', 'Eta'].map(key => {
|
||||||
const element = document.getElementById('uploadTelemetry' + key);
|
const element = document.getElementById('uploadTelemetry' + key);
|
||||||
return element?.getAttribute('aria-label') || element?.textContent.trim();
|
return element?.getAttribute('aria-label') || element?.textContent.trim();
|
||||||
}).join('|'),
|
}).join('|'),
|
||||||
@@ -526,7 +526,7 @@ setTimeout(async () => {
|
|||||||
speedPair: [document.getElementById('uploadTelemetrySpeed')?.textContent, document.getElementById('uploadSpeedValue')?.textContent].join('|')
|
speedPair: [document.getElementById('uploadTelemetrySpeed')?.textContent, document.getElementById('uploadSpeedValue')?.textContent].join('|')
|
||||||
};
|
};
|
||||||
})()\`);
|
})()\`);
|
||||||
check('Upload telemetry reflects queue and session activity', telemetryUpdate.values === '4|1|2|1|7|2|2 kB/s|00:03');
|
check('Upload telemetry reflects queue and session activity', telemetryUpdate.values === '4|1|2|5.00 KB|1|7|2|2 kB/s|00:03');
|
||||||
check('Changing integer telemetry rolls vertically', telemetryUpdate.rolling === 2 && telemetryUpdate.direction === 'up');
|
check('Changing integer telemetry rolls vertically', telemetryUpdate.rolling === 2 && telemetryUpdate.direction === 'up');
|
||||||
check('Header and sidebar speed update synchronously from the same live sample', telemetryUpdate.speedPair === '2 kB/s|2 kB/s');
|
check('Header and sidebar speed update synchronously from the same live sample', telemetryUpdate.speedPair === '2 kB/s|2 kB/s');
|
||||||
const secondSynchronizedSpeed = await wc.executeJavaScript('lastUploadStats = { ...lastUploadStats, globalSpeedKbs: 1536 }; updateStatusBar(); [document.getElementById("uploadTelemetrySpeed")?.textContent, document.getElementById("uploadSpeedValue")?.textContent].join("|")');
|
const secondSynchronizedSpeed = await wc.executeJavaScript('lastUploadStats = { ...lastUploadStats, globalSpeedKbs: 1536 }; updateStatusBar(); [document.getElementById("uploadTelemetrySpeed")?.textContent, document.getElementById("uploadSpeedValue")?.textContent].join("|")');
|
||||||
|
|||||||
@@ -4,9 +4,10 @@ const path = require('node:path');
|
|||||||
const fs = require('node:fs');
|
const fs = require('node:fs');
|
||||||
const os = require('node:os');
|
const os = require('node:os');
|
||||||
const crypto = require('node:crypto');
|
const crypto = require('node:crypto');
|
||||||
|
const Module = require('node:module');
|
||||||
const { pathToFileURL } = require('node:url');
|
const { pathToFileURL } = require('node:url');
|
||||||
|
|
||||||
const { isNewer, resolveReleaseVersion, fetchGithubReleaseNotes, prepareUpdate, launchPreparedUpdate, pickSetupAsset, parseLatestYml } = require('../lib/updater');
|
const { isNewer, resolveReleaseVersion, fetchGithubReleaseNotes, prepareUpdate, launchPreparedUpdate, pickSetupAsset, parseLatestYml, createUpdateAnnouncementState } = require('../lib/updater');
|
||||||
const releasePlanUrl = pathToFileURL(path.resolve(__dirname, '../scripts/release-plan.mjs')).href;
|
const releasePlanUrl = pathToFileURL(path.resolve(__dirname, '../scripts/release-plan.mjs')).href;
|
||||||
|
|
||||||
test('bridge title resolves product version instead of transport tag', () => {
|
test('bridge title resolves product version instead of transport tag', () => {
|
||||||
@@ -86,6 +87,114 @@ test('update preparation writes a verified installer without launching it', asyn
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('update preparation refreshes a cached release before downloading the installer', async () => {
|
||||||
|
const updaterPath = require.resolve('../lib/updater');
|
||||||
|
const originalLoad = Module._load;
|
||||||
|
const originalFetch = global.fetch;
|
||||||
|
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'mhu-updater-refresh-test-'));
|
||||||
|
const installer = Buffer.alloc(128 * 1024, 0);
|
||||||
|
installer[0] = 0x4d;
|
||||||
|
installer[1] = 0x5a;
|
||||||
|
const sha512 = crypto.createHash('sha512').update(installer).digest('base64');
|
||||||
|
const oldVersion = '2.1.23';
|
||||||
|
const latestVersion = '2.1.24';
|
||||||
|
const createRelease = version => ({
|
||||||
|
name: `Multi-Hoster-Upload v${version}`,
|
||||||
|
tag_name: `v${version}`,
|
||||||
|
html_url: `https://update.invalid/releases/${version}`,
|
||||||
|
body: `Release ${version}`,
|
||||||
|
assets: [
|
||||||
|
{
|
||||||
|
name: `Multi-Hoster-Upload Setup ${version}.exe`,
|
||||||
|
size: installer.length,
|
||||||
|
browser_download_url: `https://update.invalid/${version}/setup.exe`
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'latest.yml',
|
||||||
|
browser_download_url: `https://update.invalid/${version}/latest.yml`
|
||||||
|
}
|
||||||
|
]
|
||||||
|
});
|
||||||
|
const createResponse = payload => ({
|
||||||
|
ok: true,
|
||||||
|
status: 200,
|
||||||
|
text: async () => JSON.stringify(payload),
|
||||||
|
json: async () => payload
|
||||||
|
});
|
||||||
|
const createInstallerResponse = () => ({
|
||||||
|
ok: true,
|
||||||
|
status: 200,
|
||||||
|
body: {
|
||||||
|
getReader: () => {
|
||||||
|
let served = false;
|
||||||
|
return {
|
||||||
|
read: async () => {
|
||||||
|
if (served) return { done: true };
|
||||||
|
served = true;
|
||||||
|
return { done: false, value: installer };
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const createManifestResponse = version => ({
|
||||||
|
ok: true,
|
||||||
|
status: 200,
|
||||||
|
text: async () => `version: ${version}\npath: Multi-Hoster-Upload Setup ${version}.exe\nsha512: ${sha512}\nsize: ${installer.length}\n`
|
||||||
|
});
|
||||||
|
|
||||||
|
Module._load = function load(request, parent, isMain) {
|
||||||
|
if (request === 'electron') return { app: { getVersion: () => '2.1.22', getPath: () => tempDir } };
|
||||||
|
return originalLoad.call(this, request, parent, isMain);
|
||||||
|
};
|
||||||
|
delete require.cache[updaterPath];
|
||||||
|
const isolatedUpdater = require(updaterPath);
|
||||||
|
|
||||||
|
try {
|
||||||
|
global.fetch = async url => {
|
||||||
|
const value = String(url);
|
||||||
|
if (value.includes('/api/v1/repos/')) return createResponse([createRelease(oldVersion)]);
|
||||||
|
if (value.includes('api.github.com')) return createResponse({ body: `Release ${oldVersion}` });
|
||||||
|
throw new Error(`Unexpected initial request: ${value}`);
|
||||||
|
};
|
||||||
|
const oldCheck = await isolatedUpdater.checkForUpdate();
|
||||||
|
assert.equal(oldCheck.remoteVersion, oldVersion);
|
||||||
|
|
||||||
|
global.fetch = async url => {
|
||||||
|
const value = String(url);
|
||||||
|
if (value.includes('/api/v1/repos/')) return createResponse([createRelease(latestVersion)]);
|
||||||
|
if (value.includes('api.github.com')) return createResponse({ body: `Release ${latestVersion}` });
|
||||||
|
if (value.endsWith(`${oldVersion}/latest.yml`)) return createManifestResponse(oldVersion);
|
||||||
|
if (value.endsWith(`${latestVersion}/latest.yml`)) return createManifestResponse(latestVersion);
|
||||||
|
if (value.endsWith(`${oldVersion}/setup.exe`) || value.endsWith(`${latestVersion}/setup.exe`)) return createInstallerResponse();
|
||||||
|
throw new Error(`Unexpected refreshed request: ${value}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
const prepared = await isolatedUpdater.prepareUpdate(null, { tempDir });
|
||||||
|
assert.equal(prepared.remoteVersion, latestVersion);
|
||||||
|
assert.equal(prepared.assetName, `Multi-Hoster-Upload Setup ${latestVersion}.exe`);
|
||||||
|
} finally {
|
||||||
|
global.fetch = originalFetch;
|
||||||
|
Module._load = originalLoad;
|
||||||
|
delete require.cache[updaterPath];
|
||||||
|
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('an update found before renderer readiness remains announceable afterwards', () => {
|
||||||
|
const state = createUpdateAnnouncementState();
|
||||||
|
const update = { available: true, remoteVersion: '2.1.24' };
|
||||||
|
|
||||||
|
assert.equal(state.canAnnounce(update, false), false);
|
||||||
|
assert.equal(state.canAnnounce(update, true), true);
|
||||||
|
assert.equal(state.canAnnounce(update, true), true);
|
||||||
|
state.markAnnounced(update);
|
||||||
|
assert.equal(state.canAnnounce(update, true), false);
|
||||||
|
|
||||||
|
state.reset();
|
||||||
|
assert.equal(state.canAnnounce(update, true), true);
|
||||||
|
});
|
||||||
|
|
||||||
test('update preparation fails closed when checksum metadata is unavailable', async () => {
|
test('update preparation fails closed when checksum metadata is unavailable', async () => {
|
||||||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'mhu-updater-test-'));
|
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'mhu-updater-test-'));
|
||||||
try {
|
try {
|
||||||
|
|||||||
Reference in New Issue
Block a user