release: v2.1.12 reliability and security hardening
Verify update artifacts with exact metadata and SHA-512, require host-confirmed upload completion before cleanup, harden credentials and backups, improve queue recovery and skipped-state reporting, expand Windows path coverage, and add CI packaging checks.
This commit is contained in:
+184
-59
@@ -349,7 +349,7 @@ let _queueFilterCache = { filter: '', source: null, result: [] };
|
||||
let historyRowsData = [];
|
||||
let historySortState = { key: 'date', direction: 'desc' };
|
||||
let _historySortClicked = false;
|
||||
let historySidebarCounts = { total: 0, success: 0, error: 0 };
|
||||
let historySidebarCounts = { total: 0, success: 0, error: 0, skipped: 0 };
|
||||
let accountSidebarFilter = 'all';
|
||||
let historySidebarFilter = 'all';
|
||||
let _knownUpdateInfo = null;
|
||||
@@ -357,6 +357,8 @@ let _updateCheckBusy = false;
|
||||
let _updateInstallBusy = false;
|
||||
let _updateDialogReturnFocus = null;
|
||||
let _updateDialogInertState = [];
|
||||
let _startupAutoResumeController = null;
|
||||
let _startupAutoResumeCanceled = false;
|
||||
|
||||
// Session-specific files for the "Files" panel (resets each session)
|
||||
let sessionFilesData = [];
|
||||
@@ -386,7 +388,12 @@ window.addEventListener('unhandledrejection', (e) => {
|
||||
|
||||
// --- Init ---
|
||||
async function init() {
|
||||
config = await window.api.getConfig();
|
||||
try {
|
||||
config = await window.api.getConfig();
|
||||
} catch (error) {
|
||||
await showAppAlert(error.message || String(error), 'Zugangsdaten gesperrt');
|
||||
throw error;
|
||||
}
|
||||
setUiLanguage(config.globalSettings?.language);
|
||||
hosterSettings = config.hosterSettings || {};
|
||||
autoHealthCheckEnabled = loadAutoCheckPreference();
|
||||
@@ -553,6 +560,7 @@ async function init() {
|
||||
} catch {}
|
||||
|
||||
scheduleStartupAccountCheck();
|
||||
scheduleRestoredQueueAutoStart();
|
||||
}
|
||||
|
||||
// --- Tab switching ---
|
||||
@@ -659,7 +667,7 @@ function initMenuBar() {
|
||||
void panel.offsetHeight;
|
||||
panel.classList.add('menu-closing');
|
||||
const finish = () => {
|
||||
if (panelTokens.get(panel) !== token) return;
|
||||
if (!Object.is(panelTokens.get(panel), token)) return;
|
||||
panel.style.display = 'none';
|
||||
panel.classList.remove('menu-closing');
|
||||
};
|
||||
@@ -1597,6 +1605,54 @@ function buildQueuePreview() {
|
||||
persistQueueStateSoon();
|
||||
}
|
||||
|
||||
function hideStartupResumeBanner() {
|
||||
const banner = document.getElementById('startupResumeBanner');
|
||||
if (banner) banner.hidden = true;
|
||||
}
|
||||
|
||||
function cancelStartupQueueAutoStart() {
|
||||
_startupAutoResumeCanceled = true;
|
||||
_startupAutoResumeController?.cancel();
|
||||
hideStartupResumeBanner();
|
||||
}
|
||||
|
||||
async function startRestoredQueueAfterChecks(jobIds) {
|
||||
const message = document.getElementById('startupResumeMessage');
|
||||
async function waitForAccountCheck() {
|
||||
if (!healthCheckRunning || _startupAutoResumeCanceled) return;
|
||||
if (message) message.textContent = localizeUiText('Warte auf Account-Prüfung…');
|
||||
await new Promise(resolve => setTimeout(resolve, 250));
|
||||
return waitForAccountCheck();
|
||||
}
|
||||
await waitForAccountCheck();
|
||||
hideStartupResumeBanner();
|
||||
if (_startupAutoResumeCanceled || uploading || config?.globalSettings?.autoStartRestoredQueue !== true) return;
|
||||
const jobs = window.AutoResume.getAutoResumeJobs(queueJobs, jobIds);
|
||||
if (!jobs.length) return;
|
||||
await startSelectedUpload(jobs);
|
||||
}
|
||||
|
||||
function scheduleRestoredQueueAutoStart() {
|
||||
if (config?.globalSettings?.autoStartRestoredQueue !== true || !window.AutoResume) return;
|
||||
const jobs = window.AutoResume.getAutoResumeJobs(queueJobs);
|
||||
if (!jobs.length) return;
|
||||
_startupAutoResumeCanceled = false;
|
||||
const banner = document.getElementById('startupResumeBanner');
|
||||
const message = document.getElementById('startupResumeMessage');
|
||||
const cancelButton = document.getElementById('cancelStartupResumeBtn');
|
||||
_startupAutoResumeController = window.AutoResume.createAutoResumeController({
|
||||
delaySeconds: 8,
|
||||
onTick: (seconds, count) => {
|
||||
if (banner) banner.hidden = false;
|
||||
if (message) message.textContent = localizeUiText(`Wiederhergestellte Warteschlange startet in ${seconds} s (${count} Jobs).`);
|
||||
},
|
||||
onStart: jobIds => { startRestoredQueueAfterChecks(jobIds); },
|
||||
onCancel: hideStartupResumeBanner
|
||||
});
|
||||
if (cancelButton) cancelButton.onclick = cancelStartupQueueAutoStart;
|
||||
_startupAutoResumeController.schedule(jobs.map(job => job.id));
|
||||
}
|
||||
|
||||
// --- Job Index Management ---
|
||||
function rebuildJobIndex() {
|
||||
_jobIndexById.clear();
|
||||
@@ -2332,14 +2388,48 @@ function copySelectedRecentLinks() {
|
||||
// --- Backup export / import ---
|
||||
async function doBackupExport() {
|
||||
try {
|
||||
const protectInput = document.getElementById('protectLocalBackupInput');
|
||||
let password;
|
||||
if (protectInput?.checked) {
|
||||
const passwordInput = document.getElementById('localBackupPasswordInput');
|
||||
const confirmInput = document.getElementById('localBackupPasswordConfirmInput');
|
||||
password = passwordInput?.value || '';
|
||||
if (password.length < 8) {
|
||||
await showAppAlert('Das Backup-Passwort muss mindestens 8 Zeichen lang sein.', 'Passwort prüfen');
|
||||
passwordInput?.focus();
|
||||
return;
|
||||
}
|
||||
if (!secretsMatch(password, confirmInput?.value || '')) {
|
||||
await showAppAlert('Die beiden Backup-Passwörter stimmen nicht überein.', 'Passwort prüfen');
|
||||
confirmInput?.focus();
|
||||
return;
|
||||
}
|
||||
}
|
||||
await flushPendingSettingsSaves();
|
||||
const result = await window.api.exportBackup();
|
||||
if (result && result.ok) showCopyToast('Backup exportiert');
|
||||
const result = await window.api.exportBackup(password === undefined ? {} : { password });
|
||||
if (result && result.ok) {
|
||||
const passwordInput = document.getElementById('localBackupPasswordInput');
|
||||
const confirmInput = document.getElementById('localBackupPasswordConfirmInput');
|
||||
if (passwordInput) passwordInput.value = '';
|
||||
if (confirmInput) confirmInput.value = '';
|
||||
showCopyToast('Backup exportiert');
|
||||
}
|
||||
} catch (err) {
|
||||
await showAppAlert('Export fehlgeschlagen: ' + (err.message || err), 'Export fehlgeschlagen');
|
||||
}
|
||||
}
|
||||
|
||||
function secretsMatch(left, right) {
|
||||
const a = String(left || '');
|
||||
const b = String(right || '');
|
||||
const length = Math.max(a.length, b.length);
|
||||
let difference = a.length ^ b.length;
|
||||
for (let index = 0; index < length; index++) {
|
||||
difference |= (a.charCodeAt(index) || 0) ^ (b.charCodeAt(index) || 0);
|
||||
}
|
||||
return difference === 0;
|
||||
}
|
||||
|
||||
function applyImportedConfig(importedConfig, message) {
|
||||
const pendingQueue = buildPersistedQueueState();
|
||||
config = {
|
||||
@@ -2745,6 +2835,7 @@ function serializeUploadJob(job) {
|
||||
|
||||
async function startUpload(opts) {
|
||||
if (uploading) return;
|
||||
if (!(opts && opts._restoredAutoStart)) cancelStartupQueueAutoStart();
|
||||
if (!(opts && opts._autoRetry)) _cancelAutoRetry(true);
|
||||
else _cancelAutoRetry(false);
|
||||
uploading = true; // set immediately to prevent double-click race
|
||||
@@ -2814,7 +2905,7 @@ function _markSkippedJobs(result) {
|
||||
for (const skipped of result.skippedJobs) {
|
||||
const job = _jobIndexById.get(skipped.jobId);
|
||||
if (job) {
|
||||
job.status = 'error';
|
||||
job.status = 'skipped';
|
||||
job.error = skipped.reason || 'Kein gültiger Account';
|
||||
}
|
||||
}
|
||||
@@ -3240,7 +3331,7 @@ function _retryFailedFromBuckets(buckets, transientOnly) {
|
||||
if (toRetry.length === 0) return;
|
||||
const jobsToRetry = [];
|
||||
for (const item of toRetry) {
|
||||
const job = queueJobs.find(j => (j.fileName === item.fileName) && (j.hoster === item.hoster) && (j.status === 'error' || j.status === 'skipped'));
|
||||
const job = queueJobs.find(j => (j.fileName === item.fileName) && (j.hoster === item.hoster) && j.status === 'error');
|
||||
if (job) {
|
||||
job.status = 'queued';
|
||||
job.progress = 0;
|
||||
@@ -3565,6 +3656,9 @@ function applySummaryResults(summary) {
|
||||
} else if (result.status === 'error') {
|
||||
job.status = 'error';
|
||||
job.error = result.error || 'Fehlgeschlagen';
|
||||
} else if (result.status === 'skipped') {
|
||||
job.status = 'skipped';
|
||||
job.error = result.error || 'Übersprungen';
|
||||
}
|
||||
maybeAddSessionFile(job);
|
||||
}
|
||||
@@ -3580,38 +3674,7 @@ function applySummaryResults(summary) {
|
||||
let _queueStatsCache = null;
|
||||
function _computeQueueStats() {
|
||||
if (_queueStatsCache) return _queueStatsCache;
|
||||
|
||||
let remaining = 0, inProgress = 0, done = 0, errors = 0;
|
||||
let bytesRemaining = 0, totalSize = 0, remainingSize = 0, inProgressBytes = 0;
|
||||
const total = queueJobs.length;
|
||||
|
||||
for (let i = 0; i < total; i++) {
|
||||
const job = queueJobs[i];
|
||||
const s = job.status;
|
||||
const bt = job.bytesTotal || 0;
|
||||
const bu = job.bytesUploaded || 0;
|
||||
totalSize += bt;
|
||||
|
||||
if (s === 'uploading' || s === 'getting-server' || s === 'retrying') {
|
||||
inProgress++;
|
||||
remaining++;
|
||||
inProgressBytes += bu;
|
||||
bytesRemaining += Math.max(0, bt - bu);
|
||||
remainingSize += Math.max(0, bt - bu);
|
||||
} else if (s === 'preview' || s === 'queued') {
|
||||
remaining++;
|
||||
bytesRemaining += Math.max(0, bt - bu);
|
||||
remainingSize += Math.max(0, bt - bu);
|
||||
} else if (s === 'done') {
|
||||
done++;
|
||||
} else if (s === 'error') {
|
||||
errors++;
|
||||
} else if (s !== 'skipped') {
|
||||
remainingSize += Math.max(0, bt - bu);
|
||||
}
|
||||
}
|
||||
|
||||
_queueStatsCache = { total, remaining, inProgress, done, errors, bytesRemaining, totalSize, remainingSize, inProgressBytes };
|
||||
_queueStatsCache = window.QueueStats.calculateQueueStats(queueJobs);
|
||||
(typeof queueMicrotask === 'function' ? queueMicrotask : (fn) => Promise.resolve().then(fn))(() => { _queueStatsCache = null; });
|
||||
return _queueStatsCache;
|
||||
}
|
||||
@@ -3765,7 +3828,7 @@ function setAccountSidebarFilter(value) {
|
||||
}
|
||||
|
||||
function setHistorySidebarFilter(value) {
|
||||
if (!['all', 'success', 'error'].includes(value)) return;
|
||||
if (!['all', 'success', 'error', 'skipped'].includes(value)) return;
|
||||
historySidebarFilter = value;
|
||||
_syncSidebarFilterButtons('[data-history-filter]', 'historyFilter', value);
|
||||
const container = document.getElementById('historyContainer');
|
||||
@@ -3779,6 +3842,7 @@ function updateHistorySidebarSummary() {
|
||||
_setSidebarCount('historySidebarAllCount', historySidebarCounts.total);
|
||||
_setSidebarCount('historySidebarSuccessCount', historySidebarCounts.success);
|
||||
_setSidebarCount('historySidebarErrorCount', historySidebarCounts.error);
|
||||
_setSidebarCount('historySidebarSkippedCount', historySidebarCounts.skipped);
|
||||
const retention = document.getElementById('historySidebarRetention');
|
||||
const select = document.getElementById('historyRetentionSelect');
|
||||
const labels = {
|
||||
@@ -4191,6 +4255,14 @@ function renderSettings() {
|
||||
<input type="checkbox" class="settings-autosave" id="showDropTargetInput" ${globalSettings.showDropTarget ? 'checked' : ''}>
|
||||
</div>
|
||||
</div>
|
||||
<div class="settings-option credential-fallback-option">
|
||||
<div class="settings-option-copy">
|
||||
<label for="allowPlaintextCredentialStorageInput">Unsichere Klartext-Speicherung erlauben</label>
|
||||
<span class="settings-option-description">Nur verwenden, wenn der sichere Betriebssystem-Speicher dauerhaft nicht verfügbar ist. Passwörter und API-Keys liegen dann lesbar in der Konfigurationsdatei.</span>
|
||||
<span class="panel-status" id="secretStoreStatus">Prüfe…</span>
|
||||
</div>
|
||||
<input type="checkbox" class="settings-autosave" id="allowPlaintextCredentialStorageInput" ${globalSettings.allowPlaintextCredentialStorage ? 'checked' : ''}>
|
||||
</div>
|
||||
<div class="settings-section-label">Programmupdate</div>
|
||||
<div class="settings-row program-update-row program-update-card">
|
||||
<div class="program-update-copy">
|
||||
@@ -4236,6 +4308,13 @@ function renderSettings() {
|
||||
</div>
|
||||
<input type="checkbox" class="settings-autosave" id="resumeQueueOnLaunchInput" ${globalSettings.resumeQueueOnLaunch === false ? '' : 'checked'}>
|
||||
</div>
|
||||
<div class="settings-option">
|
||||
<div class="settings-option-copy">
|
||||
<label for="autoStartRestoredQueueInput">Wiederhergestellte Warteschlange automatisch starten</label>
|
||||
<span class="settings-option-description">Startet wartende Uploads nach einem sichtbaren, abbrechbaren Countdown. Fehler und übersprungene Einträge bleiben unangetastet.</span>
|
||||
</div>
|
||||
<input type="checkbox" class="settings-autosave" id="autoStartRestoredQueueInput" ${globalSettings.autoStartRestoredQueue ? 'checked' : ''}>
|
||||
</div>
|
||||
<div class="settings-section-label">Quelldateien</div>
|
||||
<div class="settings-option source-delete-option">
|
||||
<div class="settings-option-copy">
|
||||
@@ -4293,6 +4372,10 @@ function renderSettings() {
|
||||
<label>Unterordner einbeziehen</label>
|
||||
<input type="checkbox" class="settings-autosave" id="fmRecursiveInput" ${fm.recursive ? 'checked' : ''}>
|
||||
</div>
|
||||
<div class="settings-row checkbox-row">
|
||||
<label>Vorhandene Dateien einmalig einlesen</label>
|
||||
<input type="checkbox" class="settings-autosave" id="fmIncludeExistingInput" ${fm.includeExisting ? 'checked' : ''}>
|
||||
</div>
|
||||
<div class="settings-row checkbox-row">
|
||||
<label>Duplikate überspringen</label>
|
||||
<input type="checkbox" class="settings-autosave" id="fmSkipDuplicatesInput" ${fm.skipDuplicates !== false ? 'checked' : ''}>
|
||||
@@ -4472,6 +4555,17 @@ function renderSettings() {
|
||||
<div class="online-backup-status" id="onlineBackupStatus" role="status" aria-live="polite"></div>
|
||||
</section>
|
||||
<div class="settings-section-label">Lokales Datei-Backup</div>
|
||||
<div class="settings-row local-backup-password-row">
|
||||
<div class="settings-copy">
|
||||
<label for="protectLocalBackupInput">Mit eigenem Passwort schützen</label>
|
||||
<span class="hint">Ohne dieses Passwort kann das Backup nicht importiert werden.</span>
|
||||
</div>
|
||||
<input type="checkbox" id="protectLocalBackupInput">
|
||||
</div>
|
||||
<div class="local-backup-password-fields" id="localBackupPasswordFields" hidden>
|
||||
<input type="password" class="key-input" id="localBackupPasswordInput" minlength="8" maxlength="1024" autocomplete="new-password" placeholder="Passwort (mindestens 8 Zeichen)">
|
||||
<input type="password" class="key-input" id="localBackupPasswordConfirmInput" minlength="8" maxlength="1024" autocomplete="new-password" placeholder="Passwort wiederholen">
|
||||
</div>
|
||||
<div class="backup-file-actions">
|
||||
<button class="btn btn-secondary" id="exportBackupBtn">Datei exportieren</button>
|
||||
<button class="btn btn-secondary" id="importBackupBtn">Datei importieren</button>
|
||||
@@ -4752,6 +4846,12 @@ function renderSettings() {
|
||||
|
||||
document.getElementById('exportBackupBtn').addEventListener('click', () => doBackupExport());
|
||||
document.getElementById('importBackupBtn').addEventListener('click', () => doBackupImport());
|
||||
document.getElementById('protectLocalBackupInput').addEventListener('change', (event) => {
|
||||
const fields = document.getElementById('localBackupPasswordFields');
|
||||
if (!fields) return;
|
||||
fields.hidden = !event.target.checked;
|
||||
if (event.target.checked) document.getElementById('localBackupPasswordInput')?.focus();
|
||||
});
|
||||
document.getElementById('createOnlineBackupBtn').addEventListener('click', () => doOnlineBackupCreate());
|
||||
document.getElementById('copyOnlineBackupKeyBtn').addEventListener('click', async () => {
|
||||
const key = document.getElementById('onlineBackupKeyOutput').value;
|
||||
@@ -4770,6 +4870,13 @@ function renderSettings() {
|
||||
document.getElementById('chooseLogFilePathBtn')?.addEventListener('click', chooseLogFilePath);
|
||||
document.getElementById('openLogFolderBtn')?.addEventListener('click', () => window.api.openLogFolder());
|
||||
document.getElementById('manualUpdateCheckBtn')?.addEventListener('click', requestUpdateCheck);
|
||||
window.api.getSecretStoreStatus().then(result => {
|
||||
const badge = document.getElementById('secretStoreStatus');
|
||||
if (!badge) return;
|
||||
const available = result?.status === 'available';
|
||||
badge.textContent = available ? 'Sicher verfügbar' : 'Nicht verfügbar';
|
||||
badge.classList.toggle('active', available);
|
||||
}).catch(() => {});
|
||||
_syncHeaderUpdateState();
|
||||
container.querySelectorAll('.settings-autosave').forEach((input) => {
|
||||
const eventName = input.type === 'checkbox' || input.tagName === 'SELECT' ? 'change' : 'input';
|
||||
@@ -4787,6 +4894,15 @@ function renderSettings() {
|
||||
});
|
||||
if (!confirmed) input.checked = false;
|
||||
}
|
||||
if (input.id === 'allowPlaintextCredentialStorageInput' && input.checked) {
|
||||
const confirmed = await showAppConfirm({
|
||||
title: 'Zugangsdaten unverschlüsselt speichern?',
|
||||
message: 'Passwörter und API-Keys werden dann im Klartext auf diesem PC gespeichert. Andere Benutzer oder Programme mit Dateizugriff können sie lesen.',
|
||||
confirmText: 'Klartext-Speicherung erlauben',
|
||||
danger: true
|
||||
});
|
||||
if (!confirmed) input.checked = false;
|
||||
}
|
||||
markSettingsDirty();
|
||||
});
|
||||
});
|
||||
@@ -4911,6 +5027,8 @@ async function performSaveSettings(options = {}) {
|
||||
return (v === 'single' || v === 'daily' || v === 'session') ? v : 'single';
|
||||
})(),
|
||||
resumeQueueOnLaunch: elChk('resumeQueueOnLaunchInput', cur.resumeQueueOnLaunch !== false),
|
||||
autoStartRestoredQueue: elChk('autoStartRestoredQueueInput', !!cur.autoStartRestoredQueue),
|
||||
allowPlaintextCredentialStorage: elChk('allowPlaintextCredentialStorageInput', !!cur.allowPlaintextCredentialStorage),
|
||||
parallelUploadCount: elInt('parallelUploadCountInput', cur.parallelUploadCount ?? 0, 0, 0, 100),
|
||||
scaleParallelUploads: elChk('scaleParallelUploadsInput', !!cur.scaleParallelUploads),
|
||||
removeFromQueueOnDone: elChk('removeFromQueueOnDoneInput', !!cur.removeFromQueueOnDone),
|
||||
@@ -4931,6 +5049,7 @@ async function performSaveSettings(options = {}) {
|
||||
enabled: elChk('fmEnabledInput', !!curFm.enabled),
|
||||
folderPath: elTxt('fmFolderPathInput', curFm.folderPath || '').trim(),
|
||||
recursive: elChk('fmRecursiveInput', !!curFm.recursive),
|
||||
includeExisting: elChk('fmIncludeExistingInput', !!curFm.includeExisting),
|
||||
filterMode: (() => { const el = document.getElementById('fmFilterModeInput'); return el ? (el.value || 'include') : (curFm.filterMode || 'include'); })(),
|
||||
extensions: elTxt('fmExtensionsInput', curFm.extensions || '').trim(),
|
||||
skipDuplicates: elChk('fmSkipDuplicatesInput', curFm.skipDuplicates !== false),
|
||||
@@ -5003,7 +5122,14 @@ async function performSaveSettings(options = {}) {
|
||||
const badge = document.getElementById('folderMonitorStatusBadge');
|
||||
if (fmSettings && fmSettings.enabled && fmSettings.folderPath) {
|
||||
try {
|
||||
await window.api.folderMonitorStart(fmSettings);
|
||||
const folderStart = await window.api.folderMonitorStart(fmSettings);
|
||||
if (folderStart?.includesExisting) {
|
||||
fmSettings.includeExisting = false;
|
||||
globalSettings.folderMonitor.includeExisting = false;
|
||||
config.globalSettings.folderMonitor.includeExisting = false;
|
||||
const includeExistingInput = document.getElementById('fmIncludeExistingInput');
|
||||
if (includeExistingInput) includeExistingInput.checked = false;
|
||||
}
|
||||
if (badge) { badge.textContent = 'Aktiv'; badge.className = 'panel-status active'; }
|
||||
} catch {
|
||||
if (badge) { badge.textContent = 'Fehler'; badge.className = 'panel-status'; }
|
||||
@@ -6169,7 +6295,7 @@ async function loadHistory() {
|
||||
history = await window.api.getHistory();
|
||||
} catch (error) {
|
||||
historyRowsData = [];
|
||||
historySidebarCounts = { total: 0, success: 0, error: 0 };
|
||||
historySidebarCounts = { total: 0, success: 0, error: 0, skipped: 0 };
|
||||
updateHistorySidebarSummary();
|
||||
syncHistoryClearAction();
|
||||
if (container) container.innerHTML = `<div class="empty-state history-error-state" role="alert"><strong>${escapeHtml(localizeUiText('Verlauf konnte nicht geladen werden.'))}</strong><span>${escapeHtml(error?.message || String(error))}</span><button class="btn btn-secondary" type="button" data-retry-history>${escapeHtml(localizeUiText('Erneut versuchen'))}</button></div>`;
|
||||
@@ -6187,7 +6313,7 @@ async function loadHistory() {
|
||||
}
|
||||
if (!history || history.length === 0) {
|
||||
historyRowsData = [];
|
||||
historySidebarCounts = { total: 0, success: 0, error: 0 };
|
||||
historySidebarCounts = { total: 0, success: 0, error: 0, skipped: 0 };
|
||||
updateHistorySidebarSummary();
|
||||
syncHistoryClearAction();
|
||||
container.innerHTML = '<p class="empty-state">Noch keine Uploads.</p>';
|
||||
@@ -6196,7 +6322,7 @@ async function loadHistory() {
|
||||
|
||||
historySortState = { key: 'date', direction: 'desc' };
|
||||
historyRowsData = [];
|
||||
historySidebarCounts = { total: 0, success: 0, error: 0 };
|
||||
historySidebarCounts = { total: 0, success: 0, error: 0, skipped: 0 };
|
||||
let order = 0;
|
||||
|
||||
for (const batch of history) {
|
||||
@@ -6204,17 +6330,15 @@ async function loadHistory() {
|
||||
for (const file of (batch.files || [])) {
|
||||
for (const result of (file.results || [])) {
|
||||
historySidebarCounts.total++;
|
||||
const isError = result.status === 'aborted' || result.status === 'error';
|
||||
if (isError) historySidebarCounts.error++;
|
||||
else historySidebarCounts.success++;
|
||||
const detail = isError
|
||||
? String(result.error || result.message || (result.status === 'aborted' ? 'Abgebrochen' : 'Fehlgeschlagen'))
|
||||
: (result.download_url || result.embed_url || '');
|
||||
const category = window.HistoryStatus.classifyHistoryStatus(result.status);
|
||||
historySidebarCounts[category]++;
|
||||
const detail = window.HistoryStatus.historyDetail(result);
|
||||
historyRowsData.push({
|
||||
date: dt.text, dateTs: dt.ts, rawTimestamp: batch.timestamp,
|
||||
filename: file.name || '', host: result.hoster || '',
|
||||
link: detail,
|
||||
isError, order: order++
|
||||
status: result.status || '', category,
|
||||
isError: category === 'error', order: order++
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -6331,7 +6455,7 @@ function _renderRecentVirtualRows() {
|
||||
tbody.innerHTML = parts.join('');
|
||||
}
|
||||
|
||||
function renderRecentUploadsPanel(appendOnly = false) {
|
||||
function renderRecentUploadsPanel(_appendOnly = false) {
|
||||
const tbody = document.getElementById('recentFilesBody');
|
||||
if (!tbody) return;
|
||||
_recentPendingAppends = 0;
|
||||
@@ -6468,7 +6592,7 @@ function _renderHistoryVirtualRows() {
|
||||
const row = _historyWorking[i];
|
||||
const link = row.link || '';
|
||||
parts.push('<tr class="history-row');
|
||||
if (row.isError) parts.push(' error');
|
||||
parts.push(` ${row.category}`);
|
||||
parts.push('" data-link="');
|
||||
parts.push(escapeAttr(link));
|
||||
parts.push(`" style="height:${VIRTUAL_ROW_HEIGHT}px"><td class="col-date">`);
|
||||
@@ -6481,15 +6605,16 @@ function _renderHistoryVirtualRows() {
|
||||
parts.push(escapeAttr(link));
|
||||
parts.push('">');
|
||||
parts.push(escapeHtml(link));
|
||||
parts.push('</span><button class="history-copy-link" type="button" data-copy-link aria-label="Link kopieren" title="Link kopieren">⧉</button></div></td></tr>');
|
||||
parts.push('</span>');
|
||||
if (row.category === 'success' && link) parts.push('<button class="history-copy-link" type="button" data-copy-link aria-label="Link kopieren" title="Link kopieren">⧉</button>');
|
||||
parts.push('</div></td></tr>');
|
||||
}
|
||||
if (bottomPad > 0) parts.push(`<tr class="virtual-spacer" style="height:${bottomPad}px"><td colspan="4"></td></tr>`);
|
||||
tbody.innerHTML = parts.join('');
|
||||
}
|
||||
|
||||
function _getVisibleHistoryRows() {
|
||||
if (historySidebarFilter === 'success') return historyRowsData.filter(row => !row.isError);
|
||||
if (historySidebarFilter === 'error') return historyRowsData.filter(row => row.isError);
|
||||
if (historySidebarFilter !== 'all') return historyRowsData.filter(row => row.category === historySidebarFilter);
|
||||
return historyRowsData;
|
||||
}
|
||||
|
||||
@@ -6527,7 +6652,7 @@ function renderHistoryTable(container) {
|
||||
};
|
||||
|
||||
container.innerHTML = `<table class="results-table history-table"><thead><tr>
|
||||
${headerCell('date', 'Datum')}${headerCell('filename', 'Dateiname')}${headerCell('host', 'Hoster')}${headerCell('link', 'Link')}
|
||||
${headerCell('date', 'Datum')}${headerCell('filename', 'Dateiname')}${headerCell('host', 'Hoster')}${headerCell('link', 'Ergebnis')}
|
||||
</tr></thead><tbody id="historyBody"></tbody></table>`;
|
||||
|
||||
if (!_historyListenersBound) {
|
||||
@@ -6558,7 +6683,7 @@ function renderHistoryTable(container) {
|
||||
return;
|
||||
}
|
||||
const row = e.target.closest('.history-row');
|
||||
if (row && !row.classList.contains('error')) {
|
||||
if (row && row.classList.contains('success')) {
|
||||
const link = row.dataset.link;
|
||||
if (link) { window.api.copyToClipboard(link); showCopyToast('Link kopiert'); }
|
||||
}
|
||||
@@ -7666,7 +7791,7 @@ function formatDuration(seconds) {
|
||||
|
||||
function updateStatsPanel() {
|
||||
const stats = _computeQueueStats();
|
||||
const remaining = stats.total - stats.done - stats.errors;
|
||||
const remaining = stats.remaining;
|
||||
|
||||
const el = (id) => document.getElementById(id);
|
||||
if (el('statQueueTotal')) el('statQueueTotal').textContent = stats.total;
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
(function (root, factory) {
|
||||
const api = factory();
|
||||
if (typeof module === 'object' && module.exports) module.exports = api;
|
||||
if (root) root.AutoResume = api;
|
||||
})(typeof window !== 'undefined' ? window : globalThis, function () {
|
||||
function getAutoResumeJobs(jobs, jobIds) {
|
||||
const allowedJobIds = Array.isArray(jobIds) ? new Set(jobIds) : null;
|
||||
return Array.isArray(jobs)
|
||||
? jobs.filter(job => job && (job.status === 'queued' || job.status === 'preview') && job.file && job.hoster && (!allowedJobIds || allowedJobIds.has(job.id)))
|
||||
: [];
|
||||
}
|
||||
|
||||
function createAutoResumeController(options = {}) {
|
||||
const delaySeconds = Math.max(1, Number(options.delaySeconds) || 8);
|
||||
const setIntervalFn = options.setIntervalFn || setInterval;
|
||||
const clearIntervalFn = options.clearIntervalFn || clearInterval;
|
||||
const onTick = options.onTick || (() => {});
|
||||
const onStart = options.onStart || (() => {});
|
||||
const onCancel = options.onCancel || (() => {});
|
||||
let timer = null;
|
||||
let pending = false;
|
||||
|
||||
function stopTimer() {
|
||||
if (timer === null) return;
|
||||
clearIntervalFn(timer);
|
||||
timer = null;
|
||||
}
|
||||
|
||||
function schedule(jobIds) {
|
||||
if (pending || !Array.isArray(jobIds)) return false;
|
||||
const plannedJobIds = [...new Set(jobIds.filter(jobId => typeof jobId === 'string' && jobId))];
|
||||
if (!plannedJobIds.length) return false;
|
||||
pending = true;
|
||||
let remaining = delaySeconds;
|
||||
onTick(remaining, plannedJobIds.length);
|
||||
timer = setIntervalFn(() => {
|
||||
if (!pending) return;
|
||||
remaining--;
|
||||
if (remaining > 0) {
|
||||
onTick(remaining, plannedJobIds.length);
|
||||
return;
|
||||
}
|
||||
pending = false;
|
||||
stopTimer();
|
||||
onStart(plannedJobIds);
|
||||
}, 1000);
|
||||
return true;
|
||||
}
|
||||
|
||||
function cancel() {
|
||||
if (!pending) return false;
|
||||
pending = false;
|
||||
stopTimer();
|
||||
onCancel();
|
||||
return true;
|
||||
}
|
||||
|
||||
return {
|
||||
schedule,
|
||||
cancel,
|
||||
get pending() { return pending; }
|
||||
};
|
||||
}
|
||||
|
||||
return { getAutoResumeJobs, createAutoResumeController };
|
||||
});
|
||||
@@ -0,0 +1,22 @@
|
||||
(function (root, factory) {
|
||||
const api = factory();
|
||||
if (typeof module === 'object' && module.exports) module.exports = api;
|
||||
if (root) root.HistoryStatus = api;
|
||||
})(typeof globalThis !== 'undefined' ? globalThis : this, function () {
|
||||
function classifyHistoryStatus(status) {
|
||||
if (status === 'done') return 'success';
|
||||
if (status === 'skipped') return 'skipped';
|
||||
return 'error';
|
||||
}
|
||||
|
||||
function historyDetail(result) {
|
||||
const category = classifyHistoryStatus(result?.status);
|
||||
if (category === 'success') return String(result?.download_url || result?.embed_url || '');
|
||||
if (result?.error || result?.message) return String(result.error || result.message);
|
||||
if (result?.status === 'aborted') return 'Abgebrochen';
|
||||
if (category === 'skipped') return 'Übersprungen';
|
||||
return 'Fehlgeschlagen';
|
||||
}
|
||||
|
||||
return { classifyHistoryStatus, historyDetail };
|
||||
});
|
||||
@@ -188,6 +188,9 @@
|
||||
['Nach Abschluss aus der Liste entfernen', 'Remove from the list after completion'],
|
||||
['Erfolgreich hochgeladene Dateien verschwinden automatisch aus der Upload-Liste.', 'Successfully uploaded files are automatically removed from the upload list.'],
|
||||
['Warteschlange beim Start wiederherstellen', 'Restore queue on startup'],
|
||||
['Wiederhergestellte Warteschlange automatisch starten', 'Start restored queue automatically'],
|
||||
['Startet wartende Uploads nach einem sichtbaren, abbrechbaren Countdown. Fehler und übersprungene Einträge bleiben unangetastet.', 'Starts waiting uploads after a visible, cancelable countdown. Failed and skipped entries remain untouched.'],
|
||||
['Warte auf Account-Prüfung…', 'Waiting for account check…'],
|
||||
['Noch nicht abgeschlossene Uploads werden beim nächsten Programmstart erneut angezeigt.', 'Incomplete uploads are shown again the next time the application starts.'],
|
||||
['Quelldateien', 'Source files'],
|
||||
['Quelldatei nach vollständigem Upload dauerhaft löschen', 'Permanently delete source file after complete upload'],
|
||||
@@ -223,6 +226,7 @@
|
||||
['Verhalten', 'Behavior'],
|
||||
['Aktiviert', 'Enabled'],
|
||||
['Unterordner einbeziehen', 'Include subfolders'],
|
||||
['Vorhandene Dateien einmalig einlesen', 'Import existing files once'],
|
||||
['Duplikate überspringen', 'Skip duplicates'],
|
||||
['Auto-Upload starten', 'Start uploads automatically'],
|
||||
['Hoster-Vorauswahl', 'Host preselection'],
|
||||
@@ -277,10 +281,18 @@
|
||||
['Online importieren', 'Import online'],
|
||||
['Behandle den Schlüssel wie ein Passwort. Wer ihn besitzt, kann die verschlüsselten Einstellungen entschlüsseln.', 'Treat the key like a password. Anyone who has it can decrypt the encrypted settings.'],
|
||||
['Lokales Datei-Backup', 'Local file backup'],
|
||||
['Mit eigenem Passwort schützen', 'Protect with your own password'],
|
||||
['Ohne dieses Passwort kann das Backup nicht importiert werden.', 'The backup cannot be imported without this password.'],
|
||||
['Passwort (mindestens 8 Zeichen)', 'Password (at least 8 characters)'],
|
||||
['Passwort wiederholen', 'Repeat password'],
|
||||
['Das Backup-Passwort muss mindestens 8 Zeichen lang sein.', 'The backup password must be at least 8 characters long.'],
|
||||
['Die beiden Backup-Passwörter stimmen nicht überein.', 'The two backup passwords do not match.'],
|
||||
['Passwort prüfen', 'Check password'],
|
||||
['Datei exportieren', 'Export file'],
|
||||
['Datei importieren', 'Import file'],
|
||||
['Alle Uploads', 'All uploads'],
|
||||
['Erfolgreich', 'Successful'],
|
||||
['Ergebnis', 'Result'],
|
||||
['Aktive Regel', 'Active rule'],
|
||||
['Alles behalten', 'Keep everything'],
|
||||
['Links wiederfinden, kopieren oder als Datei exportieren.', 'Find links, copy them, or export them as a file.'],
|
||||
@@ -311,6 +323,7 @@
|
||||
['Wartende Uploads anzeigen', 'Show queued uploads'],
|
||||
['Fertige Uploads anzeigen', 'Show completed uploads'],
|
||||
['Fehlgeschlagene Uploads anzeigen', 'Show failed uploads'],
|
||||
['Übersprungene Uploads anzeigen', 'Show skipped uploads'],
|
||||
['Alle Uploads starten', 'Start all uploads'],
|
||||
['Ausgewählte Uploads starten', 'Start selected uploads'],
|
||||
['Ausgewählte Datei erneut hochladen', 'Upload selected file again'],
|
||||
@@ -454,6 +467,20 @@
|
||||
['Keine gültige .mhu Backup-Datei', 'Not a valid .mhu backup file'],
|
||||
['Falsches Passwort oder beschädigte Datei', 'Incorrect password or damaged file'],
|
||||
['Dieses Backup wurde mit einem Passwort verschlüsselt', 'This backup was encrypted with a password'],
|
||||
['Das Backup-Passwort darf nicht leer sein', 'The backup password must not be empty'],
|
||||
['Das Backup-Passwort muss zwischen 8 und 1024 Zeichen lang sein', 'The backup password must be between 8 and 1024 characters long'],
|
||||
['Passwortgeschützte Backups müssen als .mhu gespeichert werden', 'Password-protected backups must be saved as .mhu files'],
|
||||
['Sicherer Zugangsdaten-Speicher ist nicht verfügbar', 'Secure credential storage is unavailable'],
|
||||
['Zugangsdaten konnten nicht sicher verschlüsselt werden', 'Credentials could not be encrypted securely'],
|
||||
['Gespeicherte Zugangsdaten konnten nicht entschlüsselt werden', 'Stored credentials could not be decrypted'],
|
||||
['Zugangsdaten gesperrt', 'Credentials locked'],
|
||||
['Unsichere Klartext-Speicherung erlauben', 'Allow insecure plaintext storage'],
|
||||
['Nur verwenden, wenn der sichere Betriebssystem-Speicher dauerhaft nicht verfügbar ist. Passwörter und API-Keys liegen dann lesbar in der Konfigurationsdatei.', 'Use only when secure operating system storage is permanently unavailable. Passwords and API keys will then be readable in the configuration file.'],
|
||||
['Sicher verfügbar', 'Securely available'],
|
||||
['Nicht verfügbar', 'Unavailable'],
|
||||
['Zugangsdaten unverschlüsselt speichern?', 'Store credentials without encryption?'],
|
||||
['Passwörter und API-Keys werden dann im Klartext auf diesem PC gespeichert. Andere Benutzer oder Programme mit Dateizugriff können sie lesen.', 'Passwords and API keys will then be stored as plaintext on this PC. Other users or programs with file access can read them.'],
|
||||
['Klartext-Speicherung erlauben', 'Allow plaintext storage'],
|
||||
['Die Verlaufsdatei ist beschädigt und wurde nicht verändert', 'The history file is damaged and was not changed'],
|
||||
['Verlauf und Aufbewahrung konnten nicht konsistent gespeichert werden', 'History and retention could not be saved consistently'],
|
||||
['Kein Ordnerpfad angegeben', 'No folder path was provided'],
|
||||
@@ -488,6 +515,14 @@
|
||||
['SHA-512 Prüfung fehlgeschlagen', 'SHA-512 verification failed'],
|
||||
['Vorbereitetes Update ist unvollständig', 'The prepared update is incomplete'],
|
||||
['Kein Setup-Asset im Release gefunden', 'No setup asset was found in the release'],
|
||||
['Prüfsummen-Metadaten fehlen', 'Checksum metadata is missing'],
|
||||
['Prüfsummen-Metadaten sind unvollständig', 'Checksum metadata is incomplete'],
|
||||
['Prüfsummen-Metadaten enthalten keine gültige SHA-512-Prüfsumme', 'Checksum metadata does not contain a valid SHA-512 checksum'],
|
||||
['Prüfsummen-Metadaten gehören zu einer anderen Version', 'Checksum metadata belongs to a different version'],
|
||||
['Prüfsummen-Metadaten gehören nicht zum ausgewählten Installer', 'Checksum metadata does not belong to the selected installer'],
|
||||
['Prüfsummen-Metadaten enthalten eine abweichende Dateigröße', 'Checksum metadata contains a different file size'],
|
||||
['Heruntergeladene Datei hat eine abweichende Größe', 'The downloaded file has a different size'],
|
||||
['Die installierte Anwendung ist nicht gültig digital signiert', 'The installed application is not validly digitally signed'],
|
||||
['Download hängt — seit 45 s keine Daten (Netzwerk/Server überlastet). Bitte laufende Uploads stoppen und erneut versuchen.', 'The download stalled because no data was received for 45 seconds. Stop active uploads and try again.'],
|
||||
['Datei nicht gefunden', 'File not found'],
|
||||
['Netzwerkfehler', 'Network error'],
|
||||
@@ -592,6 +627,7 @@
|
||||
const patterns = target === 'en'
|
||||
? [
|
||||
[/^Update v(.+) verfügbar$/, 'Update v$1 available'],
|
||||
[/^Wiederhergestellte Warteschlange startet in (.+) s \((.+) Jobs\)\.$/, 'Restored queue starts in $1 s ($2 jobs).'],
|
||||
[/^Login ok, Upload-Form bereit \(Dateifeld: (.+)\)$/, 'Login successful, upload form ready (file field: $1)'],
|
||||
[/^Klartext-Backup ist kein gültiges JSON: (.+)$/, 'Plain JSON backup is not valid JSON: $1'],
|
||||
[/^Export fehlgeschlagen: (.+)$/, 'Export failed: $1'],
|
||||
@@ -619,6 +655,8 @@
|
||||
[/^VOE Upload-Fehler: (.+)$/, 'VOE upload error: $1'],
|
||||
[/^VOE: Upload-Server Antwort war kein JSON: (.+)$/, 'VOE: The upload server response was not JSON: $1'],
|
||||
[/^Download fehlgeschlagen: (.+)$/, 'Download failed: $1'],
|
||||
[/^Prüfsummen-Metadaten konnten nicht geladen werden: (.+)$/, 'Checksum metadata could not be loaded: $1'],
|
||||
[/^Upload zu (.+) wurde nicht bestätigt$/, 'The upload to $1 was not confirmed'],
|
||||
[/^Update-Server Antwort war kein JSON (.+)$/, 'Update server response was not JSON $1'],
|
||||
[/^(\d+) Links kopiert$/, '$1 links copied'],
|
||||
[/^(\d+) Link kopiert$/, '$1 link copied'],
|
||||
|
||||
@@ -553,6 +553,11 @@
|
||||
<span class="view-sidebar-copy">Fehler</span>
|
||||
<span class="view-sidebar-badge" id="historySidebarErrorCount">0</span>
|
||||
</button>
|
||||
<button class="view-sidebar-item" data-history-filter="skipped" aria-label="Übersprungene Uploads anzeigen">
|
||||
<svg class="view-sidebar-icon" aria-hidden="true"><use href="#icon-clock"></use></svg>
|
||||
<span class="view-sidebar-copy">Übersprungen</span>
|
||||
<span class="view-sidebar-badge" id="historySidebarSkippedCount">0</span>
|
||||
</button>
|
||||
</nav>
|
||||
<div class="view-sidebar-section">
|
||||
<span class="view-sidebar-section-label">Aufbewahrung</span>
|
||||
@@ -621,6 +626,10 @@
|
||||
</div>
|
||||
|
||||
<div class="copy-toast" id="copyToast" role="status" aria-live="polite" aria-atomic="true"></div>
|
||||
<div class="startup-resume-banner" id="startupResumeBanner" role="status" aria-live="polite" hidden>
|
||||
<span id="startupResumeMessage"></span>
|
||||
<button class="btn btn-xs btn-secondary" id="cancelStartupResumeBtn">Abbrechen</button>
|
||||
</div>
|
||||
|
||||
<div class="shutdown-overlay" id="shutdownOverlay" style="display:none" aria-hidden="true">
|
||||
<div class="shutdown-box" role="dialog" aria-modal="true" aria-labelledby="shutdownMessage">
|
||||
@@ -665,6 +674,9 @@
|
||||
<script src="../lib/speed-history.js"></script>
|
||||
<script src="account-submit.js"></script>
|
||||
<script src="account-status.js"></script>
|
||||
<script src="history-status.js"></script>
|
||||
<script src="auto-resume.js"></script>
|
||||
<script src="queue-stats.js"></script>
|
||||
<script src="i18n.js"></script>
|
||||
<script src="app.js"></script>
|
||||
</body>
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
(function (root, factory) {
|
||||
const api = factory();
|
||||
if (typeof module === 'object' && module.exports) module.exports = api;
|
||||
if (root) root.QueueStats = api;
|
||||
})(typeof window !== 'undefined' ? window : globalThis, function () {
|
||||
function calculateQueueStats(jobs) {
|
||||
const items = Array.isArray(jobs) ? jobs : [];
|
||||
let remaining = 0;
|
||||
let inProgress = 0;
|
||||
let done = 0;
|
||||
let errors = 0;
|
||||
let skipped = 0;
|
||||
let aborted = 0;
|
||||
let bytesRemaining = 0;
|
||||
let totalSize = 0;
|
||||
let remainingSize = 0;
|
||||
let inProgressBytes = 0;
|
||||
|
||||
for (const job of items) {
|
||||
const status = job?.status;
|
||||
const totalBytes = Number(job?.bytesTotal) || 0;
|
||||
const uploadedBytes = Number(job?.bytesUploaded) || 0;
|
||||
const pendingBytes = Math.max(0, totalBytes - uploadedBytes);
|
||||
totalSize += totalBytes;
|
||||
|
||||
if (status === 'uploading' || status === 'getting-server' || status === 'retrying') {
|
||||
inProgress++;
|
||||
remaining++;
|
||||
inProgressBytes += uploadedBytes;
|
||||
bytesRemaining += pendingBytes;
|
||||
remainingSize += pendingBytes;
|
||||
} else if (status === 'preview' || status === 'queued') {
|
||||
remaining++;
|
||||
bytesRemaining += pendingBytes;
|
||||
remainingSize += pendingBytes;
|
||||
} else if (status === 'done') {
|
||||
done++;
|
||||
} else if (status === 'error') {
|
||||
errors++;
|
||||
} else if (status === 'skipped') {
|
||||
skipped++;
|
||||
} else if (status === 'aborted') {
|
||||
aborted++;
|
||||
}
|
||||
}
|
||||
|
||||
return { total: items.length, remaining, inProgress, done, errors, skipped, aborted, bytesRemaining, totalSize, remainingSize, inProgressBytes };
|
||||
}
|
||||
|
||||
return { calculateQueueStats };
|
||||
});
|
||||
+48
-6
@@ -1578,6 +1578,7 @@ select.hs-input { max-width: none; width: auto; min-width: 140px; }
|
||||
.history-row:hover { background: rgba(255, 255, 255, 0.03); }
|
||||
.history-row.selected { background: rgba(102, 126, 234, 0.12); }
|
||||
.history-row.error { color: var(--danger); }
|
||||
.history-row.skipped { color: var(--warning); }
|
||||
.history-row td {
|
||||
padding: 5px 8px;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.03);
|
||||
@@ -1639,6 +1640,28 @@ select.hs-input { max-width: none; width: auto; min-width: 140px; }
|
||||
}
|
||||
.copy-toast.show { opacity: 1; transform: translateX(-50%) translateY(0); }
|
||||
|
||||
.startup-resume-banner {
|
||||
position: fixed;
|
||||
left: 50%;
|
||||
bottom: 26px;
|
||||
z-index: 2100;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
min-height: 42px;
|
||||
padding: 7px 8px 7px 14px;
|
||||
border: 1px solid var(--accent);
|
||||
border-radius: 8px;
|
||||
background: var(--bg-panel);
|
||||
color: var(--text);
|
||||
box-shadow: 0 12px 30px rgba(0, 0, 0, 0.35);
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
|
||||
.startup-resume-banner[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Shutdown overlay */
|
||||
.shutdown-overlay {
|
||||
position: fixed;
|
||||
@@ -1873,6 +1896,21 @@ select.hs-input { max-width: none; width: auto; min-width: 140px; }
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.local-backup-password-row {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.local-backup-password-fields {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 10px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.local-backup-password-fields[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.upload-speed-sparkline {
|
||||
width: 208px;
|
||||
height: 30px;
|
||||
@@ -3280,6 +3318,16 @@ input[type="checkbox"] {
|
||||
border-color: var(--danger);
|
||||
}
|
||||
|
||||
.settings-option.credential-fallback-option {
|
||||
margin-top: 12px;
|
||||
border-color: color-mix(in srgb, var(--danger) 72%, var(--border));
|
||||
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--danger) 10%, transparent);
|
||||
}
|
||||
|
||||
.settings-option.credential-fallback-option:focus-within {
|
||||
border-color: var(--danger);
|
||||
}
|
||||
|
||||
.settings-option-description,
|
||||
.hint {
|
||||
color: var(--text-dim);
|
||||
@@ -3721,12 +3769,6 @@ input[type="checkbox"] {
|
||||
grid-template-columns: 224px minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.app-brand-name {
|
||||
max-width: 155px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.upload-speed-sparkline {
|
||||
width: 168px;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user