Harden renderer modal and upload races
This commit is contained in:
+301
-328
@@ -385,11 +385,142 @@ let historySidebarFilter = 'all';
|
||||
let _knownUpdateInfo = null;
|
||||
let _updateCheckBusy = false;
|
||||
let _updateInstallBusy = false;
|
||||
let _updateDialogReturnFocus = null;
|
||||
let _updateDialogInertState = [];
|
||||
let _startupAutoResumeController = null;
|
||||
let _startupAutoResumeCanceled = false;
|
||||
|
||||
const modalController = (() => {
|
||||
const stack = [];
|
||||
const baselineInert = new Map();
|
||||
const focusSelector = 'button:not([disabled]), [href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])';
|
||||
|
||||
const resolveOverlay = value => typeof value === 'string' ? document.getElementById(value) : value;
|
||||
const getDialog = overlay => overlay?.querySelector('[role="dialog"]') || overlay;
|
||||
const isFocusable = element => {
|
||||
if (!(element instanceof HTMLElement) || element.hidden || element.matches(':disabled')) return false;
|
||||
const style = window.getComputedStyle(element);
|
||||
return style.display !== 'none' && style.visibility !== 'hidden' && element.getClientRects().length > 0;
|
||||
};
|
||||
const getFocusable = overlay => Array.from(getDialog(overlay)?.querySelectorAll(focusSelector) || []).filter(isFocusable);
|
||||
const resolveFocusTarget = (entry, value) => {
|
||||
const candidate = typeof value === 'function'
|
||||
? value()
|
||||
: typeof value === 'string'
|
||||
? entry.overlay.querySelector(value) || document.querySelector(value)
|
||||
: value;
|
||||
return isFocusable(candidate) ? candidate : null;
|
||||
};
|
||||
const syncIsolation = () => {
|
||||
const top = stack.at(-1);
|
||||
if (!top) {
|
||||
baselineInert.forEach((inert, element) => {
|
||||
if (element.isConnected) element.inert = inert;
|
||||
});
|
||||
baselineInert.clear();
|
||||
return;
|
||||
}
|
||||
Array.from(document.body.children).forEach(element => {
|
||||
if (!baselineInert.has(element)) baselineInert.set(element, element.inert);
|
||||
element.inert = element !== top.overlay;
|
||||
});
|
||||
};
|
||||
const focusEntry = entry => {
|
||||
if (!entry || stack.at(-1) !== entry) return;
|
||||
const dialog = getDialog(entry.overlay);
|
||||
const target = resolveFocusTarget(entry, entry.options.initialFocus) || getFocusable(entry.overlay)[0] || dialog;
|
||||
if (target instanceof HTMLElement && !target.matches(':disabled')) target.focus();
|
||||
};
|
||||
const open = (value, options = {}) => {
|
||||
const overlay = resolveOverlay(value);
|
||||
if (!overlay) return false;
|
||||
let entry = stack.find(item => item.overlay === overlay);
|
||||
if (entry) {
|
||||
entry.options = { ...entry.options, ...options };
|
||||
stack.splice(stack.indexOf(entry), 1);
|
||||
stack.push(entry);
|
||||
} else {
|
||||
entry = {
|
||||
overlay,
|
||||
options,
|
||||
returnFocus: options.returnFocus ?? (document.activeElement instanceof HTMLElement ? document.activeElement : null)
|
||||
};
|
||||
stack.push(entry);
|
||||
}
|
||||
overlay.style.display = options.display || 'flex';
|
||||
overlay.setAttribute('aria-hidden', 'false');
|
||||
overlay.inert = false;
|
||||
syncIsolation();
|
||||
if (!getDialog(overlay)?.contains(document.activeElement)) focusEntry(entry);
|
||||
requestAnimationFrame(() => {
|
||||
if (stack.at(-1) === entry && !getDialog(overlay)?.contains(document.activeElement)) focusEntry(entry);
|
||||
});
|
||||
return true;
|
||||
};
|
||||
const close = (value, options = {}) => {
|
||||
const overlay = resolveOverlay(value);
|
||||
const index = stack.findIndex(item => item.overlay === overlay);
|
||||
if (index < 0) {
|
||||
if (overlay) {
|
||||
overlay.style.display = 'none';
|
||||
overlay.setAttribute('aria-hidden', 'true');
|
||||
}
|
||||
return false;
|
||||
}
|
||||
const wasTop = index === stack.length - 1;
|
||||
const [entry] = stack.splice(index, 1);
|
||||
overlay.style.display = 'none';
|
||||
overlay.setAttribute('aria-hidden', 'true');
|
||||
syncIsolation();
|
||||
if (wasTop && options.restoreFocus !== false) {
|
||||
const fallback = resolveFocusTarget(entry, options.fallbackFocus ?? entry.options.fallbackFocus);
|
||||
const target = resolveFocusTarget(entry, entry.returnFocus) || fallback;
|
||||
if (target) target.focus();
|
||||
else if (stack.length > 0) focusEntry(stack.at(-1));
|
||||
}
|
||||
return true;
|
||||
};
|
||||
const isOpen = value => {
|
||||
const overlay = resolveOverlay(value);
|
||||
return Boolean(overlay && stack.some(item => item.overlay === overlay));
|
||||
};
|
||||
const handleKeydown = event => {
|
||||
const entry = stack.at(-1);
|
||||
if (!entry) return;
|
||||
const dialog = getDialog(entry.overlay);
|
||||
if (!dialog) return;
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault();
|
||||
event.stopImmediatePropagation();
|
||||
entry.options.onEscape?.();
|
||||
return;
|
||||
}
|
||||
if (event.key !== 'Tab') return;
|
||||
const focusable = getFocusable(entry.overlay);
|
||||
event.stopImmediatePropagation();
|
||||
if (focusable.length === 0) {
|
||||
event.preventDefault();
|
||||
dialog.focus();
|
||||
return;
|
||||
}
|
||||
const first = focusable[0];
|
||||
const last = focusable.at(-1);
|
||||
if (!dialog.contains(document.activeElement) || (event.shiftKey && document.activeElement === first) || (!event.shiftKey && document.activeElement === last)) {
|
||||
event.preventDefault();
|
||||
(event.shiftKey ? last : first).focus();
|
||||
}
|
||||
};
|
||||
const handleClick = event => {
|
||||
const entry = stack.at(-1);
|
||||
if (!entry || event.target !== entry.overlay || typeof entry.options.onBackdrop !== 'function') return;
|
||||
event.preventDefault();
|
||||
event.stopImmediatePropagation();
|
||||
entry.options.onBackdrop();
|
||||
};
|
||||
|
||||
document.addEventListener('keydown', handleKeydown, true);
|
||||
document.addEventListener('click', handleClick, true);
|
||||
return { open, close, isOpen };
|
||||
})();
|
||||
|
||||
// Session-specific files for the "Files" panel (resets each session)
|
||||
let sessionFilesData = [];
|
||||
let _recentSeqCounter = 0;
|
||||
@@ -1140,12 +1271,16 @@ function renderHosterModal() {
|
||||
function openHosterModal() {
|
||||
syncSelectedUploadHosters();
|
||||
renderHosterModal();
|
||||
document.getElementById('hosterModal').style.display = 'flex';
|
||||
modalController.open('hosterModal', {
|
||||
initialFocus: '#cancelHosterModalBtn',
|
||||
fallbackFocus: '#addFilesBtn',
|
||||
onEscape: cancelHosterModal,
|
||||
onBackdrop: cancelHosterModal
|
||||
});
|
||||
}
|
||||
|
||||
function closeHosterModal() {
|
||||
const modal = document.getElementById('hosterModal');
|
||||
if (modal) modal.style.display = 'none';
|
||||
modalController.close('hosterModal', { fallbackFocus: '#addFilesBtn' });
|
||||
}
|
||||
|
||||
async function applyHosterSelection() {
|
||||
@@ -1173,7 +1308,7 @@ async function applyHosterSelection() {
|
||||
|
||||
updateUploadView();
|
||||
persistQueueStateSoon(true); // immediate persist after adding files
|
||||
document.getElementById('hosterModal').style.display = 'none';
|
||||
closeHosterModal();
|
||||
}
|
||||
|
||||
function cancelHosterModal() {
|
||||
@@ -2788,21 +2923,8 @@ document.addEventListener('click', (e) => {
|
||||
if (!e.target.closest('.context-menu')) hideContextMenu();
|
||||
});
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (_isUpdateDialogVisible()) return;
|
||||
const accountModal = document.getElementById('accountModal');
|
||||
if (e.key === 'Tab' && accountModal && accountModal.style.display !== 'none') {
|
||||
const focusable = Array.from(accountModal.querySelectorAll('button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])'));
|
||||
const first = focusable[0];
|
||||
const last = focusable[focusable.length - 1];
|
||||
if (first && last && ((!e.shiftKey && document.activeElement === last) || (e.shiftKey && document.activeElement === first))) {
|
||||
e.preventDefault();
|
||||
(e.shiftKey ? last : first).focus();
|
||||
}
|
||||
}
|
||||
if (e.key === 'Escape') {
|
||||
hideContextMenu();
|
||||
cancelHosterModal();
|
||||
if (accountModal && accountModal.style.display !== 'none') closeAccountModal();
|
||||
}
|
||||
if (e.target instanceof window.Element && e.target.closest('input, textarea, select')) return;
|
||||
const activeView = document.querySelector('.view.active');
|
||||
@@ -2953,11 +3075,27 @@ async function persistSourceCleanupRevocations(preparation) {
|
||||
if (Array.isArray(preparation?.revokedHosters) && preparation.revokedHosters.length > 0) {
|
||||
sourceCleanupRevocationPending = true;
|
||||
}
|
||||
if (!sourceCleanupRevocationPending) return;
|
||||
if (!sourceCleanupRevocationPending) return false;
|
||||
await _persistQueueSnapshotBeforeUploadStart();
|
||||
return true;
|
||||
}
|
||||
|
||||
async function persistQueueBeforeUploadStart(preparation) {
|
||||
if (await persistSourceCleanupRevocations(preparation)) return;
|
||||
await _persistQueueSnapshotBeforeUploadStart();
|
||||
}
|
||||
|
||||
async function _persistQueueSnapshotBeforeUploadStart() {
|
||||
try {
|
||||
queuePersistThrottle.cancel();
|
||||
await persistQueueStateNow();
|
||||
await flushConfigWrites();
|
||||
sourceCleanupRevocationPending = false;
|
||||
} catch (cause) {
|
||||
const error = cause instanceof Error ? cause : new Error(String(cause));
|
||||
error.queuePersistenceFailure = true;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function completeSourceCleanupFinalization(data) {
|
||||
@@ -3044,7 +3182,7 @@ async function startUpload(opts) {
|
||||
updateQueueActionButtons();
|
||||
renderQueueTable();
|
||||
updateStatusBar();
|
||||
await persistSourceCleanupRevocations(cleanupPreparation);
|
||||
await persistQueueBeforeUploadStart(cleanupPreparation);
|
||||
|
||||
const uploadPayload = {
|
||||
hosters,
|
||||
@@ -3069,7 +3207,8 @@ async function startUpload(opts) {
|
||||
uploading = false;
|
||||
updateQueueActionButtons();
|
||||
updateStatusBar();
|
||||
await showAppAlert(formatLocalizedError('Upload-Start fehlgeschlagen', err), 'Upload-Start fehlgeschlagen');
|
||||
const prefix = err?.queuePersistenceFailure ? 'Warteschlange konnte vor dem Upload-Start nicht gespeichert werden' : 'Upload-Start fehlgeschlagen';
|
||||
await showAppAlert(formatLocalizedError(prefix, err), 'Upload-Start fehlgeschlagen');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3104,13 +3243,14 @@ async function startSelectedUpload(explicitJobs) {
|
||||
renderQueueTable();
|
||||
let result = null;
|
||||
try {
|
||||
await persistSourceCleanupRevocations(cleanupPreparation);
|
||||
await persistQueueBeforeUploadStart(cleanupPreparation);
|
||||
result = await window.api.addJobsToBatch({
|
||||
jobs: addable.map(serializeUploadJob),
|
||||
sourceCleanupGroups: cleanupPreparation.groups
|
||||
});
|
||||
} catch (err) {
|
||||
showCopyToast(formatLocalizedError('Jobs konnten nicht hinzugefügt werden', err));
|
||||
const prefix = err?.queuePersistenceFailure ? 'Warteschlange konnte vor dem Upload-Start nicht gespeichert werden' : 'Jobs konnten nicht hinzugefügt werden';
|
||||
showCopyToast(formatLocalizedError(prefix, err));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -3128,21 +3268,20 @@ async function startSelectedUpload(explicitJobs) {
|
||||
}
|
||||
persistQueueStateSoon();
|
||||
const added = Number(result && result.added) || 0;
|
||||
// Use ASCII-only toast text here to avoid encoding artifacts on some systems.
|
||||
const skipped = Array.isArray(result && result.skippedJobs) ? result.skippedJobs.length : 0;
|
||||
const alreadyInBatch = Array.isArray(result && result.alreadyInBatchJobIds)
|
||||
? result.alreadyInBatchJobIds.length
|
||||
: Math.max(0, addable.length - added - skipped);
|
||||
const toastParts = [];
|
||||
if (added > 0) toastParts.push(`${added} hinzugefuegt`);
|
||||
if (alreadyInBatch > 0) toastParts.push(`${alreadyInBatch} bereits im Batch`);
|
||||
if (skipped > 0) toastParts.push(`${skipped} ohne gueltigen Account`);
|
||||
if (added > 0) toastParts.push(localizeUiText(`${added} hinzugefügt`));
|
||||
if (alreadyInBatch > 0) toastParts.push(localizeUiText(`${alreadyInBatch} bereits im Batch`));
|
||||
if (skipped > 0) toastParts.push(localizeUiText(`${skipped} ohne gültigen Account`));
|
||||
if (result && result.error) {
|
||||
showCopyToast(formatLocalizedError('Jobs konnten nicht hinzugefügt werden', result.error));
|
||||
} else if (toastParts.length > 0) {
|
||||
showCopyToast(`Jobs: ${toastParts.join(', ')}`);
|
||||
} else {
|
||||
showCopyToast('Keine Jobs hinzugefuegt');
|
||||
showCopyToast('Keine Jobs hinzugefügt');
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -3168,7 +3307,7 @@ async function startSelectedUpload(explicitJobs) {
|
||||
updateQueueActionButtons();
|
||||
renderQueueTable();
|
||||
updateStatusBar();
|
||||
await persistSourceCleanupRevocations(cleanupPreparation);
|
||||
await persistQueueBeforeUploadStart(cleanupPreparation);
|
||||
|
||||
const uploadPayload = {
|
||||
hosters,
|
||||
@@ -3192,7 +3331,8 @@ async function startSelectedUpload(explicitJobs) {
|
||||
uploading = false;
|
||||
updateQueueActionButtons();
|
||||
updateStatusBar();
|
||||
await showAppAlert(formatLocalizedError('Upload-Start fehlgeschlagen', err), 'Upload-Start fehlgeschlagen');
|
||||
const prefix = err?.queuePersistenceFailure ? 'Warteschlange konnte vor dem Upload-Start nicht gespeichert werden' : 'Upload-Start fehlgeschlagen';
|
||||
await showAppAlert(formatLocalizedError(prefix, err), 'Upload-Start fehlgeschlagen');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3228,26 +3368,29 @@ function handleProgress(data) {
|
||||
}
|
||||
}
|
||||
function _handleProgressImpl(data) {
|
||||
let job = data.jobId ? _jobIndexById.get(data.jobId) : null;
|
||||
if (!job && data.uploadId) job = _jobIndexByUploadId.get(data.uploadId);
|
||||
const hasJobId = data.jobId !== undefined && data.jobId !== null && data.jobId !== '';
|
||||
let job = hasJobId ? _jobIndexById.get(data.jobId) : null;
|
||||
if (hasJobId && !job) return;
|
||||
if (!hasJobId && data.uploadId) job = _jobIndexByUploadId.get(data.uploadId);
|
||||
if (!job) {
|
||||
job = queueJobs.find(j =>
|
||||
j.fileName === data.fileName && j.hoster === data.hoster && j.status === 'queued'
|
||||
) || queueJobs.find(j =>
|
||||
j.fileName === data.fileName && j.hoster === data.hoster && j.status === 'preview'
|
||||
const candidates = queueJobs.filter(candidate =>
|
||||
candidate.fileName === data.fileName &&
|
||||
candidate.hoster === data.hoster &&
|
||||
candidate.status !== 'done' &&
|
||||
candidate.status !== 'skipped'
|
||||
);
|
||||
if (candidates.length > 1) return;
|
||||
job = candidates.length === 1 ? candidates[0] : null;
|
||||
if (job && data.uploadId) {
|
||||
job.uploadId = data.uploadId;
|
||||
_jobIndexByUploadId.set(data.uploadId, job);
|
||||
}
|
||||
}
|
||||
if (!job) {
|
||||
// Don't re-create jobs that were explicitly deleted by the user
|
||||
if ((data.jobId && _deletedJobIds.has(data.jobId)) || (data.uploadId && _deletedJobIds.has(data.uploadId))) {
|
||||
return;
|
||||
}
|
||||
if (['done', 'error', 'aborted', 'skipped'].includes(data.status)) return;
|
||||
if (data.uploadId && _deletedJobIds.has(data.uploadId)) return;
|
||||
job = {
|
||||
id: data.jobId || data.uploadId || `job-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
|
||||
id: data.uploadId || `job-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
|
||||
uploadId: data.uploadId,
|
||||
file: '', fileName: data.fileName, hoster: data.hoster,
|
||||
status: data.status, bytesUploaded: 0, bytesTotal: data.bytesTotal || 0,
|
||||
@@ -3575,6 +3718,9 @@ function _handleStatsImpl(data) {
|
||||
}
|
||||
|
||||
// --- Per-job log modal ---
|
||||
let _jobLogGeneration = 0;
|
||||
let _jobLogJobId = null;
|
||||
|
||||
async function showJobLogModal() {
|
||||
const selectedJobs = _getVisibleSelectedQueueJobs();
|
||||
if (selectedJobs.length === 0) return;
|
||||
@@ -3582,20 +3728,28 @@ async function showJobLogModal() {
|
||||
// make sense here.
|
||||
const job = selectedJobs[0];
|
||||
const jobId = job.id;
|
||||
const generation = ++_jobLogGeneration;
|
||||
_jobLogJobId = jobId;
|
||||
const modal = document.getElementById('jobLogModal');
|
||||
const titleEl = document.getElementById('jobLogTitle');
|
||||
const bodyEl = document.getElementById('jobLogBody');
|
||||
if (!modal || !titleEl || !bodyEl) return;
|
||||
|
||||
titleEl.textContent = job && job.fileName ? `Log · ${job.fileName}` : 'Upload-Log';
|
||||
bodyEl.textContent = 'Lade…';
|
||||
modal.style.display = 'flex';
|
||||
bodyEl.textContent = localizeUiText('Lade…');
|
||||
modalController.open(modal, {
|
||||
initialFocus: '#closeJobLogBtn',
|
||||
fallbackFocus: '#addFilesBtn',
|
||||
onEscape: hideJobLogModal,
|
||||
onBackdrop: hideJobLogModal
|
||||
});
|
||||
|
||||
let entries = [];
|
||||
try { entries = await window.api.getJobLog(jobId); } catch {}
|
||||
if (generation !== _jobLogGeneration || _jobLogJobId !== jobId || !modalController.isOpen(modal) || _jobIndexById.get(jobId) !== job) return;
|
||||
|
||||
if (!Array.isArray(entries) || entries.length === 0) {
|
||||
bodyEl.textContent = 'Keine Log-Einträge für diesen Job (entweder noch nichts passiert oder aus vorherigem Batch und schon geräumt).';
|
||||
bodyEl.textContent = localizeUiText('Keine Log-Einträge für diesen Job (entweder noch nichts passiert oder aus vorherigem Batch und schon geräumt).');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -3619,18 +3773,19 @@ async function showJobLogModal() {
|
||||
? Object.entries(job.failureDetails).map(([key, value]) => `${key}: ${value}`).join('\n')
|
||||
: '';
|
||||
const summary = [
|
||||
`Hoster: ${job.hoster || '–'}`,
|
||||
`Account: ${getAccountLabel(job) || job.accountId || '–'}`,
|
||||
`Versuch: ${job.attempt || '–'} / ${job.maxAttempts || '–'}`,
|
||||
job.error ? `Fehler: ${job.error}` : '',
|
||||
details ? `Diagnose:\n${details}` : ''
|
||||
`${localizeUiText('Hoster')}: ${job.hoster || '–'}`,
|
||||
`${localizeUiText('Account')}: ${getAccountLabel(job) || job.accountId || '–'}`,
|
||||
`${localizeUiText('Versuch')}: ${job.attempt || '–'} / ${job.maxAttempts || '–'}`,
|
||||
job.error ? `${localizeUiText('Fehler')}: ${job.error}` : '',
|
||||
details ? `${localizeUiText('Diagnose')}:\n${details}` : ''
|
||||
].filter(Boolean).join('\n');
|
||||
bodyEl.textContent = `${summary}\n\n${entries.map(fmt).join('\n')}`;
|
||||
}
|
||||
|
||||
function hideJobLogModal() {
|
||||
const m = document.getElementById('jobLogModal');
|
||||
if (m) m.style.display = 'none';
|
||||
_jobLogGeneration++;
|
||||
_jobLogJobId = null;
|
||||
modalController.close('jobLogModal', { fallbackFocus: '#addFilesBtn' });
|
||||
}
|
||||
|
||||
async function copyJobLogToClipboard() {
|
||||
@@ -4114,16 +4269,23 @@ function _setRollingUploadMetric(id, value) {
|
||||
|
||||
const direction = numericValue > previousValue ? 'up' : 'down';
|
||||
const previousText = element.getAttribute('aria-label') || previousValue.toLocaleString(getUiLocale());
|
||||
element.querySelectorAll(':scope > span').forEach(span => span.getAnimations().forEach(animation => animation.cancel()));
|
||||
element.dataset.numericValue = String(numericValue);
|
||||
element.setAttribute('aria-label', nextText);
|
||||
if (window.matchMedia?.('(prefers-reduced-motion: reduce)').matches) {
|
||||
const settled = document.createElement('span');
|
||||
settled.textContent = nextText;
|
||||
element.replaceChildren(settled);
|
||||
element.dataset.direction = 'none';
|
||||
return;
|
||||
}
|
||||
const outgoing = document.createElement('span');
|
||||
const incoming = document.createElement('span');
|
||||
outgoing.textContent = previousText;
|
||||
incoming.textContent = nextText;
|
||||
outgoing.className = 'upload-rolling-outgoing';
|
||||
incoming.className = 'upload-rolling-incoming';
|
||||
element.querySelectorAll(':scope > span').forEach(span => span.getAnimations().forEach(animation => animation.cancel()));
|
||||
element.dataset.numericValue = String(numericValue);
|
||||
element.dataset.direction = direction;
|
||||
element.setAttribute('aria-label', nextText);
|
||||
element.replaceChildren(outgoing, incoming);
|
||||
|
||||
const distance = direction === 'up' ? -1 : 1;
|
||||
@@ -4244,37 +4406,13 @@ function renderHealthCheckResults(_results) {
|
||||
}
|
||||
|
||||
let _appAlertResolve = null;
|
||||
let _appAlertReturnFocus = null;
|
||||
let _appAlertInertState = [];
|
||||
|
||||
function _setAppAlertBackgroundInert(active) {
|
||||
const modal = document.getElementById('appAlertModal');
|
||||
if (!modal) return;
|
||||
if (active) {
|
||||
if (_appAlertInertState.length > 0) return;
|
||||
_appAlertInertState = Array.from(document.body.children)
|
||||
.filter(element => element !== modal && 'inert' in element)
|
||||
.map(element => ({ element, inert: element.inert }));
|
||||
_appAlertInertState.forEach(({ element }) => { element.inert = true; });
|
||||
return;
|
||||
}
|
||||
_appAlertInertState.forEach(({ element, inert }) => {
|
||||
if (element.isConnected) element.inert = inert;
|
||||
});
|
||||
_appAlertInertState = [];
|
||||
}
|
||||
|
||||
function closeAppAlert(result = false) {
|
||||
const modal = document.getElementById('appAlertModal');
|
||||
if (!modal) return;
|
||||
modal.style.display = 'none';
|
||||
modal.setAttribute('aria-hidden', 'true');
|
||||
_setAppAlertBackgroundInert(false);
|
||||
const resolve = _appAlertResolve;
|
||||
_appAlertResolve = null;
|
||||
const returnFocus = _appAlertReturnFocus;
|
||||
_appAlertReturnFocus = null;
|
||||
if (returnFocus?.isConnected) returnFocus.focus();
|
||||
modalController.close(modal);
|
||||
if (resolve) resolve(result);
|
||||
}
|
||||
|
||||
@@ -4287,7 +4425,6 @@ function showAppDialog({ message, title = 'Hinweis', confirmText = 'OK', cancelT
|
||||
const alternate = document.getElementById('appAlertAlternateBtn');
|
||||
if (!modal || !titleEl || !messageEl || !confirm || !cancel || !alternate) return Promise.resolve(false);
|
||||
if (_appAlertResolve) closeAppAlert(false);
|
||||
_appAlertReturnFocus = document.activeElement instanceof HTMLElement ? document.activeElement : null;
|
||||
titleEl.textContent = localizeUiText(title);
|
||||
messageEl.textContent = localizeUiText(String(message || ''));
|
||||
confirm.textContent = localizeUiText(confirmText);
|
||||
@@ -4296,10 +4433,11 @@ function showAppDialog({ message, title = 'Hinweis', confirmText = 'OK', cancelT
|
||||
cancel.hidden = !showCancel;
|
||||
alternate.textContent = localizeUiText(alternateText);
|
||||
alternate.hidden = !alternateText;
|
||||
modal.style.display = 'flex';
|
||||
modal.setAttribute('aria-hidden', 'false');
|
||||
_setAppAlertBackgroundInert(true);
|
||||
(showCancel ? cancel : confirm).focus();
|
||||
modalController.open(modal, {
|
||||
initialFocus: () => showCancel ? cancel : confirm,
|
||||
onEscape: () => closeAppAlert(false),
|
||||
onBackdrop: () => closeAppAlert(false)
|
||||
});
|
||||
return new Promise(resolve => { _appAlertResolve = resolve; });
|
||||
}
|
||||
|
||||
@@ -4330,27 +4468,6 @@ function setupAppAlertListeners() {
|
||||
alternate.addEventListener('click', () => closeAppAlert('alternate'));
|
||||
cancel.addEventListener('click', () => closeAppAlert(false));
|
||||
close.addEventListener('click', () => closeAppAlert(false));
|
||||
modal.addEventListener('click', event => {
|
||||
if (event.target === modal) closeAppAlert(false);
|
||||
});
|
||||
document.addEventListener('keydown', event => {
|
||||
if (modal.style.display !== 'flex') return;
|
||||
const focusable = [...modal.querySelectorAll('button:not([disabled]):not([hidden])')];
|
||||
if (event.key === 'Tab' && focusable.length) {
|
||||
const first = focusable[0];
|
||||
const last = focusable.at(-1);
|
||||
if ((!event.shiftKey && document.activeElement === last) || (event.shiftKey && document.activeElement === first)) {
|
||||
event.preventDefault();
|
||||
(event.shiftKey ? last : first).focus();
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault();
|
||||
event.stopImmediatePropagation();
|
||||
closeAppAlert(false);
|
||||
}
|
||||
}, true);
|
||||
}
|
||||
|
||||
async function executeHealthCheck(hosters, _mode, generations) {
|
||||
@@ -6079,10 +6196,7 @@ function wireCredentialVisibilityButtons(container) {
|
||||
});
|
||||
}
|
||||
|
||||
let _accountModalReturnFocus = null;
|
||||
|
||||
function openAccountModal(editAccountId) {
|
||||
_accountModalReturnFocus = document.activeElement instanceof HTMLElement ? document.activeElement : null;
|
||||
editingAccountId = editAccountId || null;
|
||||
_resetAccountModalState();
|
||||
const modal = document.getElementById('accountModal');
|
||||
@@ -6126,26 +6240,19 @@ function openAccountModal(editAccountId) {
|
||||
|
||||
_wireCredFieldInvalidation();
|
||||
|
||||
modal.style.display = 'flex';
|
||||
requestAnimationFrame(() => {
|
||||
const firstControl = editingAccountId
|
||||
? document.getElementById('accField_label')
|
||||
: hosterSelect;
|
||||
if (firstControl) firstControl.focus();
|
||||
modalController.open(modal, {
|
||||
initialFocus: () => editingAccountId ? document.getElementById('accField_label') : hosterSelect,
|
||||
fallbackFocus: '#addAccountBtn',
|
||||
onEscape: closeAccountModal,
|
||||
onBackdrop: closeAccountModal
|
||||
});
|
||||
}
|
||||
|
||||
function closeAccountModal() {
|
||||
document.getElementById('accountModal').style.display = 'none';
|
||||
modalController.close('accountModal', { fallbackFocus: '#addAccountBtn' });
|
||||
_hideOtpField();
|
||||
editingAccountId = null;
|
||||
_resetAccountModalState();
|
||||
const returnFocus = _accountModalReturnFocus;
|
||||
_accountModalReturnFocus = null;
|
||||
const focusTarget = returnFocus && returnFocus.isConnected
|
||||
? returnFocus
|
||||
: document.getElementById('addAccountBtn');
|
||||
if (focusTarget) focusTarget.focus();
|
||||
}
|
||||
|
||||
function openDeleteAccountModal(accountId) {
|
||||
@@ -6155,11 +6262,16 @@ function openDeleteAccountModal(accountId) {
|
||||
const msg = document.getElementById('deleteAccountMessage');
|
||||
msg.textContent = `Account "${getAccountDisplayName(found.name, found.account)}" wirklich löschen?`;
|
||||
modal.dataset.accountId = accountId;
|
||||
modal.style.display = 'flex';
|
||||
modalController.open(modal, {
|
||||
initialFocus: '#cancelDeleteBtn',
|
||||
fallbackFocus: '#addAccountBtn',
|
||||
onEscape: closeDeleteModal,
|
||||
onBackdrop: closeDeleteModal
|
||||
});
|
||||
}
|
||||
|
||||
function closeDeleteModal() {
|
||||
document.getElementById('deleteAccountModal').style.display = 'none';
|
||||
modalController.close('deleteAccountModal', { fallbackFocus: '#addAccountBtn' });
|
||||
}
|
||||
|
||||
async function deleteAccount(accountId) {
|
||||
@@ -6175,12 +6287,12 @@ async function deleteAccount(accountId) {
|
||||
// saveConfig is async — close the modal immediately so the UI feels
|
||||
// responsive instead of waiting for the atomic write + safeStorage encrypt.
|
||||
// The in-memory config already reflects the delete; the IPC just persists it.
|
||||
closeDeleteModal();
|
||||
ensureAccountStatusEntries();
|
||||
syncSelectedUploadHosters();
|
||||
if (getAllAccountsFlat().length === 0) renderHealthCheckResults([]);
|
||||
renderAccounts();
|
||||
renderHosterSummary();
|
||||
closeDeleteModal();
|
||||
// Fire-and-forget the persist. The earlier `await getConfig()` round-trip
|
||||
// was redundant (we already have the truth in memory) and was the main
|
||||
// source of perceived lag on add/delete.
|
||||
@@ -6492,54 +6604,21 @@ function syncHistoryClearAction() {
|
||||
syncDataActionState();
|
||||
}
|
||||
|
||||
let historyClearReturnFocus = null;
|
||||
let historyClearInertState = [];
|
||||
|
||||
function setHistoryClearBackgroundInert(active) {
|
||||
const modal = document.getElementById('historyClearModal');
|
||||
if (!modal) return;
|
||||
if (active) {
|
||||
if (historyClearInertState.length > 0) return;
|
||||
historyClearInertState = Array.from(document.body.children)
|
||||
.filter(element => element !== modal)
|
||||
.map(element => ({ element, inert: element.inert }));
|
||||
historyClearInertState.forEach(({ element }) => { element.inert = true; });
|
||||
return;
|
||||
}
|
||||
historyClearInertState.forEach(({ element, inert }) => {
|
||||
if (element.isConnected) element.inert = inert;
|
||||
});
|
||||
historyClearInertState = [];
|
||||
}
|
||||
|
||||
function getHistoryClearFocusable() {
|
||||
const modal = document.getElementById('historyClearModal');
|
||||
if (!modal) return [];
|
||||
return Array.from(modal.querySelectorAll('button:not([disabled]), [href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])'))
|
||||
.filter(element => !element.hidden && window.getComputedStyle(element).display !== 'none' && window.getComputedStyle(element).visibility !== 'hidden');
|
||||
}
|
||||
|
||||
function closeHistoryClearModal() {
|
||||
const modal = document.getElementById('historyClearModal');
|
||||
if (!modal) return;
|
||||
modal.style.display = 'none';
|
||||
modal.setAttribute('aria-hidden', 'true');
|
||||
setHistoryClearBackgroundInert(false);
|
||||
const returnFocus = historyClearReturnFocus;
|
||||
historyClearReturnFocus = null;
|
||||
if (returnFocus?.isConnected && !returnFocus.disabled) returnFocus.focus();
|
||||
else document.getElementById('clearHistoryBtn')?.focus();
|
||||
modalController.close('historyClearModal', { fallbackFocus: '#clearHistoryBtn' });
|
||||
}
|
||||
|
||||
function openHistoryClearModal() {
|
||||
const button = document.getElementById('clearHistoryBtn');
|
||||
const modal = document.getElementById('historyClearModal');
|
||||
if (!modal || !button || button.disabled) return;
|
||||
historyClearReturnFocus = button;
|
||||
modal.style.display = 'flex';
|
||||
modal.setAttribute('aria-hidden', 'false');
|
||||
setHistoryClearBackgroundInert(true);
|
||||
document.getElementById('cancelHistoryClearBtn')?.focus();
|
||||
modalController.open(modal, {
|
||||
initialFocus: '#cancelHistoryClearBtn',
|
||||
returnFocus: button,
|
||||
fallbackFocus: '#clearHistoryBtn',
|
||||
onEscape: closeHistoryClearModal,
|
||||
onBackdrop: closeHistoryClearModal
|
||||
});
|
||||
}
|
||||
|
||||
async function confirmHistoryClear() {
|
||||
@@ -6982,7 +7061,6 @@ function sortHistoryRows(rows) {
|
||||
}
|
||||
|
||||
let closePreparationPromise = null;
|
||||
let closePreparationInertState = [];
|
||||
let closePreparationOverlayState = null;
|
||||
let closePreparationGeneration = 0;
|
||||
let activeClosePreparationAttempt = null;
|
||||
@@ -7003,27 +7081,31 @@ function setClosePreparationUi(active) {
|
||||
if (active) {
|
||||
if (closePreparationOverlayState) return;
|
||||
closePreparationOverlayState = {
|
||||
display: overlay.style.display,
|
||||
wasOpen: modalController.isOpen(overlay),
|
||||
message: message.innerHTML,
|
||||
cancelDisplay: cancelButton.style.display
|
||||
};
|
||||
closePreparationInertState = Array.from(document.body.children)
|
||||
.filter(element => element !== overlay && 'inert' in element)
|
||||
.map(element => ({ element, inert: element.inert }));
|
||||
closePreparationInertState.forEach(({ element }) => { element.inert = true; });
|
||||
message.textContent = 'Einstellungen werden gespeichert…';
|
||||
cancelButton.style.display = 'none';
|
||||
overlay.style.display = 'flex';
|
||||
modalController.open(overlay, {
|
||||
initialFocus: () => overlay.querySelector('[role="dialog"]'),
|
||||
onEscape: () => {}
|
||||
});
|
||||
return;
|
||||
}
|
||||
closePreparationInertState.forEach(({ element, inert }) => {
|
||||
if (element.isConnected) element.inert = inert;
|
||||
});
|
||||
closePreparationInertState = [];
|
||||
overlay.style.display = closePreparationOverlayState ? closePreparationOverlayState.display : 'none';
|
||||
message.innerHTML = closePreparationOverlayState ? closePreparationOverlayState.message : '';
|
||||
cancelButton.style.display = closePreparationOverlayState ? closePreparationOverlayState.cancelDisplay : '';
|
||||
const previousState = closePreparationOverlayState;
|
||||
message.innerHTML = previousState ? previousState.message : '';
|
||||
cancelButton.style.display = previousState ? previousState.cancelDisplay : '';
|
||||
closePreparationOverlayState = null;
|
||||
if (previousState?.wasOpen) {
|
||||
modalController.open(overlay, {
|
||||
initialFocus: '#cancelShutdownBtn',
|
||||
onEscape: cancelShutdownCountdown
|
||||
});
|
||||
requestAnimationFrame(() => cancelButton.focus());
|
||||
} else {
|
||||
modalController.close(overlay);
|
||||
}
|
||||
}
|
||||
|
||||
function isCurrentClosePreparation(generation, attempt) {
|
||||
@@ -7212,41 +7294,12 @@ function setupListeners() {
|
||||
document.getElementById('confirmHistoryClearBtn').addEventListener('click', confirmHistoryClear);
|
||||
document.getElementById('cancelHistoryClearBtn').addEventListener('click', closeHistoryClearModal);
|
||||
document.getElementById('closeHistoryClearModalBtn').addEventListener('click', closeHistoryClearModal);
|
||||
document.getElementById('historyClearModal').addEventListener('click', event => {
|
||||
if (event.target.id === 'historyClearModal') closeHistoryClearModal();
|
||||
});
|
||||
document.addEventListener('keydown', event => {
|
||||
const modal = document.getElementById('historyClearModal');
|
||||
if (modal?.style.display !== 'flex') return;
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault();
|
||||
event.stopImmediatePropagation();
|
||||
closeHistoryClearModal();
|
||||
return;
|
||||
}
|
||||
if (event.key !== 'Tab') return;
|
||||
const focusable = getHistoryClearFocusable();
|
||||
if (!focusable.length) {
|
||||
event.preventDefault();
|
||||
modal.querySelector('[role="dialog"]')?.focus();
|
||||
return;
|
||||
}
|
||||
const first = focusable[0];
|
||||
const last = focusable.at(-1);
|
||||
if (event.shiftKey && document.activeElement === first) {
|
||||
event.preventDefault();
|
||||
last.focus();
|
||||
} else if (!event.shiftKey && document.activeElement === last) {
|
||||
event.preventDefault();
|
||||
first.focus();
|
||||
}
|
||||
}, true);
|
||||
document.getElementById('exportHistoryBtn').addEventListener('click', exportHistory);
|
||||
document.getElementById('exportSessionReportBtn').addEventListener('click', async () => {
|
||||
const format = await showAppChoice({ title: 'Sitzungsbericht exportieren', message: 'Welches Format möchtest du speichern?', confirmText: 'CSV', alternateText: 'JSON' });
|
||||
if (format === false) return;
|
||||
const result = await window.api.exportSessionReport(format === 'alternate' ? 'json' : 'csv');
|
||||
if (result?.ok) showCopyToast(`Sitzungsbericht mit ${result.totalRows} Uploads exportiert`);
|
||||
if (result?.ok) showCopyToast(result.totalRows === 1 ? 'Sitzungsbericht mit 1 Upload exportiert' : `Sitzungsbericht mit ${result.totalRows} Uploads exportiert`);
|
||||
else if (!result?.canceled) await showAppAlert(result?.error || 'Sitzungsbericht konnte nicht exportiert werden.');
|
||||
});
|
||||
document.getElementById('queueSearchInput').addEventListener('input', applyQueueDetailFilters);
|
||||
@@ -7376,13 +7429,7 @@ function setupListeners() {
|
||||
setupColumnResizing();
|
||||
|
||||
// Shutdown cancel
|
||||
document.getElementById('cancelShutdownBtn').addEventListener('click', async () => {
|
||||
await window.api.cancelShutdown();
|
||||
if (shutdownCountdownInterval) { clearInterval(shutdownCountdownInterval); shutdownCountdownInterval = null; }
|
||||
const overlay = document.getElementById('shutdownOverlay');
|
||||
overlay.style.display = 'none';
|
||||
overlay.setAttribute('aria-hidden', 'true');
|
||||
});
|
||||
document.getElementById('cancelShutdownBtn').addEventListener('click', cancelShutdownCountdown);
|
||||
|
||||
// Click on empty area in queue → deselect all
|
||||
document.getElementById('upload-view').addEventListener('click', (e) => {
|
||||
@@ -7404,19 +7451,11 @@ function setupListeners() {
|
||||
showContextMenu(e.clientX, e.clientY);
|
||||
});
|
||||
|
||||
document.getElementById('hosterModal').addEventListener('click', (e) => {
|
||||
if (e.target.id === 'hosterModal') cancelHosterModal();
|
||||
});
|
||||
|
||||
// Account management
|
||||
document.getElementById('addAccountBtn').addEventListener('click', () => openAccountModal(null));
|
||||
document.getElementById('closeAccountModalBtn').addEventListener('click', closeAccountModal);
|
||||
document.getElementById('cancelAccountModalBtn').addEventListener('click', closeAccountModal);
|
||||
document.getElementById('saveAccountBtn').addEventListener('click', saveAccount);
|
||||
document.getElementById('accountModal').addEventListener('click', (e) => {
|
||||
if (e.target.id === 'accountModal') closeAccountModal();
|
||||
});
|
||||
|
||||
// Account hoster select change → update credential fields
|
||||
document.getElementById('accountHosterSelect').addEventListener('change', (e) => {
|
||||
_invalidateAccountSubmit();
|
||||
@@ -7436,26 +7475,14 @@ function setupListeners() {
|
||||
const accountId = modal.dataset.accountId;
|
||||
if (accountId) deleteAccount(accountId);
|
||||
});
|
||||
document.getElementById('deleteAccountModal').addEventListener('click', (e) => {
|
||||
if (e.target.id === 'deleteAccountModal') closeDeleteModal();
|
||||
});
|
||||
|
||||
// Job log modal
|
||||
document.getElementById('closeJobLogBtn')?.addEventListener('click', hideJobLogModal);
|
||||
document.getElementById('closeJobLogBtn2')?.addEventListener('click', hideJobLogModal);
|
||||
document.getElementById('copyJobLogBtn')?.addEventListener('click', copyJobLogToClipboard);
|
||||
document.getElementById('jobLogModal')?.addEventListener('click', (e) => {
|
||||
if (e.target.id === 'jobLogModal') hideJobLogModal();
|
||||
});
|
||||
|
||||
document.getElementById('headerUpdateBtn')?.addEventListener('click', requestUpdateCheck);
|
||||
document.getElementById('installUpdateBtn')?.addEventListener('click', installKnownUpdate);
|
||||
document.getElementById('dismissUpdateBtn')?.addEventListener('click', closeUpdateDialog);
|
||||
document.getElementById('updateCloseBtn')?.addEventListener('click', closeUpdateDialog);
|
||||
document.getElementById('updateBanner')?.addEventListener('click', (event) => {
|
||||
if (event.target.id === 'updateBanner') closeUpdateDialog();
|
||||
});
|
||||
document.addEventListener('keydown', _handleUpdateDialogKeydown, true);
|
||||
_syncHeaderUpdateState();
|
||||
}
|
||||
|
||||
@@ -7566,6 +7593,7 @@ function handleUpdateProgress(data) {
|
||||
_updateInstallBusy = false;
|
||||
_setUpdateDialogBusy(false);
|
||||
_setUpdateProgress(0, 'Update fehlgeschlagen');
|
||||
_setUpdateProgressVisible(false);
|
||||
if (message) {
|
||||
message.hidden = false;
|
||||
message.textContent = formatLocalizedError('Update fehlgeschlagen', progress.error);
|
||||
@@ -7579,98 +7607,21 @@ function handleUpdateProgress(data) {
|
||||
}
|
||||
|
||||
function _isUpdateDialogVisible() {
|
||||
const overlay = document.getElementById('updateBanner');
|
||||
return Boolean(overlay && overlay.style.display !== 'none' && overlay.getAttribute('aria-hidden') !== 'true');
|
||||
}
|
||||
|
||||
function _setUpdateBackgroundInert(active) {
|
||||
const overlay = document.getElementById('updateBanner');
|
||||
if (!overlay) return;
|
||||
if (active) {
|
||||
if (_updateDialogInertState.length > 0) return;
|
||||
_updateDialogInertState = Array.from(document.body.children)
|
||||
.filter(element => element !== overlay && 'inert' in element)
|
||||
.map(element => ({ element, inert: element.inert }));
|
||||
_updateDialogInertState.forEach(({ element }) => { element.inert = true; });
|
||||
return;
|
||||
}
|
||||
_updateDialogInertState.forEach(({ element, inert }) => {
|
||||
if (element.isConnected) element.inert = inert;
|
||||
});
|
||||
_updateDialogInertState = [];
|
||||
}
|
||||
|
||||
function _getUpdateDialogFocusable() {
|
||||
const dialog = document.querySelector('#updateBanner .update-dialog');
|
||||
if (!dialog) return [];
|
||||
return Array.from(dialog.querySelectorAll('button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [href], [tabindex]:not([tabindex="-1"])'))
|
||||
.filter(element => !element.hidden && element.getClientRects().length > 0 && window.getComputedStyle(element).visibility !== 'hidden');
|
||||
}
|
||||
|
||||
function _focusUpdateDialog() {
|
||||
const dialog = document.querySelector('#updateBanner .update-dialog');
|
||||
if (!dialog) return;
|
||||
const target = _getUpdateDialogFocusable()[0] || dialog;
|
||||
target.focus();
|
||||
}
|
||||
|
||||
function _handleUpdateDialogKeydown(event) {
|
||||
if (!_isUpdateDialogVisible()) return;
|
||||
const dialog = document.querySelector('#updateBanner .update-dialog');
|
||||
if (!dialog) return;
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault();
|
||||
event.stopImmediatePropagation();
|
||||
if (!_updateInstallBusy) closeUpdateDialog();
|
||||
return;
|
||||
}
|
||||
if (event.key !== 'Tab') {
|
||||
if (!dialog.contains(event.target)) {
|
||||
event.preventDefault();
|
||||
event.stopImmediatePropagation();
|
||||
_focusUpdateDialog();
|
||||
}
|
||||
return;
|
||||
}
|
||||
const focusable = _getUpdateDialogFocusable();
|
||||
if (focusable.length === 0) {
|
||||
event.preventDefault();
|
||||
event.stopImmediatePropagation();
|
||||
dialog.focus();
|
||||
return;
|
||||
}
|
||||
const first = focusable[0];
|
||||
const last = focusable[focusable.length - 1];
|
||||
const activeElement = document.activeElement;
|
||||
if (!dialog.contains(activeElement) || (!event.shiftKey && activeElement === last) || (event.shiftKey && activeElement === first)) {
|
||||
event.preventDefault();
|
||||
event.stopImmediatePropagation();
|
||||
(event.shiftKey ? last : first).focus();
|
||||
}
|
||||
return modalController.isOpen('updateBanner');
|
||||
}
|
||||
|
||||
function _setUpdateDialogVisible(visible) {
|
||||
const overlay = document.getElementById('updateBanner');
|
||||
if (!overlay) return;
|
||||
if (visible) {
|
||||
const wasVisible = _isUpdateDialogVisible();
|
||||
if (!wasVisible) {
|
||||
_updateDialogReturnFocus = document.activeElement instanceof HTMLElement ? document.activeElement : null;
|
||||
_setUpdateBackgroundInert(true);
|
||||
}
|
||||
overlay.style.display = 'flex';
|
||||
overlay.setAttribute('aria-hidden', 'false');
|
||||
requestAnimationFrame(() => {
|
||||
const dialog = overlay.querySelector('.update-dialog');
|
||||
if (_isUpdateDialogVisible() && dialog && !dialog.contains(document.activeElement)) _focusUpdateDialog();
|
||||
modalController.open(overlay, {
|
||||
initialFocus: '#updateCloseBtn',
|
||||
fallbackFocus: '#headerUpdateBtn',
|
||||
onEscape: closeUpdateDialog,
|
||||
onBackdrop: closeUpdateDialog
|
||||
});
|
||||
} else {
|
||||
overlay.style.display = 'none';
|
||||
overlay.setAttribute('aria-hidden', 'true');
|
||||
_setUpdateBackgroundInert(false);
|
||||
const returnFocus = _updateDialogReturnFocus;
|
||||
_updateDialogReturnFocus = null;
|
||||
if (returnFocus && returnFocus.isConnected && typeof returnFocus.focus === 'function') returnFocus.focus();
|
||||
modalController.close(overlay, { fallbackFocus: '#headerUpdateBtn' });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7691,6 +7642,7 @@ function _setUpdateDialogBusy(busy) {
|
||||
}
|
||||
|
||||
function _setUpdateProgress(percent, text) {
|
||||
_setUpdateProgressVisible(true);
|
||||
const value = Math.max(0, Math.min(100, Math.round(Number(percent) || 0)));
|
||||
const progressText = document.getElementById('updateProgressText');
|
||||
const progressBar = document.getElementById('updateProgressBar');
|
||||
@@ -7703,6 +7655,13 @@ function _setUpdateProgress(percent, text) {
|
||||
}
|
||||
}
|
||||
|
||||
function _setUpdateProgressVisible(visible) {
|
||||
const progress = document.querySelector('#updateBanner .update-progress');
|
||||
const text = document.getElementById('updateProgressText');
|
||||
if (progress) progress.hidden = !visible;
|
||||
if (text) text.hidden = !visible;
|
||||
}
|
||||
|
||||
async function installKnownUpdate() {
|
||||
if (_updateInstallBusy) return;
|
||||
if (!_knownUpdateInfo || !_knownUpdateInfo.available) {
|
||||
@@ -7725,24 +7684,38 @@ async function installKnownUpdate() {
|
||||
|
||||
// --- Shutdown ---
|
||||
let shutdownCountdownInterval = null;
|
||||
|
||||
async function cancelShutdownCountdown() {
|
||||
await window.api.cancelShutdown();
|
||||
if (shutdownCountdownInterval) clearInterval(shutdownCountdownInterval);
|
||||
shutdownCountdownInterval = null;
|
||||
modalController.close('shutdownOverlay');
|
||||
}
|
||||
|
||||
function handleShutdownCountdown(data) {
|
||||
const overlay = document.getElementById('shutdownOverlay');
|
||||
const msgEl = document.getElementById('shutdownMessage');
|
||||
const secEl = document.getElementById('shutdownSeconds');
|
||||
overlay.style.display = 'flex';
|
||||
if (!overlay || !msgEl) return;
|
||||
|
||||
const labels = { sleep: 'Ruhezustand', shutdown: 'Herunterfahren', restart: 'Neustart' };
|
||||
let remaining = data.seconds || 60;
|
||||
secEl.textContent = remaining;
|
||||
const render = () => {
|
||||
msgEl.textContent = localizeUiText(`${labels[data.mode] || data.mode} in ${remaining}s...`);
|
||||
overlay.setAttribute('aria-hidden', 'false');
|
||||
};
|
||||
render();
|
||||
modalController.open(overlay, {
|
||||
initialFocus: '#cancelShutdownBtn',
|
||||
onEscape: cancelShutdownCountdown
|
||||
});
|
||||
|
||||
if (shutdownCountdownInterval) clearInterval(shutdownCountdownInterval);
|
||||
shutdownCountdownInterval = setInterval(() => {
|
||||
remaining--;
|
||||
secEl.textContent = remaining;
|
||||
msgEl.textContent = localizeUiText(`${labels[data.mode] || data.mode} in ${remaining}s...`);
|
||||
if (remaining <= 0) { clearInterval(shutdownCountdownInterval); }
|
||||
render();
|
||||
if (remaining <= 0) {
|
||||
clearInterval(shutdownCountdownInterval);
|
||||
shutdownCountdownInterval = null;
|
||||
}
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
|
||||
+19
-1
@@ -41,6 +41,7 @@
|
||||
['Hinweis', 'Notice'],
|
||||
['Hoster-Limits automatisch hochskalieren', 'Automatically scale host limits'],
|
||||
['Hoster', 'Host'],
|
||||
['Versuch', 'Attempt'],
|
||||
['Importieren', 'Import'],
|
||||
['In Zwischenablage', 'To clipboard'],
|
||||
['In diesem Lauf hochgeladen:', 'Uploaded during this run:'],
|
||||
@@ -416,7 +417,7 @@
|
||||
['Keine Antwort vom Hoster erhalten', 'No response received from the host'],
|
||||
['Keine Einträge im Log gefunden', 'No log entries found'],
|
||||
['Keine Einträge zum Exportieren.', 'No entries to export.'],
|
||||
['Keine Jobs hinzugefuegt', 'No jobs added'],
|
||||
['Keine Jobs hinzugefügt', 'No jobs added'],
|
||||
['Keine Log-Einträge für diesen Job (entweder noch nichts passiert oder aus vorherigem Batch und schon geräumt).', 'No log entries for this job. Nothing has happened yet, or entries from a previous batch were already cleared.'],
|
||||
['Keine passenden Jobs für Retry gefunden.', 'No matching jobs found for retry.'],
|
||||
['Keine startbaren Jobs ausgewählt (alle laufen schon oder sind fertig).', 'No startable jobs selected because all are already running or completed.'],
|
||||
@@ -592,6 +593,8 @@
|
||||
['Upload-Status', 'Upload status'],
|
||||
['Upload-Übersicht', 'Upload overview'],
|
||||
['Upload-Wiederherstellung konnte nicht gespeichert werden', 'Upload recovery could not be saved'],
|
||||
['Sitzungsbericht konnte nicht exportiert werden.', 'The session report could not be exported.'],
|
||||
['Warteschlange konnte vor dem Upload-Start nicht gespeichert werden', 'The queue could not be saved before starting the upload'],
|
||||
['Verlauf als CSV exportieren?\n\nOK = CSV\nAbbrechen = JSON', 'Export history as CSV?\n\nOK = CSV\nCancel = JSON'],
|
||||
['Verlauf wirklich löschen?', 'Are you sure you want to delete the history?'],
|
||||
['Verschlüssele und speichere Einstellungen…', 'Encrypting and saving settings…'],
|
||||
@@ -647,7 +650,14 @@
|
||||
const patterns = target === 'en'
|
||||
? [
|
||||
[/^Update v(.+) verfügbar$/, 'Update v$1 available'],
|
||||
[/^1 unterbrochener Upload kann fortgesetzt werden\.$/, '1 interrupted upload can be resumed.'],
|
||||
[/^(\d+) unterbrochene Uploads können fortgesetzt werden\.$/, '$1 interrupted uploads can be resumed.'],
|
||||
[/^Wiederhergestellte Warteschlange startet in (.+) s \((.+) Jobs\)\.$/, 'Restored queue starts in $1 s ($2 jobs).'],
|
||||
[/^(\d+) hinzugefügt$/, '$1 added'],
|
||||
[/^(\d+) bereits im Batch$/, '$1 already in the batch'],
|
||||
[/^(\d+) ohne gültigen Account$/, '$1 without a valid account'],
|
||||
[/^Sitzungsbericht mit 1 Upload exportiert$/, 'Session report with 1 upload exported'],
|
||||
[/^Sitzungsbericht mit (\d+) Uploads exportiert$/, 'Session report with $1 uploads exported'],
|
||||
[/^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: (.+)$/, (_, detail) => `Export failed: ${translateErrorDetail(detail)}`],
|
||||
@@ -758,6 +768,14 @@
|
||||
[/^Import failed: (.+)$/, (_, detail) => `Import fehlgeschlagen: ${translateErrorDetail(detail)}`],
|
||||
[/^Initialization failed: (.+)$/, (_, detail) => `Initialisierung fehlgeschlagen: ${translateErrorDetail(detail)}`],
|
||||
[/^Update v(.+) available$/, 'Update v$1 verfügbar'],
|
||||
[/^1 interrupted upload can be resumed\.$/, '1 unterbrochener Upload kann fortgesetzt werden.'],
|
||||
[/^(\d+) interrupted uploads can be resumed\.$/, '$1 unterbrochene Uploads können fortgesetzt werden.'],
|
||||
[/^Restored queue starts in (.+) s \((.+) jobs\)\.$/, 'Wiederhergestellte Warteschlange startet in $1 s ($2 Jobs).'],
|
||||
[/^(\d+) added$/, '$1 hinzugefügt'],
|
||||
[/^(\d+) already in the batch$/, '$1 bereits im Batch'],
|
||||
[/^(\d+) without a valid account$/, '$1 ohne gültigen Account'],
|
||||
[/^Session report with 1 upload exported$/, 'Sitzungsbericht mit 1 Upload exportiert'],
|
||||
[/^Session report with (\d+) uploads exported$/, 'Sitzungsbericht mit $1 Uploads exportiert'],
|
||||
[/^Sleep in (\d+)s\.\.\.$/, 'Ruhezustand in $1s...'],
|
||||
[/^Shut down in (\d+)s\.\.\.$/, 'Herunterfahren in $1s...'],
|
||||
[/^Restart in (\d+)s\.\.\.$/, 'Neustart in $1s...'],
|
||||
|
||||
+11
-11
@@ -148,7 +148,7 @@
|
||||
</div>
|
||||
<div class="update-dialog-copy">
|
||||
<h2 id="updateDialogTitle">Eine neue Version ist verfügbar</h2>
|
||||
<p id="updateMessage"></p>
|
||||
<p id="updateMessage" aria-live="polite"></p>
|
||||
</div>
|
||||
<div class="update-release-notes" id="updateReleaseNotes" hidden>
|
||||
<div class="update-release-notes-title">Changelog</div>
|
||||
@@ -430,8 +430,8 @@
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<div class="modal-overlay" id="accountModal" style="display:none">
|
||||
<div class="modal-card" role="dialog" aria-modal="true" aria-labelledby="accountModalTitle" aria-describedby="accountModalSubtitle">
|
||||
<div class="modal-overlay" id="accountModal" style="display:none" aria-hidden="true">
|
||||
<div class="modal-card" role="dialog" aria-modal="true" aria-labelledby="accountModalTitle" aria-describedby="accountModalSubtitle" tabindex="-1">
|
||||
<div class="modal-header">
|
||||
<div>
|
||||
<h3 id="accountModalTitle">Account hinzufügen</h3>
|
||||
@@ -458,8 +458,8 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal-overlay" id="jobLogModal" style="display:none">
|
||||
<div class="modal-card" role="dialog" aria-modal="true" aria-labelledby="jobLogTitle" style="width:min(820px,96%);max-height:80vh;display:flex;flex-direction:column">
|
||||
<div class="modal-overlay" id="jobLogModal" style="display:none" aria-hidden="true">
|
||||
<div class="modal-card" role="dialog" aria-modal="true" aria-labelledby="jobLogTitle" tabindex="-1" style="width:min(820px,96%);max-height:80vh;display:flex;flex-direction:column">
|
||||
<div class="modal-header">
|
||||
<div><h3 id="jobLogTitle">Upload-Log</h3></div>
|
||||
<button class="icon-btn" id="closeJobLogBtn" aria-label="Schließen">×</button>
|
||||
@@ -474,8 +474,8 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal-overlay" id="deleteAccountModal" style="display:none">
|
||||
<div class="modal-card" role="dialog" aria-modal="true" aria-labelledby="deleteAccountTitle" aria-describedby="deleteAccountMessage" style="width:min(400px,100%)">
|
||||
<div class="modal-overlay" id="deleteAccountModal" style="display:none" aria-hidden="true">
|
||||
<div class="modal-card" role="dialog" aria-modal="true" aria-labelledby="deleteAccountTitle" aria-describedby="deleteAccountMessage" tabindex="-1" style="width:min(400px,100%)">
|
||||
<div class="modal-header">
|
||||
<div><h3 id="deleteAccountTitle">Account löschen?</h3></div>
|
||||
<button class="icon-btn" id="closeDeleteModalBtn" aria-label="Schließen">×</button>
|
||||
@@ -491,7 +491,7 @@
|
||||
</div>
|
||||
|
||||
<div class="modal-overlay app-alert-modal" id="appAlertModal" style="display:none" aria-hidden="true">
|
||||
<div class="modal-card" role="dialog" aria-modal="true" aria-labelledby="appAlertTitle" aria-describedby="appAlertMessage" style="width:min(420px,100%)">
|
||||
<div class="modal-card" role="dialog" aria-modal="true" aria-labelledby="appAlertTitle" aria-describedby="appAlertMessage" tabindex="-1" style="width:min(420px,100%)">
|
||||
<div class="modal-header">
|
||||
<div><h3 id="appAlertTitle">Hinweis</h3></div>
|
||||
<button class="icon-btn" id="appAlertCloseBtn" aria-label="Schließen">×</button>
|
||||
@@ -508,7 +508,7 @@
|
||||
</div>
|
||||
|
||||
<div class="modal-overlay" id="historyClearModal" style="display:none" aria-hidden="true">
|
||||
<div class="modal-card" role="dialog" aria-modal="true" aria-labelledby="historyClearModalTitle" aria-describedby="historyClearModalMessage" style="width:min(440px,100%)">
|
||||
<div class="modal-card" role="dialog" aria-modal="true" aria-labelledby="historyClearModalTitle" aria-describedby="historyClearModalMessage" tabindex="-1" style="width:min(440px,100%)">
|
||||
<div class="modal-header">
|
||||
<div><h3 id="historyClearModalTitle">Verlauf löschen?</h3></div>
|
||||
<button class="icon-btn" id="closeHistoryClearModalBtn" aria-label="Schließen">×</button>
|
||||
@@ -642,14 +642,14 @@
|
||||
</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">
|
||||
<div class="shutdown-box" role="dialog" aria-modal="true" aria-labelledby="shutdownMessage" tabindex="-1">
|
||||
<p id="shutdownMessage">System wird heruntergefahren in <span id="shutdownSeconds">60</span>s...</p>
|
||||
<button class="btn btn-danger" id="cancelShutdownBtn">Abbrechen</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal-overlay" id="hosterModal" style="display:none" aria-hidden="true">
|
||||
<div class="modal-card" role="dialog" aria-modal="true" aria-labelledby="hosterModalTitle" aria-describedby="hosterModalDescription">
|
||||
<div class="modal-card" role="dialog" aria-modal="true" aria-labelledby="hosterModalTitle" aria-describedby="hosterModalDescription" tabindex="-1">
|
||||
<div class="modal-header">
|
||||
<div>
|
||||
<h3 id="hosterModalTitle">Upload-Ziele auswählen</h3>
|
||||
|
||||
@@ -2337,6 +2337,10 @@ select.hs-input { max-width: none; width: auto; min-width: 140px; }
|
||||
display: grid;
|
||||
}
|
||||
|
||||
.update-progress[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.update-release-notes {
|
||||
grid-column: 1 / -1;
|
||||
max-height: 150px;
|
||||
|
||||
@@ -160,6 +160,31 @@ test('interpolated rare errors translate without leaking German copy', () => {
|
||||
}
|
||||
});
|
||||
|
||||
test('resume, queue, job log, and session report feedback translate in both languages', () => {
|
||||
const cases = [
|
||||
['1 unterbrochener Upload kann fortgesetzt werden.', '1 interrupted upload can be resumed.'],
|
||||
['3 unterbrochene Uploads können fortgesetzt werden.', '3 interrupted uploads can be resumed.'],
|
||||
['Wiederhergestellte Warteschlange startet in 5 s (3 Jobs).', 'Restored queue starts in 5 s (3 jobs).'],
|
||||
['2 hinzugefügt', '2 added'],
|
||||
['1 bereits im Batch', '1 already in the batch'],
|
||||
['3 ohne gültigen Account', '3 without a valid account'],
|
||||
['Keine Jobs hinzugefügt', 'No jobs added'],
|
||||
['Keine startbaren Jobs ausgewählt (alle laufen schon oder sind fertig).', 'No startable jobs selected because all are already running or completed.'],
|
||||
['Hoster', 'Host'],
|
||||
['Versuch', 'Attempt'],
|
||||
['Diagnose', 'Diagnostics'],
|
||||
['Sitzungsbericht mit 1 Upload exportiert', 'Session report with 1 upload exported'],
|
||||
['Sitzungsbericht mit 4 Uploads exportiert', 'Session report with 4 uploads exported'],
|
||||
['Sitzungsbericht konnte nicht exportiert werden.', 'The session report could not be exported.'],
|
||||
['Warteschlange konnte vor dem Upload-Start nicht gespeichert werden', 'The queue could not be saved before starting the upload']
|
||||
];
|
||||
|
||||
for (const [german, english] of cases) {
|
||||
assert.equal(translateText(german, 'en'), english, german);
|
||||
assert.equal(translateText(english, 'de'), german, english);
|
||||
}
|
||||
});
|
||||
|
||||
test('main-process user-facing copy contains no mojibake', () => {
|
||||
const source = fs.readFileSync(path.join(__dirname, '..', 'main.js'), 'utf8');
|
||||
assert.doesNotMatch(source, /Ã|Â|â€/u);
|
||||
|
||||
+275
-10
@@ -1350,6 +1350,7 @@ setTimeout(async () => {
|
||||
await wc.executeJavaScript('document.querySelector("[data-settings-page=\\\'automatik\\\']")?.click()');
|
||||
const automationInputAlignment = await wc.executeJavaScript('(() => { const first = document.getElementById("autoRetryRoundsInput")?.getBoundingClientRect(); const second = document.getElementById("autoRetryDelayMinInput")?.getBoundingClientRect(); const firstHintEl = document.getElementById("autoRetryRoundsInput")?.closest(".automation-retry-row")?.querySelector(".hint"); const secondHintEl = document.getElementById("autoRetryDelayMinInput")?.closest(".automation-retry-row")?.querySelector(".hint"); const firstHint = firstHintEl?.getBoundingClientRect(); const secondHint = secondHintEl?.getBoundingClientRect(); if (!first || !second || !firstHint || !secondHint || !firstHintEl || !secondHintEl) return "missing"; const firstTextLeft = firstHint.left + parseFloat(getComputedStyle(firstHintEl).paddingLeft); const secondTextLeft = secondHint.left + parseFloat(getComputedStyle(secondHintEl).paddingLeft); return [Math.round(Math.abs(first.left - second.left)), Math.round(first.width), Math.round(second.width), firstHint.top >= first.bottom + 6, secondHint.top >= second.bottom + 6, Math.round(Math.abs(firstTextLeft - first.left)) <= 1, Math.round(Math.abs(secondTextLeft - second.left)) <= 1].join("|"); })()');
|
||||
check('Automation retry hints start directly below their aligned inputs', automationInputAlignment === '0|100|100|true|true|true|true');
|
||||
await captureVisual('03-automation.png');
|
||||
await wc.executeJavaScript('document.querySelector("[data-settings-page=allgemein]")?.click()');
|
||||
const updateActionAlignment = await wc.executeJavaScript('(() => { const row = document.querySelector(".program-update-row")?.getBoundingClientRect(); const button = document.getElementById("manualUpdateCheckBtn")?.getBoundingClientRect(); return row && button ? [Math.abs(row.right - button.right) <= 16, button.bottom <= row.bottom, button.left > row.left + row.width / 2].join("|") : "missing"; })()');
|
||||
check('Program update action sits at the lower right of its card', updateActionAlignment === 'true|true|true');
|
||||
@@ -1843,6 +1844,91 @@ setTimeout(async () => {
|
||||
restoreInitialIpcHandler('save-pending-queue');
|
||||
await wc.executeJavaScript('flushConfigWrites()');
|
||||
check('A failed cleanup revocation save stays mandatory for the next start attempt', sourceCleanupRevocationRetry.firstFailed === true && sourceCleanupRevocationRetry.secondSucceeded === true && sourceCleanupRevocationRetry.secondRevocations.length === 0 && sourceCleanupRevocationCallsBeforeRecovery >= 2);
|
||||
const initialStartUploadHandler = initialIpcHandlers.get('start-upload');
|
||||
const initialStartQueueHandler = initialIpcHandlers.get('save-pending-queue');
|
||||
const runFreshStartPersistenceBarrier = async mode => {
|
||||
await wc.executeJavaScript('queuePersistThrottle.cancel(); flushConfigWrites()');
|
||||
const order = [];
|
||||
const snapshots = [];
|
||||
const payloads = [];
|
||||
ipcMain.removeHandler('save-pending-queue');
|
||||
ipcMain.handle('save-pending-queue', (_event, pendingQueue) => {
|
||||
order.push('save');
|
||||
snapshots.push(structuredClone(pendingQueue));
|
||||
return true;
|
||||
});
|
||||
ipcMain.removeHandler('start-upload');
|
||||
ipcMain.handle('start-upload', (_event, payload) => {
|
||||
order.push('start');
|
||||
payloads.push(structuredClone(payload));
|
||||
return { skippedJobs: [], sourceCleanupFingerprints: {} };
|
||||
});
|
||||
const invocation = mode === 'all' ? 'startUpload()' : 'startSelectedUpload([queueJobs[0]])';
|
||||
const outcome = await wc.executeJavaScript('(async () => { queuePersistThrottle.cancel(); uploading = false; selectedUploadHosters = ["voe.sx"]; selectedFiles = []; config.globalSettings = { ...(config.globalSettings || {}), deleteSourceAfterSuccessfulUpload: false }; queueJobs = [{ id: "ui-fresh-' + mode + '-start", file: "C:/ui/fresh-' + mode + '-start.bin", fileName: "fresh-' + mode + '-start.bin", hoster: "voe.sx", status: "preview", bytesTotal: 42 }]; rebuildJobIndex(); const startedAt = performance.now(); await ' + invocation + '; return { elapsed: performance.now() - startedAt, uploading }; })()');
|
||||
await wc.executeJavaScript('queuePersistThrottle.cancel(); uploading = false; selectedFiles = []; queueJobs = []; rebuildJobIndex(); updateQueueActionButtons(); updateStatusBar()');
|
||||
ipcMain.removeHandler('save-pending-queue');
|
||||
if (initialStartQueueHandler) registerIpcHandler('save-pending-queue', initialStartQueueHandler);
|
||||
ipcMain.removeHandler('start-upload');
|
||||
if (initialStartUploadHandler) registerIpcHandler('start-upload', initialStartUploadHandler);
|
||||
return { order, snapshots, payloads, outcome };
|
||||
};
|
||||
const freshAllStartBarrier = await runFreshStartPersistenceBarrier('all');
|
||||
const freshSelectedStartBarrier = await runFreshStartPersistenceBarrier('selected');
|
||||
const hasCompleteStartDescriptor = (run, id) => {
|
||||
const jobs = run.snapshots[0]?.queueJobs || [];
|
||||
const job = jobs.find(candidate => candidate.id === id);
|
||||
return Boolean(job && job.file === 'C:/ui/' + id.replace(/^ui-/, '') + '.bin' && job.fileName === id.replace(/^ui-/, '') + '.bin' && job.hoster === 'voe.sx');
|
||||
};
|
||||
check('Every fresh upload start durably saves complete job descriptors before invoking main', freshAllStartBarrier.order.join('|') === 'save|start' && freshSelectedStartBarrier.order.join('|') === 'save|start' && hasCompleteStartDescriptor(freshAllStartBarrier, 'ui-fresh-all-start') && hasCompleteStartDescriptor(freshSelectedStartBarrier, 'ui-fresh-selected-start'));
|
||||
check('Fresh upload starts remain inside the previous 500 ms persistence window', freshAllStartBarrier.outcome.elapsed < 500 && freshSelectedStartBarrier.outcome.elapsed < 500 && freshAllStartBarrier.payloads.length === 1 && freshSelectedStartBarrier.payloads.length === 1);
|
||||
|
||||
await wc.executeJavaScript('flushConfigWrites()');
|
||||
let failedFreshStartCalls = 0;
|
||||
let failedFreshSaveCalls = 0;
|
||||
ipcMain.removeHandler('save-pending-queue');
|
||||
ipcMain.handle('save-pending-queue', () => {
|
||||
failedFreshSaveCalls++;
|
||||
throw new Error('injected fresh start persistence failure');
|
||||
});
|
||||
ipcMain.removeHandler('start-upload');
|
||||
ipcMain.handle('start-upload', () => {
|
||||
failedFreshStartCalls++;
|
||||
return { skippedJobs: [], sourceCleanupFingerprints: {} };
|
||||
});
|
||||
await wc.executeJavaScript('(() => { queuePersistThrottle.cancel(); uploading = false; selectedUploadHosters = ["voe.sx"]; selectedFiles = []; config.globalSettings = { ...(config.globalSettings || {}), deleteSourceAfterSuccessfulUpload: false }; queueJobs = [{ id: "ui-fresh-start-rejected", file: "C:/ui/fresh-start-rejected.bin", fileName: "fresh-start-rejected.bin", hoster: "voe.sx", status: "preview", bytesTotal: 42 }]; rebuildJobIndex(); window.__uiFreshStartFailure = startUpload(); })()');
|
||||
await waitUntil(() => wc.executeJavaScript('document.getElementById("appAlertModal")?.style.display === "flex"'));
|
||||
await wc.executeJavaScript('document.getElementById("appAlertConfirmBtn")?.click(); window.__uiFreshStartFailure.then(() => { delete window.__uiFreshStartFailure; })');
|
||||
check('A rejected fresh queue save prevents main from starting any upload', failedFreshSaveCalls > 0 && failedFreshStartCalls === 0);
|
||||
ipcMain.removeHandler('save-pending-queue');
|
||||
if (initialStartQueueHandler) registerIpcHandler('save-pending-queue', initialStartQueueHandler);
|
||||
ipcMain.removeHandler('start-upload');
|
||||
if (initialStartUploadHandler) registerIpcHandler('start-upload', initialStartUploadHandler);
|
||||
await wc.executeJavaScript('queuePersistThrottle.cancel(); uploading = false; selectedFiles = []; queueJobs = []; rebuildJobIndex(); flushConfigWrites()');
|
||||
|
||||
const activeDescriptorOrder = [];
|
||||
const activeDescriptorSnapshots = [];
|
||||
let activeDescriptorAddCalls = 0;
|
||||
ipcMain.removeHandler('save-pending-queue');
|
||||
ipcMain.handle('save-pending-queue', (_event, pendingQueue) => {
|
||||
activeDescriptorOrder.push('save');
|
||||
activeDescriptorSnapshots.push(structuredClone(pendingQueue));
|
||||
return true;
|
||||
});
|
||||
ipcMain.removeHandler('add-jobs-to-batch');
|
||||
ipcMain.handle('add-jobs-to-batch', () => {
|
||||
activeDescriptorOrder.push('add');
|
||||
activeDescriptorAddCalls++;
|
||||
return { added: 1, skippedJobs: [], sourceCleanupFingerprints: {} };
|
||||
});
|
||||
const activeDescriptorStart = await wc.executeJavaScript('(async () => { queuePersistThrottle.cancel(); setUiLanguage("en"); uploading = true; selectedFiles = []; config.globalSettings = { ...(config.globalSettings || {}), deleteSourceAfterSuccessfulUpload: false }; queueJobs = [{ id: "ui-active-descriptor", file: "C:/ui/active-descriptor.bin", fileName: "active-descriptor.bin", hoster: "byse.sx", status: "preview", bytesTotal: 77 }]; rebuildJobIndex(); const startedAt = performance.now(); await startSelectedUpload([queueJobs[0]]); return { elapsed: performance.now() - startedAt, toast: document.getElementById("copyToast")?.textContent }; })()');
|
||||
const activePersistedDescriptor = activeDescriptorSnapshots[0]?.queueJobs?.find(job => job.id === 'ui-active-descriptor');
|
||||
check('Active-batch additions save the complete new descriptor before main receives it', activeDescriptorOrder.join('|') === 'save|add' && activeDescriptorAddCalls === 1 && activeDescriptorStart.elapsed < 500 && activePersistedDescriptor?.file === 'C:/ui/active-descriptor.bin' && activePersistedDescriptor?.fileName === 'active-descriptor.bin' && activePersistedDescriptor?.hoster === 'byse.sx');
|
||||
check('Active-batch addition feedback is localized dynamically', activeDescriptorStart.toast === 'Jobs: 1 added');
|
||||
restoreInitialIpcHandler('save-pending-queue');
|
||||
restoreInitialIpcHandler('add-jobs-to-batch');
|
||||
await wc.executeJavaScript('queuePersistThrottle.cancel(); uploading = false; selectedFiles = []; queueJobs = []; rebuildJobIndex(); flushConfigWrites()');
|
||||
const localizedResumeAndInvalidSelection = await wc.executeJavaScript('(async () => { setUiLanguage("en"); uploading = true; queueJobs = [{ id: "ui-invalid-selection", file: "C:/ui/invalid-selection.bin", fileName: "invalid-selection.bin", hoster: "voe.sx", status: "done", bytesTotal: 1 }]; rebuildJobIndex(); selectedJobIds.clear(); selectedJobIds.add("ui-invalid-selection"); await startSelectedUpload(); const invalid = document.getElementById("copyToast")?.textContent; showCopyToast("3 unterbrochene Uploads können fortgesetzt werden."); const resume = document.getElementById("copyToast")?.textContent; setUiLanguage("de"); uploading = false; queueJobs = []; selectedJobIds.clear(); rebuildJobIndex(); return { invalid, resume }; })()');
|
||||
check('Resume and invalid queue selection feedback render in the active language', localizedResumeAndInvalidSelection.invalid === 'No startable jobs selected because all are already running or completed.' && localizedResumeAndInvalidSelection.resume === '3 interrupted uploads can be resumed.');
|
||||
const activeBatchSeed = async (file, token, mode) => {
|
||||
const seed = JSON.stringify({ file, token, mode });
|
||||
const result = await wc.executeJavaScript('(() => { try { const seed = ' + seed + '; queuePersistThrottle.cancel(); selectedFiles = []; _pendingFiles = []; selectedUploadHosters = ["voe.sx"]; uploading = true; config.globalSettings = { ...(config.globalSettings || {}), deleteSourceAfterSuccessfulUpload: true, folderMonitor: { ...(config.globalSettings?.folderMonitor || {}), hosters: ["voe.sx"], autoStart: false } }; queueJobs = [{ id: "ui-active-existing-" + seed.mode, file: seed.file, fileName: "active-existing.bin", hoster: "byse.sx", status: "error", bytesTotal: 10, sourceCleanupMetadataVersion: 2, sourceCleanupToken: seed.token, sourceCleanupRequiredHosters: ["voe.sx", "byse.sx"], sourceCleanupConfirmedHosters: ["voe.sx"] }]; rebuildJobIndex(); if (seed.mode === "selection") { const list = document.getElementById("hosterModalList"); const input = document.createElement("input"); input.type = "checkbox"; input.dataset.hosterModal = "voe.sx"; input.checked = true; list.replaceChildren(input); _pendingFiles = [{ path: seed.file, name: "active-selection.bin", size: 10 }]; } return { ok: true }; } catch (error) { return { ok: false, error: String(error && (error.stack || error.message) || error) }; } })()');
|
||||
@@ -1947,6 +2033,42 @@ setTimeout(async () => {
|
||||
check('History failure keeps terminal queue results and links restart-recoverable', terminalRecoveryState.normal === null && terminalRecoveryState.recovery !== null && terminalRecoveryState.recovery.selectedFiles.length === 2 && terminalRecoveryJobs.length === 2 && terminalRecoveryJobs[0].id === 'ui-terminal-done' && terminalRecoveryJobs[0].status === 'done' && terminalRecoveryJobs[0].result?.download_url === 'https://example.invalid/terminal-done' && terminalRecoveryJobs[0].result?.embed_url === 'https://example.invalid/embed-terminal-done' && terminalRecoveryJobs[0].result?.file_code === 'terminal-code' && terminalRecoveryJobs[1].status === 'skipped' && terminalRecoveryJobs[1].error === 'Size limit');
|
||||
const finalSummaryCorrelation = await wc.executeJavaScript('(() => { queueJobs = [{ id: "summary-exact-a", file: "C:/ui/shared-a.bin", fileName: "shared.bin", hoster: "voe.sx", status: "preview", bytesTotal: 10 }, { id: "summary-exact-b", file: "C:/ui/shared-b.bin", fileName: "shared.bin", hoster: "voe.sx", status: "preview", bytesTotal: 11 }, { id: "summary-ambiguous", file: "C:/ui/shared-c.bin", fileName: "shared.bin", hoster: "voe.sx", status: "preview", bytesTotal: 12 }, { id: "summary-legacy-unique", file: "C:/ui/unique.bin", fileName: "unique.bin", hoster: "byse.sx", status: "preview", bytesTotal: 13 }]; rebuildJobIndex(); applySummaryResults({ files: [{ name: "shared.bin", size: 10, results: [{ jobId: "summary-exact-a", hoster: "voe.sx", status: "done", download_url: "https://example.invalid/exact-a" }, { jobId: "missing-summary-id", hoster: "voe.sx", status: "error", error: "Must not use legacy fallback" }, { hoster: "voe.sx", status: "error", error: "Ambiguous legacy result" }] }, { name: "different-name.bin", size: 11, results: [{ jobId: "summary-exact-b", hoster: "different.invalid", status: "done", download_url: "https://example.invalid/exact-b" }] }, { name: "unique.bin", size: 13, results: [{ hoster: "byse.sx", status: "done", download_url: "https://example.invalid/legacy-unique" }] }] }); const result = queueJobs.map(job => ({ id: job.id, status: job.status, error: job.error || null, link: job.result?.download_url || null })); queueJobs = []; selectedFiles = []; rebuildJobIndex(); renderQueueTable(); return result; })()');
|
||||
check('Final summary correlates by exact jobId and uses legacy identity only for one unique candidate', finalSummaryCorrelation[0].status === 'done' && finalSummaryCorrelation[0].link === 'https://example.invalid/exact-a' && finalSummaryCorrelation[1].status === 'done' && finalSummaryCorrelation[1].link === 'https://example.invalid/exact-b' && finalSummaryCorrelation[2].status === 'preview' && finalSummaryCorrelation[2].error === null && finalSummaryCorrelation[3].status === 'done' && finalSummaryCorrelation[3].link === 'https://example.invalid/legacy-unique');
|
||||
const completionIdentityRaces = await wc.executeJavaScript(\`(() => {
|
||||
const run = (jobs, event, deletedJobId = '') => {
|
||||
queueJobs = jobs;
|
||||
rebuildJobIndex();
|
||||
_deletedJobIds.clear();
|
||||
if (deletedJobId) _deletedJobIds.add(deletedJobId);
|
||||
_handleProgressImpl(event);
|
||||
return queueJobs.map(job => ({ id: job.id, status: job.status, link: job.result?.download_url || null }));
|
||||
};
|
||||
const exact = run([
|
||||
{ id: 'completion-exact-a', fileName: 'same.bin', hoster: 'voe.sx', status: 'queued', bytesTotal: 10 },
|
||||
{ id: 'completion-exact-b', fileName: 'same.bin', hoster: 'voe.sx', status: 'queued', bytesTotal: 10 }
|
||||
], { jobId: 'completion-exact-b', fileName: 'same.bin', hoster: 'voe.sx', status: 'done', result: { download_url: 'https://example.invalid/exact-b' } });
|
||||
const missingExact = run([
|
||||
{ id: 'completion-live', fileName: 'same.bin', hoster: 'voe.sx', status: 'queued', bytesTotal: 10 }
|
||||
], { jobId: 'completion-missing', fileName: 'same.bin', hoster: 'voe.sx', status: 'done', result: { download_url: 'https://example.invalid/missing' } });
|
||||
const deletedExact = run([
|
||||
{ id: 'completion-survivor', fileName: 'same.bin', hoster: 'voe.sx', status: 'queued', bytesTotal: 10 }
|
||||
], { jobId: 'completion-deleted', fileName: 'same.bin', hoster: 'voe.sx', status: 'done', result: { download_url: 'https://example.invalid/deleted' } }, 'completion-deleted');
|
||||
const ambiguousLegacy = run([
|
||||
{ id: 'completion-legacy-a', fileName: 'legacy.bin', hoster: 'byse.sx', status: 'queued', bytesTotal: 10 },
|
||||
{ id: 'completion-legacy-b', fileName: 'legacy.bin', hoster: 'byse.sx', status: 'preview', bytesTotal: 10 }
|
||||
], { fileName: 'legacy.bin', hoster: 'byse.sx', status: 'done', result: { download_url: 'https://example.invalid/ambiguous' } });
|
||||
const uniqueLegacy = run([
|
||||
{ id: 'completion-legacy-unique', fileName: 'unique.bin', hoster: 'byse.sx', status: 'queued', bytesTotal: 10 }
|
||||
], { fileName: 'unique.bin', hoster: 'byse.sx', status: 'done', result: { download_url: 'https://example.invalid/unique' } });
|
||||
queuePersistThrottle.cancel();
|
||||
queueJobs = [];
|
||||
selectedFiles = [];
|
||||
_deletedJobIds.clear();
|
||||
rebuildJobIndex();
|
||||
return { exact, missingExact, deletedExact, ambiguousLegacy, uniqueLegacy };
|
||||
})()\`);
|
||||
check('Completion events with jobId update only their exact live job', completionIdentityRaces.exact[0].status === 'queued' && completionIdentityRaces.exact[1].status === 'done' && completionIdentityRaces.exact[1].link === 'https://example.invalid/exact-b');
|
||||
check('Missing or deleted exact completion IDs never fall back to another job', completionIdentityRaces.missingExact.length === 1 && completionIdentityRaces.missingExact[0].status === 'queued' && completionIdentityRaces.deletedExact.length === 1 && completionIdentityRaces.deletedExact[0].status === 'queued');
|
||||
check('Legacy completion identity applies only to one unambiguous candidate', completionIdentityRaces.ambiguousLegacy.every(job => job.status !== 'done') && completionIdentityRaces.uniqueLegacy.length === 1 && completionIdentityRaces.uniqueLegacy[0].status === 'done' && completionIdentityRaces.uniqueLegacy[0].link === 'https://example.invalid/unique');
|
||||
restoreInitialIpcHandler('complete-upload-finalization');
|
||||
await wc.executeJavaScript('document.getElementById("copyToast")?.classList.remove("show")');
|
||||
|
||||
@@ -2106,6 +2228,25 @@ setTimeout(async () => {
|
||||
restoreInitialIpcHandler('save-text-file');
|
||||
check('Dynamic export errors never mix German and English interface text', englishExportError === 'Export failed: Unknown error' && germanExportError === 'Export fehlgeschlagen: Unbekannter Fehler');
|
||||
|
||||
const initialSessionReportHandler = initialIpcHandlers.get('export-session-report');
|
||||
let sessionReportResult = { ok: true, totalRows: 2 };
|
||||
ipcMain.removeHandler('export-session-report');
|
||||
ipcMain.handle('export-session-report', () => sessionReportResult);
|
||||
await wc.executeJavaScript('setUiLanguage("en"); document.getElementById("exportSessionReportBtn")?.click()');
|
||||
await waitUntil(() => wc.executeJavaScript('document.getElementById("appAlertModal")?.style.display === "flex"'));
|
||||
await wc.executeJavaScript('document.getElementById("appAlertConfirmBtn")?.click()');
|
||||
const sessionReportSuccess = await waitUntil(() => wc.executeJavaScript('document.getElementById("copyToast")?.textContent === "Session report with 2 uploads exported" ? document.getElementById("copyToast").textContent : null'));
|
||||
sessionReportResult = { ok: false };
|
||||
await wc.executeJavaScript('document.getElementById("exportSessionReportBtn")?.click()');
|
||||
await waitUntil(() => wc.executeJavaScript('document.getElementById("appAlertModal")?.style.display === "flex"'));
|
||||
await wc.executeJavaScript('document.getElementById("appAlertConfirmBtn")?.click()');
|
||||
await waitUntil(() => wc.executeJavaScript('document.getElementById("appAlertModal")?.style.display === "flex"'));
|
||||
const sessionReportFailure = await wc.executeJavaScript('document.getElementById("appAlertMessage")?.textContent');
|
||||
await wc.executeJavaScript('document.getElementById("appAlertConfirmBtn")?.click(); setUiLanguage("de")');
|
||||
ipcMain.removeHandler('export-session-report');
|
||||
if (initialSessionReportHandler) registerIpcHandler('export-session-report', initialSessionReportHandler);
|
||||
check('Session report success and failure feedback follows the active language', sessionReportSuccess === 'Session report with 2 uploads exported' && sessionReportFailure === 'The session report could not be exported.');
|
||||
|
||||
const historySidebarInformation = await wc.executeJavaScript('(() => { const sidebar = document.querySelector("#history-view > .view-sidebar")?.getBoundingClientRect(); const section = document.querySelector("#history-view .view-sidebar-section")?.getBoundingClientRect(); const retention = document.getElementById("historySidebarRetention")?.textContent?.trim(); return Boolean(sidebar && section && section.top >= sidebar.top + sidebar.height * 0.55 && retention === "Alles behalten"); })()');
|
||||
check('History sidebar shows the active retention in its lower area', historySidebarInformation === true);
|
||||
|
||||
@@ -2258,7 +2399,8 @@ setTimeout(async () => {
|
||||
const titles = [...document.querySelectorAll('#queueBody .col-status')].map(cell => cell.title);
|
||||
const result = { values, titles, toast: document.getElementById('copyToast').textContent.trim(), shutdown: document.getElementById('shutdownMessage').textContent.trim() };
|
||||
clearInterval(shutdownCountdownInterval);
|
||||
document.getElementById('shutdownOverlay').style.display = 'none';
|
||||
shutdownCountdownInterval = null;
|
||||
modalController.close('shutdownOverlay', { restoreFocus: false });
|
||||
document.getElementById('copyToast').classList.remove('show');
|
||||
return result;
|
||||
})()\`);
|
||||
@@ -2425,13 +2567,107 @@ setTimeout(async () => {
|
||||
check('Styled app dialog focuses the safe action and makes background inert', dialogContract === 'flex|true|appAlertCancelBtn|true');
|
||||
await wc.executeJavaScript('document.getElementById("appAlertCancelBtn")?.click()');
|
||||
|
||||
const initialJobLogHandler = initialIpcHandlers.get('get-job-log');
|
||||
const jobLogRequests = [];
|
||||
ipcMain.removeHandler('get-job-log');
|
||||
ipcMain.handle('get-job-log', (_event, jobId) => new Promise(resolve => jobLogRequests.push({ jobId, resolve })));
|
||||
await wc.executeJavaScript(\`(() => {
|
||||
setUiLanguage('en');
|
||||
queueJobs = [
|
||||
{ id: 'ui-job-log-a', file: 'C:/ui/job-log-a.bin', fileName: 'job-log-a.bin', hoster: 'voe.sx', status: 'error', error: 'stale-job-error', failureDetails: { status: 500 }, attempt: 1, maxAttempts: 3 },
|
||||
{ id: 'ui-job-log-b', file: 'C:/ui/job-log-b.bin', fileName: 'job-log-b.bin', hoster: 'byse.sx', status: 'error', error: 'current-job-error', failureDetails: { status: 503 }, attempt: 2, maxAttempts: 3 }
|
||||
];
|
||||
rebuildJobIndex();
|
||||
selectedJobIds.clear();
|
||||
selectedJobIds.add('ui-job-log-a');
|
||||
document.getElementById('addFilesBtn')?.focus();
|
||||
void showJobLogModal();
|
||||
})()\`);
|
||||
await waitUntil(() => jobLogRequests.length === 1);
|
||||
await wc.executeJavaScript('selectedJobIds.clear(); selectedJobIds.add("ui-job-log-b"); void showJobLogModal()');
|
||||
await waitUntil(() => jobLogRequests.length === 2);
|
||||
jobLogRequests[0].resolve([{ ts: Date.now(), kind: 'progress', status: 'error', error: 'stale-only' }]);
|
||||
await new Promise(resolve => setTimeout(resolve, 50));
|
||||
const staleJobLogResponse = await wc.executeJavaScript('({ title: document.getElementById("jobLogTitle")?.textContent, body: document.getElementById("jobLogBody")?.textContent })');
|
||||
jobLogRequests[1].resolve([{ ts: Date.now(), kind: 'progress', status: 'error', error: 'current-only' }]);
|
||||
await waitUntil(() => wc.executeJavaScript('document.getElementById("jobLogBody")?.textContent.includes("current-only")'));
|
||||
const currentJobLogResponse = await wc.executeJavaScript('({ title: document.getElementById("jobLogTitle")?.textContent, body: document.getElementById("jobLogBody")?.textContent, focusInside: document.getElementById("jobLogModal")?.contains(document.activeElement), ariaHidden: document.getElementById("jobLogModal")?.getAttribute("aria-hidden") })');
|
||||
check('Late job-log responses cannot overwrite the newer selected job', staleJobLogResponse.title === 'Log · job-log-b.bin' && !staleJobLogResponse.body.includes('stale-only') && currentJobLogResponse.title === 'Log · job-log-b.bin' && currentJobLogResponse.body.includes('current-only'));
|
||||
check('English multiline job logs localize every user-facing label', currentJobLogResponse.body.includes('Host: byse.sx') && currentJobLogResponse.body.includes('Account:') && currentJobLogResponse.body.includes('Attempt: 2 / 3') && currentJobLogResponse.body.includes('Failed: current-job-error') && currentJobLogResponse.body.includes('Diagnostics:\\nstatus: 503'));
|
||||
check('Job-log dialog opens accessibly with focus inside', currentJobLogResponse.focusInside === true && currentJobLogResponse.ariaHidden === 'false');
|
||||
await wc.executeJavaScript('selectedJobIds.clear(); selectedJobIds.add("ui-job-log-a"); void showJobLogModal()');
|
||||
await waitUntil(() => jobLogRequests.length === 3);
|
||||
const jobLogBodyBeforeClose = await wc.executeJavaScript('document.getElementById("jobLogBody")?.textContent');
|
||||
await wc.executeJavaScript('hideJobLogModal()');
|
||||
jobLogRequests[2].resolve([{ ts: Date.now(), kind: 'progress', status: 'done', error: 'closed-only' }]);
|
||||
await new Promise(resolve => setTimeout(resolve, 50));
|
||||
const closedJobLogResponse = await wc.executeJavaScript('({ display: document.getElementById("jobLogModal")?.style.display, ariaHidden: document.getElementById("jobLogModal")?.getAttribute("aria-hidden"), body: document.getElementById("jobLogBody")?.textContent, restoredFocus: document.activeElement?.id })');
|
||||
check('Closing the job log invalidates pending responses and restores focus', closedJobLogResponse.display === 'none' && closedJobLogResponse.ariaHidden === 'true' && closedJobLogResponse.body === jobLogBodyBeforeClose && closedJobLogResponse.restoredFocus === 'addFilesBtn');
|
||||
ipcMain.removeHandler('get-job-log');
|
||||
if (initialJobLogHandler) registerIpcHandler('get-job-log', initialJobLogHandler);
|
||||
|
||||
const sharedModalLifecycle = await wc.executeJavaScript(\`(async () => {
|
||||
const settle = () => new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve)));
|
||||
const opener = document.getElementById('addFilesBtn');
|
||||
const focusable = overlay => [...overlay.querySelectorAll('button:not([disabled]):not([hidden]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [href], [tabindex]:not([tabindex="-1"])')].filter(element => !element.hidden && getComputedStyle(element).display !== 'none' && getComputedStyle(element).visibility !== 'hidden');
|
||||
setUiLanguage('de');
|
||||
opener.focus();
|
||||
openHosterModal();
|
||||
await settle();
|
||||
const hoster = document.getElementById('hosterModal');
|
||||
const hosterFocusable = focusable(hoster);
|
||||
const hosterOpened = { ariaHidden: hoster.getAttribute('aria-hidden'), focusInside: hoster.contains(document.activeElement), backgroundInert: document.querySelector('.app-header')?.inert === true && document.querySelector('.view.active')?.inert === true };
|
||||
hosterFocusable.at(-1)?.focus();
|
||||
document.activeElement?.dispatchEvent(new KeyboardEvent('keydown', { key: 'Tab', bubbles: true, cancelable: true }));
|
||||
const hosterTrap = document.activeElement === hosterFocusable[0];
|
||||
void showAppAlert('Obere Ebene');
|
||||
await settle();
|
||||
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true, cancelable: true }));
|
||||
const topmost = document.getElementById('appAlertModal').style.display === 'none' && hoster.style.display === 'flex';
|
||||
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true, cancelable: true }));
|
||||
await settle();
|
||||
const hosterClosed = hoster.style.display === 'none' && hoster.getAttribute('aria-hidden') === 'true' && document.activeElement === opener;
|
||||
|
||||
const previousAccounts = config.hosters['byse.sx'];
|
||||
config.hosters['byse.sx'] = [{ id: 'ui-modal-delete-account', enabled: true, authType: 'api', apiKey: 'ui-modal-key' }];
|
||||
opener.focus();
|
||||
openDeleteAccountModal('ui-modal-delete-account');
|
||||
await settle();
|
||||
const deleteModal = document.getElementById('deleteAccountModal');
|
||||
const deleteOpened = { ariaHidden: deleteModal.getAttribute('aria-hidden'), focus: document.activeElement?.id, backgroundInert: document.querySelector('.app-header')?.inert === true };
|
||||
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true, cancelable: true }));
|
||||
await settle();
|
||||
const deleteClosed = deleteModal.style.display === 'none' && deleteModal.getAttribute('aria-hidden') === 'true' && document.activeElement === opener;
|
||||
if (deleteModal.style.display !== 'none') closeDeleteModal();
|
||||
config.hosters['byse.sx'] = previousAccounts;
|
||||
|
||||
opener.focus();
|
||||
document.getElementById('shutdownMessage').innerHTML = 'System wird heruntergefahren in <span id="shutdownSeconds">60</span>s...';
|
||||
handleShutdownCountdown({ mode: 'shutdown', seconds: 30 });
|
||||
await settle();
|
||||
const shutdown = document.getElementById('shutdownOverlay');
|
||||
const shutdownOpened = { ariaHidden: shutdown.getAttribute('aria-hidden'), focus: document.activeElement?.id, backgroundInert: document.querySelector('.app-header')?.inert === true };
|
||||
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true, cancelable: true }));
|
||||
await new Promise(resolve => setTimeout(resolve, 50));
|
||||
const shutdownClosed = shutdown.style.display === 'none' && shutdown.getAttribute('aria-hidden') === 'true' && document.activeElement === opener;
|
||||
if (shutdown.style.display !== 'none') document.getElementById('cancelShutdownBtn')?.click();
|
||||
|
||||
queueJobs = [];
|
||||
selectedFiles = [];
|
||||
selectedJobIds.clear();
|
||||
rebuildJobIndex();
|
||||
return { hosterOpened, hosterTrap, topmost, hosterClosed, deleteOpened, deleteClosed, shutdownOpened, shutdownClosed };
|
||||
})()\`);
|
||||
check('Shared modal behavior isolates and traps the hoster dialog with topmost Escape semantics', sharedModalLifecycle.hosterOpened.ariaHidden === 'false' && sharedModalLifecycle.hosterOpened.focusInside === true && sharedModalLifecycle.hosterOpened.backgroundInert === true && sharedModalLifecycle.hosterTrap === true && sharedModalLifecycle.topmost === true && sharedModalLifecycle.hosterClosed === true);
|
||||
check('Delete-account modal uses safe focus, background isolation, Escape, and focus restoration', sharedModalLifecycle.deleteOpened.ariaHidden === 'false' && sharedModalLifecycle.deleteOpened.focus === 'cancelDeleteBtn' && sharedModalLifecycle.deleteOpened.backgroundInert === true && sharedModalLifecycle.deleteClosed === true);
|
||||
check('Shutdown modal uses safe focus, background isolation, Escape, and focus restoration', sharedModalLifecycle.shutdownOpened.ariaHidden === 'false' && sharedModalLifecycle.shutdownOpened.focus === 'cancelShutdownBtn' && sharedModalLifecycle.shutdownOpened.backgroundInert === true && sharedModalLifecycle.shutdownClosed === true);
|
||||
|
||||
const modalSemantics = await wc.executeJavaScript(\`(() => [
|
||||
document.querySelector('#hosterModal .modal-card')?.getAttribute('role'),
|
||||
document.querySelector('#jobLogModal .modal-card')?.getAttribute('role'),
|
||||
document.querySelector('#deleteAccountModal .modal-card')?.getAttribute('role'),
|
||||
document.querySelector('#shutdownOverlay .shutdown-box')?.getAttribute('role')
|
||||
[...document.querySelectorAll('[role="dialog"]')].length,
|
||||
[...document.querySelectorAll('[role="dialog"]')].every(dialog => dialog.getAttribute('aria-modal') === 'true' && dialog.tabIndex === -1),
|
||||
[...document.querySelectorAll('[role="dialog"]')].every(dialog => dialog.parentElement?.getAttribute('aria-hidden') === 'true')
|
||||
].join('|'))()\`);
|
||||
check('Hoster, job log, delete-account, and shutdown surfaces expose dialog semantics', modalSemantics === 'dialog|dialog|dialog|dialog');
|
||||
check('Every renderer dialog has complete hidden modal semantics', modalSemantics === '8|true|true');
|
||||
|
||||
const rapidViewStability = await wc.executeJavaScript(\`(async () => {
|
||||
const sequence = ['upload', 'accounts', 'settings', 'history', 'settings', 'accounts', 'upload', 'history', 'upload', 'accounts', 'history', 'settings'];
|
||||
@@ -2525,6 +2761,32 @@ setTimeout(async () => {
|
||||
const invalidRollingFrames = rollingMetricStability.frames.filter(frame => !frame.text || !frame.label || frame.childCount < 1 || frame.childCount > 2 || Math.abs(frame.width - rollingMetricStability.initialRect.width) > 0.5 || Math.abs(frame.height - rollingMetricStability.initialRect.height) > 0.5);
|
||||
check('Rapid telemetry updates never expose an empty value or shift the metric layout', rollingMetricStability.frames.length === 21 && invalidRollingFrames.length === 0 && rollingMetricStability.settled.text === '1.020' && rollingMetricStability.settled.label === '1.020' && rollingMetricStability.settled.direction === 'none');
|
||||
|
||||
await wc.debugger.sendCommand('Emulation.setEmulatedMedia', { features: [{ name: 'prefers-reduced-motion', value: 'reduce' }] });
|
||||
const reducedMotionTelemetry = await wc.executeJavaScript(\`(() => {
|
||||
const metric = document.getElementById('uploadTelemetryCompleted');
|
||||
const originalAnimate = Element.prototype.animate;
|
||||
let animationCalls = 0;
|
||||
Element.prototype.animate = function (...args) {
|
||||
animationCalls++;
|
||||
return originalAnimate.apply(this, args);
|
||||
};
|
||||
metric.querySelectorAll(':scope > span').forEach(span => span.getAnimations().forEach(animation => animation.cancel()));
|
||||
metric.dataset.numericValue = '40';
|
||||
metric.setAttribute('aria-label', '40');
|
||||
metric.replaceChildren(Object.assign(document.createElement('span'), { textContent: '40' }));
|
||||
_setRollingUploadMetric('uploadTelemetryCompleted', 41);
|
||||
Element.prototype.animate = originalAnimate;
|
||||
return {
|
||||
animationCalls,
|
||||
text: metric.textContent.trim(),
|
||||
label: metric.getAttribute('aria-label'),
|
||||
direction: metric.dataset.direction,
|
||||
animations: metric.getAnimations({ subtree: true }).length
|
||||
};
|
||||
})()\`);
|
||||
check('Reduced motion renders telemetry numbers without Web Animations rolling', reducedMotionTelemetry.animationCalls === 0 && reducedMotionTelemetry.text === '41' && reducedMotionTelemetry.label === '41' && reducedMotionTelemetry.direction === 'none' && reducedMotionTelemetry.animations === 0);
|
||||
await wc.debugger.sendCommand('Emulation.setEmulatedMedia', { features: [{ name: 'prefers-reduced-motion', value: 'no-preference' }] });
|
||||
|
||||
const virtualQueueStability = await wc.executeJavaScript(\`(async () => {
|
||||
const total = 1200;
|
||||
queueJobs = Array.from({ length: total }, (_, index) => ({
|
||||
@@ -2870,6 +3132,9 @@ setTimeout(async () => {
|
||||
})()\`);
|
||||
check('Every update phase has exactly one visible progress status surface', updateProgressSurfaces.every(state => state.visibleMatches === 1 && state.button === 'Jetzt installieren'));
|
||||
|
||||
const updateErrorSurface = await wc.executeJavaScript('handleUpdateProgress({ stage: "error", error: "Netzwerkfehler" }); (() => { const surfaces = [document.getElementById("updateProgressText"), document.getElementById("updateMessage")]; const visible = surfaces.filter(element => element && !element.hidden && getComputedStyle(element).display !== "none" && element.textContent.includes("Update fehlgeschlagen")); return { count: visible.length, current: visible[0]?.textContent.trim(), messageLive: document.getElementById("updateMessage")?.getAttribute("aria-live") }; })()');
|
||||
check('A failed update preparation exposes one current error status', updateErrorSurface.count === 1 && updateErrorSurface.current === 'Update fehlgeschlagen: Netzwerkfehler' && updateErrorSurface.messageLive === 'polite');
|
||||
|
||||
const updateErrorRecovery = await wc.executeJavaScript('handleUpdateProgress({ stage: "error", error: "Netzwerkfehler" }); document.getElementById("dismissUpdateBtn").click(); document.getElementById("updateBanner").style.display + "|" + document.getElementById("updateCloseBtn").disabled + "|" + document.getElementById("dismissUpdateBtn").disabled + "|" + document.getElementById("headerUpdateBtn").hidden');
|
||||
check('Update errors restore all close actions', updateErrorRecovery === 'none|false|false|false');
|
||||
|
||||
@@ -2956,13 +3221,13 @@ setTimeout(async () => {
|
||||
await wc.executeJavaScript('(() => { queuePersistThrottle.cancel(); selectedFiles = []; queueJobs = [{ id: "ui-close-timeout-job", file: "C:/ui/close-timeout.bin", fileName: "close-timeout.bin", hoster: "byse.sx", status: "queued", bytesUploaded: 0, bytesTotal: 1, speedKbs: 0, elapsed: 0, remaining: 0, progress: 0 }]; rebuildJobIndex(); return true; })()');
|
||||
await wc.executeJavaScript('showUpdateBanner({ remoteVersion: "9.9.9" }); installKnownUpdate()');
|
||||
await waitUntil(() => restoreAckRequested, 4000);
|
||||
const recoveryBeforeAck = await wc.executeJavaScript('({ state: closePreparationState, promiseCleared: closePreparationPromise === null, overlayVisible: document.getElementById("shutdownOverlay")?.style.display === "flex", inertRetained: closePreparationInertState.length > 0 && closePreparationInertState.every(({ element }) => element.inert === true) })');
|
||||
const recoveryBeforeAck = await wc.executeJavaScript('({ state: closePreparationState, promiseCleared: closePreparationPromise === null, overlayVisible: document.getElementById("shutdownOverlay")?.style.display === "flex", inertRetained: document.querySelector(".app-header")?.inert === true && document.querySelector(".view.active")?.inert === true })');
|
||||
const windowStayedOpenAfterCloseFailure = !win.isDestroyed();
|
||||
rejectHungFinalQueueWrite?.(new Error('final queue write timeout'));
|
||||
releaseRestoreAck?.();
|
||||
const boundedCloseRecovery = await waitUntil(async () => {
|
||||
if (win.isDestroyed()) return null;
|
||||
const state = await wc.executeJavaScript('({ state: closePreparationState, promiseCleared: closePreparationPromise === null, overlayHidden: document.getElementById("shutdownOverlay")?.style.display === "none", inertRestored: closePreparationInertState.length === 0, failedWrites: failedConfigWriteOperations.length })');
|
||||
const state = await wc.executeJavaScript('({ state: closePreparationState, promiseCleared: closePreparationPromise === null, overlayHidden: document.getElementById("shutdownOverlay")?.style.display === "none", modalIsolationRestored: document.querySelector(".app-header")?.inert === true && document.querySelector(".view.active")?.inert === true && document.getElementById("updateBanner")?.inert === false, failedWrites: failedConfigWriteOperations.length })');
|
||||
return state.state === 'open' && state.promiseCleared ? state : null;
|
||||
}, 4000);
|
||||
activeConfigStore.savePendingQueue = originalSavePendingQueue;
|
||||
@@ -2973,7 +3238,7 @@ setTimeout(async () => {
|
||||
const recoveredQueue = configAfterCloseRecovery.globalSettings.pendingQueue?.queueJobs || [];
|
||||
const failedUpdateUi = await wc.executeJavaScript('({ busy: _updateInstallBusy, message: document.getElementById("updateMessage")?.textContent || "" })');
|
||||
const closeRecoveryEvidence = { windowStayedOpenAfterCloseFailure, recoveryBeforeAck, boundedCloseRecovery, closeReadyAttempt, closeRestoreAttempt, writesQuiesced: activeConfigStore._writesQuiesced, writeAfterCloseRecovery, historyAfterCloseRecovery, persistedWebhookUrl: configAfterCloseRecovery.globalSettings.webhookUrl, recoveredQueue: recoveredQueue.map(job => job.id), preparedUpdateMockCalls, launchedUpdateMockCalls, failedUpdateUi };
|
||||
const closeRecoveryOk = windowStayedOpenAfterCloseFailure === true && recoveryBeforeAck.state === 'recovering' && recoveryBeforeAck.promiseCleared === false && recoveryBeforeAck.overlayVisible === true && recoveryBeforeAck.inertRetained === true && boundedCloseRecovery?.state === 'open' && boundedCloseRecovery.promiseCleared === true && boundedCloseRecovery.overlayHidden === true && boundedCloseRecovery.inertRestored === true && boundedCloseRecovery.failedWrites === 0 && activeConfigStore._writesQuiesced === false && Number.isInteger(closeReadyAttempt) && closeRestoreAttempt === closeReadyAttempt && writeAfterCloseRecovery.ok === true && historyAfterCloseRecovery.ok === true && configAfterCloseRecovery.globalSettings.webhookUrl === closeRecoveryMarker && recoveredQueue.some(job => job.id === 'ui-close-timeout-job') && preparedUpdateMockCalls === 1 && launchedUpdateMockCalls === 0 && failedUpdateUi.busy === false && failedUpdateUi.message.includes('nicht gestartet');
|
||||
const closeRecoveryOk = windowStayedOpenAfterCloseFailure === true && recoveryBeforeAck.state === 'recovering' && recoveryBeforeAck.promiseCleared === false && recoveryBeforeAck.overlayVisible === true && recoveryBeforeAck.inertRetained === true && boundedCloseRecovery?.state === 'open' && boundedCloseRecovery.promiseCleared === true && boundedCloseRecovery.overlayHidden === true && boundedCloseRecovery.modalIsolationRestored === true && boundedCloseRecovery.failedWrites === 0 && activeConfigStore._writesQuiesced === false && Number.isInteger(closeReadyAttempt) && closeRestoreAttempt === closeReadyAttempt && writeAfterCloseRecovery.ok === true && historyAfterCloseRecovery.ok === true && configAfterCloseRecovery.globalSettings.webhookUrl === closeRecoveryMarker && recoveredQueue.some(job => job.id === 'ui-close-timeout-job') && preparedUpdateMockCalls === 1 && launchedUpdateMockCalls === 0 && failedUpdateUi.busy === false && failedUpdateUi.message.includes('nicht gestartet');
|
||||
if (!closeRecoveryOk) console.log('Close recovery evidence: ' + JSON.stringify(closeRecoveryEvidence));
|
||||
check('Close recovery waits for its correlated restore ACK and drains retained writes before reopening', closeRecoveryOk);
|
||||
restoreInitialIpcHandler('app:finish-close');
|
||||
@@ -2992,7 +3257,7 @@ setTimeout(async () => {
|
||||
await wc.executeJavaScript('showUpdateBanner({ remoteVersion: "9.9.9" }); installKnownUpdate()');
|
||||
const rendererCloseSealed = await waitUntil(async () => !win.isDestroyed() && await wc.executeJavaScript('closePreparationState === "sealed"'));
|
||||
const windowStayedOpenForClosePersistence = blockedHistoryWriteStarted === true && !win.isDestroyed();
|
||||
const closeUiQuiesced = await wc.executeJavaScript('document.getElementById("shutdownOverlay")?.style.display === "flex" && closePreparationInertState.length > 0 && closePreparationInertState.every(({ element }) => element.inert === true)');
|
||||
const closeUiQuiesced = await wc.executeJavaScript('document.getElementById("shutdownOverlay")?.style.display === "flex" && document.querySelector(".app-header")?.inert === true && document.querySelector(".view.active")?.inert === true');
|
||||
const postSealMainWrite = await wc.executeJavaScript('window.api.saveGlobalSettings({ ...(config.globalSettings || {}), webhookUrl: "https://close-persist.invalid/post-seal" }).then(() => ({ ok: true }), error => ({ ok: false, error: error.message }))');
|
||||
await wc.executeJavaScript('(() => { queueJobs.push({ id: "ui-post-seal-job", file: "C:/ui/post-seal.bin", fileName: "post-seal.bin", hoster: "byse.sx", status: "queued", bytesUploaded: 0, bytesTotal: 1 }); rebuildJobIndex(); persistQueueStateSoon(false); scheduleSettingsSave(); return true; })()');
|
||||
await new Promise(resolve => setTimeout(resolve, 650));
|
||||
|
||||
Reference in New Issue
Block a user