release: v2.0.6
Redesign the desktop workspace with task sidebars, live filters, clearer settings, and an accessible update dialog. Harden encrypted backup imports, configuration persistence, history retention, queue snapshots, shutdown recovery, and update installation ordering. Publish verified Windows artifacts and refreshed English documentation.
This commit is contained in:
+1
-1
@@ -1,6 +1,6 @@
|
||||
node_modules/
|
||||
release/
|
||||
.worktrees/
|
||||
.artifacts/
|
||||
__pycache__/
|
||||
*.pyc
|
||||
|
||||
|
||||
@@ -9,11 +9,13 @@ Multi-Hoster-Upload is a Windows desktop app for managing large file batches acr
|
||||
## Capabilities
|
||||
|
||||
- Upload one batch to several supported hosters in parallel.
|
||||
- Manage multiple accounts per hoster with validation, health checks, and automatic rotation.
|
||||
- Manage multiple accounts per hoster with validation, health checks, automatic rotation, and inline OTP completion.
|
||||
- Filter uploads, accounts, and history from task-focused sidebars without changing the underlying queue.
|
||||
- Add files by drag and drop or file selection and monitor live queue progress.
|
||||
- Control per-hoster concurrency, bandwidth limits, retries, and folder monitoring.
|
||||
- Control per-hoster concurrency, bandwidth limits, retries, folder monitoring, notifications, and completed-item cleanup.
|
||||
- Keep local upload history and copy completed links in bulk.
|
||||
- Transfer accounts and settings with a 75-character encrypted online key while encryption and decryption stay on the client.
|
||||
- Check for updates from the header and install available releases from an accessible update dialog.
|
||||
|
||||
## Supported hosters
|
||||
|
||||
@@ -29,7 +31,7 @@ Multi-Hoster-Upload is a Windows desktop app for managing large file batches acr
|
||||
|
||||
1. Download the Setup or portable executable from the [latest GitHub release](https://github.com/Sucukdeluxe/Multi-Hoster-Upload/releases/latest).
|
||||
2. Run the installer, or launch the portable executable directly.
|
||||
3. Add and validate at least one hoster account in Settings, then select files and start the queue.
|
||||
3. Add and validate at least one hoster account in Accounts, then select files and start the queue.
|
||||
|
||||
## Local data and credentials
|
||||
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 284 KiB After Width: | Height: | Size: 51 KiB |
+127
-17
@@ -137,10 +137,7 @@ function batchRowCount(batch) {
|
||||
let n = 0;
|
||||
const files = (batch && batch.files) || [];
|
||||
for (const file of files) {
|
||||
for (const result of (file.results || [])) {
|
||||
if (result.status === 'aborted' || result.status === 'error') continue;
|
||||
n++;
|
||||
}
|
||||
n += (file.results || []).length;
|
||||
}
|
||||
return n;
|
||||
}
|
||||
@@ -180,13 +177,19 @@ function applyHistoryRetention(history, retention, nowMs) {
|
||||
|
||||
class ConfigStore {
|
||||
constructor(app) {
|
||||
const dir = app && app.isPackaged
|
||||
const useUserDataDir = app && (
|
||||
app.isPackaged ||
|
||||
(app.commandLine && typeof app.commandLine.hasSwitch === 'function' && app.commandLine.hasSwitch('user-data-dir'))
|
||||
);
|
||||
const dir = useUserDataDir
|
||||
? app.getPath('userData')
|
||||
: path.join(__dirname, '..');
|
||||
this.filePath = path.join(dir, 'electron-config.json');
|
||||
this.historyPath = path.join(dir, 'electron-history.json');
|
||||
this._writeQueue = Promise.resolve(); // Serializes all writes to prevent race conditions
|
||||
this._historyWriteQueue = Promise.resolve();
|
||||
this._pendingWriteOperations = new Set();
|
||||
this._writesQuiesced = false;
|
||||
this._historyMigrated = false;
|
||||
this._cache = null;
|
||||
this._cacheKey = '';
|
||||
@@ -238,9 +241,26 @@ class ConfigStore {
|
||||
});
|
||||
}
|
||||
|
||||
_enqueueHistoryWrite(fn) {
|
||||
this._historyWriteQueue = this._historyWriteQueue.then(fn, fn);
|
||||
return this._historyWriteQueue;
|
||||
_quiescedWriteError() {
|
||||
const error = new Error('Die Anwendung wird gerade beendet');
|
||||
error.code = 'CONFIG_WRITES_QUIESCED';
|
||||
return error;
|
||||
}
|
||||
|
||||
setWritesQuiesced(quiesced) {
|
||||
this._writesQuiesced = !!quiesced;
|
||||
}
|
||||
|
||||
_enqueueHistoryWrite(fn, options = {}) {
|
||||
if (this._writesQuiesced && !options.allowDuringQuiesce) return Promise.reject(this._quiescedWriteError());
|
||||
const operation = this._historyWriteQueue.then(fn, fn);
|
||||
this._pendingWriteOperations.add(operation);
|
||||
this._historyWriteQueue = operation.then(() => undefined, () => undefined);
|
||||
operation.then(
|
||||
() => this._pendingWriteOperations.delete(operation),
|
||||
() => this._pendingWriteOperations.delete(operation)
|
||||
);
|
||||
return operation;
|
||||
}
|
||||
|
||||
_migrateHistory() {
|
||||
@@ -476,11 +496,31 @@ class ConfigStore {
|
||||
return this._atomicWrite(data);
|
||||
}
|
||||
|
||||
_enqueueWrite(fn) {
|
||||
_enqueueWrite(fn, options = {}) {
|
||||
if (this._writesQuiesced && !options.allowDuringQuiesce) return Promise.reject(this._quiescedWriteError());
|
||||
this._wqDepth++;
|
||||
const done = () => { this._wqDepth--; };
|
||||
this._writeQueue = this._writeQueue.then(fn, fn).then(done, done);
|
||||
return this._writeQueue;
|
||||
const operation = this._writeQueue.then(fn, fn);
|
||||
this._pendingWriteOperations.add(operation);
|
||||
this._writeQueue = operation.then(
|
||||
() => { this._wqDepth--; },
|
||||
() => {
|
||||
this._wqDepth--;
|
||||
}
|
||||
);
|
||||
operation.then(
|
||||
() => this._pendingWriteOperations.delete(operation),
|
||||
() => this._pendingWriteOperations.delete(operation)
|
||||
);
|
||||
return operation;
|
||||
}
|
||||
|
||||
async drainWrites() {
|
||||
while (this._pendingWriteOperations.size > 0) {
|
||||
const pending = Array.from(this._pendingWriteOperations);
|
||||
const results = await Promise.allSettled(pending);
|
||||
const failed = results.find(result => result.status === 'rejected');
|
||||
if (failed) throw failed.reason;
|
||||
}
|
||||
}
|
||||
|
||||
_anyHosters(cfg) {
|
||||
@@ -522,6 +562,56 @@ class ConfigStore {
|
||||
});
|
||||
}
|
||||
|
||||
savePendingQueue(pendingQueue, options = {}) {
|
||||
const snapshot = pendingQueue === null || pendingQueue === undefined ? null : this._clone(pendingQueue);
|
||||
return this._enqueueWrite(() => {
|
||||
const current = this.load();
|
||||
current.globalSettings = {
|
||||
...(current.globalSettings || {}),
|
||||
pendingQueue: snapshot
|
||||
};
|
||||
this._guardHosters(current, false);
|
||||
return this._commit(current);
|
||||
}, options);
|
||||
}
|
||||
|
||||
saveRendererGlobalSettings(globalSettings) {
|
||||
const snapshot = this._clone(globalSettings || {});
|
||||
return this._enqueueWrite(() => {
|
||||
const current = this.load();
|
||||
const currentGlobalSettings = current.globalSettings || {};
|
||||
const currentRemote = currentGlobalSettings.remote || {};
|
||||
const incomingRemote = snapshot.remote || {};
|
||||
current.globalSettings = {
|
||||
...snapshot,
|
||||
pendingQueue: currentGlobalSettings.pendingQueue ?? null,
|
||||
diagnostics: this._clone(currentGlobalSettings.diagnostics || {}),
|
||||
historyRetention: currentGlobalSettings.historyRetention || 'all',
|
||||
remote: {
|
||||
...incomingRemote,
|
||||
token: incomingRemote.token || currentRemote.token || ''
|
||||
}
|
||||
};
|
||||
this._guardHosters(current, false);
|
||||
return this._commit(current);
|
||||
});
|
||||
}
|
||||
|
||||
saveRemoteSettings(remoteSettings, createToken) {
|
||||
const incoming = this._clone(remoteSettings || {});
|
||||
return this._enqueueWrite(async () => {
|
||||
const current = this.load();
|
||||
const currentGlobalSettings = current.globalSettings || {};
|
||||
const currentRemote = currentGlobalSettings.remote || {};
|
||||
const token = incoming.token || currentRemote.token || (incoming.enabled && typeof createToken === 'function' ? createToken() : '');
|
||||
const canonical = { ...incoming, token };
|
||||
current.globalSettings = { ...currentGlobalSettings, remote: canonical };
|
||||
this._guardHosters(current, false);
|
||||
await this._commit(current);
|
||||
return this._clone(canonical);
|
||||
});
|
||||
}
|
||||
|
||||
replaceSettings(config) {
|
||||
return this._enqueueWrite(() => {
|
||||
const current = this.load();
|
||||
@@ -604,8 +694,12 @@ class ConfigStore {
|
||||
pruneHistory(retention, opts = {}) {
|
||||
const dryRun = !!opts.dryRun;
|
||||
if (this._historyMigrated) {
|
||||
return this._enqueueHistoryWrite(() => {
|
||||
const current = this._readHistoryFile() || [];
|
||||
return this._enqueueHistoryWrite(async () => {
|
||||
const storedHistory = this._readHistoryFile();
|
||||
if (storedHistory === null && fs.existsSync(this.historyPath)) {
|
||||
throw new Error('Die Verlaufsdatei ist beschädigt und wurde nicht verändert');
|
||||
}
|
||||
const current = storedHistory || [];
|
||||
const beforeBatches = current.length;
|
||||
const beforeRows = countHistoryRows(current);
|
||||
const pruned = applyHistoryRetention(current, retention, Date.now());
|
||||
@@ -616,9 +710,25 @@ class ConfigStore {
|
||||
keptRows: countHistoryRows(pruned)
|
||||
};
|
||||
if (dryRun) return result;
|
||||
return this._writeHistoryFileAtomic(pruned)
|
||||
.then(() => this.save({ globalSettings: { ...this.load().globalSettings, historyRetention: String(retention || 'all') } }))
|
||||
.then(() => result);
|
||||
return this._enqueueWrite(async () => {
|
||||
const config = this.load();
|
||||
const previousGlobalSettings = this._clone(config.globalSettings || {});
|
||||
config.globalSettings = { ...previousGlobalSettings, historyRetention: String(retention || 'all') };
|
||||
this._guardHosters(config, false);
|
||||
await this._commit(config);
|
||||
try {
|
||||
await this._writeHistoryFileAtomic(pruned);
|
||||
} catch (historyError) {
|
||||
config.globalSettings = previousGlobalSettings;
|
||||
try {
|
||||
await this._commit(config);
|
||||
} catch (rollbackError) {
|
||||
throw new AggregateError([historyError, rollbackError], 'Verlauf und Aufbewahrung konnten nicht konsistent gespeichert werden');
|
||||
}
|
||||
throw historyError;
|
||||
}
|
||||
return result;
|
||||
});
|
||||
});
|
||||
}
|
||||
return this._enqueueWrite(() => {
|
||||
|
||||
+36
-33
@@ -14,6 +14,7 @@ let cachedCheckTs = 0;
|
||||
const CACHE_TTL = 10 * 60 * 1000; // 10 min
|
||||
|
||||
let activeAbort = null;
|
||||
const launchedInstallerPaths = new Set();
|
||||
|
||||
function getCurrentVersion() {
|
||||
return app.getVersion();
|
||||
@@ -131,10 +132,10 @@ async function checkForUpdate() {
|
||||
return cachedCheck;
|
||||
}
|
||||
|
||||
async function parseLatestYml(url) {
|
||||
async function parseLatestYml(url, fetchImpl = fetch) {
|
||||
if (!url) return null;
|
||||
try {
|
||||
const res = await fetch(url, { redirect: 'follow' });
|
||||
const res = await fetchImpl(url, { redirect: 'follow' });
|
||||
const text = await res.text();
|
||||
// Extract sha512 from latest.yml
|
||||
const match = text.match(/sha512:\s*([A-Za-z0-9+/=]+)/);
|
||||
@@ -150,17 +151,18 @@ function verifyExeHeader(buf) {
|
||||
return buf[0] === 0x4D && buf[1] === 0x5A; // 'MZ'
|
||||
}
|
||||
|
||||
async function installUpdate(onProgress) {
|
||||
async function prepareUpdate(onProgress, options = {}) {
|
||||
if (activeAbort) activeAbort.abort();
|
||||
activeAbort = new AbortController();
|
||||
const signal = activeAbort.signal;
|
||||
const fetchImpl = options.fetchImpl || fetch;
|
||||
|
||||
try {
|
||||
// Stage: starting
|
||||
if (onProgress) onProgress({ stage: 'starting', percent: 0 });
|
||||
|
||||
// Check or use cached
|
||||
let check = cachedCheck;
|
||||
let check = options.checkResult || cachedCheck;
|
||||
if (!check || !check.available) {
|
||||
check = await checkForUpdate();
|
||||
}
|
||||
@@ -172,10 +174,10 @@ async function installUpdate(onProgress) {
|
||||
}
|
||||
|
||||
// Stage: downloading
|
||||
const tmpDir = app.getPath('temp');
|
||||
const tmpDir = options.tempDir || app.getPath('temp');
|
||||
const installerPath = path.join(tmpDir, check.assetName);
|
||||
|
||||
const res = await fetch(check.assetUrl, {
|
||||
const res = await fetchImpl(check.assetUrl, {
|
||||
method: 'GET',
|
||||
signal,
|
||||
redirect: 'follow'
|
||||
@@ -233,7 +235,7 @@ async function installUpdate(onProgress) {
|
||||
}
|
||||
|
||||
// Optional SHA-512 verification from latest.yml
|
||||
const expectedSha = await parseLatestYml(check.latestYmlUrl);
|
||||
const expectedSha = await parseLatestYml(check.latestYmlUrl, fetchImpl);
|
||||
if (expectedSha) {
|
||||
const actualSha = crypto.createHash('sha512').update(fileBuffer).digest('base64');
|
||||
if (actualSha !== expectedSha) {
|
||||
@@ -248,32 +250,14 @@ async function installUpdate(onProgress) {
|
||||
// Write to disk
|
||||
fs.writeFileSync(installerPath, fileBuffer);
|
||||
|
||||
// Stage: launching
|
||||
if (onProgress) onProgress({ stage: 'launching', percent: 100 });
|
||||
|
||||
const { spawn } = require('child_process');
|
||||
spawn(installerPath, ['/S', '--updated', '--force-run'], {
|
||||
detached: true,
|
||||
stdio: 'ignore'
|
||||
}).unref();
|
||||
|
||||
// Stage: done
|
||||
if (onProgress) onProgress({ stage: 'done', percent: 100 });
|
||||
|
||||
const _doQuit = () => setTimeout(() => app.quit(), 900);
|
||||
const _getActive = () => {
|
||||
try { return globalThis._mhuUploadManagerRef && globalThis._mhuUploadManagerRef.getActiveJobCount ? globalThis._mhuUploadManagerRef.getActiveJobCount() : 0; }
|
||||
catch { return 0; }
|
||||
const prepared = {
|
||||
installerPath,
|
||||
assetName: check.assetName,
|
||||
remoteVersion: check.remoteVersion || '',
|
||||
transportTag: check.transportTag || ''
|
||||
};
|
||||
if (_getActive() > 0) {
|
||||
const POLL_MS = 3000;
|
||||
const poller = setInterval(() => {
|
||||
if (_getActive() === 0) { clearInterval(poller); _doQuit(); }
|
||||
}, POLL_MS);
|
||||
setTimeout(() => { try { clearInterval(poller); } catch {} _doQuit(); }, 30 * 60 * 1000);
|
||||
} else {
|
||||
_doQuit();
|
||||
}
|
||||
if (onProgress) onProgress({ stage: 'prepared', percent: 100 });
|
||||
return prepared;
|
||||
|
||||
} catch (err) {
|
||||
if (onProgress) onProgress({ stage: 'error', error: err.message });
|
||||
@@ -283,6 +267,25 @@ async function installUpdate(onProgress) {
|
||||
}
|
||||
}
|
||||
|
||||
function launchPreparedUpdate(prepared, options = {}) {
|
||||
const installerPath = prepared && typeof prepared.installerPath === 'string' ? prepared.installerPath : '';
|
||||
if (!installerPath) throw new Error('Vorbereitetes Update ist unvollständig');
|
||||
const key = path.resolve(installerPath).toLowerCase();
|
||||
if (launchedInstallerPaths.has(key)) return false;
|
||||
const spawnImpl = options.spawnImpl || require('child_process').spawn;
|
||||
launchedInstallerPaths.add(key);
|
||||
try {
|
||||
spawnImpl(installerPath, ['/S', '--updated', '--force-run'], {
|
||||
detached: true,
|
||||
stdio: 'ignore'
|
||||
}).unref();
|
||||
return true;
|
||||
} catch (error) {
|
||||
launchedInstallerPaths.delete(key);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function abortUpdate() {
|
||||
if (activeAbort) {
|
||||
activeAbort.abort();
|
||||
@@ -290,4 +293,4 @@ function abortUpdate() {
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { checkForUpdate, installUpdate, abortUpdate, isNewer, resolveReleaseVersion };
|
||||
module.exports = { checkForUpdate, prepareUpdate, launchPreparedUpdate, abortUpdate, isNewer, resolveReleaseVersion };
|
||||
|
||||
@@ -15,7 +15,7 @@ const DoodstreamUploader = require('./lib/doodstream-upload');
|
||||
const { selectUploadAuth } = require('./lib/account-auth');
|
||||
const { createAccountPicker } = require('./lib/account-rotation');
|
||||
const ClouddropUploader = require('./lib/clouddrop-upload');
|
||||
const { checkForUpdate, installUpdate, abortUpdate } = require('./lib/updater');
|
||||
const { checkForUpdate, prepareUpdate, launchPreparedUpdate, abortUpdate } = require('./lib/updater');
|
||||
const backupCrypto = require('./lib/backup-crypto');
|
||||
const { createOnlineBackup, downloadOnlineBackup, uploadOnlineBackup } = require('./lib/online-backup');
|
||||
const { createPortableSettingsSnapshot, prepareImportedSettings } = require('./lib/settings-backup');
|
||||
@@ -93,16 +93,146 @@ if (_perfOn) {
|
||||
}
|
||||
|
||||
let mainWindow;
|
||||
let closeFlushApproved = false;
|
||||
let closeFlushRequested = false;
|
||||
let closeHandshakeReady = false;
|
||||
let closeFlushTimer = null;
|
||||
let restartAfterClosePreparation = false;
|
||||
let quitTeardownStarted = false;
|
||||
let closePreparationAttempt = 0;
|
||||
let closeQuiesceOwnerAttempt = null;
|
||||
let lastRestoredCloseAttempt = null;
|
||||
let closeFolderMonitorWasRunning = false;
|
||||
let preparedUpdate = null;
|
||||
let updatePreparationPromise = null;
|
||||
let updateQuitPending = false;
|
||||
let preparedUpdateLaunchStarted = false;
|
||||
let _lastImportPath = null;
|
||||
let dropTargetWindow = null;
|
||||
let tray = null;
|
||||
const configStore = new ConfigStore(app);
|
||||
configStore.setPerfLog((m) => { try { logInfo(m); } catch {} });
|
||||
let uploadManager = null;
|
||||
const activeUploadProducerTrackers = new Set();
|
||||
const settingsImportGate = createSettingsImportGate(() => !!(uploadManager && uploadManager.running));
|
||||
let diagnosticAgent = null;
|
||||
let _diagHandler = null;
|
||||
|
||||
function assertConfigWriteAllowed() {
|
||||
if (!settingsImportGate.canStartUpload()) throw new Error('Einstellungen werden gerade importiert');
|
||||
}
|
||||
|
||||
async function waitForConfigStoreWrites() {
|
||||
await configStore.drainWrites();
|
||||
}
|
||||
|
||||
function clearCloseFlushTimer() {
|
||||
if (closeFlushTimer) clearTimeout(closeFlushTimer);
|
||||
closeFlushTimer = null;
|
||||
}
|
||||
|
||||
function waitForCloseOperation(promise, timeoutMs) {
|
||||
return Promise.race([
|
||||
Promise.resolve(promise),
|
||||
new Promise((_, reject) => setTimeout(() => reject(new Error('Speichern vor dem Beenden hat zu lange gedauert')), timeoutMs))
|
||||
]);
|
||||
}
|
||||
|
||||
function trackUploadProducer(manager) {
|
||||
let settled = false;
|
||||
let resolveProducer;
|
||||
const promise = new Promise(resolve => { resolveProducer = resolve; });
|
||||
const tracker = {
|
||||
manager,
|
||||
promise,
|
||||
finish() {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
activeUploadProducerTrackers.delete(tracker);
|
||||
resolveProducer();
|
||||
}
|
||||
};
|
||||
activeUploadProducerTrackers.add(tracker);
|
||||
return tracker;
|
||||
}
|
||||
|
||||
function isClosePreparationActive(attempt) {
|
||||
return closeFlushRequested && !closeFlushApproved && attempt === closePreparationAttempt;
|
||||
}
|
||||
|
||||
function acquireCloseQuiesce(attempt) {
|
||||
if (!isClosePreparationActive(attempt)) return false;
|
||||
if (closeQuiesceOwnerAttempt !== null && closeQuiesceOwnerAttempt !== attempt) return false;
|
||||
closeQuiesceOwnerAttempt = attempt;
|
||||
configStore.setWritesQuiesced(true);
|
||||
return true;
|
||||
}
|
||||
|
||||
function releaseCloseQuiesce(attempt) {
|
||||
if (closeQuiesceOwnerAttempt !== attempt) return false;
|
||||
closeQuiesceOwnerAttempt = null;
|
||||
configStore.setWritesQuiesced(false);
|
||||
return true;
|
||||
}
|
||||
|
||||
function rejectPendingUpdate(error) {
|
||||
if (!preparedUpdate && !updateQuitPending) return false;
|
||||
preparedUpdate = null;
|
||||
updateQuitPending = false;
|
||||
preparedUpdateLaunchStarted = false;
|
||||
safeSend('app:update-progress', {
|
||||
stage: 'error',
|
||||
error: error && error.message ? error.message : String(error || 'Einstellungen konnten vor dem Update nicht gespeichert werden')
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
function restoreClosePreparation(attempt, clearRestart = true) {
|
||||
if (!isClosePreparationActive(attempt)) return lastRestoredCloseAttempt === attempt;
|
||||
closeFlushApproved = false;
|
||||
closeFlushRequested = false;
|
||||
clearCloseFlushTimer();
|
||||
releaseCloseQuiesce(attempt);
|
||||
lastRestoredCloseAttempt = attempt;
|
||||
if (clearRestart) restartAfterClosePreparation = false;
|
||||
if (closeFolderMonitorWasRunning) {
|
||||
closeFolderMonitorWasRunning = false;
|
||||
const settings = configStore.load().globalSettings?.folderMonitor;
|
||||
if (settings?.enabled && settings.folderPath) startFolderMonitor(settings);
|
||||
}
|
||||
rejectPendingUpdate(new Error('Das Update wurde nicht gestartet, weil die Einstellungen vor dem Beenden nicht gespeichert werden konnten'));
|
||||
return true;
|
||||
}
|
||||
|
||||
function armCloseFlushTimer(attempt, timeoutMs) {
|
||||
clearCloseFlushTimer();
|
||||
closeFlushTimer = setTimeout(() => {
|
||||
closeFlushTimer = null;
|
||||
restoreClosePreparation(attempt);
|
||||
}, timeoutMs);
|
||||
}
|
||||
|
||||
function requestClosePreparation() {
|
||||
if (!mainWindow || mainWindow.isDestroyed() || mainWindow.webContents.isDestroyed() || !closeHandshakeReady) return false;
|
||||
if (closeFlushRequested) return true;
|
||||
const attempt = ++closePreparationAttempt;
|
||||
closeFlushRequested = true;
|
||||
closeFolderMonitorWasRunning = !!folderMonitor.running;
|
||||
try { folderMonitor.stop(); } catch {}
|
||||
try { if (uploadManager) uploadManager.cancel(); } catch {}
|
||||
(async () => {
|
||||
try {
|
||||
await waitForCloseOperation(Promise.all(Array.from(activeUploadProducerTrackers, tracker => tracker.promise)), 2500);
|
||||
if (attempt !== closePreparationAttempt || !closeFlushRequested) return;
|
||||
safeSend('app:prepare-close', attempt);
|
||||
armCloseFlushTimer(attempt, 1500);
|
||||
} catch {
|
||||
restoreClosePreparation(attempt);
|
||||
}
|
||||
})();
|
||||
return true;
|
||||
}
|
||||
|
||||
const _hasSingleInstanceLock = app.requestSingleInstanceLock();
|
||||
if (!_hasSingleInstanceLock) {
|
||||
app.quit();
|
||||
@@ -708,6 +838,7 @@ function _flushUploadLog() {
|
||||
|
||||
function _persistFallbackLogPath(workingPath) {
|
||||
try {
|
||||
if (!settingsImportGate.canStartUpload()) return;
|
||||
const cfg = configStore.load();
|
||||
const gs = cfg.globalSettings || {};
|
||||
const mode = gs.logMode || 'single';
|
||||
@@ -913,7 +1044,7 @@ function makeAccountPicker(config) {
|
||||
function persistRotation(pick) {
|
||||
if (!pick.dirty()) return;
|
||||
_rotationCursors = { ...rotationCursors(), ...pick.indices() };
|
||||
configStore.saveRotationCursors(_rotationCursors);
|
||||
configStore.saveRotationCursors(_rotationCursors).catch(error => debugLog(`rotation cursor save failed: ${error.message}`));
|
||||
}
|
||||
|
||||
function buildUploadTasks(config, files, hosters, pick) {
|
||||
@@ -1231,7 +1362,7 @@ function createWindow() {
|
||||
height: 750,
|
||||
minWidth: 800,
|
||||
minHeight: 550,
|
||||
backgroundColor: '#16181c',
|
||||
backgroundColor: '#0f0f0f',
|
||||
autoHideMenuBar: true,
|
||||
webPreferences: {
|
||||
contextIsolation: true,
|
||||
@@ -1240,9 +1371,28 @@ function createWindow() {
|
||||
}
|
||||
});
|
||||
mainWindow = startupWindow.window;
|
||||
closePreparationAttempt++;
|
||||
closeFlushApproved = false;
|
||||
closeFlushRequested = false;
|
||||
closeHandshakeReady = false;
|
||||
closeQuiesceOwnerAttempt = null;
|
||||
lastRestoredCloseAttempt = null;
|
||||
configStore.setWritesQuiesced(false);
|
||||
clearCloseFlushTimer();
|
||||
|
||||
mainWindow.on('close', (event) => {
|
||||
if (closeFlushApproved || !closeHandshakeReady || mainWindow.webContents.isDestroyed()) return;
|
||||
event.preventDefault();
|
||||
requestClosePreparation();
|
||||
});
|
||||
|
||||
mainWindow.webContents.setBackgroundThrottling(false);
|
||||
|
||||
mainWindow.webContents.on('did-start-loading', () => {
|
||||
closeHandshakeReady = false;
|
||||
restoreClosePreparation(closePreparationAttempt);
|
||||
});
|
||||
|
||||
mainWindow.webContents.on('render-process-gone', (_event, details) => {
|
||||
_writeCrashLog('RENDER PROCESS GONE', new Error(details.reason || 'unknown'), details);
|
||||
debugLog(`RENDER PROCESS GONE: reason=${details.reason} exitCode=${details.exitCode}`);
|
||||
@@ -1422,7 +1572,27 @@ app.on('window-all-closed', () => {
|
||||
app.quit();
|
||||
});
|
||||
|
||||
app.on('before-quit', () => {
|
||||
app.on('before-quit', (event) => {
|
||||
if (!closeFlushApproved && mainWindow && !mainWindow.isDestroyed() && closeHandshakeReady) {
|
||||
event.preventDefault();
|
||||
requestClosePreparation();
|
||||
}
|
||||
});
|
||||
|
||||
app.on('will-quit', () => {
|
||||
if (quitTeardownStarted) return;
|
||||
quitTeardownStarted = true;
|
||||
if (preparedUpdate && updateQuitPending && closeFlushApproved && !preparedUpdateLaunchStarted) {
|
||||
preparedUpdateLaunchStarted = true;
|
||||
try {
|
||||
launchPreparedUpdate(preparedUpdate);
|
||||
} catch (error) {
|
||||
logError('prepared update launch failed', error);
|
||||
}
|
||||
}
|
||||
preparedUpdate = null;
|
||||
updateQuitPending = false;
|
||||
if (restartAfterClosePreparation) app.relaunch();
|
||||
if (uploadManager) try { uploadManager.cancel(); } catch {}
|
||||
try { folderMonitor.stop(); } catch {}
|
||||
try {
|
||||
@@ -1467,6 +1637,7 @@ ipcMain.handle('get-config', () => {
|
||||
});
|
||||
|
||||
ipcMain.handle('save-config', async (_event, config) => {
|
||||
assertConfigWriteAllowed();
|
||||
await configStore.save(config);
|
||||
if (config && config.globalSettings) _invalidateLogSettings();
|
||||
try {
|
||||
@@ -1519,6 +1690,7 @@ ipcMain.handle('get-history', () => {
|
||||
ipcMain.handle('prune-history', async (_event, payload) => {
|
||||
const retention = payload && payload.retention;
|
||||
const dryRun = !!(payload && payload.dryRun);
|
||||
if (!dryRun) assertConfigWriteAllowed();
|
||||
return configStore.pruneHistory(retention, { dryRun });
|
||||
});
|
||||
|
||||
@@ -1725,6 +1897,7 @@ ipcMain.handle('get-file-sizes', async (_event, paths) => {
|
||||
});
|
||||
|
||||
ipcMain.handle('start-upload', (_event, payload) => {
|
||||
if (closeFlushRequested) return { error: 'Die Anwendung wird gerade beendet' };
|
||||
if (!settingsImportGate.canStartUpload()) return { error: 'Einstellungen werden gerade importiert' };
|
||||
const config = configStore.load();
|
||||
const files = payload && Array.isArray(payload.files) ? payload.files : [];
|
||||
@@ -1784,6 +1957,8 @@ ipcMain.handle('start-upload', (_event, payload) => {
|
||||
// Pass hoster settings to the upload manager
|
||||
uploadManager = new UploadManager(config.hosterSettings || {}, config.globalSettings || {}, buildAccountPools(config));
|
||||
globalThis._mhuUploadManagerRef = uploadManager;
|
||||
const _thisManager = uploadManager;
|
||||
const _producerTracker = trackUploadProducer(_thisManager);
|
||||
|
||||
const _progressByJob = new Map();
|
||||
const _progressTerminalQueue = [];
|
||||
@@ -1907,7 +2082,6 @@ ipcMain.handle('start-upload', (_event, payload) => {
|
||||
// fires start-upload while we're still awaiting appendHistory would
|
||||
// create a fresh manager which the trailing `uploadManager = null` then
|
||||
// orphans (cancel/addJobs see null, the new batch keeps running invisibly).
|
||||
const _thisManager = uploadManager;
|
||||
uploadManager.on('batch-done', async (summary) => {
|
||||
debugLog(`batch-done: total=${summary.total} ok=${summary.succeeded} fail=${summary.failed}`);
|
||||
logMarker('BATCH END', { total: summary.total, ok: summary.succeeded, fail: summary.failed });
|
||||
@@ -1918,7 +2092,16 @@ ipcMain.handle('start-upload', (_event, payload) => {
|
||||
try { await configStore.appendHistory(summary); } catch (err) {
|
||||
debugLog(`appendHistory failed: ${err.message}`);
|
||||
}
|
||||
if (_progressFlushTimer) {
|
||||
clearTimeout(_progressFlushTimer);
|
||||
_progressFlushTimer = null;
|
||||
}
|
||||
const finalProgressBatch = _progressTerminalQueue.splice(0);
|
||||
for (const value of _progressByJob.values()) finalProgressBatch.push(value);
|
||||
_progressByJob.clear();
|
||||
if (finalProgressBatch.length) safeSend('upload-progress-batch', finalProgressBatch);
|
||||
safeSend('upload-batch-done', summary);
|
||||
_producerTracker.finish();
|
||||
|
||||
const fullyAborted = isAllAborted(summary);
|
||||
if (isAutoRetry) {
|
||||
@@ -1939,9 +2122,19 @@ ipcMain.handle('start-upload', (_event, payload) => {
|
||||
// This ensures webContents.send() calls from upload events
|
||||
// are not interleaved with the handle() response.
|
||||
process.nextTick(() => {
|
||||
if (!uploadManager) { debugLog('nextTick: uploadManager was nulled before startBatch'); return; }
|
||||
if (uploadManager !== _thisManager) {
|
||||
debugLog('nextTick: uploadManager was replaced before startBatch');
|
||||
_producerTracker.finish();
|
||||
return;
|
||||
}
|
||||
if (closeFlushRequested) {
|
||||
try { _thisManager.cancel(); } catch {}
|
||||
if (uploadManager === _thisManager) { uploadManager = null; globalThis._mhuUploadManagerRef = null; }
|
||||
_producerTracker.finish();
|
||||
return;
|
||||
}
|
||||
debugLog(`nextTick: calling startBatch now (priming ${_sessionFailedAccounts.size} failed accounts, ${_sessionAccountOverrides.size} overrides from session)`);
|
||||
uploadManager.startBatch(tasks, {
|
||||
_thisManager.startBatch(tasks, {
|
||||
primeFailedAccounts: Array.from(_sessionFailedAccounts.keys()),
|
||||
primeOverrides: Array.from(_sessionAccountOverrides.entries())
|
||||
}).catch((err) => {
|
||||
@@ -1956,6 +2149,7 @@ ipcMain.handle('start-upload', (_event, payload) => {
|
||||
error: err ? err.message : 'Unbekannter Fehler'
|
||||
};
|
||||
safeSend('upload-batch-done', errorSummary);
|
||||
_producerTracker.finish();
|
||||
if (!isAutoRetry) sendBatchWebhook(errorSummary, 0);
|
||||
if (uploadManager === _thisManager) { uploadManager = null; globalThis._mhuUploadManagerRef = null; }
|
||||
});
|
||||
@@ -1992,6 +2186,7 @@ ipcMain.handle('cancel-selected-jobs', (_event, jobIds) => {
|
||||
});
|
||||
|
||||
ipcMain.handle('add-jobs-to-batch', (_event, payload) => {
|
||||
if (closeFlushRequested) return { error: 'Die Anwendung wird gerade beendet' };
|
||||
if (!uploadManager || !uploadManager.running) {
|
||||
return { error: 'Kein Upload aktiv' };
|
||||
}
|
||||
@@ -2208,6 +2403,7 @@ ipcMain.handle('open-log-folder', async () => {
|
||||
});
|
||||
|
||||
ipcMain.handle('clear-history', async () => {
|
||||
assertConfigWriteAllowed();
|
||||
await configStore.clearHistory();
|
||||
return true;
|
||||
});
|
||||
@@ -2266,6 +2462,7 @@ async function syncImportedRuntime(config) {
|
||||
async function applyImportedSettings(imported) {
|
||||
settingsImportGate.begin();
|
||||
try {
|
||||
await waitForConfigStoreWrites();
|
||||
const prepared = prepareImportedSettings(imported);
|
||||
const ts = new Date().toISOString().replace(/[:.]/g, '-');
|
||||
const preImportPath = configStore.filePath.replace('.json', `.pre-import-${ts}.json`);
|
||||
@@ -2302,6 +2499,7 @@ ipcMain.handle('export-backup', async () => {
|
||||
]
|
||||
});
|
||||
if (canceled || !filePath) return { ok: false, canceled: true };
|
||||
await waitForConfigStoreWrites();
|
||||
const config = createPortableSettingsSnapshot(configStore.load());
|
||||
if (filePath.toLowerCase().endsWith('.json')) {
|
||||
fs.writeFileSync(filePath, JSON.stringify(config, null, 2), 'utf-8');
|
||||
@@ -2363,6 +2561,7 @@ ipcMain.handle('import-backup', async (_event, legacyPassword) => {
|
||||
|
||||
ipcMain.handle('online-backup:create', async () => {
|
||||
try {
|
||||
await waitForConfigStoreWrites();
|
||||
const snapshot = createPortableSettingsSnapshot(configStore.load());
|
||||
const created = createOnlineBackup(snapshot, app.getVersion());
|
||||
await uploadOnlineBackup(created.record);
|
||||
@@ -2455,18 +2654,37 @@ ipcMain.handle('app:check-updates', async () => {
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle('app:install-update', () => {
|
||||
try { if (uploadManager) uploadManager.cancel(); } catch {}
|
||||
installUpdate((progress) => {
|
||||
safeSend('app:update-progress', progress);
|
||||
}).catch((err) => {
|
||||
safeSend('app:update-progress', { stage: 'error', error: err.message });
|
||||
});
|
||||
return { started: true };
|
||||
ipcMain.handle('app:install-update', async () => {
|
||||
if (updatePreparationPromise || preparedUpdate || updateQuitPending) {
|
||||
return { started: false, error: 'Ein Update wird bereits vorbereitet' };
|
||||
}
|
||||
updatePreparationPromise = (async () => {
|
||||
const prepared = await prepareUpdate((progress) => {
|
||||
safeSend('app:update-progress', progress);
|
||||
});
|
||||
if (!mainWindow || mainWindow.isDestroyed() || mainWindow.webContents.isDestroyed() || !closeHandshakeReady) {
|
||||
throw new Error('Die Anwendung ist noch nicht bereit, das Update sicher zu installieren');
|
||||
}
|
||||
preparedUpdate = prepared;
|
||||
updateQuitPending = true;
|
||||
preparedUpdateLaunchStarted = false;
|
||||
setImmediate(() => app.quit());
|
||||
return { started: true };
|
||||
})();
|
||||
try {
|
||||
return await updatePreparationPromise;
|
||||
} catch (error) {
|
||||
rejectPendingUpdate(error);
|
||||
safeSend('app:update-progress', { stage: 'error', error: error.message });
|
||||
return { started: false, error: error.message };
|
||||
} finally {
|
||||
updatePreparationPromise = null;
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle('app:abort-update', () => {
|
||||
abortUpdate();
|
||||
rejectPendingUpdate(new Error('Update abgebrochen'));
|
||||
return true;
|
||||
});
|
||||
|
||||
@@ -2475,7 +2693,7 @@ ipcMain.handle('app:get-version', () => {
|
||||
});
|
||||
|
||||
ipcMain.handle('app:restart', () => {
|
||||
app.relaunch();
|
||||
restartAfterClosePreparation = true;
|
||||
app.quit();
|
||||
});
|
||||
|
||||
@@ -2483,6 +2701,53 @@ ipcMain.handle('app:quit', () => {
|
||||
app.quit();
|
||||
});
|
||||
|
||||
ipcMain.on('app:close-handshake-ready', (event) => {
|
||||
if (mainWindow && !mainWindow.isDestroyed() && event.sender === mainWindow.webContents) closeHandshakeReady = true;
|
||||
});
|
||||
|
||||
ipcMain.on('app:close-preparation-started', (event, attempt) => {
|
||||
if (mainWindow && !mainWindow.isDestroyed() && event.sender === mainWindow.webContents && isClosePreparationActive(attempt)) {
|
||||
armCloseFlushTimer(attempt, 3000);
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle('app:finish-close', async (event, payload = true) => {
|
||||
if (!mainWindow || mainWindow.isDestroyed() || event.sender !== mainWindow.webContents) return false;
|
||||
const attempt = payload && typeof payload === 'object' && Number.isInteger(payload.attempt) ? payload.attempt : null;
|
||||
if (attempt === null) return false;
|
||||
const ready = payload && typeof payload === 'object' ? payload.ready !== false : payload !== false;
|
||||
if (!ready) {
|
||||
if (isClosePreparationActive(attempt)) {
|
||||
clearCloseFlushTimer();
|
||||
return restoreClosePreparation(attempt);
|
||||
}
|
||||
return lastRestoredCloseAttempt === attempt;
|
||||
}
|
||||
if (!isClosePreparationActive(attempt)) return false;
|
||||
clearCloseFlushTimer();
|
||||
let approved = false;
|
||||
try {
|
||||
if (!acquireCloseQuiesce(attempt)) return false;
|
||||
await waitForCloseOperation(waitForConfigStoreWrites(), 1000);
|
||||
if (!isClosePreparationActive(attempt)) return false;
|
||||
if (payload && typeof payload === 'object' && Object.prototype.hasOwnProperty.call(payload, 'pendingQueue')) {
|
||||
await waitForCloseOperation(configStore.savePendingQueue(payload.pendingQueue, { allowDuringQuiesce: true }), 1000);
|
||||
}
|
||||
if (!isClosePreparationActive(attempt)) return false;
|
||||
closeFlushApproved = true;
|
||||
approved = true;
|
||||
setImmediate(() => {
|
||||
app.quit();
|
||||
});
|
||||
return true;
|
||||
} catch {
|
||||
restoreClosePreparation(attempt);
|
||||
return false;
|
||||
} finally {
|
||||
if (!approved) releaseCloseQuiesce(attempt);
|
||||
}
|
||||
});
|
||||
|
||||
// --- Hoster settings ---
|
||||
ipcMain.handle('get-hoster-settings', () => {
|
||||
const config = configStore.load();
|
||||
@@ -2490,8 +2755,11 @@ ipcMain.handle('get-hoster-settings', () => {
|
||||
});
|
||||
|
||||
ipcMain.handle('save-hoster-settings', async (_event, hosterSettings) => {
|
||||
assertConfigWriteAllowed();
|
||||
await configStore.save({ hosterSettings });
|
||||
if (uploadManager) uploadManager.updateSettings(hosterSettings, null);
|
||||
if (uploadManager) {
|
||||
try { uploadManager.updateSettings(hosterSettings, null); } catch (error) { debugLog(`hoster settings runtime update failed: ${error.message}`); }
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
@@ -2501,22 +2769,19 @@ ipcMain.handle('get-global-settings', () => {
|
||||
return config.globalSettings || {};
|
||||
});
|
||||
|
||||
function _preserveDiagSubtree(globalSettings) {
|
||||
if (!globalSettings || typeof globalSettings !== 'object') return globalSettings;
|
||||
try {
|
||||
const cur = configStore.load();
|
||||
if (cur.globalSettings && cur.globalSettings.diagnostics) {
|
||||
globalSettings.diagnostics = cur.globalSettings.diagnostics;
|
||||
}
|
||||
} catch {}
|
||||
return globalSettings;
|
||||
}
|
||||
ipcMain.handle('save-pending-queue', async (_event, pendingQueue) => {
|
||||
await configStore.savePendingQueue(pendingQueue);
|
||||
return true;
|
||||
});
|
||||
|
||||
ipcMain.handle('save-global-settings', async (_event, globalSettings) => {
|
||||
globalSettings = _preserveDiagSubtree(globalSettings);
|
||||
await configStore.save({ globalSettings });
|
||||
assertConfigWriteAllowed();
|
||||
await configStore.saveRendererGlobalSettings(globalSettings);
|
||||
globalSettings = configStore.load().globalSettings;
|
||||
_invalidateLogSettings();
|
||||
if (uploadManager) uploadManager.updateSettings(null, globalSettings);
|
||||
if (uploadManager) {
|
||||
try { uploadManager.updateSettings(null, globalSettings); } catch (error) { debugLog(`global settings runtime update failed: ${error.message}`); }
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
@@ -2546,62 +2811,6 @@ function _sweepOrphanConfigTmps() {
|
||||
} catch {}
|
||||
}
|
||||
|
||||
// Synchronous save for beforeunload — blocks renderer until write completes
|
||||
// Uses atomic write pattern (tmp + backup + rename) to prevent corruption.
|
||||
// Returns false on any failure so the renderer (which surfaces this via the
|
||||
// beforeunload chain) doesn't quietly think queue + settings persisted when
|
||||
// they didn't. Errors are logged for diagnostics regardless.
|
||||
ipcMain.on('save-global-settings-sync', (event, globalSettings) => {
|
||||
const tmpPath = configStore.filePath + '.' + process.pid + '.tmp';
|
||||
try {
|
||||
const current = configStore.load();
|
||||
const _diskDiag = current.globalSettings && current.globalSettings.diagnostics;
|
||||
current.globalSettings = globalSettings;
|
||||
if (_diskDiag) current.globalSettings.diagnostics = _diskDiag;
|
||||
try { configStore._guardHosters(current, false); } catch {}
|
||||
_invalidateLogSettings();
|
||||
const data = configStore._serializeForDisk(current);
|
||||
const backupPath = configStore.filePath + '.bak';
|
||||
const _fd = fs.openSync(tmpPath, 'w');
|
||||
try { fs.writeSync(_fd, data); fs.fsyncSync(_fd); } finally { fs.closeSync(_fd); }
|
||||
if (fs.existsSync(configStore.filePath)) {
|
||||
// Use try/catch around the read so an AV/lock race doesn't fail the
|
||||
// whole save just because we couldn't refresh the .bak — the write to
|
||||
// the live file via rename is what matters.
|
||||
try {
|
||||
const existing = fs.readFileSync(configStore.filePath, 'utf-8');
|
||||
if (existing && existing.trim().length > 2) {
|
||||
fs.writeFileSync(backupPath, existing, 'utf-8');
|
||||
}
|
||||
} catch (bakErr) {
|
||||
debugLog(`save-global-settings-sync: backup read/write skipped: ${bakErr.message}`);
|
||||
}
|
||||
}
|
||||
let renamed = false;
|
||||
let lastErr = null;
|
||||
for (let attempt = 0; attempt < 5 && !renamed; attempt++) {
|
||||
try {
|
||||
fs.renameSync(tmpPath, configStore.filePath);
|
||||
renamed = true;
|
||||
} catch (renameErr) {
|
||||
lastErr = renameErr;
|
||||
const code = renameErr && renameErr.code;
|
||||
if (code === 'EBUSY' || code === 'EPERM' || code === 'EACCES') {
|
||||
_sleepSyncMs(40);
|
||||
} else {
|
||||
throw renameErr;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!renamed) throw lastErr || new Error('renameSync failed');
|
||||
event.returnValue = true;
|
||||
} catch (err) {
|
||||
try { if (fs.existsSync(tmpPath)) fs.unlinkSync(tmpPath); } catch {}
|
||||
debugLog(`save-global-settings-sync FAILED: ${err && err.message ? err.message : err}`);
|
||||
event.returnValue = false;
|
||||
}
|
||||
});
|
||||
|
||||
// --- Folder Monitor ---
|
||||
function startFolderMonitor(settings) {
|
||||
try {
|
||||
@@ -2809,6 +3018,7 @@ ipcMain.handle('diagnostics:get-settings', () => {
|
||||
});
|
||||
|
||||
ipcMain.handle('diagnostics:save-settings', async (_e, incoming) => {
|
||||
assertConfigWriteAllowed();
|
||||
const cfg = configStore.load();
|
||||
const cur = (cfg.globalSettings && cfg.globalSettings.diagnostics) || {};
|
||||
const next = {
|
||||
@@ -2830,6 +3040,7 @@ ipcMain.handle('diagnostics:save-settings', async (_e, incoming) => {
|
||||
});
|
||||
|
||||
ipcMain.handle('diagnostics:regenerate', async () => {
|
||||
assertConfigWriteAllowed();
|
||||
const cfg = configStore.load();
|
||||
const cur = (cfg.globalSettings && cfg.globalSettings.diagnostics) || {};
|
||||
const next = { ...cur, token: generateToken(), codeIssuedAt: Date.now() };
|
||||
@@ -2908,32 +3119,38 @@ async function startRemoteServer() {
|
||||
|
||||
let token = remote.token;
|
||||
if (!token) {
|
||||
token = generateToken();
|
||||
const gs = { ...config.globalSettings, remote: { ...remote, token } };
|
||||
await configStore.save({ globalSettings: gs });
|
||||
const canonical = await configStore.saveRemoteSettings(remote, generateToken);
|
||||
token = canonical.token;
|
||||
}
|
||||
|
||||
remoteServer = new RemoteServer();
|
||||
await remoteServer.start({
|
||||
port: remote.port || 9100,
|
||||
token,
|
||||
allowInput: remote.allowInput !== false,
|
||||
mainWindow,
|
||||
onSignalingToCapture: (data) => {
|
||||
if (!captureWindow || captureWindow.isDestroyed()) {
|
||||
debugLog('remote: signaling dropped, no capture window');
|
||||
return;
|
||||
}
|
||||
if (captureWindowReady) {
|
||||
captureWindow.webContents.send('remote:signaling-to-capture', data);
|
||||
} else {
|
||||
debugLog('remote: capture window not ready, queuing', data.type, 'message');
|
||||
signalingQueue.push(data);
|
||||
}
|
||||
},
|
||||
onCreateCaptureWindow: () => createCaptureWindow(),
|
||||
onDestroyCaptureWindow: () => destroyCaptureWindow()
|
||||
});
|
||||
try {
|
||||
await remoteServer.start({
|
||||
port: remote.port || 9100,
|
||||
token,
|
||||
allowInput: remote.allowInput !== false,
|
||||
mainWindow,
|
||||
onSignalingToCapture: (data) => {
|
||||
if (!captureWindow || captureWindow.isDestroyed()) {
|
||||
debugLog('remote: signaling dropped, no capture window');
|
||||
return;
|
||||
}
|
||||
if (captureWindowReady) {
|
||||
captureWindow.webContents.send('remote:signaling-to-capture', data);
|
||||
} else {
|
||||
debugLog('remote: capture window not ready, queuing', data.type, 'message');
|
||||
signalingQueue.push(data);
|
||||
}
|
||||
},
|
||||
onCreateCaptureWindow: () => createCaptureWindow(),
|
||||
onDestroyCaptureWindow: () => destroyCaptureWindow()
|
||||
});
|
||||
} catch (error) {
|
||||
try { remoteServer.stop(); } catch {}
|
||||
remoteServer = null;
|
||||
destroyCaptureWindow();
|
||||
throw error;
|
||||
}
|
||||
|
||||
debugLog(`remote-server started on port ${remoteServer.getPort()}`);
|
||||
}
|
||||
@@ -3066,19 +3283,27 @@ ipcMain.handle('remote:get-settings', () => {
|
||||
});
|
||||
|
||||
ipcMain.handle('remote:save-settings', async (_event, remoteSettings) => {
|
||||
const config = configStore.load();
|
||||
const gs = { ...config.globalSettings, remote: remoteSettings };
|
||||
await configStore.save({ globalSettings: gs });
|
||||
assertConfigWriteAllowed();
|
||||
const canonicalSettings = await configStore.saveRemoteSettings(remoteSettings, generateToken);
|
||||
|
||||
if (remoteSettings.enabled) {
|
||||
await startRemoteServer();
|
||||
} else if (remoteServer) {
|
||||
remoteServer.stop();
|
||||
remoteServer = null;
|
||||
destroyCaptureWindow();
|
||||
debugLog('remote-server stopped');
|
||||
let runtimeError = '';
|
||||
try {
|
||||
if (canonicalSettings.enabled) {
|
||||
await startRemoteServer();
|
||||
} else if (remoteServer) {
|
||||
remoteServer.stop();
|
||||
remoteServer = null;
|
||||
destroyCaptureWindow();
|
||||
debugLog('remote-server stopped');
|
||||
}
|
||||
} catch (error) {
|
||||
runtimeError = error && error.message ? error.message : String(error);
|
||||
}
|
||||
return true;
|
||||
return {
|
||||
saved: true,
|
||||
runtimeError,
|
||||
settings: canonicalSettings
|
||||
};
|
||||
});
|
||||
|
||||
ipcMain.handle('remote:generate-token', () => {
|
||||
@@ -3095,6 +3320,7 @@ ipcMain.handle('remote:status', () => {
|
||||
|
||||
// --- Always on top ---
|
||||
ipcMain.handle('set-always-on-top', async (_event, value) => {
|
||||
assertConfigWriteAllowed();
|
||||
if (mainWindow && !mainWindow.isDestroyed()) {
|
||||
mainWindow.setAlwaysOnTop(!!value);
|
||||
}
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "multi-hoster-uploader",
|
||||
"version": "2.0.5",
|
||||
"version": "2.0.6",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "multi-hoster-uploader",
|
||||
"version": "2.0.5",
|
||||
"version": "2.0.6",
|
||||
"dependencies": {
|
||||
"chokidar": "^3.6.0",
|
||||
"undici": "^7.29.0",
|
||||
|
||||
+3
-3
@@ -1,15 +1,15 @@
|
||||
{
|
||||
"name": "multi-hoster-uploader",
|
||||
"version": "2.0.5",
|
||||
"version": "2.0.6",
|
||||
"description": "Upload files to doodstream, voe, vidmoly, byse simultaneously",
|
||||
"main": "main.js",
|
||||
"scripts": {
|
||||
"start": "electron .",
|
||||
"test": "node --test tests/*.test.js tests/ui-smoke.js",
|
||||
"test:backup-api": "npm --prefix services/backup-api test",
|
||||
"lint": "eslint .",
|
||||
"dist": "electron-builder --win",
|
||||
"release:win": "electron-builder --publish never --win nsis portable",
|
||||
"release:gitea": "node scripts/release_gitea.mjs"
|
||||
"release:win": "electron-builder --publish never --win nsis portable"
|
||||
},
|
||||
"dependencies": {
|
||||
"chokidar": "^3.6.0",
|
||||
|
||||
+10
-1
@@ -17,7 +17,15 @@ contextBridge.exposeInMainWorld('api', {
|
||||
// Global settings
|
||||
getGlobalSettings: () => ipcRenderer.invoke('get-global-settings'),
|
||||
saveGlobalSettings: (settings) => ipcRenderer.invoke('save-global-settings', settings),
|
||||
saveGlobalSettingsSync: (settings) => ipcRenderer.sendSync('save-global-settings-sync', settings),
|
||||
savePendingQueue: (pendingQueue) => ipcRenderer.invoke('save-pending-queue', pendingQueue),
|
||||
finishClosePreparation: (payload = true) => ipcRenderer.invoke('app:finish-close', payload),
|
||||
onPrepareClose: (callback) => {
|
||||
ipcRenderer.on('app:prepare-close', (_event, attempt) => {
|
||||
ipcRenderer.send('app:close-preparation-started', attempt);
|
||||
callback(attempt);
|
||||
});
|
||||
},
|
||||
signalCloseHandshakeReady: () => ipcRenderer.send('app:close-handshake-ready'),
|
||||
|
||||
// Always on top
|
||||
setAlwaysOnTop: (value) => ipcRenderer.invoke('set-always-on-top', value),
|
||||
@@ -155,6 +163,7 @@ contextBridge.exposeInMainWorld('api', {
|
||||
ipcRenderer.removeAllListeners('upload-stats');
|
||||
ipcRenderer.removeAllListeners('app:update-available');
|
||||
ipcRenderer.removeAllListeners('app:update-progress');
|
||||
ipcRenderer.removeAllListeners('app:prepare-close');
|
||||
ipcRenderer.removeAllListeners('shutdown-countdown');
|
||||
ipcRenderer.removeAllListeners('folder-monitor:new-files');
|
||||
ipcRenderer.removeAllListeners('drop-target:files');
|
||||
|
||||
+942
-131
File diff suppressed because it is too large
Load Diff
+35
-11
@@ -18,27 +18,51 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 2px dashed rgba(126, 220, 255, 0.5);
|
||||
border-radius: 10px;
|
||||
background: rgba(22, 24, 28, 0.85);
|
||||
transition: border-color 0.15s, background 0.15s;
|
||||
border: 1px dashed rgba(186, 208, 252, 0.62);
|
||||
border-radius: 8px;
|
||||
background: rgba(35, 35, 35, 0.94);
|
||||
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.025);
|
||||
transition: border-color 0.15s, background-color 0.15s, transform 0.15s;
|
||||
}
|
||||
.target.drag-over {
|
||||
border-color: rgba(126, 220, 255, 0.9);
|
||||
background: rgba(62, 167, 255, 0.15);
|
||||
border-color: #bad0fc;
|
||||
background: rgba(51, 52, 54, 0.98);
|
||||
transform: scale(0.98);
|
||||
}
|
||||
.icon {
|
||||
font-size: 64px;
|
||||
font-weight: 200;
|
||||
color: rgba(126, 220, 255, 0.7);
|
||||
line-height: 1;
|
||||
width: 54px;
|
||||
height: 54px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border-radius: 8px;
|
||||
background: #2b2b2b;
|
||||
color: #bad0fc;
|
||||
-webkit-app-region: no-drag;
|
||||
}
|
||||
.icon svg {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
fill: none;
|
||||
stroke: currentColor;
|
||||
stroke-width: 1.65;
|
||||
stroke-linecap: round;
|
||||
stroke-linejoin: round;
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.target {
|
||||
transition: none;
|
||||
}
|
||||
.target.drag-over {
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="target" id="target">
|
||||
<div class="icon">+</div>
|
||||
<div class="icon" aria-hidden="true">
|
||||
<svg viewBox="0 0 24 24"><path d="M12 16V4m0 0L7.5 8.5M12 4l4.5 4.5M5 14v4.5A1.5 1.5 0 0 0 6.5 20h11a1.5 1.5 0 0 0 1.5-1.5V14"/></svg>
|
||||
</div>
|
||||
</div>
|
||||
<script>
|
||||
const target = document.getElementById('target');
|
||||
|
||||
+267
-80
@@ -3,102 +3,225 @@
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; style-src 'self' 'unsafe-inline';">
|
||||
<meta name="theme-color" content="#0f0f0f">
|
||||
<title>Multi-Hoster-Upload</title>
|
||||
<link rel="stylesheet" href="styles.css">
|
||||
</head>
|
||||
<body>
|
||||
<nav class="menu-bar" id="menuBar">
|
||||
<div class="menu-bar-item" data-menu="datei">
|
||||
<button class="menu-bar-trigger" data-menu-trigger="datei">Datei</button>
|
||||
<div class="menu-dropdown" data-menu-dropdown="datei" style="display:none">
|
||||
<button class="menu-dropdown-item" data-menu-action="add-files"><span>Dateien hinzufügen</span></button>
|
||||
<button class="menu-dropdown-item" data-menu-action="add-folder"><span>Ordner hinzufügen</span></button>
|
||||
<div class="menu-separator"></div>
|
||||
<div class="menu-submenu" data-submenu="sicherung">
|
||||
<button class="menu-submenu-trigger">Sicherung</button>
|
||||
<div class="menu-submenu-dropdown" style="display:none">
|
||||
<button class="menu-dropdown-item" data-menu-action="backup-export"><span>Exportieren</span></button>
|
||||
<button class="menu-dropdown-item" data-menu-action="backup-import"><span>Importieren</span></button>
|
||||
<button class="menu-dropdown-item" data-menu-action="online-backup-create"><span>Online-Schlüssel erstellen</span></button>
|
||||
<button class="menu-dropdown-item" data-menu-action="online-backup-restore"><span>Online-Schlüssel importieren</span></button>
|
||||
<svg class="icon-sprite" aria-hidden="true">
|
||||
<symbol id="icon-upload" viewBox="0 0 24 24"><path d="M12 16V4m0 0L7.5 8.5M12 4l4.5 4.5M5 14v4.5A1.5 1.5 0 0 0 6.5 20h11a1.5 1.5 0 0 0 1.5-1.5V14"/></symbol>
|
||||
<symbol id="icon-accounts" viewBox="0 0 24 24"><path d="M16.5 20v-1.5a4 4 0 0 0-4-4h-5a4 4 0 0 0-4 4V20M10 10.5a3.5 3.5 0 1 0 0-7 3.5 3.5 0 0 0 0 7Zm6-1a3 3 0 0 0 0-5.8m2.5 10.9a4 4 0 0 1 2 3.4v2"/></symbol>
|
||||
<symbol id="icon-settings" viewBox="0 0 24 24"><path d="M12 15.25A3.25 3.25 0 1 0 12 8.75a3.25 3.25 0 0 0 0 6.5Zm7.2-3.25c0-.5-.05-.98-.15-1.45l2.05-1.6-2-3.46-2.52 1a8.12 8.12 0 0 0-2.5-1.44L13.7 2.4h-4l-.38 2.65a8.12 8.12 0 0 0-2.5 1.44l-2.52-1-2 3.46 2.05 1.6a7.1 7.1 0 0 0 0 2.9l-2.05 1.6 2 3.46 2.52-1a8.12 8.12 0 0 0 2.5 1.44l.38 2.65h4l.38-2.65a8.12 8.12 0 0 0 2.5-1.44l2.52 1 2-3.46-2.05-1.6c.1-.47.15-.95.15-1.45Z"/></symbol>
|
||||
<symbol id="icon-history" viewBox="0 0 24 24"><path d="M4.25 7.75A9 9 0 1 1 3 12m1.25-4.25H8m-3.75 0V4M12 7.5V12l3 2"/></symbol>
|
||||
<symbol id="icon-menu" viewBox="0 0 24 24"><path d="M4 6h16M4 12h16M4 18h16"/></symbol>
|
||||
<symbol id="icon-sliders" viewBox="0 0 24 24"><path d="M4 7h10m4 0h2M4 17h2m4 0h10M14 4v6M10 14v6"/></symbol>
|
||||
<symbol id="icon-help" viewBox="0 0 24 24"><circle cx="12" cy="12" r="9"/><path d="M9.8 9a2.3 2.3 0 1 1 3.6 1.9c-.9.6-1.4 1.1-1.4 2.1m0 3.5h.01"/></symbol>
|
||||
<symbol id="icon-download" viewBox="0 0 24 24"><path d="M12 4v11m0 0 4-4m-4 4-4-4M5 19h14"/></symbol>
|
||||
<symbol id="icon-files" viewBox="0 0 24 24"><path d="M7 3h7l4 4v12a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2Zm7 0v5h4M9 12h6m-6 4h6"/></symbol>
|
||||
<symbol id="icon-check" viewBox="0 0 24 24"><path d="m5 12 4 4L19 6"/></symbol>
|
||||
<symbol id="icon-clock" viewBox="0 0 24 24"><circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 2"/></symbol>
|
||||
<symbol id="icon-alert" viewBox="0 0 24 24"><path d="M12 3 2.8 20h18.4L12 3Zm0 6v5m0 3h.01"/></symbol>
|
||||
<symbol id="icon-cloud" viewBox="0 0 24 24"><path d="M7.5 18H18a4 4 0 0 0 .5-7.97A6.5 6.5 0 0 0 6 8.5v.25A4.75 4.75 0 0 0 7.5 18Z"/></symbol>
|
||||
<symbol id="icon-close" viewBox="0 0 24 24"><path d="m6 6 12 12M18 6 6 18"/></symbol>
|
||||
</svg>
|
||||
|
||||
<header class="app-header">
|
||||
<div class="header-cluster header-primary">
|
||||
<div class="app-brand" aria-label="Multi-Hoster Upload">
|
||||
<img src="../assets/app_icon.png" alt="" class="app-brand-icon" width="24" height="24">
|
||||
<span class="app-brand-name">MULTI-HOSTER UPLOAD</span>
|
||||
</div>
|
||||
<span class="header-divider" aria-hidden="true"></span>
|
||||
<nav class="tab-bar" role="tablist" aria-label="Hauptbereiche">
|
||||
<button class="tab active" id="upload-tab" role="tab" aria-selected="true" aria-controls="upload-view" tabindex="0" data-view="upload" title="Upload">
|
||||
<svg class="top-nav-icon" aria-hidden="true"><use href="#icon-upload"></use></svg>
|
||||
<span class="top-nav-label">Upload</span>
|
||||
</button>
|
||||
<button class="tab" id="accounts-tab" role="tab" aria-selected="false" aria-controls="accounts-view" tabindex="-1" data-view="accounts" title="Accounts">
|
||||
<svg class="top-nav-icon" aria-hidden="true"><use href="#icon-accounts"></use></svg>
|
||||
<span class="top-nav-label">Accounts</span>
|
||||
</button>
|
||||
<button class="tab" id="settings-tab" role="tab" aria-selected="false" aria-controls="settings-view" tabindex="-1" data-view="settings" title="Einstellungen">
|
||||
<svg class="top-nav-icon" aria-hidden="true"><use href="#icon-settings"></use></svg>
|
||||
<span class="top-nav-label">Einstellungen</span>
|
||||
</button>
|
||||
<button class="tab" id="history-tab" role="tab" aria-selected="false" aria-controls="history-view" tabindex="-1" data-view="history" title="Verlauf">
|
||||
<svg class="top-nav-icon" aria-hidden="true"><use href="#icon-history"></use></svg>
|
||||
<span class="top-nav-label">Verlauf</span>
|
||||
</button>
|
||||
</nav>
|
||||
</div>
|
||||
<div class="header-spacer" aria-hidden="true"></div>
|
||||
<div class="header-cluster header-utilities">
|
||||
<button class="header-update-button" id="headerUpdateBtn" title="Nach Aktualisierungen suchen" aria-label="Nach Aktualisierungen suchen" data-tooltip="Nach Aktualisierungen suchen">
|
||||
<svg class="header-action-icon" aria-hidden="true"><use href="#icon-download"></use></svg>
|
||||
<span class="header-update-label">Update</span>
|
||||
</button>
|
||||
<span class="header-divider" aria-hidden="true"></span>
|
||||
<nav class="menu-bar" id="menuBar" aria-label="Anwendungsmenüs">
|
||||
<div class="menu-bar-item" data-menu="datei">
|
||||
<button class="menu-bar-trigger" data-menu-trigger="datei" title="Datei" aria-label="Datei">
|
||||
<svg class="header-action-icon" aria-hidden="true"><use href="#icon-menu"></use></svg>
|
||||
<span class="menu-label">Datei</span>
|
||||
</button>
|
||||
<div class="menu-dropdown" data-menu-dropdown="datei" style="display:none">
|
||||
<button class="menu-dropdown-item" data-menu-action="add-files"><span>Dateien hinzufügen</span></button>
|
||||
<button class="menu-dropdown-item" data-menu-action="add-folder"><span>Ordner hinzufügen</span></button>
|
||||
<div class="menu-separator"></div>
|
||||
<div class="menu-submenu" data-submenu="sicherung">
|
||||
<button class="menu-submenu-trigger">Sicherung</button>
|
||||
<div class="menu-submenu-dropdown" style="display:none">
|
||||
<button class="menu-dropdown-item" data-menu-action="backup-export"><span>Exportieren</span></button>
|
||||
<button class="menu-dropdown-item" data-menu-action="backup-import"><span>Importieren</span></button>
|
||||
<button class="menu-dropdown-item" data-menu-action="online-backup-create"><span>Online-Schlüssel erstellen</span></button>
|
||||
<button class="menu-dropdown-item" data-menu-action="online-backup-restore"><span>Online-Schlüssel importieren</span></button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="menu-separator"></div>
|
||||
<button class="menu-dropdown-item" data-menu-action="restart"><span>Neustart</span></button>
|
||||
<button class="menu-dropdown-item" data-menu-action="quit"><span>Beenden</span></button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="menu-separator"></div>
|
||||
<button class="menu-dropdown-item" data-menu-action="restart"><span>Neustart</span></button>
|
||||
<button class="menu-dropdown-item" data-menu-action="quit"><span>Beenden</span></button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="menu-bar-item" data-menu="einstellungen">
|
||||
<button class="menu-bar-trigger" data-menu-trigger="einstellungen">Einstellungen</button>
|
||||
<div class="menu-dropdown" data-menu-dropdown="einstellungen" style="display:none">
|
||||
<button class="menu-dropdown-item" data-menu-action="open-settings"><span>Einstellungen öffnen</span></button>
|
||||
<div class="menu-separator"></div>
|
||||
<div class="menu-settings-grid" id="menuSettingsGrid">
|
||||
<span>Max. parallele Uploads</span>
|
||||
<span></span>
|
||||
<div class="menu-spinner">
|
||||
<input type="text" inputmode="numeric" id="menuParallelInput">
|
||||
<div class="menu-spinner-arrows">
|
||||
<button data-spin="parallel-up">▲</button>
|
||||
<button data-spin="parallel-down">▼</button>
|
||||
<div class="menu-bar-item" data-menu="einstellungen">
|
||||
<button class="menu-bar-trigger" data-menu-trigger="einstellungen" title="Schnelleinstellungen" aria-label="Schnelleinstellungen">
|
||||
<svg class="header-action-icon" aria-hidden="true"><use href="#icon-sliders"></use></svg>
|
||||
<span class="menu-label">Einstellungen</span>
|
||||
</button>
|
||||
<div class="menu-dropdown" data-menu-dropdown="einstellungen" style="display:none">
|
||||
<button class="menu-dropdown-item" data-menu-action="open-settings"><span>Einstellungen öffnen</span></button>
|
||||
<div class="menu-separator"></div>
|
||||
<div class="menu-settings-grid" id="menuSettingsGrid">
|
||||
<span>Max. parallele Uploads</span>
|
||||
<span></span>
|
||||
<div class="menu-spinner">
|
||||
<input type="text" inputmode="numeric" id="menuParallelInput" name="parallelUploads" autocomplete="off" aria-label="Maximale parallele Uploads">
|
||||
<div class="menu-spinner-arrows">
|
||||
<button data-spin="parallel-up" aria-label="Parallele Uploads erhöhen">▲</button>
|
||||
<button data-spin="parallel-down" aria-label="Parallele Uploads verringern">▼</button>
|
||||
</div>
|
||||
</div>
|
||||
<span></span>
|
||||
<span>Geschwindigkeitslimit</span>
|
||||
<input type="checkbox" id="menuSpeedLimitCheck" aria-label="Geschwindigkeitslimit aktivieren">
|
||||
<div class="menu-spinner" id="menuSpeedSpinner">
|
||||
<input type="text" inputmode="decimal" id="menuSpeedInput" name="speedLimit" autocomplete="off" aria-label="Geschwindigkeitslimit in Megabyte pro Sekunde">
|
||||
<div class="menu-spinner-arrows">
|
||||
<button data-spin="speed-up" aria-label="Geschwindigkeitslimit erhöhen">▲</button>
|
||||
<button data-spin="speed-down" aria-label="Geschwindigkeitslimit verringern">▼</button>
|
||||
</div>
|
||||
</div>
|
||||
<span class="menu-speed-unit">MB/s</span>
|
||||
</div>
|
||||
</div>
|
||||
<span></span>
|
||||
<span>Geschwindigkeitslimit</span>
|
||||
<input type="checkbox" id="menuSpeedLimitCheck">
|
||||
<div class="menu-spinner" id="menuSpeedSpinner">
|
||||
<input type="text" inputmode="decimal" id="menuSpeedInput">
|
||||
<div class="menu-spinner-arrows">
|
||||
<button data-spin="speed-up">▲</button>
|
||||
<button data-spin="speed-down">▼</button>
|
||||
</div>
|
||||
</div>
|
||||
<span class="menu-speed-unit">MB/s</span>
|
||||
</div>
|
||||
<div class="menu-bar-item" data-menu="hilfe">
|
||||
<button class="menu-bar-trigger" data-menu-trigger="hilfe" title="Hilfe und Support" aria-label="Hilfe und Support">
|
||||
<svg class="header-action-icon" aria-hidden="true"><use href="#icon-help"></use></svg>
|
||||
<span class="menu-label">Hilfe</span>
|
||||
</button>
|
||||
<div class="menu-dropdown" data-menu-dropdown="hilfe" style="display:none">
|
||||
<button class="menu-dropdown-item" data-menu-action="open-log-folder"><span>Log-Ordner öffnen</span></button>
|
||||
<button class="menu-dropdown-item" data-menu-action="support-bundle"><span>Diagnose-Paket exportieren</span></button>
|
||||
<div class="menu-separator"></div>
|
||||
<button class="menu-dropdown-item" data-menu-action="check-updates"><span>Suche Aktualisierungen</span></button>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
<div class="version-badge" title="Installierte Version">
|
||||
<span class="version-monogram" aria-hidden="true">U</span>
|
||||
<span class="version-label" id="versionLabel"></span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="menu-bar-item" data-menu="hilfe">
|
||||
<button class="menu-bar-trigger" data-menu-trigger="hilfe">Hilfe</button>
|
||||
<div class="menu-dropdown" data-menu-dropdown="hilfe" style="display:none">
|
||||
<button class="menu-dropdown-item" data-menu-action="open-log-folder"><span>Log-Ordner öffnen</span></button>
|
||||
<button class="menu-dropdown-item" data-menu-action="support-bundle"><span>Diagnose-Paket exportieren</span></button>
|
||||
<div class="menu-separator"></div>
|
||||
<button class="menu-dropdown-item" data-menu-action="check-updates"><span>Suche Aktualisierungen</span></button>
|
||||
</header>
|
||||
|
||||
<div id="updateBanner" class="update-overlay" style="display:none" aria-hidden="true">
|
||||
<section class="update-dialog" role="dialog" aria-modal="true" aria-labelledby="updateDialogTitle" aria-describedby="updateMessage" tabindex="-1">
|
||||
<button class="update-close-button" id="updateCloseBtn" aria-label="Schließen">
|
||||
<svg aria-hidden="true"><use href="#icon-close"></use></svg>
|
||||
</button>
|
||||
<div class="update-dialog-icon" aria-hidden="true">
|
||||
<svg><use href="#icon-download"></use></svg>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<nav class="tab-bar" role="tablist" aria-label="Hauptbereiche">
|
||||
<button class="tab active" id="upload-tab" role="tab" aria-selected="true" aria-controls="upload-view" tabindex="0" data-view="upload">Upload</button>
|
||||
<button class="tab" id="accounts-tab" role="tab" aria-selected="false" aria-controls="accounts-view" tabindex="-1" data-view="accounts">Accounts</button>
|
||||
<button class="tab" id="settings-tab" role="tab" aria-selected="false" aria-controls="settings-view" tabindex="-1" data-view="settings">Einstellungen</button>
|
||||
<button class="tab" id="history-tab" role="tab" aria-selected="false" aria-controls="history-view" tabindex="-1" data-view="history">Verlauf</button>
|
||||
<span class="version-label" id="versionLabel"></span>
|
||||
</nav>
|
||||
|
||||
<div id="updateBanner" class="update-banner" style="display:none">
|
||||
<span id="updateMessage"></span>
|
||||
<button class="btn btn-sm btn-primary" id="installUpdateBtn">Update installieren</button>
|
||||
<button class="btn btn-sm btn-secondary" id="dismissUpdateBtn">×</button>
|
||||
<div class="update-dialog-copy">
|
||||
<h2 id="updateDialogTitle">Eine neue Version ist verfügbar</h2>
|
||||
<p id="updateMessage"></p>
|
||||
</div>
|
||||
<div class="update-release-notes" id="updateReleaseNotes" hidden></div>
|
||||
<div class="update-progress" aria-live="polite">
|
||||
<div class="update-progress-track"><span id="updateProgressBar" role="progressbar" aria-label="Update-Fortschritt" aria-valuemin="0" aria-valuemax="100" aria-valuenow="0" aria-valuetext="0%"></span></div>
|
||||
<span id="updateProgressText"></span>
|
||||
</div>
|
||||
<div class="update-dialog-actions">
|
||||
<button class="btn btn-secondary" id="dismissUpdateBtn">Später erinnern</button>
|
||||
<button class="btn btn-primary" id="installUpdateBtn">Jetzt updaten</button>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div id="upload-view" class="view active" role="tabpanel" aria-labelledby="upload-tab">
|
||||
<div class="upload-toolbar">
|
||||
<aside class="view-sidebar" aria-label="Upload-Übersicht">
|
||||
<div class="view-sidebar-header">
|
||||
<span class="view-sidebar-kicker">Arbeitsbereich</span>
|
||||
<h1 class="view-sidebar-title">Uploads</h1>
|
||||
</div>
|
||||
<nav class="view-sidebar-navigation" aria-label="Upload-Status">
|
||||
<button class="view-sidebar-item active" data-upload-sidebar-target="all" aria-label="Alle Dateien anzeigen">
|
||||
<svg class="view-sidebar-icon" aria-hidden="true"><use href="#icon-files"></use></svg>
|
||||
<span class="view-sidebar-copy">Alle Dateien</span>
|
||||
<span class="view-sidebar-badge" id="uploadSidebarAllCount">0</span>
|
||||
</button>
|
||||
<button class="view-sidebar-item" data-upload-sidebar-target="active" aria-label="Aktive Uploads anzeigen">
|
||||
<svg class="view-sidebar-icon" aria-hidden="true"><use href="#icon-upload"></use></svg>
|
||||
<span class="view-sidebar-copy">Aktiv</span>
|
||||
<span class="view-sidebar-badge" id="uploadSidebarActiveCount">0</span>
|
||||
</button>
|
||||
<button class="view-sidebar-item" data-upload-sidebar-target="waiting" aria-label="Wartende Uploads anzeigen">
|
||||
<svg class="view-sidebar-icon" aria-hidden="true"><use href="#icon-clock"></use></svg>
|
||||
<span class="view-sidebar-copy">Warteschlange</span>
|
||||
<span class="view-sidebar-badge" id="uploadSidebarWaitingCount">0</span>
|
||||
</button>
|
||||
<button class="view-sidebar-item" data-upload-sidebar-target="done" aria-label="Fertige Uploads anzeigen">
|
||||
<svg class="view-sidebar-icon" aria-hidden="true"><use href="#icon-check"></use></svg>
|
||||
<span class="view-sidebar-copy">Fertig</span>
|
||||
<span class="view-sidebar-badge" id="uploadSidebarDoneCount">0</span>
|
||||
</button>
|
||||
<button class="view-sidebar-item" data-upload-sidebar-target="error" aria-label="Fehlgeschlagene Uploads anzeigen">
|
||||
<svg class="view-sidebar-icon" aria-hidden="true"><use href="#icon-alert"></use></svg>
|
||||
<span class="view-sidebar-copy">Fehler</span>
|
||||
<span class="view-sidebar-badge" id="uploadSidebarErrorCount">0</span>
|
||||
</button>
|
||||
</nav>
|
||||
<div class="view-sidebar-section">
|
||||
<span class="view-sidebar-section-label">Verfügbarkeit</span>
|
||||
<div class="view-sidebar-summary">
|
||||
<span>Bereite Accounts</span>
|
||||
<strong id="uploadSidebarAccountsCount">0</strong>
|
||||
</div>
|
||||
<div class="view-sidebar-summary view-sidebar-summary-block hoster-summary" id="hosterSummary">Keine Upload-Ziele ausgewählt</div>
|
||||
</div>
|
||||
<div class="view-sidebar-footnote">Dateien ablegen, Ziele wählen und Uploads zentral steuern.</div>
|
||||
</aside>
|
||||
<main class="view-main upload-main">
|
||||
<div class="upload-toolbar">
|
||||
<div class="toolbar-left">
|
||||
<span class="hoster-summary" id="hosterSummary" style="display:none"></span>
|
||||
<div class="page-heading">
|
||||
<h2>Upload-Aufträge</h2>
|
||||
<p>Dateien hinzufügen, Ziele auswählen und Fortschritt verfolgen</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="toolbar-right">
|
||||
<button class="btn btn-xs btn-primary" id="addFilesBtn">+ Dateien</button>
|
||||
<button class="btn btn-xs btn-secondary" id="addFolderBtn">+ Ordner</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="upload-workspace">
|
||||
<div class="drop-zone" id="dropZone">
|
||||
<div class="drop-icon">📁</div>
|
||||
<p>Dateien hierher ziehen oder klicken</p>
|
||||
</div>
|
||||
|
||||
<div class="upload-workspace">
|
||||
<div class="drop-zone" id="dropZone">
|
||||
<div class="drop-icon" aria-hidden="true"><svg><use href="#icon-cloud"></use></svg></div>
|
||||
<p>Dateien hierher ziehen oder klicken</p>
|
||||
<span>Dateien und Ordner werden vor dem Upload geprüft.</span>
|
||||
</div>
|
||||
|
||||
<div class="queue-shell" id="queueShell" style="display:none">
|
||||
<div class="queue-command-bar" id="queueCommandBar">
|
||||
<button class="toolbar-btn" id="startUploadBtn" title="Alle Uploads starten" aria-label="Alle Uploads starten" disabled>
|
||||
@@ -217,12 +340,46 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<div id="accounts-view" class="view" role="tabpanel" aria-labelledby="accounts-tab">
|
||||
<div class="accounts-container">
|
||||
<aside class="view-sidebar" aria-label="Account-Übersicht">
|
||||
<div class="view-sidebar-header">
|
||||
<span class="view-sidebar-kicker">Zugänge</span>
|
||||
<h1 class="view-sidebar-title">Accounts</h1>
|
||||
</div>
|
||||
<nav class="view-sidebar-navigation" aria-label="Account-Status">
|
||||
<button class="view-sidebar-item active" data-accounts-sidebar-filter="all" aria-label="Alle Accounts anzeigen">
|
||||
<svg class="view-sidebar-icon" aria-hidden="true"><use href="#icon-accounts"></use></svg>
|
||||
<span class="view-sidebar-copy">Alle Accounts</span>
|
||||
<span class="view-sidebar-badge" id="accountsSidebarAllCount">0</span>
|
||||
</button>
|
||||
<button class="view-sidebar-item" data-accounts-sidebar-filter="ready" aria-label="Bereite Accounts anzeigen">
|
||||
<svg class="view-sidebar-icon" aria-hidden="true"><use href="#icon-check"></use></svg>
|
||||
<span class="view-sidebar-copy">Bereit</span>
|
||||
<span class="view-sidebar-badge" id="accountsSidebarReadyCount">0</span>
|
||||
</button>
|
||||
<button class="view-sidebar-item" data-accounts-sidebar-filter="warning" aria-label="Accounts mit Handlungsbedarf anzeigen">
|
||||
<svg class="view-sidebar-icon" aria-hidden="true"><use href="#icon-clock"></use></svg>
|
||||
<span class="view-sidebar-copy">Aktion nötig</span>
|
||||
<span class="view-sidebar-badge" id="accountsSidebarWarningCount">0</span>
|
||||
</button>
|
||||
<button class="view-sidebar-item" data-accounts-sidebar-filter="error" aria-label="Fehlerhafte Accounts anzeigen">
|
||||
<svg class="view-sidebar-icon" aria-hidden="true"><use href="#icon-alert"></use></svg>
|
||||
<span class="view-sidebar-copy">Fehler</span>
|
||||
<span class="view-sidebar-badge" id="accountsSidebarErrorCount">0</span>
|
||||
</button>
|
||||
</nav>
|
||||
<div class="view-sidebar-section view-sidebar-hoster-section">
|
||||
<span class="view-sidebar-section-label">Hoster</span>
|
||||
<div class="view-sidebar-hosters" id="accountsSidebarHosters"></div>
|
||||
</div>
|
||||
<div class="view-sidebar-footnote">Zugänge prüfen, priorisieren und für Uploads bereitstellen.</div>
|
||||
</aside>
|
||||
<main class="accounts-container view-main accounts-main">
|
||||
<div class="accounts-header">
|
||||
<div>
|
||||
<h2>Accounts</h2>
|
||||
@@ -242,7 +399,7 @@
|
||||
<div class="accounts-list-footer" id="accountsListFooter" style="display:none">
|
||||
<button class="btn btn-secondary" id="toggleAllAccountsBtn">Alle ausklappen</button>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<div class="modal-overlay" id="accountModal" style="display:none">
|
||||
@@ -313,7 +470,6 @@
|
||||
<p class="settings-hint">Alle Optionen nach Aufgaben sortiert. Änderungen werden automatisch gespeichert.</p>
|
||||
</div>
|
||||
<div class="settings-save-row">
|
||||
<span class="save-feedback" id="saveFeedback">Automatisch gespeichert</span>
|
||||
<button class="btn btn-secondary" id="saveSettingsBtn">Jetzt speichern</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -322,10 +478,41 @@
|
||||
</div>
|
||||
|
||||
<div id="history-view" class="view" role="tabpanel" aria-labelledby="history-tab">
|
||||
<div class="history-container">
|
||||
<aside class="view-sidebar" aria-label="Verlaufsübersicht">
|
||||
<div class="view-sidebar-header">
|
||||
<span class="view-sidebar-kicker">Archiv</span>
|
||||
<h1 class="view-sidebar-title">Verlauf</h1>
|
||||
</div>
|
||||
<nav class="view-sidebar-navigation" aria-label="Verlaufsstatus">
|
||||
<button class="view-sidebar-item active" data-history-filter="all" aria-label="Gesamten Verlauf anzeigen">
|
||||
<svg class="view-sidebar-icon" aria-hidden="true"><use href="#icon-history"></use></svg>
|
||||
<span class="view-sidebar-copy">Alle Uploads</span>
|
||||
<span class="view-sidebar-badge" id="historySidebarAllCount">0</span>
|
||||
</button>
|
||||
<button class="view-sidebar-item" data-history-filter="success" aria-label="Erfolgreiche Uploads anzeigen">
|
||||
<svg class="view-sidebar-icon" aria-hidden="true"><use href="#icon-check"></use></svg>
|
||||
<span class="view-sidebar-copy">Erfolgreich</span>
|
||||
<span class="view-sidebar-badge" id="historySidebarSuccessCount">0</span>
|
||||
</button>
|
||||
<button class="view-sidebar-item" data-history-filter="error" aria-label="Fehlgeschlagene Uploads anzeigen">
|
||||
<svg class="view-sidebar-icon" aria-hidden="true"><use href="#icon-alert"></use></svg>
|
||||
<span class="view-sidebar-copy">Fehler</span>
|
||||
<span class="view-sidebar-badge" id="historySidebarErrorCount">0</span>
|
||||
</button>
|
||||
</nav>
|
||||
<div class="view-sidebar-section">
|
||||
<span class="view-sidebar-section-label">Aufbewahrung</span>
|
||||
<div class="view-sidebar-summary">
|
||||
<span>Aktive Regel</span>
|
||||
<strong id="historySidebarRetention">Alles behalten</strong>
|
||||
</div>
|
||||
</div>
|
||||
<div class="view-sidebar-footnote">Links wiederfinden, kopieren oder als Datei exportieren.</div>
|
||||
</aside>
|
||||
<main class="history-container view-main history-main">
|
||||
<div class="history-header">
|
||||
<h2>Upload-Verlauf</h2>
|
||||
<div style="display:flex; gap:8px; align-items:center">
|
||||
<div class="history-header-actions">
|
||||
<label for="historyRetentionSelect" class="history-retention-label">Aufbewahrung</label>
|
||||
<select id="historyRetentionSelect" class="key-input history-retention-select">
|
||||
<option value="all">Alles behalten</option>
|
||||
@@ -341,7 +528,7 @@
|
||||
</div>
|
||||
<div id="historyCapNotice" class="history-cap-notice" style="display:none"></div>
|
||||
<div id="historyContainer"></div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<div class="context-menu" id="contextMenu" style="display:none">
|
||||
|
||||
+1628
-23
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,47 @@
|
||||
const PRODUCT_NAME = 'Multi-Hoster-Upload';
|
||||
|
||||
export function parseReleaseArgs(args) {
|
||||
const version = Array.isArray(args) ? args[0] : '';
|
||||
if (!/^\d+\.\d+\.\d+$/.test(version || '')) {
|
||||
throw new Error('Usage: <version> --transport-tag <vX.Y.Z> [release notes] [--dry-run]');
|
||||
}
|
||||
|
||||
const transportTagIndex = args.indexOf('--transport-tag');
|
||||
const transportTag = transportTagIndex >= 0 ? args[transportTagIndex + 1] : '';
|
||||
if (!/^v\d+\.\d+\.\d+$/.test(transportTag)) {
|
||||
throw new Error('--transport-tag must match vX.Y.Z');
|
||||
}
|
||||
|
||||
const excludedIndexes = new Set([0, transportTagIndex, transportTagIndex + 1]);
|
||||
const notes = args.filter((arg, index) => !excludedIndexes.has(index) && arg !== '--dry-run').join(' ');
|
||||
return { version, transportTag, notes, dryRun: args.includes('--dry-run') };
|
||||
}
|
||||
|
||||
export function createReleasePlan(options) {
|
||||
const releaseTitle = `${PRODUCT_NAME} v${options.version}`;
|
||||
const setupName = `${PRODUCT_NAME} Setup ${options.version}.exe`;
|
||||
const portableName = `${PRODUCT_NAME} ${options.version}.exe`;
|
||||
const blockmapName = `${setupName}.blockmap`;
|
||||
return {
|
||||
...options,
|
||||
tag: options.transportTag,
|
||||
releaseTitle,
|
||||
releaseBody: options.notes || releaseTitle,
|
||||
setupName,
|
||||
portableName,
|
||||
blockmapName,
|
||||
expectedArtifacts: [setupName, portableName, blockmapName, 'latest.yml']
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveExistingReleaseId(plan, release) {
|
||||
const existingTitle = typeof release?.name === 'string' ? release.name : '';
|
||||
if (existingTitle !== plan.releaseTitle) {
|
||||
throw new Error(`Refusing recovery for ${plan.tag}: existing release title "${existingTitle}" does not match "${plan.releaseTitle}"`);
|
||||
}
|
||||
return release.id;
|
||||
}
|
||||
|
||||
export function renderLatestYml(plan, sha, size, releaseDate = new Date().toISOString()) {
|
||||
return `version: ${plan.version}\nfiles:\n - url: ${plan.setupName}\n sha512: ${sha}\n size: ${size}\npath: ${plan.setupName}\nsha512: ${sha}\nreleaseDate: '${releaseDate}'\n`;
|
||||
}
|
||||
@@ -1,63 +1,133 @@
|
||||
import { readFile, readdir } from 'node:fs/promises';
|
||||
import { lstat, readFile, readdir } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import process from 'node:process';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const args = process.argv.slice(2);
|
||||
const sourceOnly = args.includes('--source-only');
|
||||
const failures = new Map();
|
||||
|
||||
const requiredFiles = [
|
||||
const sourceFiles = [
|
||||
'.gitignore',
|
||||
'README.md',
|
||||
'SECURITY.md',
|
||||
'package.json',
|
||||
'package-lock.json',
|
||||
'eslint.config.mjs',
|
||||
'main.js',
|
||||
'preload.js',
|
||||
'preload-drop-target.js',
|
||||
'assets/app_icon.ico',
|
||||
'assets/app_icon.png',
|
||||
'eslint.config.mjs',
|
||||
'lib/account-auth.js',
|
||||
'lib/account-rotation.js',
|
||||
'lib/backup-crypto.js',
|
||||
'lib/clouddrop-upload.js',
|
||||
'lib/coalesced-set.js',
|
||||
'lib/config-store.js',
|
||||
'lib/diagnostics-agent.js',
|
||||
'lib/diagnostics-collectors.js',
|
||||
'lib/doodstream-upload.js',
|
||||
'lib/file-probe.js',
|
||||
'lib/folder-monitor.js',
|
||||
'lib/hosters.js',
|
||||
'lib/ip-allowlist.js',
|
||||
'lib/log-mode.js',
|
||||
'lib/log-policy.js',
|
||||
'lib/log-rotation.js',
|
||||
'lib/online-backup.js',
|
||||
'lib/orphan-tmp.js',
|
||||
'lib/queue-dedup.js',
|
||||
'lib/queue-prune.js',
|
||||
'lib/remote-capture-preload.js',
|
||||
'lib/remote-capture.html',
|
||||
'lib/remote-server.js',
|
||||
'lib/secret-store.js',
|
||||
'lib/semaphore.js',
|
||||
'lib/serialized-runner.js',
|
||||
'lib/settings-backup.js',
|
||||
'lib/settings-import-gate.js',
|
||||
'lib/startup-renderer.js',
|
||||
'lib/stats.js',
|
||||
'lib/support-bundle.js',
|
||||
'lib/throttle-timer.js',
|
||||
'lib/throttle.js',
|
||||
'lib/throttled-cache.js',
|
||||
'lib/updater.js',
|
||||
'lib/upload-log.js',
|
||||
'lib/upload-manager.js',
|
||||
'lib/vidmoly-upload.js',
|
||||
'lib/voe-upload.js',
|
||||
'lib/webhook-notify.js',
|
||||
'main.js',
|
||||
'package-lock.json',
|
||||
'package.json',
|
||||
'preload-drop-target.js',
|
||||
'preload.js',
|
||||
'renderer/account-status.js',
|
||||
'renderer/account-submit.js',
|
||||
'renderer/app.js',
|
||||
'renderer/drop-target.html',
|
||||
'renderer/index.html',
|
||||
'renderer/styles.css',
|
||||
'scripts/afterPack.cjs',
|
||||
'scripts/verify-public-release.mjs'
|
||||
'scripts/release-plan.mjs',
|
||||
'scripts/verify-public-release.mjs',
|
||||
'services/backup-api/package-lock.json',
|
||||
'services/backup-api/package.json',
|
||||
'services/backup-api/src/cli.mjs',
|
||||
'services/backup-api/src/server.mjs',
|
||||
'services/backup-api/test/server.test.mjs',
|
||||
'tests/account-auth.test.js',
|
||||
'tests/account-rotation.test.js',
|
||||
'tests/account-status.test.js',
|
||||
'tests/backup-crypto.test.js',
|
||||
'tests/byse-reject-recovery.test.js',
|
||||
'tests/coalesced-set.test.js',
|
||||
'tests/config-store.test.js',
|
||||
'tests/diagnostics-agent.test.js',
|
||||
'tests/diagnostics-collectors.test.js',
|
||||
'tests/diagnostics-protocol.test.js',
|
||||
'tests/doodstream-api-upload.test.js',
|
||||
'tests/doodstream-upload.test.js',
|
||||
'tests/file-probe.test.js',
|
||||
'tests/history-retention.test.js',
|
||||
'tests/hosters.test.js',
|
||||
'tests/ip-allowlist.test.js',
|
||||
'tests/log-mode.test.js',
|
||||
'tests/log-policy.test.js',
|
||||
'tests/log-rotation.test.js',
|
||||
'tests/online-backup-service.test.js',
|
||||
'tests/online-backup.test.js',
|
||||
'tests/orphan-tmp.test.js',
|
||||
'tests/package-build-files.test.js',
|
||||
'tests/public-release-verifier.test.js',
|
||||
'tests/queue-dedup-property.test.js',
|
||||
'tests/queue-dedup.test.js',
|
||||
'tests/queue-persistence-scenario.test.js',
|
||||
'tests/queue-prune.test.js',
|
||||
'tests/remote-config.test.js',
|
||||
'tests/remote-server.test.js',
|
||||
'tests/semaphore.test.js',
|
||||
'tests/serialized-runner.test.js',
|
||||
'tests/settings-backup.test.js',
|
||||
'tests/settings-import-gate.test.js',
|
||||
'tests/startup-renderer.test.js',
|
||||
'tests/stats.test.js',
|
||||
'tests/support-bundle.test.js',
|
||||
'tests/suspect-reject-alternates.test.js',
|
||||
'tests/throttle-timer.test.js',
|
||||
'tests/throttle.test.js',
|
||||
'tests/throttled-cache.test.js',
|
||||
'tests/ui-smoke.js',
|
||||
'tests/updater-version.test.js',
|
||||
'tests/upload-log.test.js',
|
||||
'tests/upload-manager.test.js',
|
||||
'tests/validate-credentials.test.js',
|
||||
'tests/webhook-notify.test.js'
|
||||
];
|
||||
|
||||
const allowedFiles = new Set([...requiredFiles, 'assets/product-overview.png']);
|
||||
const allowedPrefixes = ['lib/', 'renderer/', 'tests/'];
|
||||
const ignoredDirectories = new Set(['.git', 'node_modules', 'release']);
|
||||
const deniedDirectories = new Set([
|
||||
`.${['clau', 'de'].join('')}`,
|
||||
`.${['co', 'dex'].join('')}`,
|
||||
'.playwright-mcp',
|
||||
'.superpowers',
|
||||
'__pycache__',
|
||||
'backups',
|
||||
'docs',
|
||||
'gateway',
|
||||
'logs',
|
||||
'memories',
|
||||
'prompts',
|
||||
'tasks'
|
||||
]);
|
||||
const deniedBasenames = new Set([
|
||||
'agents.md',
|
||||
'app.py',
|
||||
`${['clau', 'de'].join('')}.md`,
|
||||
'credentials.json',
|
||||
'gemini.md',
|
||||
'hosters.py',
|
||||
'memory.md',
|
||||
'memory_summary.md',
|
||||
'raw_memories.md',
|
||||
'requirements.txt'
|
||||
]);
|
||||
const screenshotFile = 'assets/product-overview.png';
|
||||
const allowedFiles = new Set([...sourceFiles, screenshotFile]);
|
||||
const textExtensions = new Set(['.cjs', '.css', '.html', '.js', '.json', '.md', '.mjs', '.txt', '.yaml', '.yml']);
|
||||
const binaryExtensions = new Set(['.ico', '.png']);
|
||||
const expectedScripts = {
|
||||
start: 'electron .',
|
||||
test: 'node --test tests/*.test.js tests/ui-smoke.js',
|
||||
'test:backup-api': 'npm --prefix services/backup-api test',
|
||||
lint: 'eslint .',
|
||||
dist: 'electron-builder --win',
|
||||
'release:win': 'electron-builder --publish never --win nsis portable'
|
||||
@@ -71,6 +141,19 @@ const expectedBuildFiles = [
|
||||
'assets/app_icon.ico',
|
||||
'assets/app_icon.png'
|
||||
];
|
||||
const deniedBasenames = new Set([
|
||||
'agents.md',
|
||||
'app.py',
|
||||
`${['clau', 'de'].join('')}.md`,
|
||||
'credentials.json',
|
||||
'gemini.md',
|
||||
'hosters.py',
|
||||
'memory.md',
|
||||
'memory_summary.md',
|
||||
'raw_memories.md',
|
||||
'requirements.txt',
|
||||
['release_', ['gi', 'tea'].join(''), '.mjs'].join('')
|
||||
]);
|
||||
const aiTerms = [
|
||||
['clau', 'de'].join(''),
|
||||
['co', 'dex'].join(''),
|
||||
@@ -80,9 +163,17 @@ const personalTerms = [
|
||||
['pl', 'oet'].join(''),
|
||||
['baker', 'edwin318'].join('')
|
||||
].join('|');
|
||||
const internalTerms = [
|
||||
['internal', ' investigation'].join(''),
|
||||
['interne', ' untersuchung'].join(''),
|
||||
['audit', ' method'].join(''),
|
||||
['test', ' chronicle'].join(''),
|
||||
['generated', ' by'].join(''),
|
||||
['co-authored', '-by'].join('')
|
||||
].join('|');
|
||||
const forbiddenAiPattern = new RegExp(`\\b(?:${aiTerms}|multi[\\s-]+agents?)\\b`, 'i');
|
||||
const forbiddenPersonalPattern = new RegExp(`(?:[a-z]:[\\\\/]+users[\\\\/]+|\\b(?:${personalTerms})\\b|\\bdesktop-[a-z0-9-]+\\b)`, 'i');
|
||||
const forbiddenInvestigationPattern = new RegExp(`\\b(?:${['internal', 'investigation'].join(' ')}|${['interne', 'untersuchung'].join(' ')}|${['audit', 'method'].join(' ')}|${['test', 'chronicle'].join(' ')})\\b`, 'i');
|
||||
const forbiddenInternalPattern = new RegExp(`\\b(?:${internalTerms})\\b`, 'i');
|
||||
const updaterOnlyPattern = new RegExp([
|
||||
['gi', 'tea'].join(''),
|
||||
['git', '24-music', 'de'].join('\\.'),
|
||||
@@ -98,10 +189,20 @@ function normalizeRelative(value) {
|
||||
return value.split(path.sep).join('/');
|
||||
}
|
||||
|
||||
function isAllowedFile(relativePath) {
|
||||
return allowedFiles.has(relativePath) || allowedPrefixes.some((prefix) => relativePath.startsWith(prefix));
|
||||
function buildAllowedDirectories(files) {
|
||||
const directories = new Set();
|
||||
for (const file of files) {
|
||||
let current = path.posix.dirname(file);
|
||||
while (current && current !== '.') {
|
||||
directories.add(current);
|
||||
current = path.posix.dirname(current);
|
||||
}
|
||||
}
|
||||
return directories;
|
||||
}
|
||||
|
||||
const allowedDirectories = buildAllowedDirectories(allowedFiles);
|
||||
|
||||
function isDeniedBasename(basename) {
|
||||
const lower = basename.toLowerCase();
|
||||
return deniedBasenames.has(lower)
|
||||
@@ -109,18 +210,50 @@ function isDeniedBasename(basename) {
|
||||
|| /\.(?:bak|db|log|sqlite|sqlite3|tmp)$/i.test(basename);
|
||||
}
|
||||
|
||||
function parseArguments() {
|
||||
const sourceOnlyCount = args.filter((arg) => arg === '--source-only').length;
|
||||
const versionFlagIndexes = args.map((arg, index) => arg === '--version' ? index : -1).filter((index) => index >= 0);
|
||||
const versionIndex = versionFlagIndexes[0] ?? -1;
|
||||
const expectedVersion = versionIndex >= 0 ? args[versionIndex + 1] : '';
|
||||
const consumed = new Set();
|
||||
|
||||
if (sourceOnlyCount === 1) consumed.add(args.indexOf('--source-only'));
|
||||
if (sourceOnlyCount > 1) addFailure('scripts/verify-public-release.mjs', 'duplicate-source-only');
|
||||
if (versionFlagIndexes.length !== 1 || !/^\d+\.\d+\.\d+$/.test(expectedVersion || '')) {
|
||||
addFailure('scripts/verify-public-release.mjs', 'expected-version-argument');
|
||||
} else {
|
||||
consumed.add(versionIndex);
|
||||
consumed.add(versionIndex + 1);
|
||||
}
|
||||
|
||||
for (let index = 0; index < args.length; index++) {
|
||||
if (!consumed.has(index)) addFailure('scripts/verify-public-release.mjs', 'argument-allowlist');
|
||||
}
|
||||
|
||||
return { sourceOnly: sourceOnlyCount === 1, expectedVersion };
|
||||
}
|
||||
|
||||
async function enumerate(directory = root, relativeDirectory = '') {
|
||||
const entries = await readdir(directory, { withFileTypes: true });
|
||||
const files = [];
|
||||
|
||||
for (const entry of entries) {
|
||||
const relativePath = normalizeRelative(path.join(relativeDirectory, entry.name));
|
||||
const lowerName = entry.name.toLowerCase();
|
||||
if (relativePath === '.git') continue;
|
||||
const absolutePath = path.join(directory, entry.name);
|
||||
const stats = await lstat(absolutePath);
|
||||
|
||||
if (stats.isSymbolicLink()) {
|
||||
addFailure(relativePath, 'unsupported-file-type');
|
||||
continue;
|
||||
}
|
||||
|
||||
if (entry.isDirectory()) {
|
||||
if (ignoredDirectories.has(lowerName)) continue;
|
||||
if (deniedDirectories.has(lowerName)) addFailure(relativePath, 'denied-directory');
|
||||
files.push(...await enumerate(path.join(directory, entry.name), relativePath));
|
||||
if (!allowedDirectories.has(relativePath)) {
|
||||
addFailure(relativePath, 'source-layout-allowlist');
|
||||
continue;
|
||||
}
|
||||
files.push(...await enumerate(absolutePath, relativePath));
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -130,7 +263,7 @@ async function enumerate(directory = root, relativeDirectory = '') {
|
||||
}
|
||||
|
||||
if (isDeniedBasename(entry.name)) addFailure(relativePath, 'denied-basename');
|
||||
if (!isAllowedFile(relativePath)) addFailure(relativePath, 'source-layout-allowlist');
|
||||
if (!allowedFiles.has(relativePath)) addFailure(relativePath, 'source-layout-allowlist');
|
||||
files.push(relativePath);
|
||||
}
|
||||
|
||||
@@ -163,34 +296,28 @@ async function validateTextFiles(files) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const text = await readFile(path.join(root, relativePath), 'utf8');
|
||||
if (forbiddenPersonalPattern.test(text)) addFailure(relativePath, 'forbidden-personal-term');
|
||||
if (forbiddenAiPattern.test(text)) addFailure(relativePath, 'forbidden-ai-term');
|
||||
if (forbiddenInvestigationPattern.test(text)) addFailure(relativePath, 'forbidden-investigation-term');
|
||||
if (relativePath !== 'lib/updater.js' && updaterOnlyPattern.test(text)) addFailure(relativePath, 'updater-endpoint-scope');
|
||||
const value = await readFile(path.join(root, relativePath), 'utf8');
|
||||
if (forbiddenPersonalPattern.test(value)) addFailure(relativePath, 'forbidden-personal-term');
|
||||
if (forbiddenAiPattern.test(value)) addFailure(relativePath, 'forbidden-ai-term');
|
||||
if (forbiddenInternalPattern.test(value)) addFailure(relativePath, 'forbidden-internal-term');
|
||||
if (relativePath !== 'lib/updater.js' && updaterOnlyPattern.test(value)) addFailure(relativePath, 'updater-endpoint-scope');
|
||||
}
|
||||
}
|
||||
|
||||
function validatePackage(packageJson, packageLock, files) {
|
||||
function validatePackage(packageJson, packageLock, files, expectedVersion) {
|
||||
if (!packageJson) return;
|
||||
|
||||
if (packageJson.version !== '3.3.108') addFailure('package.json', 'package-version');
|
||||
if (packageJson.version !== expectedVersion) addFailure('package.json', 'package-version-target');
|
||||
if (stableJson(packageJson.scripts) !== stableJson(expectedScripts)) addFailure('package.json', 'package-script-allowlist');
|
||||
|
||||
const buildFiles = packageJson.build?.files;
|
||||
if (!Array.isArray(buildFiles) || stableJson(buildFiles) !== stableJson(expectedBuildFiles)) {
|
||||
addFailure('package.json', 'build-file-allowlist');
|
||||
}
|
||||
|
||||
for (const requiredEntry of expectedBuildFiles) {
|
||||
if (!Array.isArray(buildFiles) || !buildFiles.includes(requiredEntry)) addFailure(requiredEntry.replace('/**/*', ''), 'build-file-entry');
|
||||
}
|
||||
|
||||
if (packageJson.build?.afterPack !== 'scripts/afterPack.cjs') addFailure('scripts/afterPack.cjs', 'build-hook-entry');
|
||||
|
||||
if (packageLock) {
|
||||
const lockRoot = packageLock.packages?.[''];
|
||||
if (packageLock.version !== '3.3.108' || lockRoot?.version !== '3.3.108') {
|
||||
if (packageLock.version !== expectedVersion || lockRoot?.version !== expectedVersion) {
|
||||
addFailure('package-lock.json', 'package-lock-version');
|
||||
}
|
||||
if (!lockRoot
|
||||
@@ -211,6 +338,32 @@ function validatePackage(packageJson, packageLock, files) {
|
||||
}
|
||||
}
|
||||
|
||||
function validateServicePackage(packageJson, packageLock) {
|
||||
if (!packageJson || !packageLock) return;
|
||||
const lockRoot = packageLock.packages?.[''];
|
||||
if (packageLock.version !== packageJson.version || lockRoot?.version !== packageJson.version) {
|
||||
addFailure('services/backup-api/package-lock.json', 'service-lock-version');
|
||||
}
|
||||
if (!lockRoot || lockRoot.name !== packageJson.name || stableJson(lockRoot.dependencies) !== stableJson(packageJson.dependencies)) {
|
||||
addFailure('services/backup-api/package-lock.json', 'service-lock-root-metadata');
|
||||
}
|
||||
}
|
||||
|
||||
async function validateScreenshot(sourceOnly) {
|
||||
if (sourceOnly) return;
|
||||
try {
|
||||
const data = await readFile(path.join(root, screenshotFile));
|
||||
const signature = data.subarray(0, 8).toString('hex');
|
||||
const width = data.length >= 24 ? data.readUInt32BE(16) : 0;
|
||||
const height = data.length >= 24 ? data.readUInt32BE(20) : 0;
|
||||
if (signature !== '89504e470d0a1a0a' || width < 1000 || height < 650) {
|
||||
addFailure(screenshotFile, 'product-screenshot');
|
||||
}
|
||||
} catch {
|
||||
addFailure(screenshotFile, 'required-screenshot');
|
||||
}
|
||||
}
|
||||
|
||||
function printFailures() {
|
||||
for (const file of [...failures.keys()].sort()) {
|
||||
for (const rule of [...failures.get(file)].sort()) {
|
||||
@@ -220,23 +373,21 @@ function printFailures() {
|
||||
}
|
||||
|
||||
async function main() {
|
||||
if (args.some((arg) => arg !== '--source-only') || args.filter((arg) => arg === '--source-only').length > 1) {
|
||||
addFailure('scripts/verify-public-release.mjs', 'argument-allowlist');
|
||||
}
|
||||
|
||||
const { sourceOnly, expectedVersion } = parseArguments();
|
||||
const files = await enumerate();
|
||||
|
||||
const requiredFiles = sourceOnly ? sourceFiles : [...sourceFiles, screenshotFile];
|
||||
for (const requiredFile of requiredFiles) {
|
||||
if (!files.includes(requiredFile)) addFailure(requiredFile, 'required-source-file');
|
||||
}
|
||||
if (!sourceOnly && !files.includes('assets/product-overview.png')) {
|
||||
addFailure('assets/product-overview.png', 'required-screenshot');
|
||||
}
|
||||
|
||||
await validateTextFiles(files);
|
||||
const packageJson = await readJson('package.json', 'package-json');
|
||||
const packageLock = await readJson('package-lock.json', 'package-lock-json');
|
||||
validatePackage(packageJson, packageLock, files);
|
||||
const servicePackage = await readJson('services/backup-api/package.json', 'service-package-json');
|
||||
const serviceLock = await readJson('services/backup-api/package-lock.json', 'service-package-lock-json');
|
||||
validatePackage(packageJson, packageLock, files, expectedVersion);
|
||||
validateServicePackage(servicePackage, serviceLock);
|
||||
await validateScreenshot(sourceOnly);
|
||||
|
||||
if (failures.size > 0) {
|
||||
printFailures();
|
||||
@@ -244,7 +395,7 @@ async function main() {
|
||||
return;
|
||||
}
|
||||
|
||||
process.stdout.write(`public-release-source-ok files=${files.length} denied-paths=0 forbidden-terms=0 version=${packageJson.version} scripts=${Object.keys(packageJson.scripts).length} build-files=${packageJson.build.files.length} layout=valid screenshot=${sourceOnly ? 'deferred' : 'present'}\n`);
|
||||
process.stdout.write(`public-release-source-ok files=${files.length} denied-paths=0 internal-terms=0 version=${packageJson.version} scripts=${Object.keys(packageJson.scripts).length} build-files=${packageJson.build.files.length} layout=exact screenshot=${sourceOnly ? 'deferred' : 'valid'}\n`);
|
||||
}
|
||||
|
||||
main().catch(() => {
|
||||
|
||||
@@ -31,6 +31,36 @@ describe('ConfigStore', () => {
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('explicit user-data-dir isolates development config writes from the project', async () => {
|
||||
const isolatedDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cfg-user-data-'));
|
||||
const projectConfigPath = path.join(__dirname, '..', 'electron-config.json');
|
||||
const projectConfigExisted = fs.existsSync(projectConfigPath);
|
||||
const projectConfigBefore = projectConfigExisted ? fs.readFileSync(projectConfigPath) : null;
|
||||
const explicitStore = new ConfigStore({
|
||||
isPackaged: false,
|
||||
commandLine: {
|
||||
hasSwitch: (name) => name === 'user-data-dir'
|
||||
},
|
||||
getPath: (name) => {
|
||||
if (name === 'userData') return isolatedDir;
|
||||
if (name === 'exe') return path.join(isolatedDir, 'Multi-Hoster-Upload.exe');
|
||||
throw new Error(`Unexpected app path: ${name}`);
|
||||
}
|
||||
});
|
||||
|
||||
try {
|
||||
assert.equal(explicitStore.filePath, path.join(isolatedDir, 'electron-config.json'));
|
||||
await explicitStore.save({ globalSettings: { alwaysOnTop: true } });
|
||||
assert.equal(fs.existsSync(path.join(isolatedDir, 'electron-config.json')), true);
|
||||
assert.equal(fs.existsSync(projectConfigPath), projectConfigExisted);
|
||||
if (projectConfigBefore) {
|
||||
assert.equal(fs.readFileSync(projectConfigPath).equals(projectConfigBefore), true);
|
||||
}
|
||||
} finally {
|
||||
fs.rmSync(isolatedDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('load returns defaults when file does not exist', () => {
|
||||
const config = store.load();
|
||||
assert.ok(config.hosters);
|
||||
@@ -254,6 +284,84 @@ describe('ConfigStore', () => {
|
||||
assert.equal(config.globalSettings.alwaysOnTop, true);
|
||||
});
|
||||
|
||||
it('drainWrites waits for config and history writes appended while draining', async () => {
|
||||
assert.equal(typeof store.drainWrites, 'function');
|
||||
await store.save({ globalSettings: { alwaysOnTop: false } });
|
||||
store._historyMigrated = true;
|
||||
fs.writeFileSync(store.historyPath, '[]', 'utf-8');
|
||||
|
||||
const originalAtomicWrite = store._atomicWrite.bind(store);
|
||||
const originalHistoryWrite = store._writeHistoryFileAtomic.bind(store);
|
||||
const configReleases = [];
|
||||
const historyReleases = [];
|
||||
const block = (releases, operation) => new Promise((resolve, reject) => {
|
||||
releases.push(() => Promise.resolve().then(operation).then(resolve, reject));
|
||||
});
|
||||
store._atomicWrite = (data) => block(configReleases, () => originalAtomicWrite(data));
|
||||
store._writeHistoryFileAtomic = (history) => block(historyReleases, () => originalHistoryWrite(history));
|
||||
|
||||
const configWrites = store.save({ globalSettings: { alwaysOnTop: true } })
|
||||
.then(() => store.save({ hosterSettings: { 'byse.sx': { retries: 8 } } }));
|
||||
const historyWrites = store.appendHistory({ id: 'first', files: [] })
|
||||
.then(() => store.appendHistory({ id: 'second', files: [] }));
|
||||
|
||||
while (configReleases.length < 1 || historyReleases.length < 1) await new Promise(resolve => setImmediate(resolve));
|
||||
let drained = false;
|
||||
const draining = store.drainWrites().then(() => { drained = true; });
|
||||
await new Promise(resolve => setImmediate(resolve));
|
||||
assert.equal(drained, false);
|
||||
|
||||
configReleases.shift()();
|
||||
historyReleases.shift()();
|
||||
while (configReleases.length < 1 || historyReleases.length < 1) await new Promise(resolve => setImmediate(resolve));
|
||||
await new Promise(resolve => setImmediate(resolve));
|
||||
assert.equal(drained, false);
|
||||
|
||||
configReleases.shift()();
|
||||
historyReleases.shift()();
|
||||
await Promise.all([configWrites, historyWrites, draining]);
|
||||
assert.equal(store.load().globalSettings.alwaysOnTop, true);
|
||||
assert.equal(store.load().hosterSettings['byse.sx'].retries, 8);
|
||||
assert.deepEqual(store.loadHistory().map(entry => entry.id), ['first', 'second']);
|
||||
});
|
||||
|
||||
it('drainWrites ignores caller-handled failures but propagates a write failing during the drain', async () => {
|
||||
const originalAtomicWrite = store._atomicWrite.bind(store);
|
||||
store._atomicWrite = () => Promise.reject(new Error('config write failed'));
|
||||
await assert.rejects(store.save({ globalSettings: { alwaysOnTop: true } }), /config write failed/);
|
||||
await store.drainWrites();
|
||||
|
||||
store._atomicWrite = originalAtomicWrite;
|
||||
store._historyMigrated = true;
|
||||
fs.writeFileSync(store.historyPath, '[]', 'utf-8');
|
||||
let rejectHistoryWrite;
|
||||
store._writeHistoryFileAtomic = () => new Promise((_resolve, reject) => { rejectHistoryWrite = reject; });
|
||||
const pendingWrite = store.appendHistory({ id: 'failed', files: [] });
|
||||
pendingWrite.catch(() => {});
|
||||
while (!rejectHistoryWrite) await new Promise(resolve => setImmediate(resolve));
|
||||
const draining = store.drainWrites();
|
||||
rejectHistoryWrite(new Error('history write failed'));
|
||||
await assert.rejects(draining, /history write failed/);
|
||||
await assert.rejects(pendingWrite, /history write failed/);
|
||||
await store.drainWrites();
|
||||
});
|
||||
|
||||
it('surfaces a failed queued write and keeps later writes usable', async () => {
|
||||
const originalAtomicWrite = store._atomicWrite.bind(store);
|
||||
let failNextWrite = true;
|
||||
store._atomicWrite = (data) => {
|
||||
if (failNextWrite) {
|
||||
failNextWrite = false;
|
||||
return Promise.reject(new Error('write failed'));
|
||||
}
|
||||
return originalAtomicWrite(data);
|
||||
};
|
||||
|
||||
await assert.rejects(store.appendHistory({ id: 'failed', files: [] }), /write failed/);
|
||||
await store.appendHistory({ id: 'saved', files: [] });
|
||||
assert.deepEqual(store.loadHistory().map(entry => entry.id), ['saved']);
|
||||
});
|
||||
|
||||
it('serializes a complete settings replacement with pending saves', async () => {
|
||||
const originalAtomicWrite = store._atomicWrite.bind(store);
|
||||
let activeWrites = 0;
|
||||
@@ -288,6 +396,103 @@ describe('ConfigStore', () => {
|
||||
assert.deepEqual(config.rotationCursors, {});
|
||||
});
|
||||
|
||||
it('patches the pending queue after an import without reverting imported settings', async () => {
|
||||
await store.save({
|
||||
globalSettings: {
|
||||
alwaysOnTop: true,
|
||||
webhookUrl: 'https://before.invalid',
|
||||
pendingQueue: { savedAt: 1, queueJobs: [{ id: 'before' }] }
|
||||
}
|
||||
});
|
||||
|
||||
const replace = store.replaceSettings({
|
||||
hosters: { 'byse.sx': [{ id: 'imported', enabled: true, authType: 'api', apiKey: 'imported-key' }] },
|
||||
hosterSettings: { 'byse.sx': { retries: 9 } },
|
||||
globalSettings: {
|
||||
alwaysOnTop: false,
|
||||
webhookUrl: 'https://imported.invalid',
|
||||
pendingQueue: null
|
||||
},
|
||||
history: [],
|
||||
rotationCursors: {}
|
||||
});
|
||||
const pendingQueue = {
|
||||
savedAt: 2,
|
||||
queueJobs: [{ id: 'live', status: 'done' }]
|
||||
};
|
||||
const saveQueue = store.savePendingQueue(pendingQueue);
|
||||
|
||||
await Promise.all([replace, saveQueue]);
|
||||
const config = store.load();
|
||||
assert.equal(config.hosters['byse.sx'][0].id, 'imported');
|
||||
assert.equal(config.globalSettings.alwaysOnTop, false);
|
||||
assert.equal(config.globalSettings.webhookUrl, 'https://imported.invalid');
|
||||
assert.deepEqual(config.globalSettings.pendingQueue, pendingQueue);
|
||||
});
|
||||
|
||||
it('preserves main-owned global state when saving a renderer snapshot', async () => {
|
||||
await store.save({
|
||||
globalSettings: {
|
||||
alwaysOnTop: false,
|
||||
pendingQueue: { savedAt: 3, queueJobs: [{ id: 'local' }] },
|
||||
diagnostics: { enabled: true, port: 7777 },
|
||||
historyRetention: '7d',
|
||||
remote: { enabled: true, port: 9100, token: 'main-token', allowInput: true }
|
||||
}
|
||||
});
|
||||
|
||||
await store.saveRendererGlobalSettings({
|
||||
alwaysOnTop: true,
|
||||
pendingQueue: null,
|
||||
diagnostics: { enabled: false, port: 1 },
|
||||
historyRetention: 'all',
|
||||
remote: { enabled: true, port: 9200, token: '', allowInput: false }
|
||||
});
|
||||
|
||||
const config = store.load();
|
||||
assert.equal(config.globalSettings.alwaysOnTop, true);
|
||||
assert.deepEqual(config.globalSettings.pendingQueue, { savedAt: 3, queueJobs: [{ id: 'local' }] });
|
||||
assert.equal(config.globalSettings.diagnostics.enabled, true);
|
||||
assert.equal(config.globalSettings.diagnostics.port, 7777);
|
||||
assert.equal(config.globalSettings.historyRetention, '7d');
|
||||
assert.deepEqual(config.globalSettings.remote, { enabled: true, port: 9200, token: 'main-token', allowInput: false });
|
||||
});
|
||||
|
||||
it('merges remote settings in the write queue and returns the canonical token', async () => {
|
||||
await store.save({
|
||||
globalSettings: {
|
||||
remote: { enabled: false, port: 9100, token: 'canonical-token', allowInput: true }
|
||||
}
|
||||
});
|
||||
|
||||
const rendererSave = store.saveRendererGlobalSettings({
|
||||
alwaysOnTop: true,
|
||||
remote: { enabled: false, port: 9200, token: '', allowInput: false }
|
||||
});
|
||||
const remoteSave = store.saveRemoteSettings(
|
||||
{ enabled: true, port: 9300, token: '', allowInput: false },
|
||||
() => 'generated-token'
|
||||
);
|
||||
|
||||
const [, canonical] = await Promise.all([rendererSave, remoteSave]);
|
||||
assert.deepEqual(canonical, { enabled: true, port: 9300, token: 'canonical-token', allowInput: false });
|
||||
assert.deepEqual(store.load().globalSettings.remote, canonical);
|
||||
});
|
||||
|
||||
it('rejects ordinary writes while quiesced and permits only the final pending queue snapshot', async () => {
|
||||
store.setWritesQuiesced(true);
|
||||
|
||||
await assert.rejects(store.save({ globalSettings: { alwaysOnTop: true } }), /beendet/);
|
||||
await assert.rejects(store.appendHistory({ id: 'late-history', files: [] }), /beendet/);
|
||||
await store.savePendingQueue({ savedAt: 4, queueJobs: [{ id: 'final' }] }, { allowDuringQuiesce: true });
|
||||
|
||||
assert.equal(store.load().globalSettings.alwaysOnTop, false);
|
||||
assert.deepEqual(store.load().globalSettings.pendingQueue, { savedAt: 4, queueJobs: [{ id: 'final' }] });
|
||||
store.setWritesQuiesced(false);
|
||||
await store.save({ globalSettings: { ...store.load().globalSettings, alwaysOnTop: true } });
|
||||
assert.equal(store.load().globalSettings.alwaysOnTop, true);
|
||||
});
|
||||
|
||||
it('load() returns independent clones — mutating one result must not leak into the cache', () => {
|
||||
store.load(); // warm the cache
|
||||
const a = store.load();
|
||||
@@ -433,6 +638,18 @@ describe('ConfigStore history split (electron-history.json)', () => {
|
||||
assert.equal(s.loadHistory().length, 7, 'legacy path still serves history if migration never ran');
|
||||
});
|
||||
|
||||
it('migrated prune refuses to overwrite a corrupted history file', async () => {
|
||||
writeConfigWithHistory(12);
|
||||
s._migrateHistory();
|
||||
fs.writeFileSync(s.historyPath, '{broken-history', 'utf-8');
|
||||
const historyBefore = fs.readFileSync(s.historyPath, 'utf-8');
|
||||
|
||||
await assert.rejects(s.pruneHistory('7d', { dryRun: false }), /Verlaufsdatei ist beschädigt/);
|
||||
|
||||
assert.equal(fs.readFileSync(s.historyPath, 'utf-8'), historyBefore);
|
||||
assert.equal(JSON.parse(fs.readFileSync(s.filePath, 'utf-8')).globalSettings.historyRetention, 'all');
|
||||
});
|
||||
|
||||
it('pruneHistory trims history.json and persists the retention setting', async () => {
|
||||
writeConfigWithHistory(12);
|
||||
s._migrateHistory();
|
||||
@@ -440,4 +657,114 @@ describe('ConfigStore history split (electron-history.json)', () => {
|
||||
assert.equal(s.loadHistory().length, 12);
|
||||
assert.ok(res.keptBatches === 12);
|
||||
});
|
||||
|
||||
it('migrated prune leaves history unchanged when the retention commit fails', async () => {
|
||||
writeConfigWithHistory(12);
|
||||
s._migrateHistory();
|
||||
const historyBefore = fs.readFileSync(s.historyPath, 'utf-8');
|
||||
s._atomicWrite = () => Promise.reject(new Error('retention commit failed'));
|
||||
|
||||
await assert.rejects(s.pruneHistory('7d', { dryRun: false }), /retention commit failed/);
|
||||
|
||||
assert.equal(fs.readFileSync(s.historyPath, 'utf-8'), historyBefore);
|
||||
assert.equal(JSON.parse(fs.readFileSync(s.filePath, 'utf-8')).globalSettings.historyRetention, 'all');
|
||||
});
|
||||
|
||||
it('migrated prune restores the previous retention when the history write fails', async () => {
|
||||
writeConfigWithHistory(12);
|
||||
s._migrateHistory();
|
||||
const historyBefore = fs.readFileSync(s.historyPath, 'utf-8');
|
||||
const originalAtomicWrite = s._atomicWrite.bind(s);
|
||||
const retentionWrites = [];
|
||||
s._atomicWrite = (data) => {
|
||||
retentionWrites.push(JSON.parse(data).globalSettings.historyRetention);
|
||||
return originalAtomicWrite(data);
|
||||
};
|
||||
s._writeHistoryFileAtomic = () => Promise.reject(new Error('history prune failed'));
|
||||
|
||||
await assert.rejects(s.pruneHistory('7d', { dryRun: false }), /history prune failed/);
|
||||
|
||||
assert.deepEqual(retentionWrites, ['7d', 'all']);
|
||||
assert.equal(JSON.parse(fs.readFileSync(s.filePath, 'utf-8')).globalSettings.historyRetention, 'all');
|
||||
assert.equal(fs.readFileSync(s.historyPath, 'utf-8'), historyBefore);
|
||||
});
|
||||
|
||||
it('migrated prune serializes surrounding global settings saves across an internal rollback', async () => {
|
||||
writeConfigWithHistory(12);
|
||||
s._migrateHistory();
|
||||
const historyBefore = fs.readFileSync(s.historyPath, 'utf-8');
|
||||
const originalAtomicWrite = s._atomicWrite.bind(s);
|
||||
const configWrites = [];
|
||||
let priorWriteStarted = false;
|
||||
let releasePriorWrite;
|
||||
s._atomicWrite = (data) => {
|
||||
const settings = JSON.parse(data).globalSettings;
|
||||
configWrites.push({ webhookUrl: settings.webhookUrl || '', alwaysOnTop: !!settings.alwaysOnTop, historyRetention: settings.historyRetention });
|
||||
if (!priorWriteStarted && settings.webhookUrl === 'https://prune-race.invalid/prior') {
|
||||
priorWriteStarted = true;
|
||||
return new Promise((resolve, reject) => {
|
||||
releasePriorWrite = () => originalAtomicWrite(data).then(resolve, reject);
|
||||
});
|
||||
}
|
||||
return originalAtomicWrite(data);
|
||||
};
|
||||
|
||||
let rejectHistoryWrite;
|
||||
s._writeHistoryFileAtomic = () => new Promise((_resolve, reject) => { rejectHistoryWrite = reject; });
|
||||
const priorSettings = { ...s.load().globalSettings, alwaysOnTop: true, webhookUrl: 'https://prune-race.invalid/prior', historyRetention: 'all' };
|
||||
const priorSave = s.save({ globalSettings: priorSettings });
|
||||
while (!releasePriorWrite) await new Promise(resolve => setImmediate(resolve));
|
||||
|
||||
let pruneError = null;
|
||||
const pruning = s.pruneHistory('7d', { dryRun: false }).catch(error => { pruneError = error; });
|
||||
releasePriorWrite();
|
||||
await priorSave;
|
||||
while (!rejectHistoryWrite) await new Promise(resolve => setImmediate(resolve));
|
||||
|
||||
const laterSettings = { ...priorSettings, alwaysOnTop: false, webhookUrl: 'https://prune-race.invalid/later', historyRetention: 'all' };
|
||||
const laterSave = s.save({ globalSettings: laterSettings });
|
||||
await new Promise(resolve => setImmediate(resolve));
|
||||
await new Promise(resolve => setImmediate(resolve));
|
||||
const laterCommittedBeforeRollback = configWrites.some(write => write.webhookUrl === 'https://prune-race.invalid/later');
|
||||
rejectHistoryWrite(new Error('history prune race failed'));
|
||||
await pruning;
|
||||
await laterSave;
|
||||
|
||||
assert.match(pruneError?.message || '', /history prune race failed/);
|
||||
assert.equal(laterCommittedBeforeRollback, false);
|
||||
assert.deepEqual(configWrites.map(write => `${write.webhookUrl}:${write.historyRetention}`), [
|
||||
'https://prune-race.invalid/prior:all',
|
||||
'https://prune-race.invalid/prior:7d',
|
||||
'https://prune-race.invalid/prior:all',
|
||||
'https://prune-race.invalid/later:all'
|
||||
]);
|
||||
const finalSettings = JSON.parse(fs.readFileSync(s.filePath, 'utf-8')).globalSettings;
|
||||
assert.equal(finalSettings.webhookUrl, 'https://prune-race.invalid/later');
|
||||
assert.equal(finalSettings.alwaysOnTop, false);
|
||||
assert.equal(finalSettings.historyRetention, 'all');
|
||||
assert.equal(fs.readFileSync(s.historyPath, 'utf-8'), historyBefore);
|
||||
});
|
||||
|
||||
it('renderer snapshots cannot revert retention after a successful prune', async () => {
|
||||
writeConfigWithHistory(12);
|
||||
s._migrateHistory();
|
||||
const originalHistoryWrite = s._writeHistoryFileAtomic.bind(s);
|
||||
let releaseHistoryWrite;
|
||||
s._writeHistoryFileAtomic = (history) => new Promise((resolve, reject) => {
|
||||
releaseHistoryWrite = () => originalHistoryWrite(history).then(resolve, reject);
|
||||
});
|
||||
|
||||
const pruning = s.pruneHistory('7d', { dryRun: false });
|
||||
while (!releaseHistoryWrite) await new Promise(resolve => setImmediate(resolve));
|
||||
const staleRendererSave = s.saveRendererGlobalSettings({
|
||||
...s.load().globalSettings,
|
||||
alwaysOnTop: true,
|
||||
historyRetention: 'all'
|
||||
});
|
||||
releaseHistoryWrite();
|
||||
await Promise.all([pruning, staleRendererSave]);
|
||||
|
||||
assert.equal(s.load().globalSettings.historyRetention, '7d');
|
||||
assert.equal(s.load().globalSettings.alwaysOnTop, true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -12,9 +12,20 @@ function batch(timestamp, okRows, extras = {}) {
|
||||
|
||||
const DAY = 86400000;
|
||||
|
||||
test('countHistoryRows counts only non-aborted, non-error results', () => {
|
||||
test('countHistoryRows counts every visible history result', () => {
|
||||
const h = [batch('2026-01-01', 3, { aborted: 2, error: 1 })];
|
||||
assert.strictEqual(countHistoryRows(h), 3);
|
||||
assert.strictEqual(countHistoryRows(h), 6);
|
||||
});
|
||||
|
||||
test('count policy prunes histories made only from failed or aborted uploads', () => {
|
||||
const h = [
|
||||
batch('2026-01-01', 0, { error: 60 }),
|
||||
batch('2026-01-02', 0, { aborted: 60 }),
|
||||
batch('2026-01-03', 0, { error: 60 })
|
||||
];
|
||||
const pruned = applyHistoryRetention(h, '100', Date.parse('2026-06-01'));
|
||||
assert.deepStrictEqual(pruned.map(b => b.timestamp), ['2026-01-02', '2026-01-03']);
|
||||
assert.strictEqual(countHistoryRows(pruned), 120);
|
||||
});
|
||||
|
||||
test('retention "all" returns the array unchanged', () => {
|
||||
|
||||
@@ -1,8 +1,55 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const Module = require('node:module');
|
||||
const packageJson = require('../package.json');
|
||||
|
||||
test('packages every Electron preload referenced by the main process', () => {
|
||||
assert.ok(packageJson.build.files.includes('preload.js'));
|
||||
assert.ok(packageJson.build.files.includes('preload-drop-target.js'));
|
||||
});
|
||||
|
||||
test('close readiness is signaled only after the renderer explicitly finishes initialization', () => {
|
||||
const listeners = new Map();
|
||||
const sent = [];
|
||||
let exposedApi = null;
|
||||
const electronMock = {
|
||||
contextBridge: {
|
||||
exposeInMainWorld: (_name, api) => { exposedApi = api; }
|
||||
},
|
||||
ipcRenderer: {
|
||||
invoke: () => Promise.resolve(),
|
||||
on: (channel, listener) => { listeners.set(channel, listener); },
|
||||
send: (...args) => { sent.push(args); },
|
||||
removeAllListeners: () => {}
|
||||
},
|
||||
webUtils: {
|
||||
getPathForFile: () => ''
|
||||
}
|
||||
};
|
||||
const originalLoad = Module._load;
|
||||
const preloadPath = require.resolve('../preload');
|
||||
delete require.cache[preloadPath];
|
||||
Module._load = function (request, parent, isMain) {
|
||||
if (request === 'electron') return electronMock;
|
||||
return originalLoad.call(this, request, parent, isMain);
|
||||
};
|
||||
try {
|
||||
require(preloadPath);
|
||||
} finally {
|
||||
Module._load = originalLoad;
|
||||
}
|
||||
|
||||
let closeAttempt = null;
|
||||
exposedApi.onPrepareClose(attempt => { closeAttempt = attempt; });
|
||||
assert.deepEqual(sent, []);
|
||||
|
||||
listeners.get('app:prepare-close')({}, 7);
|
||||
assert.equal(closeAttempt, 7);
|
||||
assert.deepEqual(sent, [['app:close-preparation-started', 7]]);
|
||||
|
||||
exposedApi.signalCloseHandshakeReady();
|
||||
assert.deepEqual(sent, [
|
||||
['app:close-preparation-started', 7],
|
||||
['app:close-handshake-ready']
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
const { spawnSync } = require('node:child_process');
|
||||
|
||||
const root = path.resolve(__dirname, '..');
|
||||
const rootFiles = [
|
||||
'.gitignore',
|
||||
'README.md',
|
||||
'SECURITY.md',
|
||||
'eslint.config.mjs',
|
||||
'main.js',
|
||||
'package-lock.json',
|
||||
'package.json',
|
||||
'preload-drop-target.js',
|
||||
'preload.js'
|
||||
];
|
||||
const directoryRoots = ['assets', 'lib', 'renderer', 'services/backup-api', 'tests'];
|
||||
const scriptFiles = ['scripts/afterPack.cjs', 'scripts/release-plan.mjs', 'scripts/verify-public-release.mjs'];
|
||||
|
||||
function copyDirectory(source, destination) {
|
||||
fs.mkdirSync(destination, { recursive: true });
|
||||
for (const entry of fs.readdirSync(source, { withFileTypes: true })) {
|
||||
if (/^_ui-inject\..+\.tmp\.js$/.test(entry.name)) continue;
|
||||
const sourcePath = path.join(source, entry.name);
|
||||
const destinationPath = path.join(destination, entry.name);
|
||||
if (entry.isDirectory()) copyDirectory(sourcePath, destinationPath);
|
||||
else if (entry.isFile()) fs.copyFileSync(sourcePath, destinationPath);
|
||||
}
|
||||
}
|
||||
|
||||
function createStage() {
|
||||
const stage = fs.mkdtempSync(path.join(os.tmpdir(), 'mhu-public-verifier-'));
|
||||
for (const relativePath of rootFiles) {
|
||||
const destination = path.join(stage, relativePath);
|
||||
fs.mkdirSync(path.dirname(destination), { recursive: true });
|
||||
fs.copyFileSync(path.join(root, relativePath), destination);
|
||||
}
|
||||
for (const relativePath of directoryRoots) copyDirectory(path.join(root, relativePath), path.join(stage, relativePath));
|
||||
for (const relativePath of scriptFiles) {
|
||||
const destination = path.join(stage, relativePath);
|
||||
fs.mkdirSync(path.dirname(destination), { recursive: true });
|
||||
fs.copyFileSync(path.join(root, relativePath), destination);
|
||||
}
|
||||
fs.rmSync(path.join(stage, 'assets', 'product-overview.png'), { force: true });
|
||||
return stage;
|
||||
}
|
||||
|
||||
function verify(stage, version = '2.0.6') {
|
||||
return spawnSync(process.execPath, ['scripts/verify-public-release.mjs', '--source-only', '--version', version], {
|
||||
cwd: stage,
|
||||
encoding: 'utf8'
|
||||
});
|
||||
}
|
||||
|
||||
test('public release verifier accepts only the exact source manifest and target version', (t) => {
|
||||
const stage = createStage();
|
||||
t.after(() => fs.rmSync(stage, { recursive: true, force: true }));
|
||||
|
||||
const baseline = verify(stage);
|
||||
assert.equal(baseline.status, 0, baseline.stderr);
|
||||
assert.match(baseline.stdout, /layout=exact/);
|
||||
|
||||
fs.writeFileSync(path.join(stage, 'tests', 'unexpected.json'), '{}');
|
||||
const extra = verify(stage);
|
||||
assert.equal(extra.status, 1);
|
||||
assert.match(extra.stderr, /tests\/unexpected\.json\tsource-layout-allowlist/);
|
||||
fs.rmSync(path.join(stage, 'tests', 'unexpected.json'));
|
||||
|
||||
const wrongVersion = verify(stage, '2.0.5');
|
||||
assert.equal(wrongVersion.status, 1);
|
||||
assert.match(wrongVersion.stderr, /package\.json\tpackage-version-target/);
|
||||
});
|
||||
+968
-18
File diff suppressed because it is too large
Load Diff
@@ -1,10 +1,12 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const path = require('node:path');
|
||||
const { spawnSync } = require('node:child_process');
|
||||
const fs = require('node:fs');
|
||||
const os = require('node:os');
|
||||
const { pathToFileURL } = require('node:url');
|
||||
|
||||
const { isNewer, resolveReleaseVersion } = require('../lib/updater');
|
||||
const { isNewer, resolveReleaseVersion, prepareUpdate, launchPreparedUpdate } = require('../lib/updater');
|
||||
const releasePlanUrl = pathToFileURL(path.resolve(__dirname, '../scripts/release-plan.mjs')).href;
|
||||
|
||||
test('bridge title resolves product version instead of transport tag', () => {
|
||||
assert.equal(resolveReleaseVersion({ name: 'Multi-Hoster-Upload v2.0.1', tag_name: 'v3.3.109' }), '2.0.1');
|
||||
@@ -12,57 +14,101 @@ test('bridge title resolves product version instead of transport tag', () => {
|
||||
assert.equal(isNewer('2.0.2', '2.0.1'), true);
|
||||
});
|
||||
|
||||
test('release CLI rejects a malformed transport tag before release work', () => {
|
||||
const script = path.resolve(__dirname, '../scripts/release_gitea.mjs');
|
||||
const result = spawnSync(process.execPath, [script, '2.0.1', '--transport-tag', '3.3.109', 'Bridge', '--dry-run'], {
|
||||
cwd: path.resolve(__dirname, '..'),
|
||||
encoding: 'utf8'
|
||||
});
|
||||
|
||||
assert.equal(result.status, 1);
|
||||
assert.match(result.stderr, /--transport-tag must match vX\.Y\.Z/);
|
||||
assert.doesNotMatch(result.stdout, /npm run release:win/);
|
||||
test('release arguments reject a malformed transport tag', async () => {
|
||||
const { parseReleaseArgs } = await import(releasePlanUrl);
|
||||
assert.throws(
|
||||
() => parseReleaseArgs(['2.0.1', '--transport-tag', '3.3.109', 'Bridge', '--dry-run']),
|
||||
/--transport-tag must match vX\.Y\.Z/
|
||||
);
|
||||
});
|
||||
|
||||
test('release plan keeps product artifacts separate from the transport tag', () => {
|
||||
const script = path.resolve(__dirname, '../scripts/release_gitea.mjs');
|
||||
const moduleUrl = pathToFileURL(script).href;
|
||||
const source = `
|
||||
import { createReleasePlan, parseReleaseArgs, renderLatestYml } from ${JSON.stringify(moduleUrl)};
|
||||
const plan = createReleasePlan(parseReleaseArgs(['2.0.1', '--transport-tag', 'v3.3.109', 'Bridge', 'notes']));
|
||||
const latestYml = renderLatestYml(plan, 'abc123', 456, '2026-08-07T12:00:00.000Z');
|
||||
process.stdout.write(JSON.stringify({
|
||||
version: plan.version,
|
||||
transportTag: plan.transportTag,
|
||||
releaseTitle: plan.releaseTitle,
|
||||
releaseBody: plan.releaseBody,
|
||||
expectedArtifacts: plan.expectedArtifacts,
|
||||
latestYml
|
||||
}));
|
||||
`;
|
||||
const result = spawnSync(process.execPath, ['--input-type=module', '--eval', source], {
|
||||
cwd: path.resolve(__dirname, '..'),
|
||||
encoding: 'utf8'
|
||||
});
|
||||
test('update preparation writes a verified installer without launching it', async () => {
|
||||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'mhu-updater-test-'));
|
||||
const installer = Buffer.alloc(128 * 1024, 0);
|
||||
installer[0] = 0x4d;
|
||||
installer[1] = 0x5a;
|
||||
let reads = 0;
|
||||
const progress = [];
|
||||
try {
|
||||
const prepared = await prepareUpdate(value => progress.push(value), {
|
||||
checkResult: {
|
||||
available: true,
|
||||
assetUrl: 'https://update.invalid/setup.exe',
|
||||
assetName: 'setup.exe',
|
||||
assetSize: installer.length,
|
||||
latestYmlUrl: null
|
||||
},
|
||||
tempDir,
|
||||
fetchImpl: async () => ({
|
||||
ok: true,
|
||||
status: 200,
|
||||
body: {
|
||||
getReader: () => ({
|
||||
read: async () => {
|
||||
reads++;
|
||||
return reads === 1 ? { done: false, value: installer } : { done: true };
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
assert.deepEqual(JSON.parse(result.stdout), {
|
||||
version: '2.0.1',
|
||||
transportTag: 'v3.3.109',
|
||||
releaseTitle: 'Multi-Hoster-Upload v2.0.1',
|
||||
releaseBody: 'Bridge notes',
|
||||
assert.equal(prepared.installerPath, path.join(tempDir, 'setup.exe'));
|
||||
assert.deepEqual(fs.readFileSync(prepared.installerPath), installer);
|
||||
assert.equal(progress.at(-1).stage, 'prepared');
|
||||
} finally {
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('a prepared installer launches at most once', () => {
|
||||
const calls = [];
|
||||
const child = { unrefCalls: 0, unref() { this.unrefCalls++; } };
|
||||
const prepared = { installerPath: 'C:\\Temp\\mhu-setup.exe' };
|
||||
const spawnImpl = (...args) => {
|
||||
calls.push(args);
|
||||
return child;
|
||||
};
|
||||
|
||||
assert.equal(launchPreparedUpdate(prepared, { spawnImpl }), true);
|
||||
assert.equal(launchPreparedUpdate(prepared, { spawnImpl }), false);
|
||||
assert.equal(calls.length, 1);
|
||||
assert.deepEqual(calls[0], [
|
||||
prepared.installerPath,
|
||||
['/S', '--updated', '--force-run'],
|
||||
{ detached: true, stdio: 'ignore' }
|
||||
]);
|
||||
assert.equal(child.unrefCalls, 1);
|
||||
});
|
||||
|
||||
test('release plan keeps product artifacts separate from the transport tag', async () => {
|
||||
const { createReleasePlan, parseReleaseArgs, renderLatestYml } = await import(releasePlanUrl);
|
||||
const plan = createReleasePlan(parseReleaseArgs(['2.0.6', '--transport-tag', 'v3.3.114', 'Interface', 'redesign']));
|
||||
const latestYml = renderLatestYml(plan, 'abc123', 456, '2026-08-07T12:00:00.000Z');
|
||||
assert.deepEqual({
|
||||
version: plan.version,
|
||||
transportTag: plan.transportTag,
|
||||
releaseTitle: plan.releaseTitle,
|
||||
releaseBody: plan.releaseBody,
|
||||
expectedArtifacts: plan.expectedArtifacts,
|
||||
latestYml
|
||||
}, {
|
||||
version: '2.0.6',
|
||||
transportTag: 'v3.3.114',
|
||||
releaseTitle: 'Multi-Hoster-Upload v2.0.6',
|
||||
releaseBody: 'Interface redesign',
|
||||
expectedArtifacts: [
|
||||
'Multi-Hoster-Upload Setup 2.0.1.exe',
|
||||
'Multi-Hoster-Upload 2.0.1.exe',
|
||||
'Multi-Hoster-Upload Setup 2.0.6.exe',
|
||||
'Multi-Hoster-Upload 2.0.6.exe',
|
||||
'Multi-Hoster-Upload Setup 2.0.6.exe.blockmap',
|
||||
'latest.yml'
|
||||
],
|
||||
latestYml: "version: 2.0.1\nfiles:\n - url: Multi-Hoster-Upload Setup 2.0.1.exe\n sha512: abc123\n size: 456\npath: Multi-Hoster-Upload Setup 2.0.1.exe\nsha512: abc123\nreleaseDate: '2026-08-07T12:00:00.000Z'\n"
|
||||
latestYml: "version: 2.0.6\nfiles:\n - url: Multi-Hoster-Upload Setup 2.0.6.exe\n sha512: abc123\n size: 456\npath: Multi-Hoster-Upload Setup 2.0.6.exe\nsha512: abc123\nreleaseDate: '2026-08-07T12:00:00.000Z'\n"
|
||||
});
|
||||
});
|
||||
|
||||
test('compatible existing release preserves the recovery id', async () => {
|
||||
const moduleUrl = pathToFileURL(path.resolve(__dirname, '../scripts/release_gitea.mjs')).href;
|
||||
const { createReleasePlan, parseReleaseArgs, resolveExistingReleaseId } = await import(moduleUrl);
|
||||
const { createReleasePlan, parseReleaseArgs, resolveExistingReleaseId } = await import(releasePlanUrl);
|
||||
const plan = createReleasePlan(parseReleaseArgs(['2.0.1', '--transport-tag', 'v3.3.109', 'Bridge notes']));
|
||||
const release = {
|
||||
id: 81,
|
||||
@@ -78,8 +124,7 @@ test('compatible existing release preserves the recovery id', async () => {
|
||||
});
|
||||
|
||||
test('incompatible existing release title fails closed', async () => {
|
||||
const moduleUrl = pathToFileURL(path.resolve(__dirname, '../scripts/release_gitea.mjs')).href;
|
||||
const { createReleasePlan, parseReleaseArgs, resolveExistingReleaseId } = await import(moduleUrl);
|
||||
const { createReleasePlan, parseReleaseArgs, resolveExistingReleaseId } = await import(releasePlanUrl);
|
||||
const plan = createReleasePlan(parseReleaseArgs(['2.0.1', '--transport-tag', 'v3.3.109', 'Bridge notes']));
|
||||
const release = {
|
||||
id: 81,
|
||||
|
||||
Reference in New Issue
Block a user