Add complete device and kiosk backup
This commit is contained in:
@@ -0,0 +1,92 @@
|
||||
const BACKUP_KIND = 'phoenix-device-backup';
|
||||
const BACKUP_SCHEMA_VERSION = 1;
|
||||
|
||||
function setStatus(element, message, isError = false) {
|
||||
if (!element) return;
|
||||
element.textContent = message;
|
||||
element.style.color = isError ? '#ff6b6b' : '';
|
||||
}
|
||||
|
||||
function backupFileName() {
|
||||
const stamp = new Date().toISOString().replace(/[:.]/g, '-');
|
||||
return `phoenix-device-backup-${stamp}.json`;
|
||||
}
|
||||
|
||||
function validateBackup(payload) {
|
||||
if (!payload || typeof payload !== 'object' || Array.isArray(payload)) {
|
||||
throw new Error('Die Datei enthält kein gültiges JSON-Objekt.');
|
||||
}
|
||||
if (payload.kind !== BACKUP_KIND) {
|
||||
throw new Error('Die Datei ist keine Phoenix-Gerätesicherung.');
|
||||
}
|
||||
if (Number(payload.schemaVersion) !== BACKUP_SCHEMA_VERSION) {
|
||||
throw new Error(`Nicht unterstützte Backup-Version: ${payload.schemaVersion ?? '?'}.`);
|
||||
}
|
||||
if (!payload.globalConfig || !payload.rtaConfig) {
|
||||
throw new Error('Gerätekonfiguration oder RTA-Konfiguration fehlt.');
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
|
||||
export function setupDeviceBackup({ exportButton, importInput, status, resolveApiBaseUrl }) {
|
||||
if (!exportButton || !importInput || typeof resolveApiBaseUrl !== 'function') return;
|
||||
|
||||
exportButton.addEventListener('click', async () => {
|
||||
const original = exportButton.textContent;
|
||||
exportButton.disabled = true;
|
||||
exportButton.textContent = 'Sichere…';
|
||||
setStatus(status, 'Gerätekonfiguration wird gelesen…');
|
||||
try {
|
||||
const response = await fetch(`${resolveApiBaseUrl()}/api/v1/device-backup?_=${Date.now()}`, {
|
||||
cache: 'no-store',
|
||||
});
|
||||
const payload = await response.json().catch(() => null);
|
||||
if (!response.ok) throw new Error(payload?.error || `HTTP ${response.status}`);
|
||||
validateBackup(payload);
|
||||
const blob = new Blob([JSON.stringify(payload, null, 2)], { type: 'application/json' });
|
||||
const link = document.createElement('a');
|
||||
link.href = URL.createObjectURL(blob);
|
||||
link.download = backupFileName();
|
||||
link.click();
|
||||
URL.revokeObjectURL(link.href);
|
||||
const kioskCount = Object.keys(payload.kioskBrowserStorage || {}).length;
|
||||
setStatus(status, `Backup vollständig heruntergeladen (${kioskCount} Kiosk-Einstellungen).`);
|
||||
} catch (error) {
|
||||
console.error('Device backup export error:', error);
|
||||
setStatus(status, `Backup fehlgeschlagen: ${error?.message || String(error)}`, true);
|
||||
} finally {
|
||||
exportButton.disabled = false;
|
||||
exportButton.textContent = original;
|
||||
}
|
||||
});
|
||||
|
||||
importInput.addEventListener('change', async () => {
|
||||
const file = importInput.files?.[0];
|
||||
if (!file) return;
|
||||
try {
|
||||
const payload = validateBackup(JSON.parse(await file.text()));
|
||||
const confirmed = globalThis.confirm?.(
|
||||
'Gerätesicherung wiederherstellen?\n\nGlobale Geräteeinstellungen, RTA, Software-Presets, Layout-Slots und die Oberfläche des internen Pi-Displays werden ersetzt. Externe Browser bleiben unverändert.'
|
||||
);
|
||||
if (!confirmed) return;
|
||||
setStatus(status, 'Backup wird geprüft und wiederhergestellt…');
|
||||
const response = await fetch(`${resolveApiBaseUrl()}/api/v1/device-backup`, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
cache: 'no-store',
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
const result = await response.json().catch(() => null);
|
||||
if (!response.ok || result?.ok === false) {
|
||||
throw new Error(result?.error || `HTTP ${response.status}`);
|
||||
}
|
||||
setStatus(status, 'Wiederherstellung abgeschlossen. Oberfläche wird neu geladen.');
|
||||
setTimeout(() => globalThis.location?.reload?.(), 500);
|
||||
} catch (error) {
|
||||
console.error('Device backup restore error:', error);
|
||||
setStatus(status, `Wiederherstellung fehlgeschlagen: ${error?.message || String(error)}`, true);
|
||||
} finally {
|
||||
importInput.value = '';
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
const KIOSK_QUERY_VALUE = '1';
|
||||
const REVISION_KEY = 'analyzer_kiosk_backup_revision_v1';
|
||||
const SYNC_INTERVAL_MS = 2000;
|
||||
|
||||
function isKioskBrowser() {
|
||||
try {
|
||||
return new URLSearchParams(globalThis.location?.search || '').get('kiosk') === KIOSK_QUERY_VALUE;
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function isPhoenixStorageKey(key) {
|
||||
return key !== REVISION_KEY && (
|
||||
key.startsWith('analyzer_') ||
|
||||
key.startsWith('MIGRATE_') ||
|
||||
[
|
||||
'ppm_din_mode',
|
||||
'calibration_notice_seen_v1',
|
||||
'recorder_warning_ack_v1',
|
||||
'ppm_din_loudness_warn_ack_v1',
|
||||
'migrate_rta_peak_fall_default_v1',
|
||||
'migrate_vu_offset_default_zero_v2',
|
||||
'migrate_lr_delay_zero_to_0_05_v1',
|
||||
].includes(key)
|
||||
);
|
||||
}
|
||||
|
||||
function collectStorage() {
|
||||
const storage = {};
|
||||
try {
|
||||
for (let index = 0; index < localStorage.length; index += 1) {
|
||||
const key = localStorage.key(index);
|
||||
if (!key || !isPhoenixStorageKey(key)) continue;
|
||||
const value = localStorage.getItem(key);
|
||||
if (value !== null) storage[key] = value;
|
||||
}
|
||||
} catch (_) {}
|
||||
return storage;
|
||||
}
|
||||
|
||||
function storageFingerprint(storage) {
|
||||
return JSON.stringify(Object.entries(storage).sort(([left], [right]) => left.localeCompare(right)));
|
||||
}
|
||||
|
||||
function localRevision() {
|
||||
try {
|
||||
const value = Number(localStorage.getItem(REVISION_KEY));
|
||||
return Number.isSafeInteger(value) && value >= 0 ? value : 0;
|
||||
} catch (_) {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
function setLocalRevision(revision) {
|
||||
try { localStorage.setItem(REVISION_KEY, String(revision)); } catch (_) {}
|
||||
}
|
||||
|
||||
function applyStorage(storage, revision) {
|
||||
try {
|
||||
const remove = [];
|
||||
for (let index = 0; index < localStorage.length; index += 1) {
|
||||
const key = localStorage.key(index);
|
||||
if (key && isPhoenixStorageKey(key)) remove.push(key);
|
||||
}
|
||||
remove.forEach((key) => localStorage.removeItem(key));
|
||||
Object.entries(storage || {}).forEach(([key, value]) => {
|
||||
if (isPhoenixStorageKey(key) && typeof value === 'string') localStorage.setItem(key, value);
|
||||
});
|
||||
setLocalRevision(revision);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.warn('Kiosk browser restore failed:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function apiUrl() {
|
||||
const hostname = String(globalThis.location?.hostname || '127.0.0.1');
|
||||
return `http://${hostname}:8789/api/v1/kiosk-browser-config`;
|
||||
}
|
||||
|
||||
async function readRemote() {
|
||||
const response = await fetch(`${apiUrl()}?_=${Date.now()}`, { cache: 'no-store' });
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||
return response.json();
|
||||
}
|
||||
|
||||
async function writeRemote(baseRevision, storage) {
|
||||
const response = await fetch(apiUrl(), {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
cache: 'no-store',
|
||||
body: JSON.stringify({ baseRevision, storage }),
|
||||
});
|
||||
const payload = await response.json().catch(() => null);
|
||||
if (!response.ok) {
|
||||
const error = new Error(payload?.error || `HTTP ${response.status}`);
|
||||
error.status = response.status;
|
||||
throw error;
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
|
||||
export async function setupKioskBrowserBackup() {
|
||||
if (!isKioskBrowser()) return true;
|
||||
|
||||
let remote;
|
||||
try {
|
||||
remote = await readRemote();
|
||||
} catch (error) {
|
||||
console.warn('Kiosk browser backup unavailable:', error);
|
||||
return true;
|
||||
}
|
||||
|
||||
let revision = Number(remote?.revision) || 0;
|
||||
const storedRevision = localRevision();
|
||||
if (revision > storedRevision) {
|
||||
if (applyStorage(remote?.storage || {}, revision)) {
|
||||
globalThis.location?.reload?.();
|
||||
return false;
|
||||
}
|
||||
} else if (revision === 0 && Object.keys(remote?.storage || {}).length === 0) {
|
||||
try {
|
||||
const result = await writeRemote(0, collectStorage());
|
||||
revision = Number(result?.revision) || 1;
|
||||
setLocalRevision(revision);
|
||||
} catch (error) {
|
||||
console.warn('Initial kiosk browser backup failed:', error);
|
||||
}
|
||||
} else {
|
||||
setLocalRevision(revision);
|
||||
}
|
||||
|
||||
let fingerprint = storageFingerprint(collectStorage());
|
||||
let running = false;
|
||||
globalThis.setInterval(async () => {
|
||||
if (running) return;
|
||||
running = true;
|
||||
try {
|
||||
const latest = await readRemote();
|
||||
const latestRevision = Number(latest?.revision) || 0;
|
||||
if (latestRevision > revision) {
|
||||
if (applyStorage(latest?.storage || {}, latestRevision)) globalThis.location?.reload?.();
|
||||
return;
|
||||
}
|
||||
const storage = collectStorage();
|
||||
const nextFingerprint = storageFingerprint(storage);
|
||||
if (nextFingerprint !== fingerprint) {
|
||||
const result = await writeRemote(revision, storage);
|
||||
revision = Number(result?.revision) || (revision + 1);
|
||||
setLocalRevision(revision);
|
||||
fingerprint = nextFingerprint;
|
||||
}
|
||||
} catch (error) {
|
||||
if (error?.status !== 409) console.warn('Kiosk browser backup sync failed:', error);
|
||||
} finally {
|
||||
running = false;
|
||||
}
|
||||
}, SYNC_INTERVAL_MS);
|
||||
return true;
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import { CONFIG, saveConfig, loadConfig, applyAlignmentProfile, applyRtaBpoSelec
|
||||
import { clamp, clampPow2 } from '../core/utils.js';
|
||||
import { bindOptionsViewportAssist } from './options_viewport.js';
|
||||
import { setupLazyChangelog } from './changelog.js';
|
||||
import { setupDeviceBackup } from './device_backup.js';
|
||||
|
||||
// DOM helper
|
||||
const E = (id) => document.getElementById(id);
|
||||
@@ -90,6 +91,12 @@ export function setupOptions(env) {
|
||||
wireHandlers(env);
|
||||
bindOptionsViewportAssist(E('optionsPanelNew'));
|
||||
setupLazyChangelog(E('changelogDetails'), E('changelogContent'));
|
||||
setupDeviceBackup({
|
||||
exportButton: E('btnDeviceBackupExport'),
|
||||
importInput: E('impDeviceBackup'),
|
||||
status: E('deviceBackupStatus'),
|
||||
resolveApiBaseUrl: resolvePhoenixApiBaseUrl,
|
||||
});
|
||||
bindAlignmentToggle(env);
|
||||
bindVuRefToggle(env);
|
||||
// Sichtbarkeit nach View steuern übernimmt main.js — hier nur Export der Sync-Funktion
|
||||
|
||||
Reference in New Issue
Block a user