feat: manage online backup keys in settings
This commit is contained in:
@@ -83,7 +83,6 @@ contextBridge.exposeInMainWorld('api', {
|
|||||||
createManagedOnlineBackup: () => ipcRenderer.invoke('online-backup:create-managed'),
|
createManagedOnlineBackup: () => ipcRenderer.invoke('online-backup:create-managed'),
|
||||||
copyManagedOnlineBackup: (id) => ipcRenderer.invoke('online-backup:copy-managed', id),
|
copyManagedOnlineBackup: (id) => ipcRenderer.invoke('online-backup:copy-managed', id),
|
||||||
deleteManagedOnlineBackup: (id) => ipcRenderer.invoke('online-backup:delete-managed', id),
|
deleteManagedOnlineBackup: (id) => ipcRenderer.invoke('online-backup:delete-managed', id),
|
||||||
createOnlineBackup: () => ipcRenderer.invoke('online-backup:create-managed'),
|
|
||||||
restoreOnlineBackup: (key) => ipcRenderer.invoke('online-backup:restore', key),
|
restoreOnlineBackup: (key) => ipcRenderer.invoke('online-backup:restore', key),
|
||||||
|
|
||||||
// Folder Monitor
|
// Folder Monitor
|
||||||
|
|||||||
+164
-38
@@ -41,6 +41,7 @@ function refreshLocalizedRuntimeUi() {
|
|||||||
const activeRecentTab = document.querySelector('.recent-tab.active');
|
const activeRecentTab = document.querySelector('.recent-tab.active');
|
||||||
const hint = document.getElementById('recentFilesHint');
|
const hint = document.getElementById('recentFilesHint');
|
||||||
if (hint && activeRecentTab) hint.textContent = localizeUiText(activeRecentTab.dataset.panel === 'statsTab' ? 'Upload-Statistiken' : 'Zuletzt erzeugte Upload-Links');
|
if (hint && activeRecentTab) hint.textContent = localizeUiText(activeRecentTab.dataset.panel === 'statsTab' ? 'Upload-Statistiken' : 'Zuletzt erzeugte Upload-Links');
|
||||||
|
renderManagedOnlineBackups();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Dropdown options for "Add Account" modal: value -> label
|
// Dropdown options for "Add Account" modal: value -> label
|
||||||
@@ -61,6 +62,9 @@ let config = { hosters: {}, hosterSettings: {}, globalSettings: {} };
|
|||||||
let hosterSettings = {};
|
let hosterSettings = {};
|
||||||
let uploading = false;
|
let uploading = false;
|
||||||
let healthCheckRunning = false;
|
let healthCheckRunning = false;
|
||||||
|
let managedOnlineBackups = [];
|
||||||
|
let managedOnlineBackupLoadGeneration = 0;
|
||||||
|
const managedOnlineBackupPendingActions = new Map();
|
||||||
|
|
||||||
let _rLongTasks = 0, _rLongTaskMax = 0, _rFrameLast = 0, _rFrameWorst = 0, _rFrameCount = 0, _rFrameJank = 0, _rPerfLastLog = 0, _rPerfWindowStart = 0;
|
let _rLongTasks = 0, _rLongTaskMax = 0, _rFrameLast = 0, _rFrameWorst = 0, _rFrameCount = 0, _rFrameJank = 0, _rPerfLastLog = 0, _rPerfWindowStart = 0;
|
||||||
function _rElLabel(el) {
|
function _rElLabel(el) {
|
||||||
@@ -2637,15 +2641,140 @@ function showImportQueuePersistenceError(error) {
|
|||||||
function setOnlineBackupStatus(message, state = '') {
|
function setOnlineBackupStatus(message, state = '') {
|
||||||
const status = document.getElementById('onlineBackupStatus');
|
const status = document.getElementById('onlineBackupStatus');
|
||||||
if (!status) return;
|
if (!status) return;
|
||||||
status.textContent = message;
|
status.textContent = String(message || '').replace(/MHU2-[A-Za-z0-9_-]{70}/gu, localizeUiText('Geschützter Schlüssel'));
|
||||||
status.dataset.state = state;
|
status.dataset.state = state;
|
||||||
}
|
}
|
||||||
|
|
||||||
function setOnlineBackupBusy(busy) {
|
function syncOnlineBackupRestoreButton(busy = false) {
|
||||||
const createButton = document.getElementById('createOnlineBackupBtn');
|
|
||||||
const restoreButton = document.getElementById('restoreOnlineBackupBtn');
|
const restoreButton = document.getElementById('restoreOnlineBackupBtn');
|
||||||
if (createButton) createButton.disabled = busy;
|
if (!restoreButton) return;
|
||||||
if (restoreButton) restoreButton.disabled = busy || !/^MHU2-[A-Za-z0-9_-]{70}$/.test(document.getElementById('onlineBackupKeyInput')?.value.trim() || '');
|
restoreButton.dataset.busy = busy ? 'true' : 'false';
|
||||||
|
restoreButton.disabled = busy || !/^MHU2-[A-Za-z0-9_-]{70}$/.test(document.getElementById('onlineBackupKeyInput')?.value.trim() || '');
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeManagedOnlineBackups(entries) {
|
||||||
|
const sanitized = new Map();
|
||||||
|
for (const entry of Array.isArray(entries) ? entries : []) {
|
||||||
|
if (!entry || typeof entry !== 'object') continue;
|
||||||
|
if (typeof entry.id !== 'string' || !/^[A-Za-z0-9_-]{22}$/.test(entry.id)) continue;
|
||||||
|
if (typeof entry.displayKey !== 'string' || !entry.displayKey || /MHU2-[A-Za-z0-9_-]{70}/.test(entry.displayKey)) continue;
|
||||||
|
const createdAt = new Date(entry.createdAt);
|
||||||
|
if (Number.isNaN(createdAt.getTime())) continue;
|
||||||
|
sanitized.set(entry.id, { id: entry.id, displayKey: entry.displayKey, createdAt: createdAt.toISOString() });
|
||||||
|
}
|
||||||
|
return [...sanitized.values()].sort((left, right) => right.createdAt.localeCompare(left.createdAt));
|
||||||
|
}
|
||||||
|
|
||||||
|
function replaceManagedOnlineBackups(entries) {
|
||||||
|
managedOnlineBackups = normalizeManagedOnlineBackups(entries);
|
||||||
|
renderManagedOnlineBackups();
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderManagedOnlineBackups() {
|
||||||
|
const list = document.getElementById('managedOnlineBackupList');
|
||||||
|
if (!list) return;
|
||||||
|
const content = document.createDocumentFragment();
|
||||||
|
if (managedOnlineBackups.length === 0) {
|
||||||
|
const empty = document.createElement('p');
|
||||||
|
empty.className = 'online-backup-managed-empty';
|
||||||
|
empty.textContent = localizeUiText('Noch keine Schlüssel auf diesem Gerät erstellt.');
|
||||||
|
content.append(empty);
|
||||||
|
} else {
|
||||||
|
for (const entry of managedOnlineBackups) {
|
||||||
|
const row = document.createElement('article');
|
||||||
|
row.className = 'online-backup-managed-row';
|
||||||
|
const key = document.createElement('span');
|
||||||
|
key.className = 'online-backup-managed-key';
|
||||||
|
key.textContent = entry.displayKey;
|
||||||
|
const created = document.createElement('span');
|
||||||
|
created.className = 'online-backup-managed-created';
|
||||||
|
created.textContent = formatDateTime(entry.createdAt).text;
|
||||||
|
const actions = document.createElement('div');
|
||||||
|
actions.className = 'online-backup-managed-actions';
|
||||||
|
const copyButton = document.createElement('button');
|
||||||
|
copyButton.type = 'button';
|
||||||
|
copyButton.className = 'btn btn-secondary online-backup-copy-btn';
|
||||||
|
copyButton.textContent = localizeUiText('Schlüssel kopieren');
|
||||||
|
const deleteButton = document.createElement('button');
|
||||||
|
deleteButton.type = 'button';
|
||||||
|
deleteButton.className = 'btn btn-danger online-backup-delete-btn';
|
||||||
|
deleteButton.textContent = localizeUiText('Online-Backup löschen');
|
||||||
|
const pendingAction = managedOnlineBackupPendingActions.get(entry.id);
|
||||||
|
copyButton.disabled = pendingAction === 'copy' || pendingAction === 'delete';
|
||||||
|
deleteButton.disabled = pendingAction === 'delete';
|
||||||
|
copyButton.addEventListener('click', () => copyManagedOnlineBackup(entry, copyButton));
|
||||||
|
deleteButton.addEventListener('click', () => deleteManagedOnlineBackup(entry, row));
|
||||||
|
actions.append(copyButton, deleteButton);
|
||||||
|
row.append(key, created, actions);
|
||||||
|
content.append(row);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
list.replaceChildren(content);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadManagedOnlineBackups() {
|
||||||
|
const generation = ++managedOnlineBackupLoadGeneration;
|
||||||
|
try {
|
||||||
|
const result = await window.api.listManagedOnlineBackups();
|
||||||
|
if (generation !== managedOnlineBackupLoadGeneration) return false;
|
||||||
|
if (!result?.ok || !Array.isArray(result.entries)) {
|
||||||
|
setOnlineBackupStatus('Online-Sicherungen konnten nicht geladen werden', 'error');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
replaceManagedOnlineBackups(result.entries);
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
if (generation === managedOnlineBackupLoadGeneration) setOnlineBackupStatus('Online-Sicherungen konnten nicht geladen werden', 'error');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function copyManagedOnlineBackup(entry, button) {
|
||||||
|
if (button.disabled || managedOnlineBackupPendingActions.has(entry.id)) return;
|
||||||
|
managedOnlineBackupPendingActions.set(entry.id, 'copy');
|
||||||
|
button.disabled = true;
|
||||||
|
try {
|
||||||
|
const result = await window.api.copyManagedOnlineBackup(entry.id);
|
||||||
|
if (!result?.ok) {
|
||||||
|
setOnlineBackupStatus('Online-Sicherung konnte nicht kopiert werden', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
showCopyToast('Online-Schlüssel kopiert');
|
||||||
|
} catch {
|
||||||
|
setOnlineBackupStatus('Online-Sicherung konnte nicht kopiert werden', 'error');
|
||||||
|
} finally {
|
||||||
|
managedOnlineBackupPendingActions.delete(entry.id);
|
||||||
|
renderManagedOnlineBackups();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteManagedOnlineBackup(entry, row) {
|
||||||
|
const confirmed = await showAppConfirm({
|
||||||
|
title: 'Online-Backup löschen',
|
||||||
|
message: 'Dieses verschlüsselte Online-Backup wird dauerhaft vom Server gelöscht.',
|
||||||
|
confirmText: 'Löschen',
|
||||||
|
danger: true
|
||||||
|
});
|
||||||
|
if (!confirmed || !row.isConnected) return;
|
||||||
|
if (managedOnlineBackupPendingActions.has(entry.id)) return;
|
||||||
|
managedOnlineBackupPendingActions.set(entry.id, 'delete');
|
||||||
|
const controls = [...row.querySelectorAll('button')];
|
||||||
|
controls.forEach(button => { button.disabled = true; });
|
||||||
|
try {
|
||||||
|
const result = await window.api.deleteManagedOnlineBackup(entry.id);
|
||||||
|
if (!result?.ok || result.removedId !== entry.id) {
|
||||||
|
setOnlineBackupStatus('Online-Sicherung konnte nicht gelöscht werden', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
managedOnlineBackupLoadGeneration++;
|
||||||
|
replaceManagedOnlineBackups(managedOnlineBackups.filter(item => item.id !== result.removedId));
|
||||||
|
setOnlineBackupStatus('Schlüssel gelöscht', 'success');
|
||||||
|
} catch {
|
||||||
|
setOnlineBackupStatus('Online-Sicherung konnte nicht gelöscht werden', 'error');
|
||||||
|
} finally {
|
||||||
|
managedOnlineBackupPendingActions.delete(entry.id);
|
||||||
|
renderManagedOnlineBackups();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function doOnlineBackupCreate() {
|
async function doOnlineBackupCreate() {
|
||||||
@@ -2654,27 +2783,30 @@ async function doOnlineBackupCreate() {
|
|||||||
try {
|
try {
|
||||||
await flushPendingSettingsSaves();
|
await flushPendingSettingsSaves();
|
||||||
openOnlineBackupView();
|
openOnlineBackupView();
|
||||||
} catch (error) {
|
await loadManagedOnlineBackups();
|
||||||
|
} catch {
|
||||||
openOnlineBackupView();
|
openOnlineBackupView();
|
||||||
setOnlineBackupStatus(error.message || String(error), 'error');
|
setOnlineBackupStatus('Nicht alle Einstellungen konnten gespeichert werden', 'error');
|
||||||
doOnlineBackupCreate.busy = false;
|
doOnlineBackupCreate.busy = false;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setOnlineBackupBusy(true);
|
const createButton = document.getElementById('createOnlineBackupBtn');
|
||||||
|
if (createButton) createButton.disabled = true;
|
||||||
setOnlineBackupStatus('Verschlüssele und speichere Einstellungen…', 'busy');
|
setOnlineBackupStatus('Verschlüssele und speichere Einstellungen…', 'busy');
|
||||||
try {
|
try {
|
||||||
const result = await window.api.createOnlineBackup();
|
const result = await window.api.createManagedOnlineBackup();
|
||||||
if (!result || !result.ok) throw new Error(result?.error || 'Online-Sicherung konnte nicht erstellt werden');
|
if (!result?.ok || normalizeManagedOnlineBackups([result.entry]).length !== 1) {
|
||||||
const output = document.getElementById('onlineBackupKeyOutput');
|
setOnlineBackupStatus('Online-Sicherung konnte nicht erstellt werden', 'error');
|
||||||
const copyButton = document.getElementById('copyOnlineBackupKeyBtn');
|
return;
|
||||||
if (output) output.value = result.key;
|
}
|
||||||
if (copyButton) copyButton.disabled = false;
|
managedOnlineBackupLoadGeneration++;
|
||||||
|
replaceManagedOnlineBackups([...managedOnlineBackups, result.entry]);
|
||||||
setOnlineBackupStatus('Neuer Schlüssel erstellt. Ältere Schlüssel bleiben gültig.', 'success');
|
setOnlineBackupStatus('Neuer Schlüssel erstellt. Ältere Schlüssel bleiben gültig.', 'success');
|
||||||
showCopyToast('Online-Schlüssel erstellt');
|
showCopyToast('Online-Schlüssel erstellt');
|
||||||
} catch (error) {
|
} catch {
|
||||||
setOnlineBackupStatus(error.message || String(error), 'error');
|
setOnlineBackupStatus('Online-Sicherung konnte nicht erstellt werden', 'error');
|
||||||
} finally {
|
} finally {
|
||||||
setOnlineBackupBusy(false);
|
if (createButton?.isConnected) createButton.disabled = false;
|
||||||
doOnlineBackupCreate.busy = false;
|
doOnlineBackupCreate.busy = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2702,7 +2834,7 @@ async function doOnlineBackupRestore() {
|
|||||||
input?.focus();
|
input?.focus();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setOnlineBackupBusy(true);
|
syncOnlineBackupRestoreButton(true);
|
||||||
setOnlineBackupStatus('Speichere aktuelle Einstellungen…', 'busy');
|
setOnlineBackupStatus('Speichere aktuelle Einstellungen…', 'busy');
|
||||||
try {
|
try {
|
||||||
await flushPendingSettingsSaves();
|
await flushPendingSettingsSaves();
|
||||||
@@ -2722,10 +2854,10 @@ async function doOnlineBackupRestore() {
|
|||||||
if (warnings.length) setOnlineBackupStatus(`Einstellungen übernommen. Bitte prüfen: ${warnings.join(', ')}.`, 'warning');
|
if (warnings.length) setOnlineBackupStatus(`Einstellungen übernommen. Bitte prüfen: ${warnings.join(', ')}.`, 'warning');
|
||||||
else setOnlineBackupStatus('Alle Accounts und Einstellungen wurden übernommen.', 'success');
|
else setOnlineBackupStatus('Alle Accounts und Einstellungen wurden übernommen.', 'success');
|
||||||
if (queuePersistenceError) showImportQueuePersistenceError(queuePersistenceError);
|
if (queuePersistenceError) showImportQueuePersistenceError(queuePersistenceError);
|
||||||
} catch (error) {
|
} catch {
|
||||||
setOnlineBackupStatus(error.message || String(error), 'error');
|
setOnlineBackupStatus('Online-Sicherung konnte nicht importiert werden', 'error');
|
||||||
} finally {
|
} finally {
|
||||||
setOnlineBackupBusy(false);
|
syncOnlineBackupRestoreButton(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -4867,22 +4999,20 @@ function renderSettings() {
|
|||||||
<h3 id="onlineBackupHeading">Verschlüsseltes Online-Backup</h3>
|
<h3 id="onlineBackupHeading">Verschlüsseltes Online-Backup</h3>
|
||||||
<p>Die Verschlüsselung findet ausschließlich auf diesem Gerät statt. Der Server speichert nur verschlüsselte Daten.</p>
|
<p>Die Verschlüsselung findet ausschließlich auf diesem Gerät statt. Der Server speichert nur verschlüsselte Daten.</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="online-backup-action">
|
|
||||||
<button class="btn btn-primary" id="createOnlineBackupBtn">Neuen Schlüssel erzeugen</button>
|
|
||||||
<span class="hint">Jeder Export erzeugt einen neuen Schlüssel. Ältere Schlüssel bleiben gültig.</span>
|
|
||||||
</div>
|
|
||||||
<div class="online-backup-key-row">
|
|
||||||
<label for="onlineBackupKeyOutput">Neuer Schlüssel</label>
|
|
||||||
<input type="text" class="key-input" id="onlineBackupKeyOutput" readonly spellcheck="false" autocomplete="off" placeholder="Nach dem Export erscheint hier der 75-stellige Schlüssel">
|
|
||||||
<button class="btn btn-secondary" id="copyOnlineBackupKeyBtn" disabled>Kopieren</button>
|
|
||||||
</div>
|
|
||||||
<div class="online-backup-key-row">
|
<div class="online-backup-key-row">
|
||||||
<label for="onlineBackupKeyInput">Schlüssel importieren</label>
|
<label for="onlineBackupKeyInput">Schlüssel importieren</label>
|
||||||
<input type="password" class="key-input" id="onlineBackupKeyInput" maxlength="75" pattern="MHU2-[A-Za-z0-9_-]{70}" spellcheck="false" autocomplete="off" placeholder="MHU2-…">
|
<input type="password" class="key-input" id="onlineBackupKeyInput" maxlength="75" pattern="MHU2-[A-Za-z0-9_-]{70}" spellcheck="false" autocomplete="off" placeholder="MHU2-…">
|
||||||
<button class="btn btn-secondary" id="restoreOnlineBackupBtn" disabled>Online importieren</button>
|
<button class="btn btn-secondary" id="restoreOnlineBackupBtn" disabled>Importieren</button>
|
||||||
</div>
|
</div>
|
||||||
<p class="online-backup-warning">Behandle den Schlüssel wie ein Passwort. Wer ihn besitzt, kann die verschlüsselten Einstellungen entschlüsseln.</p>
|
<p class="online-backup-warning">Behandle den Schlüssel wie ein Passwort. Wer ihn besitzt, kann die verschlüsselten Einstellungen entschlüsseln.</p>
|
||||||
|
<section class="online-backup-managed" aria-labelledby="managedOnlineBackupHeading">
|
||||||
|
<h4 id="managedOnlineBackupHeading">Auf diesem Gerät erstellt</h4>
|
||||||
|
<div class="online-backup-managed-list" id="managedOnlineBackupList"></div>
|
||||||
|
</section>
|
||||||
<div class="online-backup-status" id="onlineBackupStatus" role="status" aria-live="polite"></div>
|
<div class="online-backup-status" id="onlineBackupStatus" role="status" aria-live="polite"></div>
|
||||||
|
<footer class="online-backup-footer">
|
||||||
|
<button class="btn btn-primary" id="createOnlineBackupBtn">Neuen Schlüssel erzeugen</button>
|
||||||
|
</footer>
|
||||||
</section>
|
</section>
|
||||||
<div class="settings-section-label">Lokales Datei-Backup</div>
|
<div class="settings-section-label">Lokales Datei-Backup</div>
|
||||||
<div class="settings-row local-backup-password-row">
|
<div class="settings-row local-backup-password-row">
|
||||||
@@ -4918,6 +5048,7 @@ function renderSettings() {
|
|||||||
_syncSidebarIndicator(activeButton);
|
_syncSidebarIndicator(activeButton);
|
||||||
if (focus) activeButton.focus();
|
if (focus) activeButton.focus();
|
||||||
content.scrollTop = 0;
|
content.scrollTop = 0;
|
||||||
|
if (target === 'backup') loadManagedOnlineBackups();
|
||||||
};
|
};
|
||||||
|
|
||||||
navigation.addEventListener('click', (event) => {
|
navigation.addEventListener('click', (event) => {
|
||||||
@@ -5189,15 +5320,10 @@ function renderSettings() {
|
|||||||
if (event.target.checked) document.getElementById('localBackupPasswordInput')?.focus();
|
if (event.target.checked) document.getElementById('localBackupPasswordInput')?.focus();
|
||||||
});
|
});
|
||||||
document.getElementById('createOnlineBackupBtn').addEventListener('click', () => doOnlineBackupCreate());
|
document.getElementById('createOnlineBackupBtn').addEventListener('click', () => doOnlineBackupCreate());
|
||||||
document.getElementById('copyOnlineBackupKeyBtn').addEventListener('click', async () => {
|
renderManagedOnlineBackups();
|
||||||
const key = document.getElementById('onlineBackupKeyOutput').value;
|
|
||||||
if (!key) return;
|
|
||||||
await window.api.copyToClipboard(key);
|
|
||||||
showCopyToast('Online-Schlüssel kopiert');
|
|
||||||
});
|
|
||||||
document.getElementById('onlineBackupKeyInput').addEventListener('input', (event) => {
|
document.getElementById('onlineBackupKeyInput').addEventListener('input', (event) => {
|
||||||
const valid = /^MHU2-[A-Za-z0-9_-]{70}$/.test(event.target.value.trim());
|
const valid = /^MHU2-[A-Za-z0-9_-]{70}$/.test(event.target.value.trim());
|
||||||
document.getElementById('restoreOnlineBackupBtn').disabled = !valid;
|
document.getElementById('restoreOnlineBackupBtn').disabled = document.getElementById('restoreOnlineBackupBtn').dataset.busy === 'true' || !valid;
|
||||||
if (event.target.value && !valid) setOnlineBackupStatus('Der Schlüssel muss exakt 75 Zeichen lang sein.', '');
|
if (event.target.value && !valid) setOnlineBackupStatus('Der Schlüssel muss exakt 75 Zeichen lang sein.', '');
|
||||||
else setOnlineBackupStatus('', '');
|
else setOnlineBackupStatus('', '');
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -307,6 +307,12 @@
|
|||||||
['Verschlüsseltes Online-Backup', 'Encrypted online backup'],
|
['Verschlüsseltes Online-Backup', 'Encrypted online backup'],
|
||||||
['Die Verschlüsselung findet ausschließlich auf diesem Gerät statt. Der Server speichert nur verschlüsselte Daten.', 'Encryption takes place only on this device. The server stores encrypted data only.'],
|
['Die Verschlüsselung findet ausschließlich auf diesem Gerät statt. Der Server speichert nur verschlüsselte Daten.', 'Encryption takes place only on this device. The server stores encrypted data only.'],
|
||||||
['Neuen Schlüssel erzeugen', 'Generate new key'],
|
['Neuen Schlüssel erzeugen', 'Generate new key'],
|
||||||
|
['Auf diesem Gerät erstellt', 'Created on this device'],
|
||||||
|
['Noch keine Schlüssel auf diesem Gerät erstellt.', 'No keys have been created on this device yet.'],
|
||||||
|
['Schlüssel kopieren', 'Copy key'],
|
||||||
|
['Online-Backup löschen', 'Delete online backup'],
|
||||||
|
['Dieses verschlüsselte Online-Backup wird dauerhaft vom Server gelöscht.', 'This encrypted online backup will be permanently deleted from the server.'],
|
||||||
|
['Schlüssel gelöscht', 'Key deleted'],
|
||||||
['Jeder Export erzeugt einen neuen Schlüssel. Ältere Schlüssel bleiben gültig.', 'Each export creates a new key. Older keys remain valid.'],
|
['Jeder Export erzeugt einen neuen Schlüssel. Ältere Schlüssel bleiben gültig.', 'Each export creates a new key. Older keys remain valid.'],
|
||||||
['Dein neuer Schlüssel', 'Your new key'],
|
['Dein neuer Schlüssel', 'Your new key'],
|
||||||
['Neuer Schlüssel', 'Your new key'],
|
['Neuer Schlüssel', 'Your new key'],
|
||||||
@@ -458,6 +464,8 @@
|
|||||||
['Online-Schlüssel erstellt', 'Online key created'],
|
['Online-Schlüssel erstellt', 'Online key created'],
|
||||||
['Online-Schlüssel kopiert', 'Online key copied'],
|
['Online-Schlüssel kopiert', 'Online key copied'],
|
||||||
['Online-Sicherung konnte nicht erstellt werden', 'Online backup could not be created'],
|
['Online-Sicherung konnte nicht erstellt werden', 'Online backup could not be created'],
|
||||||
|
['Online-Sicherungen konnten nicht geladen werden', 'Online backups could not be loaded'],
|
||||||
|
['Online-Sicherung konnte nicht kopiert werden', 'Online backup could not be copied'],
|
||||||
['Online-Sicherung konnte nicht importiert werden', 'Online backup could not be imported'],
|
['Online-Sicherung konnte nicht importiert werden', 'Online backup could not be imported'],
|
||||||
['OTP wird geprüft…', 'Checking OTP…'],
|
['OTP wird geprüft…', 'Checking OTP…'],
|
||||||
['OTP-Prüfung fehlgeschlagen', 'OTP check failed'],
|
['OTP-Prüfung fehlgeschlagen', 'OTP check failed'],
|
||||||
@@ -530,6 +538,12 @@
|
|||||||
['Online-Sicherung konnte nicht geladen werden', 'Online backup could not be loaded'],
|
['Online-Sicherung konnte nicht geladen werden', 'Online backup could not be loaded'],
|
||||||
['Online-Sicherungsdienst hat ungültige Daten geliefert', 'The online backup service returned invalid data'],
|
['Online-Sicherungsdienst hat ungültige Daten geliefert', 'The online backup service returned invalid data'],
|
||||||
['Online-Sicherung konnte nicht gelöscht werden', 'Online backup could not be deleted'],
|
['Online-Sicherung konnte nicht gelöscht werden', 'Online backup could not be deleted'],
|
||||||
|
['Online-Sicherungsanfrage wurde abgewiesen', 'Online backup request was rejected'],
|
||||||
|
['Online-Sicherungs-ID ist ungültig', 'Online backup ID is invalid'],
|
||||||
|
['Ungültiger Erstellungszeitpunkt', 'Invalid creation timestamp'],
|
||||||
|
['Gespeicherter Online-Schlüsselbund ist ungültig', 'Stored online keyring is invalid'],
|
||||||
|
['Online-Sicherungsschlüssel konnte nicht sicher vorbereitet werden', 'Online backup key could not be prepared securely'],
|
||||||
|
['Geschützter Schlüssel', 'Protected key'],
|
||||||
['Backup hat eine ungültige Struktur', 'The backup has an invalid structure'],
|
['Backup hat eine ungültige Struktur', 'The backup has an invalid structure'],
|
||||||
['Einstellungen werden bereits importiert', 'Settings are already being imported'],
|
['Einstellungen werden bereits importiert', 'Settings are already being imported'],
|
||||||
['Während laufender Uploads können keine Einstellungen importiert werden', 'Settings cannot be imported while uploads are running'],
|
['Während laufender Uploads können keine Einstellungen importiert werden', 'Settings cannot be imported while uploads are running'],
|
||||||
|
|||||||
+86
-2
@@ -1868,6 +1868,7 @@ select.hs-input { max-width: none; width: auto; min-width: 140px; }
|
|||||||
.online-backup-panel {
|
.online-backup-panel {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 16px;
|
gap: 16px;
|
||||||
|
min-width: 0;
|
||||||
padding: 18px;
|
padding: 18px;
|
||||||
margin-bottom: 18px;
|
margin-bottom: 18px;
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
@@ -1886,7 +1887,6 @@ select.hs-input { max-width: none; width: auto; min-width: 140px; }
|
|||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
}
|
}
|
||||||
|
|
||||||
.online-backup-action,
|
|
||||||
.backup-file-actions {
|
.backup-file-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -1920,6 +1920,75 @@ select.hs-input { max-width: none; width: auto; min-width: 140px; }
|
|||||||
color: #fbbf24;
|
color: #fbbf24;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.online-backup-managed {
|
||||||
|
display: grid;
|
||||||
|
gap: 10px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.online-backup-managed h4 {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--text);
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.online-backup-managed-list {
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.online-backup-managed-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 168px 188px minmax(0, 1fr);
|
||||||
|
gap: 10px;
|
||||||
|
align-items: center;
|
||||||
|
min-width: 0;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--bg-card);
|
||||||
|
}
|
||||||
|
|
||||||
|
.online-backup-managed-key {
|
||||||
|
min-width: 0;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
color: var(--text);
|
||||||
|
font-family: Consolas, monospace;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.online-backup-managed-created {
|
||||||
|
min-width: 0;
|
||||||
|
color: var(--text-dim);
|
||||||
|
font-size: 13px;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
.online-backup-managed-actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 8px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.online-backup-managed-actions .btn {
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.online-backup-managed-empty {
|
||||||
|
padding: 12px;
|
||||||
|
border: 1px dashed var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.online-backup-footer {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.online-backup-status {
|
.online-backup-status {
|
||||||
min-height: 18px;
|
min-height: 18px;
|
||||||
color: var(--text-dim);
|
color: var(--text-dim);
|
||||||
@@ -3607,9 +3676,24 @@ input[type="checkbox"] {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 820px) {
|
@media (max-width: 820px) {
|
||||||
.online-backup-key-row {
|
.online-backup-key-row,
|
||||||
|
.online-backup-managed-row {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.online-backup-managed-actions {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.online-backup-managed-actions .btn {
|
||||||
|
flex: 1 1 0;
|
||||||
|
min-width: 0;
|
||||||
|
white-space: normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
.online-backup-footer .btn {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#settings-view {
|
#settings-view {
|
||||||
|
|||||||
@@ -23,6 +23,23 @@ test('translates compact online backup labels in both directions', () => {
|
|||||||
assert.equal(translateText('Import existing key', 'de'), 'Schlüssel importieren');
|
assert.equal(translateText('Import existing key', 'de'), 'Schlüssel importieren');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('translates managed online backup controls in both directions', () => {
|
||||||
|
const pairs = [
|
||||||
|
['Auf diesem Gerät erstellt', 'Created on this device'],
|
||||||
|
['Noch keine Schlüssel auf diesem Gerät erstellt.', 'No keys have been created on this device yet.'],
|
||||||
|
['Schlüssel kopieren', 'Copy key'],
|
||||||
|
['Online-Backup löschen', 'Delete online backup'],
|
||||||
|
['Dieses verschlüsselte Online-Backup wird dauerhaft vom Server gelöscht.', 'This encrypted online backup will be permanently deleted from the server.'],
|
||||||
|
['Schlüssel gelöscht', 'Key deleted'],
|
||||||
|
['Importieren', 'Import']
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const [german, english] of pairs) {
|
||||||
|
assert.equal(translateText(german, 'en'), english);
|
||||||
|
assert.equal(translateText(english, 'de'), german);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
test('translates the account check timestamp label', () => {
|
test('translates the account check timestamp label', () => {
|
||||||
assert.equal(translateText('geprüft', 'en'), 'checked');
|
assert.equal(translateText('geprüft', 'en'), 'checked');
|
||||||
assert.equal(translateText('checked', 'de'), 'geprüft');
|
assert.equal(translateText('checked', 'de'), 'geprüft');
|
||||||
|
|||||||
@@ -24,7 +24,12 @@ test('exposes managed online backup operations through narrow IPC boundaries', (
|
|||||||
assert.match(preloadSource, /createManagedOnlineBackup:\s*\(\)\s*=>\s*ipcRenderer\.invoke\('online-backup:create-managed'\)/u);
|
assert.match(preloadSource, /createManagedOnlineBackup:\s*\(\)\s*=>\s*ipcRenderer\.invoke\('online-backup:create-managed'\)/u);
|
||||||
assert.match(preloadSource, /copyManagedOnlineBackup:\s*\(id\)\s*=>\s*ipcRenderer\.invoke\('online-backup:copy-managed',\s*id\)/u);
|
assert.match(preloadSource, /copyManagedOnlineBackup:\s*\(id\)\s*=>\s*ipcRenderer\.invoke\('online-backup:copy-managed',\s*id\)/u);
|
||||||
assert.match(preloadSource, /deleteManagedOnlineBackup:\s*\(id\)\s*=>\s*ipcRenderer\.invoke\('online-backup:delete-managed',\s*id\)/u);
|
assert.match(preloadSource, /deleteManagedOnlineBackup:\s*\(id\)\s*=>\s*ipcRenderer\.invoke\('online-backup:delete-managed',\s*id\)/u);
|
||||||
assert.match(preloadSource, /createOnlineBackup:\s*\(\)\s*=>\s*ipcRenderer\.invoke\('online-backup:create-managed'\)/u);
|
assert.match(preloadSource, /restoreOnlineBackup:\s*\(key\)\s*=>\s*ipcRenderer\.invoke\('online-backup:restore',\s*key\)/u);
|
||||||
|
assert.deepEqual(
|
||||||
|
[...preloadSource.matchAll(/^\s{2}(\w*OnlineBackups?):/gmu)].map((match) => match[1]),
|
||||||
|
['listManagedOnlineBackups', 'createManagedOnlineBackup', 'copyManagedOnlineBackup', 'deleteManagedOnlineBackup', 'restoreOnlineBackup']
|
||||||
|
);
|
||||||
|
assert.doesNotMatch(preloadSource, /^\s{2}createOnlineBackup:/mu);
|
||||||
assert.doesNotMatch(preloadSource, /ipcRenderer\.invoke\('online-backup:create'\)/u);
|
assert.doesNotMatch(preloadSource, /ipcRenderer\.invoke\('online-backup:create'\)/u);
|
||||||
assert.match(mainSource, /path\.join\(app\.getPath\('userData'\),\s*'online-backup-keys\.json'\)/u);
|
assert.match(mainSource, /path\.join\(app\.getPath\('userData'\),\s*'online-backup-keys\.json'\)/u);
|
||||||
assert.match(mainSource, /Buffer\.from\(id,\s*'base64url'\)/u);
|
assert.match(mainSource, /Buffer\.from\(id,\s*'base64url'\)/u);
|
||||||
|
|||||||
+147
-15
@@ -62,23 +62,116 @@ test('Windows compositor paints the full hidden surface with an RDP session envi
|
|||||||
const userDataPath = path.join(probeRoot, 'user-data');
|
const userDataPath = path.join(probeRoot, 'user-data');
|
||||||
fs.writeFileSync(preloadPath, `
|
fs.writeFileSync(preloadPath, `
|
||||||
const { contextBridge } = require('electron');
|
const { contextBridge } = require('electron');
|
||||||
|
const managedOnlineBackupProbeCalls = [];
|
||||||
|
let pendingManagedOnlineBackupDelete = null;
|
||||||
contextBridge.exposeInMainWorld('api', {
|
contextBridge.exposeInMainWorld('api', {
|
||||||
onUpdateAvailable() {},
|
onUpdateAvailable() {},
|
||||||
onUpdateProgress() {},
|
onUpdateProgress() {},
|
||||||
onPrepareClose() {},
|
onPrepareClose() {},
|
||||||
getConfig() { return new Promise(() => {}); }
|
getConfig() { return new Promise(() => {}); },
|
||||||
|
listManagedOnlineBackups() {
|
||||||
|
managedOnlineBackupProbeCalls.push(['list']);
|
||||||
|
return Promise.resolve({ ok: true, entries: [
|
||||||
|
{ id: 'AAAAAAAAAAAAAAAAAAAAAA', displayKey: 'MHU2-ABCD…1234', createdAt: '2026-08-20T08:00:00.000Z' },
|
||||||
|
{ id: 'BBBBBBBBBBBBBBBBBBBBBB', displayKey: 'MHU2-ZYXW…9876', createdAt: '2026-08-22T10:00:00.000Z' }
|
||||||
|
] });
|
||||||
|
},
|
||||||
|
createManagedOnlineBackup() {
|
||||||
|
managedOnlineBackupProbeCalls.push(['create']);
|
||||||
|
return Promise.resolve({ ok: true, entry: { id: 'CCCCCCCCCCCCCCCCCCCCCC', displayKey: 'MHU2-QWER…4321', createdAt: '2026-08-23T12:00:00.000Z' } });
|
||||||
|
},
|
||||||
|
copyManagedOnlineBackup(id) {
|
||||||
|
managedOnlineBackupProbeCalls.push(['copy', id]);
|
||||||
|
return Promise.resolve({ ok: true });
|
||||||
|
},
|
||||||
|
deleteManagedOnlineBackup(id) {
|
||||||
|
managedOnlineBackupProbeCalls.push(['delete', id]);
|
||||||
|
return new Promise(resolve => { pendingManagedOnlineBackupDelete = { id, resolve }; });
|
||||||
|
},
|
||||||
|
releaseManagedOnlineBackupDelete() {
|
||||||
|
const pending = pendingManagedOnlineBackupDelete;
|
||||||
|
pendingManagedOnlineBackupDelete = null;
|
||||||
|
pending.resolve({ ok: true, removedId: pending.id, notFound: false });
|
||||||
|
},
|
||||||
|
getManagedOnlineBackupProbeCalls() { return managedOnlineBackupProbeCalls; }
|
||||||
});
|
});
|
||||||
`, 'utf8');
|
`, 'utf8');
|
||||||
|
const onlineBackupBehaviorScript = `(async () => {
|
||||||
|
const fixture = document.createElement('section');
|
||||||
|
fixture.innerHTML = '<div id="managedOnlineBackupList"></div><div id="onlineBackupStatus"></div><button id="createOnlineBackupBtn"></button><input id="onlineBackupKeyInput"><button id="restoreOnlineBackupBtn"></button>';
|
||||||
|
document.body.append(fixture);
|
||||||
|
let confirmation = null;
|
||||||
|
showAppConfirm = async options => { confirmation = options; return true; };
|
||||||
|
flushPendingSettingsSaves = async () => {};
|
||||||
|
openOnlineBackupView = () => {};
|
||||||
|
await loadManagedOnlineBackups();
|
||||||
|
const initialKeys = [...document.querySelectorAll('.online-backup-managed-key')].map(element => element.textContent);
|
||||||
|
const maliciousEntry = { id: 'DDDDDDDDDDDDDDDDDDDDDD', displayKey: 'prefix MHU2-' + 'X'.repeat(70) + ' suffix', createdAt: '2026-08-24T12:00:00.000Z' };
|
||||||
|
replaceManagedOnlineBackups([...managedOnlineBackups, maliciousEntry]);
|
||||||
|
const secretRejected = !document.body.textContent.includes(maliciousEntry.displayKey) && managedOnlineBackups.every(entry => entry.displayKey !== maliciousEntry.displayKey);
|
||||||
|
document.querySelector('.online-backup-copy-btn').click();
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 0));
|
||||||
|
document.querySelector('.online-backup-delete-btn').click();
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 0));
|
||||||
|
renderManagedOnlineBackups();
|
||||||
|
const pendingDeleteControlsDisabled = [...document.querySelector('.online-backup-managed-row').querySelectorAll('button')].every(button => button.disabled);
|
||||||
|
window.api.releaseManagedOnlineBackupDelete();
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 0));
|
||||||
|
const afterDelete = [...document.querySelectorAll('.online-backup-managed-key')].map(element => element.textContent);
|
||||||
|
await doOnlineBackupCreate();
|
||||||
|
const afterCreate = [...document.querySelectorAll('.online-backup-managed-key')].map(element => element.textContent);
|
||||||
|
return {
|
||||||
|
initialKeys,
|
||||||
|
secretRejected,
|
||||||
|
pendingDeleteControlsDisabled,
|
||||||
|
afterDelete,
|
||||||
|
afterCreate,
|
||||||
|
confirmation,
|
||||||
|
calls: await window.api.getManagedOnlineBackupProbeCalls(),
|
||||||
|
stateFields: Object.keys(managedOnlineBackups[0] || {}).sort(),
|
||||||
|
secretInBody: /MHU2-[A-Za-z0-9_-]{70}/.test(document.body.textContent)
|
||||||
|
};
|
||||||
|
})()`;
|
||||||
const onlineBackupLayoutScript = `(() => {
|
const onlineBackupLayoutScript = `(() => {
|
||||||
document.documentElement.lang = 'de';
|
const measure = language => {
|
||||||
document.body.innerHTML = '<section class="online-backup-panel"><div class="online-backup-key-row"><label>Dein neuer Schlüssel</label><input class="key-input"><button class="btn btn-secondary">Kopieren</button></div><div class="online-backup-key-row"><label>Vorhandenen Schlüssel importieren</label><input class="key-input"><button class="btn btn-secondary">Online importieren</button></div></section>';
|
const copy = language === 'de' ? 'Schlüssel kopieren' : 'Copy key';
|
||||||
const rows = [...document.querySelectorAll('.online-backup-key-row')];
|
const remove = language === 'de' ? 'Online-Backup löschen' : 'Delete online backup';
|
||||||
return rows.map(row => {
|
document.documentElement.lang = language;
|
||||||
const label = row.querySelector('label').getBoundingClientRect();
|
document.body.innerHTML = '<section class="online-backup-panel"><section class="online-backup-managed"><h4>Managed</h4><div class="online-backup-managed-list"><article class="online-backup-managed-row"><span class="online-backup-managed-key">ABCDEFGH…1234</span><span class="online-backup-managed-created">22.08.2026 12:00</span><div class="online-backup-managed-actions"><button class="btn btn-secondary">' + copy + '</button><button class="btn btn-danger">' + remove + '</button></div></article><article class="online-backup-managed-row"><span class="online-backup-managed-key">ZYXWVUTS…9876</span><span class="online-backup-managed-created">21.08.2026 11:00</span><div class="online-backup-managed-actions"><button class="btn btn-secondary">' + copy + '</button><button class="btn btn-danger">' + remove + '</button></div></article></div></section><footer class="online-backup-footer"><button class="btn btn-primary">Generate new key</button></footer></section>';
|
||||||
const input = row.querySelector('input').getBoundingClientRect();
|
const panel = document.querySelector('.online-backup-panel');
|
||||||
const button = row.querySelector('button').getBoundingClientRect();
|
const panelRect = panel.getBoundingClientRect();
|
||||||
return { labelWidth: label.width, inputLeft: input.left, buttonRight: button.right };
|
const panelStyle = getComputedStyle(panel);
|
||||||
});
|
const rows = [...document.querySelectorAll('.online-backup-managed-row')].map(row => {
|
||||||
|
const key = row.querySelector('.online-backup-managed-key').getBoundingClientRect();
|
||||||
|
const created = row.querySelector('.online-backup-managed-created').getBoundingClientRect();
|
||||||
|
const actions = row.querySelector('.online-backup-managed-actions').getBoundingClientRect();
|
||||||
|
return { keyLeft: key.left, createdLeft: created.left, actionsRight: actions.right };
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
rows,
|
||||||
|
contentRight: panelRect.right - parseFloat(panelStyle.paddingRight),
|
||||||
|
createRight: document.querySelector('.online-backup-footer button').getBoundingClientRect().right
|
||||||
|
};
|
||||||
|
};
|
||||||
|
return { german: measure('de'), english: measure('en') };
|
||||||
|
})()`;
|
||||||
|
const onlineBackupNarrowLayoutScript = `(() => {
|
||||||
|
document.body.innerHTML = '<section class="online-backup-panel"><section class="online-backup-managed"><div class="online-backup-managed-list"><article class="online-backup-managed-row"><span class="online-backup-managed-key">ABCDEFGH…1234</span><span class="online-backup-managed-created">22/08/2026, 12:00</span><div class="online-backup-managed-actions"><button class="btn btn-secondary">Copy key</button><button class="btn btn-danger">Delete online backup</button></div></article></div></section><footer class="online-backup-footer"><button class="btn btn-primary">Generate new key</button></footer></section>';
|
||||||
|
const row = document.querySelector('.online-backup-managed-row').getBoundingClientRect();
|
||||||
|
const key = document.querySelector('.online-backup-managed-key').getBoundingClientRect();
|
||||||
|
const created = document.querySelector('.online-backup-managed-created').getBoundingClientRect();
|
||||||
|
const actions = document.querySelector('.online-backup-managed-actions').getBoundingClientRect();
|
||||||
|
const rowStyle = getComputedStyle(document.querySelector('.online-backup-managed-row'));
|
||||||
|
const rowContentWidth = row.width - parseFloat(rowStyle.paddingLeft) - parseFloat(rowStyle.paddingRight) - parseFloat(rowStyle.borderLeftWidth) - parseFloat(rowStyle.borderRightWidth);
|
||||||
|
const footer = document.querySelector('.online-backup-footer').getBoundingClientRect();
|
||||||
|
const create = document.querySelector('.online-backup-footer button').getBoundingClientRect();
|
||||||
|
return {
|
||||||
|
horizontalOverflow: document.documentElement.scrollWidth > document.documentElement.clientWidth + 1,
|
||||||
|
rowOverflow: document.querySelector('.online-backup-managed-row').scrollWidth > document.querySelector('.online-backup-managed-row').clientWidth + 1,
|
||||||
|
stacked: key.top < created.top && created.top < actions.top,
|
||||||
|
actionsStretched: Math.abs(actions.width - rowContentWidth) <= 1,
|
||||||
|
createStretched: Math.abs(create.width - footer.width) <= 1
|
||||||
|
};
|
||||||
})()`;
|
})()`;
|
||||||
const probeSource = `
|
const probeSource = `
|
||||||
const { app, BrowserWindow, screen } = require('electron');
|
const { app, BrowserWindow, screen } = require('electron');
|
||||||
@@ -115,7 +208,11 @@ app.whenReady().then(async () => {
|
|||||||
const middleY = Math.floor(size.height / 2);
|
const middleY = Math.floor(size.height / 2);
|
||||||
await window.loadFile(process.env.MHU_RENDERER_PATH, { query: { language: 'en', version: '2.1.31' } });
|
await window.loadFile(process.env.MHU_RENDERER_PATH, { query: { language: 'en', version: '2.1.31' } });
|
||||||
const liveSpeedChart = await window.webContents.executeJavaScript('({ baselinePresent: Boolean(document.querySelector(".upload-speed-baseline")), canvasWidth: document.getElementById("uploadSpeedCanvas")?.getBoundingClientRect().width || 0 })');
|
const liveSpeedChart = await window.webContents.executeJavaScript('({ baselinePresent: Boolean(document.querySelector(".upload-speed-baseline")), canvasWidth: document.getElementById("uploadSpeedCanvas")?.getBoundingClientRect().width || 0 })');
|
||||||
|
const onlineBackupBehavior = await window.webContents.executeJavaScript(${JSON.stringify(onlineBackupBehaviorScript)});
|
||||||
const onlineBackupLayout = await window.webContents.executeJavaScript(${JSON.stringify(onlineBackupLayoutScript)});
|
const onlineBackupLayout = await window.webContents.executeJavaScript(${JSON.stringify(onlineBackupLayoutScript)});
|
||||||
|
window.setContentSize(760, Math.min(900, display.workAreaSize.height));
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 50));
|
||||||
|
const onlineBackupNarrowLayout = await window.webContents.executeJavaScript(${JSON.stringify(onlineBackupNarrowLayoutScript)});
|
||||||
fs.writeFileSync(outputPath, JSON.stringify({
|
fs.writeFileSync(outputPath, JSON.stringify({
|
||||||
size,
|
size,
|
||||||
dom,
|
dom,
|
||||||
@@ -127,7 +224,9 @@ app.whenReady().then(async () => {
|
|||||||
leftEdge: pixelAt(bitmap, size.width, 0, middleY),
|
leftEdge: pixelAt(bitmap, size.width, 0, middleY),
|
||||||
rightEdge: pixelAt(bitmap, size.width, size.width - 1, middleY),
|
rightEdge: pixelAt(bitmap, size.width, size.width - 1, middleY),
|
||||||
liveSpeedChart,
|
liveSpeedChart,
|
||||||
onlineBackupLayout
|
onlineBackupBehavior,
|
||||||
|
onlineBackupLayout,
|
||||||
|
onlineBackupNarrowLayout
|
||||||
}), 'utf8');
|
}), 'utf8');
|
||||||
window.destroy();
|
window.destroy();
|
||||||
app.exit(0);
|
app.exit(0);
|
||||||
@@ -167,10 +266,43 @@ app.whenReady().then(async () => {
|
|||||||
assert.ok(result.rightEdge[0] > result.rightEdge[1] && result.rightEdge[2] > result.rightEdge[1]);
|
assert.ok(result.rightEdge[0] > result.rightEdge[1] && result.rightEdge[2] > result.rightEdge[1]);
|
||||||
assert.ok(result.liveSpeedChart.canvasWidth > 0);
|
assert.ok(result.liveSpeedChart.canvasWidth > 0);
|
||||||
assert.equal(result.liveSpeedChart.baselinePresent, false);
|
assert.equal(result.liveSpeedChart.baselinePresent, false);
|
||||||
assert.equal(result.onlineBackupLayout.length, 2);
|
assert.deepEqual(result.onlineBackupBehavior.initialKeys, ['MHU2-ZYXW…9876', 'MHU2-ABCD…1234']);
|
||||||
assert.ok(Math.abs(result.onlineBackupLayout[0].labelWidth - result.onlineBackupLayout[1].labelWidth) <= 1);
|
assert.equal(result.onlineBackupBehavior.secretRejected, true);
|
||||||
assert.ok(Math.abs(result.onlineBackupLayout[0].inputLeft - result.onlineBackupLayout[1].inputLeft) <= 1);
|
assert.equal(result.onlineBackupBehavior.pendingDeleteControlsDisabled, true);
|
||||||
assert.ok(Math.abs(result.onlineBackupLayout[0].buttonRight - result.onlineBackupLayout[1].buttonRight) <= 1);
|
assert.deepEqual(result.onlineBackupBehavior.afterDelete, ['MHU2-ABCD…1234']);
|
||||||
|
assert.equal(result.onlineBackupBehavior.afterCreate[0], 'MHU2-QWER…4321');
|
||||||
|
assert.equal(result.onlineBackupBehavior.afterCreate.length, 3);
|
||||||
|
assert.deepEqual(result.onlineBackupBehavior.confirmation, {
|
||||||
|
title: 'Online-Backup löschen',
|
||||||
|
message: 'Dieses verschlüsselte Online-Backup wird dauerhaft vom Server gelöscht.',
|
||||||
|
confirmText: 'Löschen',
|
||||||
|
danger: true
|
||||||
|
});
|
||||||
|
assert.deepEqual(result.onlineBackupBehavior.calls, [
|
||||||
|
['list'],
|
||||||
|
['copy', 'BBBBBBBBBBBBBBBBBBBBBB'],
|
||||||
|
['delete', 'BBBBBBBBBBBBBBBBBBBBBB'],
|
||||||
|
['list'],
|
||||||
|
['create']
|
||||||
|
]);
|
||||||
|
assert.deepEqual(result.onlineBackupBehavior.stateFields, ['createdAt', 'displayKey', 'id']);
|
||||||
|
assert.equal(result.onlineBackupBehavior.secretInBody, false);
|
||||||
|
assert.ok(Math.abs(result.onlineBackupLayout.german.createRight - result.onlineBackupLayout.german.contentRight) <= 1);
|
||||||
|
assert.ok(Math.abs(result.onlineBackupLayout.english.createRight - result.onlineBackupLayout.english.contentRight) <= 1);
|
||||||
|
for (const layout of [result.onlineBackupLayout.german, result.onlineBackupLayout.english]) {
|
||||||
|
assert.equal(layout.rows.length, 2);
|
||||||
|
assert.ok(Math.abs(layout.rows[0].keyLeft - layout.rows[1].keyLeft) <= 1);
|
||||||
|
assert.ok(Math.abs(layout.rows[0].createdLeft - layout.rows[1].createdLeft) <= 1);
|
||||||
|
assert.ok(Math.abs(layout.rows[0].actionsRight - layout.rows[1].actionsRight) <= 1);
|
||||||
|
}
|
||||||
|
assert.ok(Math.abs(result.onlineBackupLayout.german.rows[0].keyLeft - result.onlineBackupLayout.english.rows[0].keyLeft) <= 1);
|
||||||
|
assert.ok(Math.abs(result.onlineBackupLayout.german.rows[0].createdLeft - result.onlineBackupLayout.english.rows[0].createdLeft) <= 1);
|
||||||
|
assert.ok(Math.abs(result.onlineBackupLayout.german.rows[0].actionsRight - result.onlineBackupLayout.english.rows[0].actionsRight) <= 1);
|
||||||
|
assert.equal(result.onlineBackupNarrowLayout.horizontalOverflow, false);
|
||||||
|
assert.equal(result.onlineBackupNarrowLayout.rowOverflow, false);
|
||||||
|
assert.equal(result.onlineBackupNarrowLayout.stacked, true);
|
||||||
|
assert.equal(result.onlineBackupNarrowLayout.actionsStretched, true);
|
||||||
|
assert.equal(result.onlineBackupNarrowLayout.createStretched, true);
|
||||||
} finally {
|
} finally {
|
||||||
fs.rmSync(probeRoot, { recursive: true, force: true });
|
fs.rmSync(probeRoot, { recursive: true, force: true });
|
||||||
}
|
}
|
||||||
|
|||||||
+71
-7
@@ -61,8 +61,41 @@ const initialIpcHandlers = new Map();
|
|||||||
const registerIpcHandler = ipcMain.handle.bind(ipcMain);
|
const registerIpcHandler = ipcMain.handle.bind(ipcMain);
|
||||||
let initialConfigReadDelayed = false;
|
let initialConfigReadDelayed = false;
|
||||||
let startupLanguagePendingSnapshot = null;
|
let startupLanguagePendingSnapshot = null;
|
||||||
|
let managedOnlineBackupEntries = [
|
||||||
|
{ id: 'AAAAAAAAAAAAAAAAAAAAAA', displayKey: 'MHU2-ABCD…1234', createdAt: '2026-08-20T08:00:00.000Z' },
|
||||||
|
{ id: 'BBBBBBBBBBBBBBBBBBBBBB', displayKey: 'MHU2-ZYXW…9876', createdAt: '2026-08-22T10:00:00.000Z' }
|
||||||
|
];
|
||||||
|
const managedOnlineBackupCopyIds = [];
|
||||||
|
const managedOnlineBackupDeleteIds = [];
|
||||||
|
let managedOnlineBackupCreateCalls = 0;
|
||||||
|
let managedOnlineBackupDeleteMode = 'failure';
|
||||||
|
let releaseManagedOnlineBackupDelete = null;
|
||||||
|
const managedOnlineBackupHandlers = {
|
||||||
|
'online-backup:list-managed': async () => ({ ok: true, entries: managedOnlineBackupEntries.map(entry => ({ ...entry })) }),
|
||||||
|
'online-backup:create-managed': async () => {
|
||||||
|
managedOnlineBackupCreateCalls++;
|
||||||
|
const entry = { id: 'CCCCCCCCCCCCCCCCCCCCCC', displayKey: 'MHU2-QWER…4321', createdAt: '2026-08-23T12:00:00.000Z' };
|
||||||
|
managedOnlineBackupEntries = [...managedOnlineBackupEntries, entry];
|
||||||
|
return { ok: true, entry: { ...entry } };
|
||||||
|
},
|
||||||
|
'online-backup:copy-managed': async (_event, id) => {
|
||||||
|
managedOnlineBackupCopyIds.push(id);
|
||||||
|
return { ok: true };
|
||||||
|
},
|
||||||
|
'online-backup:delete-managed': async (_event, id) => {
|
||||||
|
managedOnlineBackupDeleteIds.push(id);
|
||||||
|
if (managedOnlineBackupDeleteMode === 'failure') return { ok: false, error: 'Online-Sicherung konnte nicht gelöscht werden' };
|
||||||
|
return new Promise(resolve => {
|
||||||
|
releaseManagedOnlineBackupDelete = () => {
|
||||||
|
managedOnlineBackupEntries = managedOnlineBackupEntries.filter(entry => entry.id !== id);
|
||||||
|
releaseManagedOnlineBackupDelete = null;
|
||||||
|
resolve({ ok: true, removedId: id, notFound: false });
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
ipcMain.handle = (channel, listener) => {
|
ipcMain.handle = (channel, listener) => {
|
||||||
const registeredListener = channel === 'get-config'
|
const defaultListener = channel === 'get-config'
|
||||||
? async (...args) => {
|
? async (...args) => {
|
||||||
const result = await listener(...args);
|
const result = await listener(...args);
|
||||||
if (!initialConfigReadDelayed) {
|
if (!initialConfigReadDelayed) {
|
||||||
@@ -86,6 +119,7 @@ ipcMain.handle = (channel, listener) => {
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
: listener;
|
: listener;
|
||||||
|
const registeredListener = managedOnlineBackupHandlers[channel] || defaultListener;
|
||||||
if (!initialIpcHandlers.has(channel)) initialIpcHandlers.set(channel, registeredListener);
|
if (!initialIpcHandlers.has(channel)) initialIpcHandlers.set(channel, registeredListener);
|
||||||
return registerIpcHandler(channel, registeredListener);
|
return registerIpcHandler(channel, registeredListener);
|
||||||
};
|
};
|
||||||
@@ -1371,17 +1405,47 @@ setTimeout(async () => {
|
|||||||
check('Global parallel uploads default 0', parallel === '0');
|
check('Global parallel uploads default 0', parallel === '0');
|
||||||
|
|
||||||
await wc.executeJavaScript('document.querySelector("[data-settings-page=\\'backup\\']")?.click()');
|
await wc.executeJavaScript('document.querySelector("[data-settings-page=\\'backup\\']")?.click()');
|
||||||
const onlineBackupControls = await wc.executeJavaScript('["createOnlineBackupBtn", "onlineBackupKeyOutput", "copyOnlineBackupKeyBtn", "onlineBackupKeyInput", "restoreOnlineBackupBtn", "onlineBackupStatus"].every(id => Boolean(document.getElementById(id)))');
|
const onlineBackupControls = await wc.executeJavaScript('["createOnlineBackupBtn", "managedOnlineBackupHeading", "managedOnlineBackupList", "onlineBackupKeyInput", "restoreOnlineBackupBtn", "onlineBackupStatus"].every(id => Boolean(document.getElementById(id))) && !document.getElementById("onlineBackupKeyOutput") && !document.getElementById("copyOnlineBackupKeyBtn")');
|
||||||
check('Online backup controls exist', onlineBackupControls);
|
check('Online backup controls exist', onlineBackupControls);
|
||||||
|
|
||||||
const onlineBackupKeyContract = await wc.executeJavaScript('document.getElementById("onlineBackupKeyInput")?.maxLength + "|" + document.getElementById("onlineBackupKeyInput")?.getAttribute("pattern")');
|
const onlineBackupKeyContract = await wc.executeJavaScript('document.getElementById("onlineBackupKeyInput")?.maxLength + "|" + document.getElementById("onlineBackupKeyInput")?.getAttribute("pattern")');
|
||||||
check('Online backup input enforces the 75-character MHU key format', onlineBackupKeyContract === '75|MHU2-[A-Za-z0-9_-]{70}');
|
check('Online backup input enforces the 75-character MHU key format', onlineBackupKeyContract === '75|MHU2-[A-Za-z0-9_-]{70}');
|
||||||
|
|
||||||
const onlineBackupBridge = await wc.executeJavaScript('["listManagedOnlineBackups", "createManagedOnlineBackup", "copyManagedOnlineBackup", "deleteManagedOnlineBackup"].map(name => typeof window.api[name]).join("|")');
|
const onlineBackupBridge = await wc.executeJavaScript('["listManagedOnlineBackups", "createManagedOnlineBackup", "copyManagedOnlineBackup", "deleteManagedOnlineBackup", "restoreOnlineBackup", "createOnlineBackup"].map(name => typeof window.api[name]).join("|")');
|
||||||
check('Online backup uses a narrow managed preload bridge', onlineBackupBridge === 'function|function|function|function');
|
check('Online backup uses a narrow managed preload bridge', onlineBackupBridge === 'function|function|function|function|function|undefined');
|
||||||
|
|
||||||
const managedOnlineBackupCopyResult = await wc.executeJavaScript('window.api.copyManagedOnlineBackup("AAAAAAAAAAAAAAAAAAAAAA").then(result => JSON.stringify(result))');
|
const managedOnlineBackupLoaded = await waitUntil(() => wc.executeJavaScript('document.querySelectorAll(".online-backup-managed-row").length === 2'));
|
||||||
check('Managed online backup copy never returns a complete key', !managedOnlineBackupCopyResult.includes('MHU2-'));
|
const managedOnlineBackupInitialState = await wc.executeJavaScript('(() => ({ keys: [...document.querySelectorAll(".online-backup-managed-key")].map(element => element.textContent), secretInBody: /MHU2-[A-Za-z0-9_-]{70}/.test(document.body.textContent) }))()');
|
||||||
|
check('Managed online backups render newest first without a complete key in the DOM', managedOnlineBackupLoaded === true && managedOnlineBackupInitialState.keys.join('|') === 'MHU2-ZYXW…9876|MHU2-ABCD…1234' && managedOnlineBackupInitialState.secretInBody === false);
|
||||||
|
|
||||||
|
await wc.executeJavaScript('document.querySelector(".online-backup-managed-row .online-backup-copy-btn").click()');
|
||||||
|
await waitUntil(() => managedOnlineBackupCopyIds.length === 1);
|
||||||
|
check('Managed online backup copy passes only the selected sanitized entry ID', managedOnlineBackupCopyIds.length === 1 && managedOnlineBackupCopyIds[0] === 'BBBBBBBBBBBBBBBBBBBBBB');
|
||||||
|
|
||||||
|
await wc.executeJavaScript('document.querySelector(".online-backup-managed-row .online-backup-delete-btn").click()');
|
||||||
|
await waitUntil(() => wc.executeJavaScript('document.getElementById("appAlertModal").style.display === "flex"'));
|
||||||
|
const managedDeleteConfirmation = await wc.executeJavaScript('(() => ({ danger: document.getElementById("appAlertConfirmBtn").classList.contains("btn-danger"), message: document.getElementById("appAlertMessage").textContent }))()');
|
||||||
|
await wc.executeJavaScript('document.getElementById("appAlertConfirmBtn").click()');
|
||||||
|
await waitUntil(() => managedOnlineBackupDeleteIds.length === 1);
|
||||||
|
await waitUntil(() => wc.executeJavaScript('document.getElementById("onlineBackupStatus")?.textContent === "Online-Sicherung konnte nicht gelöscht werden"'));
|
||||||
|
const managedDeleteFailureState = await wc.executeJavaScript('document.querySelectorAll(".online-backup-managed-row").length');
|
||||||
|
check('Managed online backup deletion uses danger confirmation and preserves rows on failure', managedDeleteConfirmation.danger === true && managedDeleteConfirmation.message === 'Dieses verschlüsselte Online-Backup wird dauerhaft vom Server gelöscht.' && managedDeleteFailureState === 2);
|
||||||
|
|
||||||
|
managedOnlineBackupDeleteMode = 'success';
|
||||||
|
await wc.executeJavaScript('document.querySelector(".online-backup-managed-row .online-backup-delete-btn").click()');
|
||||||
|
await waitUntil(() => wc.executeJavaScript('document.getElementById("appAlertModal").style.display === "flex"'));
|
||||||
|
await wc.executeJavaScript('document.getElementById("appAlertConfirmBtn").click()');
|
||||||
|
await waitUntil(() => typeof releaseManagedOnlineBackupDelete === 'function');
|
||||||
|
const managedDeletePendingState = await wc.executeJavaScript('(() => { const row = document.querySelector(".online-backup-managed-row"); return { count: document.querySelectorAll(".online-backup-managed-row").length, controlsDisabled: [...row.querySelectorAll("button")].every(button => button.disabled) }; })()');
|
||||||
|
releaseManagedOnlineBackupDelete();
|
||||||
|
const managedDeleteSucceeded = await waitUntil(() => wc.executeJavaScript('document.querySelectorAll(".online-backup-managed-row").length === 1'));
|
||||||
|
const managedDeleteSuccessState = await wc.executeJavaScript('(() => ({ key: document.querySelector(".online-backup-managed-key")?.textContent, status: document.getElementById("onlineBackupStatus")?.textContent, secretInBody: /MHU2-[A-Za-z0-9_-]{70}/.test(document.body.textContent) }))()');
|
||||||
|
check('Managed online backup row disappears only after successful deletion', managedDeletePendingState.count === 2 && managedDeletePendingState.controlsDisabled === true && managedDeleteSucceeded === true && managedDeleteSuccessState.key === 'MHU2-ABCD…1234' && managedDeleteSuccessState.status === 'Schlüssel gelöscht' && managedDeleteSuccessState.secretInBody === false);
|
||||||
|
|
||||||
|
await wc.executeJavaScript('document.getElementById("createOnlineBackupBtn").click()');
|
||||||
|
const managedCreateSucceeded = await waitUntil(() => wc.executeJavaScript('document.querySelectorAll(".online-backup-managed-row").length === 2'));
|
||||||
|
const managedCreateState = await wc.executeJavaScript('(() => ({ first: document.querySelector(".online-backup-managed-key")?.textContent, secretInBody: /MHU2-[A-Za-z0-9_-]{70}/.test(document.body.textContent) }))()');
|
||||||
|
check('Creating a managed online backup inserts the returned sanitized entry newest first', managedOnlineBackupCreateCalls === 1 && managedCreateSucceeded === true && managedCreateState.first === 'MHU2-QWER…4321' && managedCreateState.secretInBody === false);
|
||||||
|
|
||||||
const invalidOnlineBackup = await wc.executeJavaScript('document.getElementById("onlineBackupKeyInput").value = "MHU2-short"; document.getElementById("onlineBackupKeyInput").dispatchEvent(new Event("input", { bubbles: true })); document.getElementById("restoreOnlineBackupBtn").disabled + "|" + document.getElementById("onlineBackupStatus").textContent');
|
const invalidOnlineBackup = await wc.executeJavaScript('document.getElementById("onlineBackupKeyInput").value = "MHU2-short"; document.getElementById("onlineBackupKeyInput").dispatchEvent(new Event("input", { bubbles: true })); document.getElementById("restoreOnlineBackupBtn").disabled + "|" + document.getElementById("onlineBackupStatus").textContent');
|
||||||
check('Invalid online backup keys stay blocked with visible guidance', invalidOnlineBackup === 'true|Der Schlüssel muss exakt 75 Zeichen lang sein.');
|
check('Invalid online backup keys stay blocked with visible guidance', invalidOnlineBackup === 'true|Der Schlüssel muss exakt 75 Zeichen lang sein.');
|
||||||
@@ -2485,7 +2549,7 @@ setTimeout(async () => {
|
|||||||
check('Minimum Accounts keeps auto-check reachable and content contained', minimumResponsiveContract.autoCheckVisible && minimumResponsiveContract.accountsMainFits);
|
check('Minimum Accounts keeps auto-check reachable and content contained', minimumResponsiveContract.autoCheckVisible && minimumResponsiveContract.accountsMainFits);
|
||||||
check('Minimum Uploads preserves availability and telemetry information', minimumResponsiveContract.telemetryVisible && minimumResponsiveContract.availabilityVisible);
|
check('Minimum Uploads preserves availability and telemetry information', minimumResponsiveContract.telemetryVisible && minimumResponsiveContract.availabilityVisible);
|
||||||
|
|
||||||
const updateOverlayState = await wc.executeJavaScript('_knownUpdateInfo = { available: true, remoteVersion: "9.9.9" }; _syncHeaderUpdateState(); document.getElementById("headerUpdateBtn").focus(); showUpdateBanner({ remoteVersion: "9.9.9", releaseNotes: "\\\\n\\\\n\\\\n## What\'s new\\\\n\\\\n\\\\n### Menus and navigation\\\\n\\\\n- Added live language switching.\\\\n- Improved settings layout.\\\\n\\\\n\\\\n" }); (() => { const overlay = document.getElementById("updateBanner"); const dialog = overlay?.querySelector(".update-dialog"); const button = document.getElementById("headerUpdateBtn"); return [overlay?.classList.contains("update-overlay"), overlay?.style.display, dialog?.getAttribute("role"), dialog?.getAttribute("aria-modal"), button?.hidden, getComputedStyle(button).display].join("|"); })()');
|
const updateOverlayState = await wc.executeJavaScript('_knownUpdateInfo = { available: true, remoteVersion: "9.9.9" }; _syncHeaderUpdateState(); document.getElementById("headerUpdateBtn").focus(); showUpdateBanner({ remoteVersion: "9.9.9", releaseNotes: "\\\\n\\\\n\\\\n## What\\'s new\\\\n\\\\n\\\\n### Menus and navigation\\\\n\\\\n- Added live language switching.\\\\n- Improved settings layout.\\\\n\\\\n\\\\n" }); (() => { const overlay = document.getElementById("updateBanner"); const dialog = overlay?.querySelector(".update-dialog"); const button = document.getElementById("headerUpdateBtn"); return [overlay?.classList.contains("update-overlay"), overlay?.style.display, dialog?.getAttribute("role"), dialog?.getAttribute("aria-modal"), button?.hidden, getComputedStyle(button).display].join("|"); })()');
|
||||||
check('Available update opens an accessible update dialog', updateOverlayState === 'true|flex|dialog|true|false|flex');
|
check('Available update opens an accessible update dialog', updateOverlayState === 'true|flex|dialog|true|false|flex');
|
||||||
|
|
||||||
await new Promise(resolve => setTimeout(resolve, 100));
|
await new Promise(resolve => setTimeout(resolve, 100));
|
||||||
|
|||||||
Reference in New Issue
Block a user