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/
|
node_modules/
|
||||||
release/
|
release/
|
||||||
.worktrees/
|
.artifacts/
|
||||||
__pycache__/
|
__pycache__/
|
||||||
*.pyc
|
*.pyc
|
||||||
|
|
||||||
|
|||||||
@@ -9,11 +9,13 @@ Multi-Hoster-Upload is a Windows desktop app for managing large file batches acr
|
|||||||
## Capabilities
|
## Capabilities
|
||||||
|
|
||||||
- Upload one batch to several supported hosters in parallel.
|
- 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.
|
- 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.
|
- 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.
|
- 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
|
## 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).
|
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.
|
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
|
## 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;
|
let n = 0;
|
||||||
const files = (batch && batch.files) || [];
|
const files = (batch && batch.files) || [];
|
||||||
for (const file of files) {
|
for (const file of files) {
|
||||||
for (const result of (file.results || [])) {
|
n += (file.results || []).length;
|
||||||
if (result.status === 'aborted' || result.status === 'error') continue;
|
|
||||||
n++;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return n;
|
return n;
|
||||||
}
|
}
|
||||||
@@ -180,13 +177,19 @@ function applyHistoryRetention(history, retention, nowMs) {
|
|||||||
|
|
||||||
class ConfigStore {
|
class ConfigStore {
|
||||||
constructor(app) {
|
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')
|
? app.getPath('userData')
|
||||||
: path.join(__dirname, '..');
|
: path.join(__dirname, '..');
|
||||||
this.filePath = path.join(dir, 'electron-config.json');
|
this.filePath = path.join(dir, 'electron-config.json');
|
||||||
this.historyPath = path.join(dir, 'electron-history.json');
|
this.historyPath = path.join(dir, 'electron-history.json');
|
||||||
this._writeQueue = Promise.resolve(); // Serializes all writes to prevent race conditions
|
this._writeQueue = Promise.resolve(); // Serializes all writes to prevent race conditions
|
||||||
this._historyWriteQueue = Promise.resolve();
|
this._historyWriteQueue = Promise.resolve();
|
||||||
|
this._pendingWriteOperations = new Set();
|
||||||
|
this._writesQuiesced = false;
|
||||||
this._historyMigrated = false;
|
this._historyMigrated = false;
|
||||||
this._cache = null;
|
this._cache = null;
|
||||||
this._cacheKey = '';
|
this._cacheKey = '';
|
||||||
@@ -238,9 +241,26 @@ class ConfigStore {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
_enqueueHistoryWrite(fn) {
|
_quiescedWriteError() {
|
||||||
this._historyWriteQueue = this._historyWriteQueue.then(fn, fn);
|
const error = new Error('Die Anwendung wird gerade beendet');
|
||||||
return this._historyWriteQueue;
|
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() {
|
_migrateHistory() {
|
||||||
@@ -476,11 +496,31 @@ class ConfigStore {
|
|||||||
return this._atomicWrite(data);
|
return this._atomicWrite(data);
|
||||||
}
|
}
|
||||||
|
|
||||||
_enqueueWrite(fn) {
|
_enqueueWrite(fn, options = {}) {
|
||||||
|
if (this._writesQuiesced && !options.allowDuringQuiesce) return Promise.reject(this._quiescedWriteError());
|
||||||
this._wqDepth++;
|
this._wqDepth++;
|
||||||
const done = () => { this._wqDepth--; };
|
const operation = this._writeQueue.then(fn, fn);
|
||||||
this._writeQueue = this._writeQueue.then(fn, fn).then(done, done);
|
this._pendingWriteOperations.add(operation);
|
||||||
return this._writeQueue;
|
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) {
|
_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) {
|
replaceSettings(config) {
|
||||||
return this._enqueueWrite(() => {
|
return this._enqueueWrite(() => {
|
||||||
const current = this.load();
|
const current = this.load();
|
||||||
@@ -604,8 +694,12 @@ class ConfigStore {
|
|||||||
pruneHistory(retention, opts = {}) {
|
pruneHistory(retention, opts = {}) {
|
||||||
const dryRun = !!opts.dryRun;
|
const dryRun = !!opts.dryRun;
|
||||||
if (this._historyMigrated) {
|
if (this._historyMigrated) {
|
||||||
return this._enqueueHistoryWrite(() => {
|
return this._enqueueHistoryWrite(async () => {
|
||||||
const current = this._readHistoryFile() || [];
|
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 beforeBatches = current.length;
|
||||||
const beforeRows = countHistoryRows(current);
|
const beforeRows = countHistoryRows(current);
|
||||||
const pruned = applyHistoryRetention(current, retention, Date.now());
|
const pruned = applyHistoryRetention(current, retention, Date.now());
|
||||||
@@ -616,9 +710,25 @@ class ConfigStore {
|
|||||||
keptRows: countHistoryRows(pruned)
|
keptRows: countHistoryRows(pruned)
|
||||||
};
|
};
|
||||||
if (dryRun) return result;
|
if (dryRun) return result;
|
||||||
return this._writeHistoryFileAtomic(pruned)
|
return this._enqueueWrite(async () => {
|
||||||
.then(() => this.save({ globalSettings: { ...this.load().globalSettings, historyRetention: String(retention || 'all') } }))
|
const config = this.load();
|
||||||
.then(() => result);
|
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(() => {
|
return this._enqueueWrite(() => {
|
||||||
|
|||||||
+36
-33
@@ -14,6 +14,7 @@ let cachedCheckTs = 0;
|
|||||||
const CACHE_TTL = 10 * 60 * 1000; // 10 min
|
const CACHE_TTL = 10 * 60 * 1000; // 10 min
|
||||||
|
|
||||||
let activeAbort = null;
|
let activeAbort = null;
|
||||||
|
const launchedInstallerPaths = new Set();
|
||||||
|
|
||||||
function getCurrentVersion() {
|
function getCurrentVersion() {
|
||||||
return app.getVersion();
|
return app.getVersion();
|
||||||
@@ -131,10 +132,10 @@ async function checkForUpdate() {
|
|||||||
return cachedCheck;
|
return cachedCheck;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function parseLatestYml(url) {
|
async function parseLatestYml(url, fetchImpl = fetch) {
|
||||||
if (!url) return null;
|
if (!url) return null;
|
||||||
try {
|
try {
|
||||||
const res = await fetch(url, { redirect: 'follow' });
|
const res = await fetchImpl(url, { redirect: 'follow' });
|
||||||
const text = await res.text();
|
const text = await res.text();
|
||||||
// Extract sha512 from latest.yml
|
// Extract sha512 from latest.yml
|
||||||
const match = text.match(/sha512:\s*([A-Za-z0-9+/=]+)/);
|
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'
|
return buf[0] === 0x4D && buf[1] === 0x5A; // 'MZ'
|
||||||
}
|
}
|
||||||
|
|
||||||
async function installUpdate(onProgress) {
|
async function prepareUpdate(onProgress, options = {}) {
|
||||||
if (activeAbort) activeAbort.abort();
|
if (activeAbort) activeAbort.abort();
|
||||||
activeAbort = new AbortController();
|
activeAbort = new AbortController();
|
||||||
const signal = activeAbort.signal;
|
const signal = activeAbort.signal;
|
||||||
|
const fetchImpl = options.fetchImpl || fetch;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Stage: starting
|
// Stage: starting
|
||||||
if (onProgress) onProgress({ stage: 'starting', percent: 0 });
|
if (onProgress) onProgress({ stage: 'starting', percent: 0 });
|
||||||
|
|
||||||
// Check or use cached
|
// Check or use cached
|
||||||
let check = cachedCheck;
|
let check = options.checkResult || cachedCheck;
|
||||||
if (!check || !check.available) {
|
if (!check || !check.available) {
|
||||||
check = await checkForUpdate();
|
check = await checkForUpdate();
|
||||||
}
|
}
|
||||||
@@ -172,10 +174,10 @@ async function installUpdate(onProgress) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Stage: downloading
|
// Stage: downloading
|
||||||
const tmpDir = app.getPath('temp');
|
const tmpDir = options.tempDir || app.getPath('temp');
|
||||||
const installerPath = path.join(tmpDir, check.assetName);
|
const installerPath = path.join(tmpDir, check.assetName);
|
||||||
|
|
||||||
const res = await fetch(check.assetUrl, {
|
const res = await fetchImpl(check.assetUrl, {
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
signal,
|
signal,
|
||||||
redirect: 'follow'
|
redirect: 'follow'
|
||||||
@@ -233,7 +235,7 @@ async function installUpdate(onProgress) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Optional SHA-512 verification from latest.yml
|
// Optional SHA-512 verification from latest.yml
|
||||||
const expectedSha = await parseLatestYml(check.latestYmlUrl);
|
const expectedSha = await parseLatestYml(check.latestYmlUrl, fetchImpl);
|
||||||
if (expectedSha) {
|
if (expectedSha) {
|
||||||
const actualSha = crypto.createHash('sha512').update(fileBuffer).digest('base64');
|
const actualSha = crypto.createHash('sha512').update(fileBuffer).digest('base64');
|
||||||
if (actualSha !== expectedSha) {
|
if (actualSha !== expectedSha) {
|
||||||
@@ -248,32 +250,14 @@ async function installUpdate(onProgress) {
|
|||||||
// Write to disk
|
// Write to disk
|
||||||
fs.writeFileSync(installerPath, fileBuffer);
|
fs.writeFileSync(installerPath, fileBuffer);
|
||||||
|
|
||||||
// Stage: launching
|
const prepared = {
|
||||||
if (onProgress) onProgress({ stage: 'launching', percent: 100 });
|
installerPath,
|
||||||
|
assetName: check.assetName,
|
||||||
const { spawn } = require('child_process');
|
remoteVersion: check.remoteVersion || '',
|
||||||
spawn(installerPath, ['/S', '--updated', '--force-run'], {
|
transportTag: check.transportTag || ''
|
||||||
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; }
|
|
||||||
};
|
};
|
||||||
if (_getActive() > 0) {
|
if (onProgress) onProgress({ stage: 'prepared', percent: 100 });
|
||||||
const POLL_MS = 3000;
|
return prepared;
|
||||||
const poller = setInterval(() => {
|
|
||||||
if (_getActive() === 0) { clearInterval(poller); _doQuit(); }
|
|
||||||
}, POLL_MS);
|
|
||||||
setTimeout(() => { try { clearInterval(poller); } catch {} _doQuit(); }, 30 * 60 * 1000);
|
|
||||||
} else {
|
|
||||||
_doQuit();
|
|
||||||
}
|
|
||||||
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (onProgress) onProgress({ stage: 'error', error: err.message });
|
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() {
|
function abortUpdate() {
|
||||||
if (activeAbort) {
|
if (activeAbort) {
|
||||||
activeAbort.abort();
|
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 { selectUploadAuth } = require('./lib/account-auth');
|
||||||
const { createAccountPicker } = require('./lib/account-rotation');
|
const { createAccountPicker } = require('./lib/account-rotation');
|
||||||
const ClouddropUploader = require('./lib/clouddrop-upload');
|
const ClouddropUploader = require('./lib/clouddrop-upload');
|
||||||
const { checkForUpdate, installUpdate, abortUpdate } = require('./lib/updater');
|
const { checkForUpdate, prepareUpdate, launchPreparedUpdate, abortUpdate } = require('./lib/updater');
|
||||||
const backupCrypto = require('./lib/backup-crypto');
|
const backupCrypto = require('./lib/backup-crypto');
|
||||||
const { createOnlineBackup, downloadOnlineBackup, uploadOnlineBackup } = require('./lib/online-backup');
|
const { createOnlineBackup, downloadOnlineBackup, uploadOnlineBackup } = require('./lib/online-backup');
|
||||||
const { createPortableSettingsSnapshot, prepareImportedSettings } = require('./lib/settings-backup');
|
const { createPortableSettingsSnapshot, prepareImportedSettings } = require('./lib/settings-backup');
|
||||||
@@ -93,16 +93,146 @@ if (_perfOn) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let mainWindow;
|
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 _lastImportPath = null;
|
||||||
let dropTargetWindow = null;
|
let dropTargetWindow = null;
|
||||||
let tray = null;
|
let tray = null;
|
||||||
const configStore = new ConfigStore(app);
|
const configStore = new ConfigStore(app);
|
||||||
configStore.setPerfLog((m) => { try { logInfo(m); } catch {} });
|
configStore.setPerfLog((m) => { try { logInfo(m); } catch {} });
|
||||||
let uploadManager = null;
|
let uploadManager = null;
|
||||||
|
const activeUploadProducerTrackers = new Set();
|
||||||
const settingsImportGate = createSettingsImportGate(() => !!(uploadManager && uploadManager.running));
|
const settingsImportGate = createSettingsImportGate(() => !!(uploadManager && uploadManager.running));
|
||||||
let diagnosticAgent = null;
|
let diagnosticAgent = null;
|
||||||
let _diagHandler = 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();
|
const _hasSingleInstanceLock = app.requestSingleInstanceLock();
|
||||||
if (!_hasSingleInstanceLock) {
|
if (!_hasSingleInstanceLock) {
|
||||||
app.quit();
|
app.quit();
|
||||||
@@ -708,6 +838,7 @@ function _flushUploadLog() {
|
|||||||
|
|
||||||
function _persistFallbackLogPath(workingPath) {
|
function _persistFallbackLogPath(workingPath) {
|
||||||
try {
|
try {
|
||||||
|
if (!settingsImportGate.canStartUpload()) return;
|
||||||
const cfg = configStore.load();
|
const cfg = configStore.load();
|
||||||
const gs = cfg.globalSettings || {};
|
const gs = cfg.globalSettings || {};
|
||||||
const mode = gs.logMode || 'single';
|
const mode = gs.logMode || 'single';
|
||||||
@@ -913,7 +1044,7 @@ function makeAccountPicker(config) {
|
|||||||
function persistRotation(pick) {
|
function persistRotation(pick) {
|
||||||
if (!pick.dirty()) return;
|
if (!pick.dirty()) return;
|
||||||
_rotationCursors = { ...rotationCursors(), ...pick.indices() };
|
_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) {
|
function buildUploadTasks(config, files, hosters, pick) {
|
||||||
@@ -1231,7 +1362,7 @@ function createWindow() {
|
|||||||
height: 750,
|
height: 750,
|
||||||
minWidth: 800,
|
minWidth: 800,
|
||||||
minHeight: 550,
|
minHeight: 550,
|
||||||
backgroundColor: '#16181c',
|
backgroundColor: '#0f0f0f',
|
||||||
autoHideMenuBar: true,
|
autoHideMenuBar: true,
|
||||||
webPreferences: {
|
webPreferences: {
|
||||||
contextIsolation: true,
|
contextIsolation: true,
|
||||||
@@ -1240,9 +1371,28 @@ function createWindow() {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
mainWindow = startupWindow.window;
|
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.setBackgroundThrottling(false);
|
||||||
|
|
||||||
|
mainWindow.webContents.on('did-start-loading', () => {
|
||||||
|
closeHandshakeReady = false;
|
||||||
|
restoreClosePreparation(closePreparationAttempt);
|
||||||
|
});
|
||||||
|
|
||||||
mainWindow.webContents.on('render-process-gone', (_event, details) => {
|
mainWindow.webContents.on('render-process-gone', (_event, details) => {
|
||||||
_writeCrashLog('RENDER PROCESS GONE', new Error(details.reason || 'unknown'), details);
|
_writeCrashLog('RENDER PROCESS GONE', new Error(details.reason || 'unknown'), details);
|
||||||
debugLog(`RENDER PROCESS GONE: reason=${details.reason} exitCode=${details.exitCode}`);
|
debugLog(`RENDER PROCESS GONE: reason=${details.reason} exitCode=${details.exitCode}`);
|
||||||
@@ -1422,7 +1572,27 @@ app.on('window-all-closed', () => {
|
|||||||
app.quit();
|
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 {}
|
if (uploadManager) try { uploadManager.cancel(); } catch {}
|
||||||
try { folderMonitor.stop(); } catch {}
|
try { folderMonitor.stop(); } catch {}
|
||||||
try {
|
try {
|
||||||
@@ -1467,6 +1637,7 @@ ipcMain.handle('get-config', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
ipcMain.handle('save-config', async (_event, config) => {
|
ipcMain.handle('save-config', async (_event, config) => {
|
||||||
|
assertConfigWriteAllowed();
|
||||||
await configStore.save(config);
|
await configStore.save(config);
|
||||||
if (config && config.globalSettings) _invalidateLogSettings();
|
if (config && config.globalSettings) _invalidateLogSettings();
|
||||||
try {
|
try {
|
||||||
@@ -1519,6 +1690,7 @@ ipcMain.handle('get-history', () => {
|
|||||||
ipcMain.handle('prune-history', async (_event, payload) => {
|
ipcMain.handle('prune-history', async (_event, payload) => {
|
||||||
const retention = payload && payload.retention;
|
const retention = payload && payload.retention;
|
||||||
const dryRun = !!(payload && payload.dryRun);
|
const dryRun = !!(payload && payload.dryRun);
|
||||||
|
if (!dryRun) assertConfigWriteAllowed();
|
||||||
return configStore.pruneHistory(retention, { dryRun });
|
return configStore.pruneHistory(retention, { dryRun });
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1725,6 +1897,7 @@ ipcMain.handle('get-file-sizes', async (_event, paths) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
ipcMain.handle('start-upload', (_event, payload) => {
|
ipcMain.handle('start-upload', (_event, payload) => {
|
||||||
|
if (closeFlushRequested) return { error: 'Die Anwendung wird gerade beendet' };
|
||||||
if (!settingsImportGate.canStartUpload()) return { error: 'Einstellungen werden gerade importiert' };
|
if (!settingsImportGate.canStartUpload()) return { error: 'Einstellungen werden gerade importiert' };
|
||||||
const config = configStore.load();
|
const config = configStore.load();
|
||||||
const files = payload && Array.isArray(payload.files) ? payload.files : [];
|
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
|
// Pass hoster settings to the upload manager
|
||||||
uploadManager = new UploadManager(config.hosterSettings || {}, config.globalSettings || {}, buildAccountPools(config));
|
uploadManager = new UploadManager(config.hosterSettings || {}, config.globalSettings || {}, buildAccountPools(config));
|
||||||
globalThis._mhuUploadManagerRef = uploadManager;
|
globalThis._mhuUploadManagerRef = uploadManager;
|
||||||
|
const _thisManager = uploadManager;
|
||||||
|
const _producerTracker = trackUploadProducer(_thisManager);
|
||||||
|
|
||||||
const _progressByJob = new Map();
|
const _progressByJob = new Map();
|
||||||
const _progressTerminalQueue = [];
|
const _progressTerminalQueue = [];
|
||||||
@@ -1907,7 +2082,6 @@ ipcMain.handle('start-upload', (_event, payload) => {
|
|||||||
// fires start-upload while we're still awaiting appendHistory would
|
// fires start-upload while we're still awaiting appendHistory would
|
||||||
// create a fresh manager which the trailing `uploadManager = null` then
|
// create a fresh manager which the trailing `uploadManager = null` then
|
||||||
// orphans (cancel/addJobs see null, the new batch keeps running invisibly).
|
// orphans (cancel/addJobs see null, the new batch keeps running invisibly).
|
||||||
const _thisManager = uploadManager;
|
|
||||||
uploadManager.on('batch-done', async (summary) => {
|
uploadManager.on('batch-done', async (summary) => {
|
||||||
debugLog(`batch-done: total=${summary.total} ok=${summary.succeeded} fail=${summary.failed}`);
|
debugLog(`batch-done: total=${summary.total} ok=${summary.succeeded} fail=${summary.failed}`);
|
||||||
logMarker('BATCH END', { 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) {
|
try { await configStore.appendHistory(summary); } catch (err) {
|
||||||
debugLog(`appendHistory failed: ${err.message}`);
|
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);
|
safeSend('upload-batch-done', summary);
|
||||||
|
_producerTracker.finish();
|
||||||
|
|
||||||
const fullyAborted = isAllAborted(summary);
|
const fullyAborted = isAllAborted(summary);
|
||||||
if (isAutoRetry) {
|
if (isAutoRetry) {
|
||||||
@@ -1939,9 +2122,19 @@ ipcMain.handle('start-upload', (_event, payload) => {
|
|||||||
// This ensures webContents.send() calls from upload events
|
// This ensures webContents.send() calls from upload events
|
||||||
// are not interleaved with the handle() response.
|
// are not interleaved with the handle() response.
|
||||||
process.nextTick(() => {
|
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)`);
|
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()),
|
primeFailedAccounts: Array.from(_sessionFailedAccounts.keys()),
|
||||||
primeOverrides: Array.from(_sessionAccountOverrides.entries())
|
primeOverrides: Array.from(_sessionAccountOverrides.entries())
|
||||||
}).catch((err) => {
|
}).catch((err) => {
|
||||||
@@ -1956,6 +2149,7 @@ ipcMain.handle('start-upload', (_event, payload) => {
|
|||||||
error: err ? err.message : 'Unbekannter Fehler'
|
error: err ? err.message : 'Unbekannter Fehler'
|
||||||
};
|
};
|
||||||
safeSend('upload-batch-done', errorSummary);
|
safeSend('upload-batch-done', errorSummary);
|
||||||
|
_producerTracker.finish();
|
||||||
if (!isAutoRetry) sendBatchWebhook(errorSummary, 0);
|
if (!isAutoRetry) sendBatchWebhook(errorSummary, 0);
|
||||||
if (uploadManager === _thisManager) { uploadManager = null; globalThis._mhuUploadManagerRef = null; }
|
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) => {
|
ipcMain.handle('add-jobs-to-batch', (_event, payload) => {
|
||||||
|
if (closeFlushRequested) return { error: 'Die Anwendung wird gerade beendet' };
|
||||||
if (!uploadManager || !uploadManager.running) {
|
if (!uploadManager || !uploadManager.running) {
|
||||||
return { error: 'Kein Upload aktiv' };
|
return { error: 'Kein Upload aktiv' };
|
||||||
}
|
}
|
||||||
@@ -2208,6 +2403,7 @@ ipcMain.handle('open-log-folder', async () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
ipcMain.handle('clear-history', async () => {
|
ipcMain.handle('clear-history', async () => {
|
||||||
|
assertConfigWriteAllowed();
|
||||||
await configStore.clearHistory();
|
await configStore.clearHistory();
|
||||||
return true;
|
return true;
|
||||||
});
|
});
|
||||||
@@ -2266,6 +2462,7 @@ async function syncImportedRuntime(config) {
|
|||||||
async function applyImportedSettings(imported) {
|
async function applyImportedSettings(imported) {
|
||||||
settingsImportGate.begin();
|
settingsImportGate.begin();
|
||||||
try {
|
try {
|
||||||
|
await waitForConfigStoreWrites();
|
||||||
const prepared = prepareImportedSettings(imported);
|
const prepared = prepareImportedSettings(imported);
|
||||||
const ts = new Date().toISOString().replace(/[:.]/g, '-');
|
const ts = new Date().toISOString().replace(/[:.]/g, '-');
|
||||||
const preImportPath = configStore.filePath.replace('.json', `.pre-import-${ts}.json`);
|
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 };
|
if (canceled || !filePath) return { ok: false, canceled: true };
|
||||||
|
await waitForConfigStoreWrites();
|
||||||
const config = createPortableSettingsSnapshot(configStore.load());
|
const config = createPortableSettingsSnapshot(configStore.load());
|
||||||
if (filePath.toLowerCase().endsWith('.json')) {
|
if (filePath.toLowerCase().endsWith('.json')) {
|
||||||
fs.writeFileSync(filePath, JSON.stringify(config, null, 2), 'utf-8');
|
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 () => {
|
ipcMain.handle('online-backup:create', async () => {
|
||||||
try {
|
try {
|
||||||
|
await waitForConfigStoreWrites();
|
||||||
const snapshot = createPortableSettingsSnapshot(configStore.load());
|
const snapshot = createPortableSettingsSnapshot(configStore.load());
|
||||||
const created = createOnlineBackup(snapshot, app.getVersion());
|
const created = createOnlineBackup(snapshot, app.getVersion());
|
||||||
await uploadOnlineBackup(created.record);
|
await uploadOnlineBackup(created.record);
|
||||||
@@ -2455,18 +2654,37 @@ ipcMain.handle('app:check-updates', async () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
ipcMain.handle('app:install-update', () => {
|
ipcMain.handle('app:install-update', async () => {
|
||||||
try { if (uploadManager) uploadManager.cancel(); } catch {}
|
if (updatePreparationPromise || preparedUpdate || updateQuitPending) {
|
||||||
installUpdate((progress) => {
|
return { started: false, error: 'Ein Update wird bereits vorbereitet' };
|
||||||
safeSend('app:update-progress', progress);
|
}
|
||||||
}).catch((err) => {
|
updatePreparationPromise = (async () => {
|
||||||
safeSend('app:update-progress', { stage: 'error', error: err.message });
|
const prepared = await prepareUpdate((progress) => {
|
||||||
});
|
safeSend('app:update-progress', progress);
|
||||||
return { started: true };
|
});
|
||||||
|
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', () => {
|
ipcMain.handle('app:abort-update', () => {
|
||||||
abortUpdate();
|
abortUpdate();
|
||||||
|
rejectPendingUpdate(new Error('Update abgebrochen'));
|
||||||
return true;
|
return true;
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -2475,7 +2693,7 @@ ipcMain.handle('app:get-version', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
ipcMain.handle('app:restart', () => {
|
ipcMain.handle('app:restart', () => {
|
||||||
app.relaunch();
|
restartAfterClosePreparation = true;
|
||||||
app.quit();
|
app.quit();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -2483,6 +2701,53 @@ ipcMain.handle('app:quit', () => {
|
|||||||
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 ---
|
// --- Hoster settings ---
|
||||||
ipcMain.handle('get-hoster-settings', () => {
|
ipcMain.handle('get-hoster-settings', () => {
|
||||||
const config = configStore.load();
|
const config = configStore.load();
|
||||||
@@ -2490,8 +2755,11 @@ ipcMain.handle('get-hoster-settings', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
ipcMain.handle('save-hoster-settings', async (_event, hosterSettings) => {
|
ipcMain.handle('save-hoster-settings', async (_event, hosterSettings) => {
|
||||||
|
assertConfigWriteAllowed();
|
||||||
await configStore.save({ hosterSettings });
|
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;
|
return true;
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -2501,22 +2769,19 @@ ipcMain.handle('get-global-settings', () => {
|
|||||||
return config.globalSettings || {};
|
return config.globalSettings || {};
|
||||||
});
|
});
|
||||||
|
|
||||||
function _preserveDiagSubtree(globalSettings) {
|
ipcMain.handle('save-pending-queue', async (_event, pendingQueue) => {
|
||||||
if (!globalSettings || typeof globalSettings !== 'object') return globalSettings;
|
await configStore.savePendingQueue(pendingQueue);
|
||||||
try {
|
return true;
|
||||||
const cur = configStore.load();
|
});
|
||||||
if (cur.globalSettings && cur.globalSettings.diagnostics) {
|
|
||||||
globalSettings.diagnostics = cur.globalSettings.diagnostics;
|
|
||||||
}
|
|
||||||
} catch {}
|
|
||||||
return globalSettings;
|
|
||||||
}
|
|
||||||
|
|
||||||
ipcMain.handle('save-global-settings', async (_event, globalSettings) => {
|
ipcMain.handle('save-global-settings', async (_event, globalSettings) => {
|
||||||
globalSettings = _preserveDiagSubtree(globalSettings);
|
assertConfigWriteAllowed();
|
||||||
await configStore.save({ globalSettings });
|
await configStore.saveRendererGlobalSettings(globalSettings);
|
||||||
|
globalSettings = configStore.load().globalSettings;
|
||||||
_invalidateLogSettings();
|
_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;
|
return true;
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -2546,62 +2811,6 @@ function _sweepOrphanConfigTmps() {
|
|||||||
} catch {}
|
} 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 ---
|
// --- Folder Monitor ---
|
||||||
function startFolderMonitor(settings) {
|
function startFolderMonitor(settings) {
|
||||||
try {
|
try {
|
||||||
@@ -2809,6 +3018,7 @@ ipcMain.handle('diagnostics:get-settings', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
ipcMain.handle('diagnostics:save-settings', async (_e, incoming) => {
|
ipcMain.handle('diagnostics:save-settings', async (_e, incoming) => {
|
||||||
|
assertConfigWriteAllowed();
|
||||||
const cfg = configStore.load();
|
const cfg = configStore.load();
|
||||||
const cur = (cfg.globalSettings && cfg.globalSettings.diagnostics) || {};
|
const cur = (cfg.globalSettings && cfg.globalSettings.diagnostics) || {};
|
||||||
const next = {
|
const next = {
|
||||||
@@ -2830,6 +3040,7 @@ ipcMain.handle('diagnostics:save-settings', async (_e, incoming) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
ipcMain.handle('diagnostics:regenerate', async () => {
|
ipcMain.handle('diagnostics:regenerate', async () => {
|
||||||
|
assertConfigWriteAllowed();
|
||||||
const cfg = configStore.load();
|
const cfg = configStore.load();
|
||||||
const cur = (cfg.globalSettings && cfg.globalSettings.diagnostics) || {};
|
const cur = (cfg.globalSettings && cfg.globalSettings.diagnostics) || {};
|
||||||
const next = { ...cur, token: generateToken(), codeIssuedAt: Date.now() };
|
const next = { ...cur, token: generateToken(), codeIssuedAt: Date.now() };
|
||||||
@@ -2908,32 +3119,38 @@ async function startRemoteServer() {
|
|||||||
|
|
||||||
let token = remote.token;
|
let token = remote.token;
|
||||||
if (!token) {
|
if (!token) {
|
||||||
token = generateToken();
|
const canonical = await configStore.saveRemoteSettings(remote, generateToken);
|
||||||
const gs = { ...config.globalSettings, remote: { ...remote, token } };
|
token = canonical.token;
|
||||||
await configStore.save({ globalSettings: gs });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
remoteServer = new RemoteServer();
|
remoteServer = new RemoteServer();
|
||||||
await remoteServer.start({
|
try {
|
||||||
port: remote.port || 9100,
|
await remoteServer.start({
|
||||||
token,
|
port: remote.port || 9100,
|
||||||
allowInput: remote.allowInput !== false,
|
token,
|
||||||
mainWindow,
|
allowInput: remote.allowInput !== false,
|
||||||
onSignalingToCapture: (data) => {
|
mainWindow,
|
||||||
if (!captureWindow || captureWindow.isDestroyed()) {
|
onSignalingToCapture: (data) => {
|
||||||
debugLog('remote: signaling dropped, no capture window');
|
if (!captureWindow || captureWindow.isDestroyed()) {
|
||||||
return;
|
debugLog('remote: signaling dropped, no capture window');
|
||||||
}
|
return;
|
||||||
if (captureWindowReady) {
|
}
|
||||||
captureWindow.webContents.send('remote:signaling-to-capture', data);
|
if (captureWindowReady) {
|
||||||
} else {
|
captureWindow.webContents.send('remote:signaling-to-capture', data);
|
||||||
debugLog('remote: capture window not ready, queuing', data.type, 'message');
|
} else {
|
||||||
signalingQueue.push(data);
|
debugLog('remote: capture window not ready, queuing', data.type, 'message');
|
||||||
}
|
signalingQueue.push(data);
|
||||||
},
|
}
|
||||||
onCreateCaptureWindow: () => createCaptureWindow(),
|
},
|
||||||
onDestroyCaptureWindow: () => destroyCaptureWindow()
|
onCreateCaptureWindow: () => createCaptureWindow(),
|
||||||
});
|
onDestroyCaptureWindow: () => destroyCaptureWindow()
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
try { remoteServer.stop(); } catch {}
|
||||||
|
remoteServer = null;
|
||||||
|
destroyCaptureWindow();
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
debugLog(`remote-server started on port ${remoteServer.getPort()}`);
|
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) => {
|
ipcMain.handle('remote:save-settings', async (_event, remoteSettings) => {
|
||||||
const config = configStore.load();
|
assertConfigWriteAllowed();
|
||||||
const gs = { ...config.globalSettings, remote: remoteSettings };
|
const canonicalSettings = await configStore.saveRemoteSettings(remoteSettings, generateToken);
|
||||||
await configStore.save({ globalSettings: gs });
|
|
||||||
|
|
||||||
if (remoteSettings.enabled) {
|
let runtimeError = '';
|
||||||
await startRemoteServer();
|
try {
|
||||||
} else if (remoteServer) {
|
if (canonicalSettings.enabled) {
|
||||||
remoteServer.stop();
|
await startRemoteServer();
|
||||||
remoteServer = null;
|
} else if (remoteServer) {
|
||||||
destroyCaptureWindow();
|
remoteServer.stop();
|
||||||
debugLog('remote-server stopped');
|
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', () => {
|
ipcMain.handle('remote:generate-token', () => {
|
||||||
@@ -3095,6 +3320,7 @@ ipcMain.handle('remote:status', () => {
|
|||||||
|
|
||||||
// --- Always on top ---
|
// --- Always on top ---
|
||||||
ipcMain.handle('set-always-on-top', async (_event, value) => {
|
ipcMain.handle('set-always-on-top', async (_event, value) => {
|
||||||
|
assertConfigWriteAllowed();
|
||||||
if (mainWindow && !mainWindow.isDestroyed()) {
|
if (mainWindow && !mainWindow.isDestroyed()) {
|
||||||
mainWindow.setAlwaysOnTop(!!value);
|
mainWindow.setAlwaysOnTop(!!value);
|
||||||
}
|
}
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "multi-hoster-uploader",
|
"name": "multi-hoster-uploader",
|
||||||
"version": "2.0.5",
|
"version": "2.0.6",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "multi-hoster-uploader",
|
"name": "multi-hoster-uploader",
|
||||||
"version": "2.0.5",
|
"version": "2.0.6",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"chokidar": "^3.6.0",
|
"chokidar": "^3.6.0",
|
||||||
"undici": "^7.29.0",
|
"undici": "^7.29.0",
|
||||||
|
|||||||
+3
-3
@@ -1,15 +1,15 @@
|
|||||||
{
|
{
|
||||||
"name": "multi-hoster-uploader",
|
"name": "multi-hoster-uploader",
|
||||||
"version": "2.0.5",
|
"version": "2.0.6",
|
||||||
"description": "Upload files to doodstream, voe, vidmoly, byse simultaneously",
|
"description": "Upload files to doodstream, voe, vidmoly, byse simultaneously",
|
||||||
"main": "main.js",
|
"main": "main.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "electron .",
|
"start": "electron .",
|
||||||
"test": "node --test tests/*.test.js tests/ui-smoke.js",
|
"test": "node --test tests/*.test.js tests/ui-smoke.js",
|
||||||
"test:backup-api": "npm --prefix services/backup-api test",
|
"test:backup-api": "npm --prefix services/backup-api test",
|
||||||
|
"lint": "eslint .",
|
||||||
"dist": "electron-builder --win",
|
"dist": "electron-builder --win",
|
||||||
"release:win": "electron-builder --publish never --win nsis portable",
|
"release:win": "electron-builder --publish never --win nsis portable"
|
||||||
"release:gitea": "node scripts/release_gitea.mjs"
|
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"chokidar": "^3.6.0",
|
"chokidar": "^3.6.0",
|
||||||
|
|||||||
+10
-1
@@ -17,7 +17,15 @@ contextBridge.exposeInMainWorld('api', {
|
|||||||
// Global settings
|
// Global settings
|
||||||
getGlobalSettings: () => ipcRenderer.invoke('get-global-settings'),
|
getGlobalSettings: () => ipcRenderer.invoke('get-global-settings'),
|
||||||
saveGlobalSettings: (settings) => ipcRenderer.invoke('save-global-settings', 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
|
// Always on top
|
||||||
setAlwaysOnTop: (value) => ipcRenderer.invoke('set-always-on-top', value),
|
setAlwaysOnTop: (value) => ipcRenderer.invoke('set-always-on-top', value),
|
||||||
@@ -155,6 +163,7 @@ contextBridge.exposeInMainWorld('api', {
|
|||||||
ipcRenderer.removeAllListeners('upload-stats');
|
ipcRenderer.removeAllListeners('upload-stats');
|
||||||
ipcRenderer.removeAllListeners('app:update-available');
|
ipcRenderer.removeAllListeners('app:update-available');
|
||||||
ipcRenderer.removeAllListeners('app:update-progress');
|
ipcRenderer.removeAllListeners('app:update-progress');
|
||||||
|
ipcRenderer.removeAllListeners('app:prepare-close');
|
||||||
ipcRenderer.removeAllListeners('shutdown-countdown');
|
ipcRenderer.removeAllListeners('shutdown-countdown');
|
||||||
ipcRenderer.removeAllListeners('folder-monitor:new-files');
|
ipcRenderer.removeAllListeners('folder-monitor:new-files');
|
||||||
ipcRenderer.removeAllListeners('drop-target: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;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
border: 2px dashed rgba(126, 220, 255, 0.5);
|
border: 1px dashed rgba(186, 208, 252, 0.62);
|
||||||
border-radius: 10px;
|
border-radius: 8px;
|
||||||
background: rgba(22, 24, 28, 0.85);
|
background: rgba(35, 35, 35, 0.94);
|
||||||
transition: border-color 0.15s, background 0.15s;
|
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 {
|
.target.drag-over {
|
||||||
border-color: rgba(126, 220, 255, 0.9);
|
border-color: #bad0fc;
|
||||||
background: rgba(62, 167, 255, 0.15);
|
background: rgba(51, 52, 54, 0.98);
|
||||||
|
transform: scale(0.98);
|
||||||
}
|
}
|
||||||
.icon {
|
.icon {
|
||||||
font-size: 64px;
|
width: 54px;
|
||||||
font-weight: 200;
|
height: 54px;
|
||||||
color: rgba(126, 220, 255, 0.7);
|
display: grid;
|
||||||
line-height: 1;
|
place-items: center;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #2b2b2b;
|
||||||
|
color: #bad0fc;
|
||||||
-webkit-app-region: no-drag;
|
-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>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div class="target" id="target">
|
<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>
|
</div>
|
||||||
<script>
|
<script>
|
||||||
const target = document.getElementById('target');
|
const target = document.getElementById('target');
|
||||||
|
|||||||
+267
-80
@@ -3,102 +3,225 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; style-src 'self' 'unsafe-inline';">
|
<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>
|
<title>Multi-Hoster-Upload</title>
|
||||||
<link rel="stylesheet" href="styles.css">
|
<link rel="stylesheet" href="styles.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<nav class="menu-bar" id="menuBar">
|
<svg class="icon-sprite" aria-hidden="true">
|
||||||
<div class="menu-bar-item" data-menu="datei">
|
<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>
|
||||||
<button class="menu-bar-trigger" data-menu-trigger="datei">Datei</button>
|
<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>
|
||||||
<div class="menu-dropdown" data-menu-dropdown="datei" style="display:none">
|
<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>
|
||||||
<button class="menu-dropdown-item" data-menu-action="add-files"><span>Dateien hinzufügen</span></button>
|
<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>
|
||||||
<button class="menu-dropdown-item" data-menu-action="add-folder"><span>Ordner hinzufügen</span></button>
|
<symbol id="icon-menu" viewBox="0 0 24 24"><path d="M4 6h16M4 12h16M4 18h16"/></symbol>
|
||||||
<div class="menu-separator"></div>
|
<symbol id="icon-sliders" viewBox="0 0 24 24"><path d="M4 7h10m4 0h2M4 17h2m4 0h10M14 4v6M10 14v6"/></symbol>
|
||||||
<div class="menu-submenu" data-submenu="sicherung">
|
<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>
|
||||||
<button class="menu-submenu-trigger">Sicherung</button>
|
<symbol id="icon-download" viewBox="0 0 24 24"><path d="M12 4v11m0 0 4-4m-4 4-4-4M5 19h14"/></symbol>
|
||||||
<div class="menu-submenu-dropdown" style="display:none">
|
<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>
|
||||||
<button class="menu-dropdown-item" data-menu-action="backup-export"><span>Exportieren</span></button>
|
<symbol id="icon-check" viewBox="0 0 24 24"><path d="m5 12 4 4L19 6"/></symbol>
|
||||||
<button class="menu-dropdown-item" data-menu-action="backup-import"><span>Importieren</span></button>
|
<symbol id="icon-clock" viewBox="0 0 24 24"><circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 2"/></symbol>
|
||||||
<button class="menu-dropdown-item" data-menu-action="online-backup-create"><span>Online-Schlüssel erstellen</span></button>
|
<symbol id="icon-alert" viewBox="0 0 24 24"><path d="M12 3 2.8 20h18.4L12 3Zm0 6v5m0 3h.01"/></symbol>
|
||||||
<button class="menu-dropdown-item" data-menu-action="online-backup-restore"><span>Online-Schlüssel importieren</span></button>
|
<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>
|
</div>
|
||||||
<div class="menu-separator"></div>
|
<div class="menu-bar-item" data-menu="einstellungen">
|
||||||
<button class="menu-dropdown-item" data-menu-action="restart"><span>Neustart</span></button>
|
<button class="menu-bar-trigger" data-menu-trigger="einstellungen" title="Schnelleinstellungen" aria-label="Schnelleinstellungen">
|
||||||
<button class="menu-dropdown-item" data-menu-action="quit"><span>Beenden</span></button>
|
<svg class="header-action-icon" aria-hidden="true"><use href="#icon-sliders"></use></svg>
|
||||||
</div>
|
<span class="menu-label">Einstellungen</span>
|
||||||
</div>
|
</button>
|
||||||
<div class="menu-bar-item" data-menu="einstellungen">
|
<div class="menu-dropdown" data-menu-dropdown="einstellungen" style="display:none">
|
||||||
<button class="menu-bar-trigger" data-menu-trigger="einstellungen">Einstellungen</button>
|
<button class="menu-dropdown-item" data-menu-action="open-settings"><span>Einstellungen öffnen</span></button>
|
||||||
<div class="menu-dropdown" data-menu-dropdown="einstellungen" style="display:none">
|
<div class="menu-separator"></div>
|
||||||
<button class="menu-dropdown-item" data-menu-action="open-settings"><span>Einstellungen öffnen</span></button>
|
<div class="menu-settings-grid" id="menuSettingsGrid">
|
||||||
<div class="menu-separator"></div>
|
<span>Max. parallele Uploads</span>
|
||||||
<div class="menu-settings-grid" id="menuSettingsGrid">
|
<span></span>
|
||||||
<span>Max. parallele Uploads</span>
|
<div class="menu-spinner">
|
||||||
<span></span>
|
<input type="text" inputmode="numeric" id="menuParallelInput" name="parallelUploads" autocomplete="off" aria-label="Maximale parallele Uploads">
|
||||||
<div class="menu-spinner">
|
<div class="menu-spinner-arrows">
|
||||||
<input type="text" inputmode="numeric" id="menuParallelInput">
|
<button data-spin="parallel-up" aria-label="Parallele Uploads erhöhen">▲</button>
|
||||||
<div class="menu-spinner-arrows">
|
<button data-spin="parallel-down" aria-label="Parallele Uploads verringern">▼</button>
|
||||||
<button data-spin="parallel-up">▲</button>
|
</div>
|
||||||
<button data-spin="parallel-down">▼</button>
|
</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>
|
||||||
</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>
|
||||||
|
<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>
|
</div>
|
||||||
<div class="menu-bar-item" data-menu="hilfe">
|
</header>
|
||||||
<button class="menu-bar-trigger" data-menu-trigger="hilfe">Hilfe</button>
|
|
||||||
<div class="menu-dropdown" data-menu-dropdown="hilfe" style="display:none">
|
<div id="updateBanner" class="update-overlay" style="display:none" aria-hidden="true">
|
||||||
<button class="menu-dropdown-item" data-menu-action="open-log-folder"><span>Log-Ordner öffnen</span></button>
|
<section class="update-dialog" role="dialog" aria-modal="true" aria-labelledby="updateDialogTitle" aria-describedby="updateMessage" tabindex="-1">
|
||||||
<button class="menu-dropdown-item" data-menu-action="support-bundle"><span>Diagnose-Paket exportieren</span></button>
|
<button class="update-close-button" id="updateCloseBtn" aria-label="Schließen">
|
||||||
<div class="menu-separator"></div>
|
<svg aria-hidden="true"><use href="#icon-close"></use></svg>
|
||||||
<button class="menu-dropdown-item" data-menu-action="check-updates"><span>Suche Aktualisierungen</span></button>
|
</button>
|
||||||
|
<div class="update-dialog-icon" aria-hidden="true">
|
||||||
|
<svg><use href="#icon-download"></use></svg>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<div class="update-dialog-copy">
|
||||||
</nav>
|
<h2 id="updateDialogTitle">Eine neue Version ist verfügbar</h2>
|
||||||
|
<p id="updateMessage"></p>
|
||||||
<nav class="tab-bar" role="tablist" aria-label="Hauptbereiche">
|
</div>
|
||||||
<button class="tab active" id="upload-tab" role="tab" aria-selected="true" aria-controls="upload-view" tabindex="0" data-view="upload">Upload</button>
|
<div class="update-release-notes" id="updateReleaseNotes" hidden></div>
|
||||||
<button class="tab" id="accounts-tab" role="tab" aria-selected="false" aria-controls="accounts-view" tabindex="-1" data-view="accounts">Accounts</button>
|
<div class="update-progress" aria-live="polite">
|
||||||
<button class="tab" id="settings-tab" role="tab" aria-selected="false" aria-controls="settings-view" tabindex="-1" data-view="settings">Einstellungen</button>
|
<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>
|
||||||
<button class="tab" id="history-tab" role="tab" aria-selected="false" aria-controls="history-view" tabindex="-1" data-view="history">Verlauf</button>
|
<span id="updateProgressText"></span>
|
||||||
<span class="version-label" id="versionLabel"></span>
|
</div>
|
||||||
</nav>
|
<div class="update-dialog-actions">
|
||||||
|
<button class="btn btn-secondary" id="dismissUpdateBtn">Später erinnern</button>
|
||||||
<div id="updateBanner" class="update-banner" style="display:none">
|
<button class="btn btn-primary" id="installUpdateBtn">Jetzt updaten</button>
|
||||||
<span id="updateMessage"></span>
|
</div>
|
||||||
<button class="btn btn-sm btn-primary" id="installUpdateBtn">Update installieren</button>
|
</section>
|
||||||
<button class="btn btn-sm btn-secondary" id="dismissUpdateBtn">×</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="upload-view" class="view active" role="tabpanel" aria-labelledby="upload-tab">
|
<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">
|
<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>
|
||||||
<div class="toolbar-right">
|
<div class="toolbar-right">
|
||||||
<button class="btn btn-xs btn-primary" id="addFilesBtn">+ Dateien</button>
|
<button class="btn btn-xs btn-primary" id="addFilesBtn">+ Dateien</button>
|
||||||
<button class="btn btn-xs btn-secondary" id="addFolderBtn">+ Ordner</button>
|
<button class="btn btn-xs btn-secondary" id="addFolderBtn">+ Ordner</button>
|
||||||
</div>
|
</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>
|
||||||
|
|
||||||
|
<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-shell" id="queueShell" style="display:none">
|
||||||
<div class="queue-command-bar" id="queueCommandBar">
|
<div class="queue-command-bar" id="queueCommandBar">
|
||||||
<button class="toolbar-btn" id="startUploadBtn" title="Alle Uploads starten" aria-label="Alle Uploads starten" disabled>
|
<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>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="accounts-view" class="view" role="tabpanel" aria-labelledby="accounts-tab">
|
<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 class="accounts-header">
|
||||||
<div>
|
<div>
|
||||||
<h2>Accounts</h2>
|
<h2>Accounts</h2>
|
||||||
@@ -242,7 +399,7 @@
|
|||||||
<div class="accounts-list-footer" id="accountsListFooter" style="display:none">
|
<div class="accounts-list-footer" id="accountsListFooter" style="display:none">
|
||||||
<button class="btn btn-secondary" id="toggleAllAccountsBtn">Alle ausklappen</button>
|
<button class="btn btn-secondary" id="toggleAllAccountsBtn">Alle ausklappen</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="modal-overlay" id="accountModal" style="display:none">
|
<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>
|
<p class="settings-hint">Alle Optionen nach Aufgaben sortiert. Änderungen werden automatisch gespeichert.</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="settings-save-row">
|
<div class="settings-save-row">
|
||||||
<span class="save-feedback" id="saveFeedback">Automatisch gespeichert</span>
|
|
||||||
<button class="btn btn-secondary" id="saveSettingsBtn">Jetzt speichern</button>
|
<button class="btn btn-secondary" id="saveSettingsBtn">Jetzt speichern</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -322,10 +478,41 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="history-view" class="view" role="tabpanel" aria-labelledby="history-tab">
|
<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">
|
<div class="history-header">
|
||||||
<h2>Upload-Verlauf</h2>
|
<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>
|
<label for="historyRetentionSelect" class="history-retention-label">Aufbewahrung</label>
|
||||||
<select id="historyRetentionSelect" class="key-input history-retention-select">
|
<select id="historyRetentionSelect" class="key-input history-retention-select">
|
||||||
<option value="all">Alles behalten</option>
|
<option value="all">Alles behalten</option>
|
||||||
@@ -341,7 +528,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div id="historyCapNotice" class="history-cap-notice" style="display:none"></div>
|
<div id="historyCapNotice" class="history-cap-notice" style="display:none"></div>
|
||||||
<div id="historyContainer"></div>
|
<div id="historyContainer"></div>
|
||||||
</div>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="context-menu" id="contextMenu" style="display:none">
|
<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 path from 'node:path';
|
||||||
import process from 'node:process';
|
import process from 'node:process';
|
||||||
import { fileURLToPath } from 'node:url';
|
import { fileURLToPath } from 'node:url';
|
||||||
|
|
||||||
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||||
const args = process.argv.slice(2);
|
const args = process.argv.slice(2);
|
||||||
const sourceOnly = args.includes('--source-only');
|
|
||||||
const failures = new Map();
|
const failures = new Map();
|
||||||
|
const sourceFiles = [
|
||||||
const requiredFiles = [
|
|
||||||
'.gitignore',
|
'.gitignore',
|
||||||
'README.md',
|
'README.md',
|
||||||
'SECURITY.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.ico',
|
||||||
'assets/app_icon.png',
|
'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/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 screenshotFile = 'assets/product-overview.png';
|
||||||
const allowedFiles = new Set([...requiredFiles, 'assets/product-overview.png']);
|
const allowedFiles = new Set([...sourceFiles, screenshotFile]);
|
||||||
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 textExtensions = new Set(['.cjs', '.css', '.html', '.js', '.json', '.md', '.mjs', '.txt', '.yaml', '.yml']);
|
const textExtensions = new Set(['.cjs', '.css', '.html', '.js', '.json', '.md', '.mjs', '.txt', '.yaml', '.yml']);
|
||||||
const binaryExtensions = new Set(['.ico', '.png']);
|
const binaryExtensions = new Set(['.ico', '.png']);
|
||||||
const expectedScripts = {
|
const expectedScripts = {
|
||||||
start: 'electron .',
|
start: 'electron .',
|
||||||
test: 'node --test tests/*.test.js tests/ui-smoke.js',
|
test: 'node --test tests/*.test.js tests/ui-smoke.js',
|
||||||
|
'test:backup-api': 'npm --prefix services/backup-api test',
|
||||||
lint: 'eslint .',
|
lint: 'eslint .',
|
||||||
dist: 'electron-builder --win',
|
dist: 'electron-builder --win',
|
||||||
'release:win': 'electron-builder --publish never --win nsis portable'
|
'release:win': 'electron-builder --publish never --win nsis portable'
|
||||||
@@ -71,6 +141,19 @@ const expectedBuildFiles = [
|
|||||||
'assets/app_icon.ico',
|
'assets/app_icon.ico',
|
||||||
'assets/app_icon.png'
|
'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 = [
|
const aiTerms = [
|
||||||
['clau', 'de'].join(''),
|
['clau', 'de'].join(''),
|
||||||
['co', 'dex'].join(''),
|
['co', 'dex'].join(''),
|
||||||
@@ -80,9 +163,17 @@ const personalTerms = [
|
|||||||
['pl', 'oet'].join(''),
|
['pl', 'oet'].join(''),
|
||||||
['baker', 'edwin318'].join('')
|
['baker', 'edwin318'].join('')
|
||||||
].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 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 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([
|
const updaterOnlyPattern = new RegExp([
|
||||||
['gi', 'tea'].join(''),
|
['gi', 'tea'].join(''),
|
||||||
['git', '24-music', 'de'].join('\\.'),
|
['git', '24-music', 'de'].join('\\.'),
|
||||||
@@ -98,10 +189,20 @@ function normalizeRelative(value) {
|
|||||||
return value.split(path.sep).join('/');
|
return value.split(path.sep).join('/');
|
||||||
}
|
}
|
||||||
|
|
||||||
function isAllowedFile(relativePath) {
|
function buildAllowedDirectories(files) {
|
||||||
return allowedFiles.has(relativePath) || allowedPrefixes.some((prefix) => relativePath.startsWith(prefix));
|
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) {
|
function isDeniedBasename(basename) {
|
||||||
const lower = basename.toLowerCase();
|
const lower = basename.toLowerCase();
|
||||||
return deniedBasenames.has(lower)
|
return deniedBasenames.has(lower)
|
||||||
@@ -109,18 +210,50 @@ function isDeniedBasename(basename) {
|
|||||||
|| /\.(?:bak|db|log|sqlite|sqlite3|tmp)$/i.test(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 = '') {
|
async function enumerate(directory = root, relativeDirectory = '') {
|
||||||
const entries = await readdir(directory, { withFileTypes: true });
|
const entries = await readdir(directory, { withFileTypes: true });
|
||||||
const files = [];
|
const files = [];
|
||||||
|
|
||||||
for (const entry of entries) {
|
for (const entry of entries) {
|
||||||
const relativePath = normalizeRelative(path.join(relativeDirectory, entry.name));
|
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 (entry.isDirectory()) {
|
||||||
if (ignoredDirectories.has(lowerName)) continue;
|
if (!allowedDirectories.has(relativePath)) {
|
||||||
if (deniedDirectories.has(lowerName)) addFailure(relativePath, 'denied-directory');
|
addFailure(relativePath, 'source-layout-allowlist');
|
||||||
files.push(...await enumerate(path.join(directory, entry.name), relativePath));
|
continue;
|
||||||
|
}
|
||||||
|
files.push(...await enumerate(absolutePath, relativePath));
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -130,7 +263,7 @@ async function enumerate(directory = root, relativeDirectory = '') {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (isDeniedBasename(entry.name)) addFailure(relativePath, 'denied-basename');
|
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);
|
files.push(relativePath);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -163,34 +296,28 @@ async function validateTextFiles(files) {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
const text = await readFile(path.join(root, relativePath), 'utf8');
|
const value = await readFile(path.join(root, relativePath), 'utf8');
|
||||||
if (forbiddenPersonalPattern.test(text)) addFailure(relativePath, 'forbidden-personal-term');
|
if (forbiddenPersonalPattern.test(value)) addFailure(relativePath, 'forbidden-personal-term');
|
||||||
if (forbiddenAiPattern.test(text)) addFailure(relativePath, 'forbidden-ai-term');
|
if (forbiddenAiPattern.test(value)) addFailure(relativePath, 'forbidden-ai-term');
|
||||||
if (forbiddenInvestigationPattern.test(text)) addFailure(relativePath, 'forbidden-investigation-term');
|
if (forbiddenInternalPattern.test(value)) addFailure(relativePath, 'forbidden-internal-term');
|
||||||
if (relativePath !== 'lib/updater.js' && updaterOnlyPattern.test(text)) addFailure(relativePath, 'updater-endpoint-scope');
|
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) return;
|
||||||
|
if (packageJson.version !== expectedVersion) addFailure('package.json', 'package-version-target');
|
||||||
if (packageJson.version !== '3.3.108') addFailure('package.json', 'package-version');
|
|
||||||
if (stableJson(packageJson.scripts) !== stableJson(expectedScripts)) addFailure('package.json', 'package-script-allowlist');
|
if (stableJson(packageJson.scripts) !== stableJson(expectedScripts)) addFailure('package.json', 'package-script-allowlist');
|
||||||
|
|
||||||
const buildFiles = packageJson.build?.files;
|
const buildFiles = packageJson.build?.files;
|
||||||
if (!Array.isArray(buildFiles) || stableJson(buildFiles) !== stableJson(expectedBuildFiles)) {
|
if (!Array.isArray(buildFiles) || stableJson(buildFiles) !== stableJson(expectedBuildFiles)) {
|
||||||
addFailure('package.json', 'build-file-allowlist');
|
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 (packageJson.build?.afterPack !== 'scripts/afterPack.cjs') addFailure('scripts/afterPack.cjs', 'build-hook-entry');
|
||||||
|
|
||||||
if (packageLock) {
|
if (packageLock) {
|
||||||
const lockRoot = packageLock.packages?.[''];
|
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');
|
addFailure('package-lock.json', 'package-lock-version');
|
||||||
}
|
}
|
||||||
if (!lockRoot
|
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() {
|
function printFailures() {
|
||||||
for (const file of [...failures.keys()].sort()) {
|
for (const file of [...failures.keys()].sort()) {
|
||||||
for (const rule of [...failures.get(file)].sort()) {
|
for (const rule of [...failures.get(file)].sort()) {
|
||||||
@@ -220,23 +373,21 @@ function printFailures() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function main() {
|
async function main() {
|
||||||
if (args.some((arg) => arg !== '--source-only') || args.filter((arg) => arg === '--source-only').length > 1) {
|
const { sourceOnly, expectedVersion } = parseArguments();
|
||||||
addFailure('scripts/verify-public-release.mjs', 'argument-allowlist');
|
|
||||||
}
|
|
||||||
|
|
||||||
const files = await enumerate();
|
const files = await enumerate();
|
||||||
|
const requiredFiles = sourceOnly ? sourceFiles : [...sourceFiles, screenshotFile];
|
||||||
for (const requiredFile of requiredFiles) {
|
for (const requiredFile of requiredFiles) {
|
||||||
if (!files.includes(requiredFile)) addFailure(requiredFile, 'required-source-file');
|
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);
|
await validateTextFiles(files);
|
||||||
const packageJson = await readJson('package.json', 'package-json');
|
const packageJson = await readJson('package.json', 'package-json');
|
||||||
const packageLock = await readJson('package-lock.json', 'package-lock-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) {
|
if (failures.size > 0) {
|
||||||
printFailures();
|
printFailures();
|
||||||
@@ -244,7 +395,7 @@ async function main() {
|
|||||||
return;
|
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(() => {
|
main().catch(() => {
|
||||||
|
|||||||
@@ -31,6 +31,36 @@ describe('ConfigStore', () => {
|
|||||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
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', () => {
|
it('load returns defaults when file does not exist', () => {
|
||||||
const config = store.load();
|
const config = store.load();
|
||||||
assert.ok(config.hosters);
|
assert.ok(config.hosters);
|
||||||
@@ -254,6 +284,84 @@ describe('ConfigStore', () => {
|
|||||||
assert.equal(config.globalSettings.alwaysOnTop, true);
|
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 () => {
|
it('serializes a complete settings replacement with pending saves', async () => {
|
||||||
const originalAtomicWrite = store._atomicWrite.bind(store);
|
const originalAtomicWrite = store._atomicWrite.bind(store);
|
||||||
let activeWrites = 0;
|
let activeWrites = 0;
|
||||||
@@ -288,6 +396,103 @@ describe('ConfigStore', () => {
|
|||||||
assert.deepEqual(config.rotationCursors, {});
|
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', () => {
|
it('load() returns independent clones — mutating one result must not leak into the cache', () => {
|
||||||
store.load(); // warm the cache
|
store.load(); // warm the cache
|
||||||
const a = store.load();
|
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');
|
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 () => {
|
it('pruneHistory trims history.json and persists the retention setting', async () => {
|
||||||
writeConfigWithHistory(12);
|
writeConfigWithHistory(12);
|
||||||
s._migrateHistory();
|
s._migrateHistory();
|
||||||
@@ -440,4 +657,114 @@ describe('ConfigStore history split (electron-history.json)', () => {
|
|||||||
assert.equal(s.loadHistory().length, 12);
|
assert.equal(s.loadHistory().length, 12);
|
||||||
assert.ok(res.keptBatches === 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;
|
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 })];
|
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', () => {
|
test('retention "all" returns the array unchanged', () => {
|
||||||
|
|||||||
@@ -1,8 +1,55 @@
|
|||||||
const test = require('node:test');
|
const test = require('node:test');
|
||||||
const assert = require('node:assert/strict');
|
const assert = require('node:assert/strict');
|
||||||
|
const Module = require('node:module');
|
||||||
const packageJson = require('../package.json');
|
const packageJson = require('../package.json');
|
||||||
|
|
||||||
test('packages every Electron preload referenced by the main process', () => {
|
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.js'));
|
||||||
assert.ok(packageJson.build.files.includes('preload-drop-target.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 test = require('node:test');
|
||||||
const assert = require('node:assert/strict');
|
const assert = require('node:assert/strict');
|
||||||
const path = require('node:path');
|
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 { 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', () => {
|
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');
|
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);
|
assert.equal(isNewer('2.0.2', '2.0.1'), true);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('release CLI rejects a malformed transport tag before release work', () => {
|
test('release arguments reject a malformed transport tag', async () => {
|
||||||
const script = path.resolve(__dirname, '../scripts/release_gitea.mjs');
|
const { parseReleaseArgs } = await import(releasePlanUrl);
|
||||||
const result = spawnSync(process.execPath, [script, '2.0.1', '--transport-tag', '3.3.109', 'Bridge', '--dry-run'], {
|
assert.throws(
|
||||||
cwd: path.resolve(__dirname, '..'),
|
() => parseReleaseArgs(['2.0.1', '--transport-tag', '3.3.109', 'Bridge', '--dry-run']),
|
||||||
encoding: 'utf8'
|
/--transport-tag must match vX\.Y\.Z/
|
||||||
});
|
);
|
||||||
|
|
||||||
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 plan keeps product artifacts separate from the transport tag', () => {
|
test('update preparation writes a verified installer without launching it', async () => {
|
||||||
const script = path.resolve(__dirname, '../scripts/release_gitea.mjs');
|
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'mhu-updater-test-'));
|
||||||
const moduleUrl = pathToFileURL(script).href;
|
const installer = Buffer.alloc(128 * 1024, 0);
|
||||||
const source = `
|
installer[0] = 0x4d;
|
||||||
import { createReleasePlan, parseReleaseArgs, renderLatestYml } from ${JSON.stringify(moduleUrl)};
|
installer[1] = 0x5a;
|
||||||
const plan = createReleasePlan(parseReleaseArgs(['2.0.1', '--transport-tag', 'v3.3.109', 'Bridge', 'notes']));
|
let reads = 0;
|
||||||
const latestYml = renderLatestYml(plan, 'abc123', 456, '2026-08-07T12:00:00.000Z');
|
const progress = [];
|
||||||
process.stdout.write(JSON.stringify({
|
try {
|
||||||
version: plan.version,
|
const prepared = await prepareUpdate(value => progress.push(value), {
|
||||||
transportTag: plan.transportTag,
|
checkResult: {
|
||||||
releaseTitle: plan.releaseTitle,
|
available: true,
|
||||||
releaseBody: plan.releaseBody,
|
assetUrl: 'https://update.invalid/setup.exe',
|
||||||
expectedArtifacts: plan.expectedArtifacts,
|
assetName: 'setup.exe',
|
||||||
latestYml
|
assetSize: installer.length,
|
||||||
}));
|
latestYmlUrl: null
|
||||||
`;
|
},
|
||||||
const result = spawnSync(process.execPath, ['--input-type=module', '--eval', source], {
|
tempDir,
|
||||||
cwd: path.resolve(__dirname, '..'),
|
fetchImpl: async () => ({
|
||||||
encoding: 'utf8'
|
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.equal(prepared.installerPath, path.join(tempDir, 'setup.exe'));
|
||||||
assert.deepEqual(JSON.parse(result.stdout), {
|
assert.deepEqual(fs.readFileSync(prepared.installerPath), installer);
|
||||||
version: '2.0.1',
|
assert.equal(progress.at(-1).stage, 'prepared');
|
||||||
transportTag: 'v3.3.109',
|
} finally {
|
||||||
releaseTitle: 'Multi-Hoster-Upload v2.0.1',
|
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||||
releaseBody: 'Bridge notes',
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
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: [
|
expectedArtifacts: [
|
||||||
'Multi-Hoster-Upload Setup 2.0.1.exe',
|
'Multi-Hoster-Upload Setup 2.0.6.exe',
|
||||||
'Multi-Hoster-Upload 2.0.1.exe',
|
'Multi-Hoster-Upload 2.0.6.exe',
|
||||||
|
'Multi-Hoster-Upload Setup 2.0.6.exe.blockmap',
|
||||||
'latest.yml'
|
'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 () => {
|
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(releasePlanUrl);
|
||||||
const { createReleasePlan, parseReleaseArgs, resolveExistingReleaseId } = await import(moduleUrl);
|
|
||||||
const plan = createReleasePlan(parseReleaseArgs(['2.0.1', '--transport-tag', 'v3.3.109', 'Bridge notes']));
|
const plan = createReleasePlan(parseReleaseArgs(['2.0.1', '--transport-tag', 'v3.3.109', 'Bridge notes']));
|
||||||
const release = {
|
const release = {
|
||||||
id: 81,
|
id: 81,
|
||||||
@@ -78,8 +124,7 @@ test('compatible existing release preserves the recovery id', async () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('incompatible existing release title fails closed', 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(releasePlanUrl);
|
||||||
const { createReleasePlan, parseReleaseArgs, resolveExistingReleaseId } = await import(moduleUrl);
|
|
||||||
const plan = createReleasePlan(parseReleaseArgs(['2.0.1', '--transport-tag', 'v3.3.109', 'Bridge notes']));
|
const plan = createReleasePlan(parseReleaseArgs(['2.0.1', '--transport-tag', 'v3.3.109', 'Bridge notes']));
|
||||||
const release = {
|
const release = {
|
||||||
id: 81,
|
id: 81,
|
||||||
|
|||||||
Reference in New Issue
Block a user