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:
+127
-17
@@ -137,10 +137,7 @@ function batchRowCount(batch) {
|
||||
let n = 0;
|
||||
const files = (batch && batch.files) || [];
|
||||
for (const file of files) {
|
||||
for (const result of (file.results || [])) {
|
||||
if (result.status === 'aborted' || result.status === 'error') continue;
|
||||
n++;
|
||||
}
|
||||
n += (file.results || []).length;
|
||||
}
|
||||
return n;
|
||||
}
|
||||
@@ -180,13 +177,19 @@ function applyHistoryRetention(history, retention, nowMs) {
|
||||
|
||||
class ConfigStore {
|
||||
constructor(app) {
|
||||
const dir = app && app.isPackaged
|
||||
const useUserDataDir = app && (
|
||||
app.isPackaged ||
|
||||
(app.commandLine && typeof app.commandLine.hasSwitch === 'function' && app.commandLine.hasSwitch('user-data-dir'))
|
||||
);
|
||||
const dir = useUserDataDir
|
||||
? app.getPath('userData')
|
||||
: path.join(__dirname, '..');
|
||||
this.filePath = path.join(dir, 'electron-config.json');
|
||||
this.historyPath = path.join(dir, 'electron-history.json');
|
||||
this._writeQueue = Promise.resolve(); // Serializes all writes to prevent race conditions
|
||||
this._historyWriteQueue = Promise.resolve();
|
||||
this._pendingWriteOperations = new Set();
|
||||
this._writesQuiesced = false;
|
||||
this._historyMigrated = false;
|
||||
this._cache = null;
|
||||
this._cacheKey = '';
|
||||
@@ -238,9 +241,26 @@ class ConfigStore {
|
||||
});
|
||||
}
|
||||
|
||||
_enqueueHistoryWrite(fn) {
|
||||
this._historyWriteQueue = this._historyWriteQueue.then(fn, fn);
|
||||
return this._historyWriteQueue;
|
||||
_quiescedWriteError() {
|
||||
const error = new Error('Die Anwendung wird gerade beendet');
|
||||
error.code = 'CONFIG_WRITES_QUIESCED';
|
||||
return error;
|
||||
}
|
||||
|
||||
setWritesQuiesced(quiesced) {
|
||||
this._writesQuiesced = !!quiesced;
|
||||
}
|
||||
|
||||
_enqueueHistoryWrite(fn, options = {}) {
|
||||
if (this._writesQuiesced && !options.allowDuringQuiesce) return Promise.reject(this._quiescedWriteError());
|
||||
const operation = this._historyWriteQueue.then(fn, fn);
|
||||
this._pendingWriteOperations.add(operation);
|
||||
this._historyWriteQueue = operation.then(() => undefined, () => undefined);
|
||||
operation.then(
|
||||
() => this._pendingWriteOperations.delete(operation),
|
||||
() => this._pendingWriteOperations.delete(operation)
|
||||
);
|
||||
return operation;
|
||||
}
|
||||
|
||||
_migrateHistory() {
|
||||
@@ -476,11 +496,31 @@ class ConfigStore {
|
||||
return this._atomicWrite(data);
|
||||
}
|
||||
|
||||
_enqueueWrite(fn) {
|
||||
_enqueueWrite(fn, options = {}) {
|
||||
if (this._writesQuiesced && !options.allowDuringQuiesce) return Promise.reject(this._quiescedWriteError());
|
||||
this._wqDepth++;
|
||||
const done = () => { this._wqDepth--; };
|
||||
this._writeQueue = this._writeQueue.then(fn, fn).then(done, done);
|
||||
return this._writeQueue;
|
||||
const operation = this._writeQueue.then(fn, fn);
|
||||
this._pendingWriteOperations.add(operation);
|
||||
this._writeQueue = operation.then(
|
||||
() => { this._wqDepth--; },
|
||||
() => {
|
||||
this._wqDepth--;
|
||||
}
|
||||
);
|
||||
operation.then(
|
||||
() => this._pendingWriteOperations.delete(operation),
|
||||
() => this._pendingWriteOperations.delete(operation)
|
||||
);
|
||||
return operation;
|
||||
}
|
||||
|
||||
async drainWrites() {
|
||||
while (this._pendingWriteOperations.size > 0) {
|
||||
const pending = Array.from(this._pendingWriteOperations);
|
||||
const results = await Promise.allSettled(pending);
|
||||
const failed = results.find(result => result.status === 'rejected');
|
||||
if (failed) throw failed.reason;
|
||||
}
|
||||
}
|
||||
|
||||
_anyHosters(cfg) {
|
||||
@@ -522,6 +562,56 @@ class ConfigStore {
|
||||
});
|
||||
}
|
||||
|
||||
savePendingQueue(pendingQueue, options = {}) {
|
||||
const snapshot = pendingQueue === null || pendingQueue === undefined ? null : this._clone(pendingQueue);
|
||||
return this._enqueueWrite(() => {
|
||||
const current = this.load();
|
||||
current.globalSettings = {
|
||||
...(current.globalSettings || {}),
|
||||
pendingQueue: snapshot
|
||||
};
|
||||
this._guardHosters(current, false);
|
||||
return this._commit(current);
|
||||
}, options);
|
||||
}
|
||||
|
||||
saveRendererGlobalSettings(globalSettings) {
|
||||
const snapshot = this._clone(globalSettings || {});
|
||||
return this._enqueueWrite(() => {
|
||||
const current = this.load();
|
||||
const currentGlobalSettings = current.globalSettings || {};
|
||||
const currentRemote = currentGlobalSettings.remote || {};
|
||||
const incomingRemote = snapshot.remote || {};
|
||||
current.globalSettings = {
|
||||
...snapshot,
|
||||
pendingQueue: currentGlobalSettings.pendingQueue ?? null,
|
||||
diagnostics: this._clone(currentGlobalSettings.diagnostics || {}),
|
||||
historyRetention: currentGlobalSettings.historyRetention || 'all',
|
||||
remote: {
|
||||
...incomingRemote,
|
||||
token: incomingRemote.token || currentRemote.token || ''
|
||||
}
|
||||
};
|
||||
this._guardHosters(current, false);
|
||||
return this._commit(current);
|
||||
});
|
||||
}
|
||||
|
||||
saveRemoteSettings(remoteSettings, createToken) {
|
||||
const incoming = this._clone(remoteSettings || {});
|
||||
return this._enqueueWrite(async () => {
|
||||
const current = this.load();
|
||||
const currentGlobalSettings = current.globalSettings || {};
|
||||
const currentRemote = currentGlobalSettings.remote || {};
|
||||
const token = incoming.token || currentRemote.token || (incoming.enabled && typeof createToken === 'function' ? createToken() : '');
|
||||
const canonical = { ...incoming, token };
|
||||
current.globalSettings = { ...currentGlobalSettings, remote: canonical };
|
||||
this._guardHosters(current, false);
|
||||
await this._commit(current);
|
||||
return this._clone(canonical);
|
||||
});
|
||||
}
|
||||
|
||||
replaceSettings(config) {
|
||||
return this._enqueueWrite(() => {
|
||||
const current = this.load();
|
||||
@@ -604,8 +694,12 @@ class ConfigStore {
|
||||
pruneHistory(retention, opts = {}) {
|
||||
const dryRun = !!opts.dryRun;
|
||||
if (this._historyMigrated) {
|
||||
return this._enqueueHistoryWrite(() => {
|
||||
const current = this._readHistoryFile() || [];
|
||||
return this._enqueueHistoryWrite(async () => {
|
||||
const storedHistory = this._readHistoryFile();
|
||||
if (storedHistory === null && fs.existsSync(this.historyPath)) {
|
||||
throw new Error('Die Verlaufsdatei ist beschädigt und wurde nicht verändert');
|
||||
}
|
||||
const current = storedHistory || [];
|
||||
const beforeBatches = current.length;
|
||||
const beforeRows = countHistoryRows(current);
|
||||
const pruned = applyHistoryRetention(current, retention, Date.now());
|
||||
@@ -616,9 +710,25 @@ class ConfigStore {
|
||||
keptRows: countHistoryRows(pruned)
|
||||
};
|
||||
if (dryRun) return result;
|
||||
return this._writeHistoryFileAtomic(pruned)
|
||||
.then(() => this.save({ globalSettings: { ...this.load().globalSettings, historyRetention: String(retention || 'all') } }))
|
||||
.then(() => result);
|
||||
return this._enqueueWrite(async () => {
|
||||
const config = this.load();
|
||||
const previousGlobalSettings = this._clone(config.globalSettings || {});
|
||||
config.globalSettings = { ...previousGlobalSettings, historyRetention: String(retention || 'all') };
|
||||
this._guardHosters(config, false);
|
||||
await this._commit(config);
|
||||
try {
|
||||
await this._writeHistoryFileAtomic(pruned);
|
||||
} catch (historyError) {
|
||||
config.globalSettings = previousGlobalSettings;
|
||||
try {
|
||||
await this._commit(config);
|
||||
} catch (rollbackError) {
|
||||
throw new AggregateError([historyError, rollbackError], 'Verlauf und Aufbewahrung konnten nicht konsistent gespeichert werden');
|
||||
}
|
||||
throw historyError;
|
||||
}
|
||||
return result;
|
||||
});
|
||||
});
|
||||
}
|
||||
return this._enqueueWrite(() => {
|
||||
|
||||
+36
-33
@@ -14,6 +14,7 @@ let cachedCheckTs = 0;
|
||||
const CACHE_TTL = 10 * 60 * 1000; // 10 min
|
||||
|
||||
let activeAbort = null;
|
||||
const launchedInstallerPaths = new Set();
|
||||
|
||||
function getCurrentVersion() {
|
||||
return app.getVersion();
|
||||
@@ -131,10 +132,10 @@ async function checkForUpdate() {
|
||||
return cachedCheck;
|
||||
}
|
||||
|
||||
async function parseLatestYml(url) {
|
||||
async function parseLatestYml(url, fetchImpl = fetch) {
|
||||
if (!url) return null;
|
||||
try {
|
||||
const res = await fetch(url, { redirect: 'follow' });
|
||||
const res = await fetchImpl(url, { redirect: 'follow' });
|
||||
const text = await res.text();
|
||||
// Extract sha512 from latest.yml
|
||||
const match = text.match(/sha512:\s*([A-Za-z0-9+/=]+)/);
|
||||
@@ -150,17 +151,18 @@ function verifyExeHeader(buf) {
|
||||
return buf[0] === 0x4D && buf[1] === 0x5A; // 'MZ'
|
||||
}
|
||||
|
||||
async function installUpdate(onProgress) {
|
||||
async function prepareUpdate(onProgress, options = {}) {
|
||||
if (activeAbort) activeAbort.abort();
|
||||
activeAbort = new AbortController();
|
||||
const signal = activeAbort.signal;
|
||||
const fetchImpl = options.fetchImpl || fetch;
|
||||
|
||||
try {
|
||||
// Stage: starting
|
||||
if (onProgress) onProgress({ stage: 'starting', percent: 0 });
|
||||
|
||||
// Check or use cached
|
||||
let check = cachedCheck;
|
||||
let check = options.checkResult || cachedCheck;
|
||||
if (!check || !check.available) {
|
||||
check = await checkForUpdate();
|
||||
}
|
||||
@@ -172,10 +174,10 @@ async function installUpdate(onProgress) {
|
||||
}
|
||||
|
||||
// Stage: downloading
|
||||
const tmpDir = app.getPath('temp');
|
||||
const tmpDir = options.tempDir || app.getPath('temp');
|
||||
const installerPath = path.join(tmpDir, check.assetName);
|
||||
|
||||
const res = await fetch(check.assetUrl, {
|
||||
const res = await fetchImpl(check.assetUrl, {
|
||||
method: 'GET',
|
||||
signal,
|
||||
redirect: 'follow'
|
||||
@@ -233,7 +235,7 @@ async function installUpdate(onProgress) {
|
||||
}
|
||||
|
||||
// Optional SHA-512 verification from latest.yml
|
||||
const expectedSha = await parseLatestYml(check.latestYmlUrl);
|
||||
const expectedSha = await parseLatestYml(check.latestYmlUrl, fetchImpl);
|
||||
if (expectedSha) {
|
||||
const actualSha = crypto.createHash('sha512').update(fileBuffer).digest('base64');
|
||||
if (actualSha !== expectedSha) {
|
||||
@@ -248,32 +250,14 @@ async function installUpdate(onProgress) {
|
||||
// Write to disk
|
||||
fs.writeFileSync(installerPath, fileBuffer);
|
||||
|
||||
// Stage: launching
|
||||
if (onProgress) onProgress({ stage: 'launching', percent: 100 });
|
||||
|
||||
const { spawn } = require('child_process');
|
||||
spawn(installerPath, ['/S', '--updated', '--force-run'], {
|
||||
detached: true,
|
||||
stdio: 'ignore'
|
||||
}).unref();
|
||||
|
||||
// Stage: done
|
||||
if (onProgress) onProgress({ stage: 'done', percent: 100 });
|
||||
|
||||
const _doQuit = () => setTimeout(() => app.quit(), 900);
|
||||
const _getActive = () => {
|
||||
try { return globalThis._mhuUploadManagerRef && globalThis._mhuUploadManagerRef.getActiveJobCount ? globalThis._mhuUploadManagerRef.getActiveJobCount() : 0; }
|
||||
catch { return 0; }
|
||||
const prepared = {
|
||||
installerPath,
|
||||
assetName: check.assetName,
|
||||
remoteVersion: check.remoteVersion || '',
|
||||
transportTag: check.transportTag || ''
|
||||
};
|
||||
if (_getActive() > 0) {
|
||||
const POLL_MS = 3000;
|
||||
const poller = setInterval(() => {
|
||||
if (_getActive() === 0) { clearInterval(poller); _doQuit(); }
|
||||
}, POLL_MS);
|
||||
setTimeout(() => { try { clearInterval(poller); } catch {} _doQuit(); }, 30 * 60 * 1000);
|
||||
} else {
|
||||
_doQuit();
|
||||
}
|
||||
if (onProgress) onProgress({ stage: 'prepared', percent: 100 });
|
||||
return prepared;
|
||||
|
||||
} catch (err) {
|
||||
if (onProgress) onProgress({ stage: 'error', error: err.message });
|
||||
@@ -283,6 +267,25 @@ async function installUpdate(onProgress) {
|
||||
}
|
||||
}
|
||||
|
||||
function launchPreparedUpdate(prepared, options = {}) {
|
||||
const installerPath = prepared && typeof prepared.installerPath === 'string' ? prepared.installerPath : '';
|
||||
if (!installerPath) throw new Error('Vorbereitetes Update ist unvollständig');
|
||||
const key = path.resolve(installerPath).toLowerCase();
|
||||
if (launchedInstallerPaths.has(key)) return false;
|
||||
const spawnImpl = options.spawnImpl || require('child_process').spawn;
|
||||
launchedInstallerPaths.add(key);
|
||||
try {
|
||||
spawnImpl(installerPath, ['/S', '--updated', '--force-run'], {
|
||||
detached: true,
|
||||
stdio: 'ignore'
|
||||
}).unref();
|
||||
return true;
|
||||
} catch (error) {
|
||||
launchedInstallerPaths.delete(key);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function abortUpdate() {
|
||||
if (activeAbort) {
|
||||
activeAbort.abort();
|
||||
@@ -290,4 +293,4 @@ function abortUpdate() {
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { checkForUpdate, installUpdate, abortUpdate, isNewer, resolveReleaseVersion };
|
||||
module.exports = { checkForUpdate, prepareUpdate, launchPreparedUpdate, abortUpdate, isNewer, resolveReleaseVersion };
|
||||
|
||||
Reference in New Issue
Block a user