From 22444ff5bd5bc1aaf56585b8a578f9d55cd43255 Mon Sep 17 00:00:00 2001 From: Mikei386 <44135113+Mikei386@users.noreply.github.com> Date: Wed, 22 Jul 2026 11:58:58 +0200 Subject: [PATCH] Add complete device and kiosk backup --- scripts/test_gui_contracts.mjs | 13 ++ session.sh | 8 +- session_volumio.sh | 8 +- src/config.rs | 34 +++ src/main.rs | 8 + src/routes.rs | 378 ++++++++++++++++++++++++++++++++- src/state.rs | 56 ++++- www/changelog.html | 6 +- www/index.html | 11 +- www/main.js | 2 + www/ui/device_backup.js | 92 ++++++++ www/ui/kiosk_browser_backup.js | 162 ++++++++++++++ www/ui/options.js | 7 + 13 files changed, 767 insertions(+), 18 deletions(-) create mode 100644 scripts/test_gui_contracts.mjs create mode 100644 www/ui/device_backup.js create mode 100644 www/ui/kiosk_browser_backup.js diff --git a/scripts/test_gui_contracts.mjs b/scripts/test_gui_contracts.mjs new file mode 100644 index 0000000..a00a4eb --- /dev/null +++ b/scripts/test_gui_contracts.mjs @@ -0,0 +1,13 @@ +import fs from 'node:fs'; +import assert from 'node:assert/strict'; + +const html = fs.readFileSync(new URL('../www/index.html', import.meta.url), 'utf8'); +const main = fs.readFileSync(new URL('../www/main.js', import.meta.url), 'utf8'); + +const styleSelect = html.match(/]*\bid="styleSel"[^>]*>/)?.[0] || ''; +assert(styleSelect, 'style selector is missing'); +assert(!/\bopt-w-\d+\b/.test(styleSelect), 'style selector must not have a fixed-width utility class'); +assert(/adjustStyleDropdownWidth\(\)/.test(main), 'dynamic style selector sizing is missing'); +assert(/styleSel\.style\.width\s*=/.test(main), 'dynamic style selector width assignment is missing'); + +console.log('GUI contract regression tests passed'); diff --git a/session.sh b/session.sh index f7e519c..6d75ce2 100644 --- a/session.sh +++ b/session.sh @@ -33,6 +33,8 @@ PHOENIX_SAMPLE_RATE="${PHOENIX_SAMPLE_RATE:-48000}" PHOENIX_PERIOD_SIZE="${PHOENIX_PERIOD_SIZE:-128}" PHOENIX_BUFFER_SIZE="${PHOENIX_BUFFER_SIZE:-512}" PHOENIX_GLOBAL_CONFIG_PATH="${PHOENIX_GLOBAL_CONFIG_PATH:-/home/analyzer/.config/phoenix/global-config.json}" +PHOENIX_RTA_CONFIG_PATH="${PHOENIX_RTA_CONFIG_PATH:-/home/analyzer/.config/phoenix/rta-config.json}" +PHOENIX_KIOSK_BROWSER_CONFIG_PATH="${PHOENIX_KIOSK_BROWSER_CONFIG_PATH:-/home/analyzer/.config/phoenix/kiosk-browser-config.json}" PHOENIX_RECORDINGS_DIR_ANALYZER="${PHOENIX_RECORDINGS_DIR_ANALYZER:-/home/analyzer/Aufnahmen}" PHOENIX_RECORDINGS_DIR_VOLUMIO="${PHOENIX_RECORDINGS_DIR_VOLUMIO:-/home/volumio/Aufnahmen}" PHOENIX_LR_FRAC_DELAY_ENABLED="${PHOENIX_LR_FRAC_DELAY_ENABLED:-true}" @@ -83,6 +85,8 @@ else PHOENIX_PERIOD_SIZE="$PHOENIX_PERIOD_SIZE" \ PHOENIX_BUFFER_SIZE="$PHOENIX_BUFFER_SIZE" \ PHOENIX_GLOBAL_CONFIG_PATH="$PHOENIX_GLOBAL_CONFIG_PATH" \ + PHOENIX_RTA_CONFIG_PATH="$PHOENIX_RTA_CONFIG_PATH" \ + PHOENIX_KIOSK_BROWSER_CONFIG_PATH="$PHOENIX_KIOSK_BROWSER_CONFIG_PATH" \ PHOENIX_RECORDINGS_DIR_ANALYZER="$PHOENIX_RECORDINGS_DIR_ANALYZER" \ PHOENIX_RECORDINGS_DIR_VOLUMIO="$PHOENIX_RECORDINGS_DIR_VOLUMIO" \ PHOENIX_LR_FRAC_DELAY_ENABLED="$PHOENIX_LR_FRAC_DELAY_ENABLED" \ @@ -97,6 +101,8 @@ else PHOENIX_PERIOD_SIZE="$PHOENIX_PERIOD_SIZE" \ PHOENIX_BUFFER_SIZE="$PHOENIX_BUFFER_SIZE" \ PHOENIX_GLOBAL_CONFIG_PATH="$PHOENIX_GLOBAL_CONFIG_PATH" \ + PHOENIX_RTA_CONFIG_PATH="$PHOENIX_RTA_CONFIG_PATH" \ + PHOENIX_KIOSK_BROWSER_CONFIG_PATH="$PHOENIX_KIOSK_BROWSER_CONFIG_PATH" \ PHOENIX_RECORDINGS_DIR_ANALYZER="$PHOENIX_RECORDINGS_DIR_ANALYZER" \ PHOENIX_RECORDINGS_DIR_VOLUMIO="$PHOENIX_RECORDINGS_DIR_VOLUMIO" \ PHOENIX_LR_FRAC_DELAY_ENABLED="$PHOENIX_LR_FRAC_DELAY_ENABLED" \ @@ -158,7 +164,7 @@ sudo -u "$KIOSK_USER" /usr/bin/chromium \ --lang=de-DE --disable-translate --disable-features=Translate,TranslateUI \ --memory-pressure-off \ --max-active-webgl-contexts=1 \ - http://localhost/index.html \ + 'http://localhost/index.html?kiosk=1' \ >>"$LOG" 2>&1 echo "=== $(date): session.sh END ===" diff --git a/session_volumio.sh b/session_volumio.sh index 9354edc..397314e 100644 --- a/session_volumio.sh +++ b/session_volumio.sh @@ -24,6 +24,8 @@ PHOENIX_SAMPLE_RATE="${PHOENIX_SAMPLE_RATE:-48000}" PHOENIX_PERIOD_SIZE="${PHOENIX_PERIOD_SIZE:-128}" PHOENIX_BUFFER_SIZE="${PHOENIX_BUFFER_SIZE:-512}" PHOENIX_GLOBAL_CONFIG_PATH="${PHOENIX_GLOBAL_CONFIG_PATH:-/home/volumio/.config/phoenix/global-config.json}" +PHOENIX_RTA_CONFIG_PATH="${PHOENIX_RTA_CONFIG_PATH:-/home/volumio/.config/phoenix/rta-config.json}" +PHOENIX_KIOSK_BROWSER_CONFIG_PATH="${PHOENIX_KIOSK_BROWSER_CONFIG_PATH:-/home/volumio/.config/phoenix/kiosk-browser-config.json}" PHOENIX_RECORDINGS_DIR_ANALYZER="${PHOENIX_RECORDINGS_DIR_ANALYZER:-/home/analyzer/Aufnahmen}" PHOENIX_RECORDINGS_DIR_VOLUMIO="${PHOENIX_RECORDINGS_DIR_VOLUMIO:-/home/volumio/Aufnahmen}" PHOENIX_LR_FRAC_DELAY_ENABLED="${PHOENIX_LR_FRAC_DELAY_ENABLED:-true}" @@ -32,7 +34,7 @@ PHOENIX_LR_FRAC_DELAY_SAMPLES="${PHOENIX_LR_FRAC_DELAY_SAMPLES:-0.996}" CHROME_BIN="${CHROME_BIN:-/usr/bin/chromium-browser}" EXT_VK="${EXT_VK:-/data/volumiokioskextensions/VirtualKeyboard}" EXT_TOGGLE="${EXT_TOGGLE:-/opt/analyzer/toggle-ext}" -ANALYZER_URL="${ANALYZER_URL:-http://localhost:8088/index.html}" +ANALYZER_URL="${ANALYZER_URL:-http://localhost:8088/index.html?kiosk=1}" VOLUMIO_URL="${VOLUMIO_URL:-http://localhost:3000}" mkdir -p "$(dirname "$LOG")" @@ -96,6 +98,8 @@ else PHOENIX_PERIOD_SIZE="$PHOENIX_PERIOD_SIZE" \ PHOENIX_BUFFER_SIZE="$PHOENIX_BUFFER_SIZE" \ PHOENIX_GLOBAL_CONFIG_PATH="$PHOENIX_GLOBAL_CONFIG_PATH" \ + PHOENIX_RTA_CONFIG_PATH="$PHOENIX_RTA_CONFIG_PATH" \ + PHOENIX_KIOSK_BROWSER_CONFIG_PATH="$PHOENIX_KIOSK_BROWSER_CONFIG_PATH" \ PHOENIX_RECORDINGS_DIR_ANALYZER="$PHOENIX_RECORDINGS_DIR_ANALYZER" \ PHOENIX_RECORDINGS_DIR_VOLUMIO="$PHOENIX_RECORDINGS_DIR_VOLUMIO" \ PHOENIX_LR_FRAC_DELAY_ENABLED="$PHOENIX_LR_FRAC_DELAY_ENABLED" \ @@ -112,6 +116,8 @@ else PHOENIX_PERIOD_SIZE="$PHOENIX_PERIOD_SIZE" \ PHOENIX_BUFFER_SIZE="$PHOENIX_BUFFER_SIZE" \ PHOENIX_GLOBAL_CONFIG_PATH="$PHOENIX_GLOBAL_CONFIG_PATH" \ + PHOENIX_RTA_CONFIG_PATH="$PHOENIX_RTA_CONFIG_PATH" \ + PHOENIX_KIOSK_BROWSER_CONFIG_PATH="$PHOENIX_KIOSK_BROWSER_CONFIG_PATH" \ PHOENIX_RECORDINGS_DIR_ANALYZER="$PHOENIX_RECORDINGS_DIR_ANALYZER" \ PHOENIX_RECORDINGS_DIR_VOLUMIO="$PHOENIX_RECORDINGS_DIR_VOLUMIO" \ PHOENIX_LR_FRAC_DELAY_ENABLED="$PHOENIX_LR_FRAC_DELAY_ENABLED" \ diff --git a/src/config.rs b/src/config.rs index c09a27d..a6dab50 100644 --- a/src/config.rs +++ b/src/config.rs @@ -14,8 +14,10 @@ pub struct PhoenixConfig { #[cfg_attr(not(target_os = "linux"), allow(dead_code))] pub buffer_size: u32, pub global_config_path: PathBuf, + pub rta_config_path: PathBuf, pub frontend_presets_path: PathBuf, pub frontend_layouts_path: PathBuf, + pub kiosk_browser_config_path: PathBuf, pub recordings_dir_analyzer: PathBuf, pub recordings_dir_volumio: PathBuf, pub max_recording_upload_bytes: u64, @@ -53,6 +55,11 @@ impl PhoenixConfig { .map(|v| PathBuf::from(v.trim())) .filter(|v| !v.as_os_str().is_empty()) .unwrap_or_else(default_global_config_path), + rta_config_path: std::env::var("PHOENIX_RTA_CONFIG_PATH") + .ok() + .map(|v| PathBuf::from(v.trim())) + .filter(|v| !v.as_os_str().is_empty()) + .unwrap_or_else(default_rta_config_path), frontend_presets_path: std::env::var("PHOENIX_FRONTEND_PRESETS_PATH") .ok() .map(|v| PathBuf::from(v.trim())) @@ -63,6 +70,11 @@ impl PhoenixConfig { .map(|v| PathBuf::from(v.trim())) .filter(|v| !v.as_os_str().is_empty()) .unwrap_or_else(default_frontend_layouts_path), + kiosk_browser_config_path: std::env::var("PHOENIX_KIOSK_BROWSER_CONFIG_PATH") + .ok() + .map(|v| PathBuf::from(v.trim())) + .filter(|v| !v.as_os_str().is_empty()) + .unwrap_or_else(default_kiosk_browser_config_path), recordings_dir_analyzer: std::env::var("PHOENIX_RECORDINGS_DIR_ANALYZER") .ok() .map(|v| PathBuf::from(v.trim())) @@ -128,6 +140,17 @@ fn default_global_config_path() -> PathBuf { PathBuf::from("phoenix-global-config.json") } +fn default_rta_config_path() -> PathBuf { + if let Some(home) = std::env::var_os("HOME") { + let mut path = PathBuf::from(home); + path.push(".config"); + path.push("phoenix"); + path.push("rta-config.json"); + return path; + } + PathBuf::from("phoenix-rta-config.json") +} + fn default_frontend_presets_path() -> PathBuf { if let Some(home) = std::env::var_os("HOME") { let mut path = PathBuf::from(home); @@ -150,6 +173,17 @@ fn default_frontend_layouts_path() -> PathBuf { PathBuf::from("phoenix-frontend-layouts.json") } +fn default_kiosk_browser_config_path() -> PathBuf { + if let Some(home) = std::env::var_os("HOME") { + let mut path = PathBuf::from(home); + path.push(".config"); + path.push("phoenix"); + path.push("kiosk-browser-config.json"); + return path; + } + PathBuf::from("phoenix-kiosk-browser-config.json") +} + fn default_recordings_dir_analyzer() -> PathBuf { PathBuf::from("/home/analyzer/Aufnahmen") } diff --git a/src/main.rs b/src/main.rs index d77e3ab..0408333 100644 --- a/src/main.rs +++ b/src/main.rs @@ -53,6 +53,14 @@ async fn main() -> anyhow::Result<()> { "/api/v1/frontend-layouts", get(routes::get_frontend_layouts).post(routes::set_frontend_layout), ) + .route( + "/api/v1/device-backup", + get(routes::export_device_backup).post(routes::restore_device_backup), + ) + .route( + "/api/v1/kiosk-browser-config", + get(routes::get_kiosk_browser_config).post(routes::set_kiosk_browser_config), + ) .route( "/api/v1/update/download", post(routes::download_online_update), diff --git a/src/routes.rs b/src/routes.rs index f9a2e03..4de525e 100644 --- a/src/routes.rs +++ b/src/routes.rs @@ -7,10 +7,12 @@ use axum::{ Json, }; use http_body_util::BodyExt; -use serde::Deserialize; +use serde::{Deserialize, Serialize}; use std::{ + collections::BTreeMap, path::PathBuf, process::Stdio, + sync::atomic::{AtomicU64, Ordering}, sync::Arc, time::{SystemTime, UNIX_EPOCH}, }; @@ -23,6 +25,9 @@ use crate::{ }; const ONLINE_UPDATE_ZIP_URL: &str = "https://webshare.casaderoll.de/share/Phoenix.zip"; +const DEVICE_BACKUP_KIND: &str = "phoenix-device-backup"; +const DEVICE_BACKUP_SCHEMA_VERSION: u32 = 1; +static JSON_WRITE_TOKEN: AtomicU64 = AtomicU64::new(1); fn stable_script_command(program: &str) -> Command { let mut command = Command::new(program); @@ -42,6 +47,39 @@ pub struct FrontendPresetSavePayload { pub config: serde_json::Value, } +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct DeviceBackup { + pub kind: String, + pub schema_version: u32, + pub exported_at_unix_ms: u128, + pub global_config: PhoenixGlobalConfig, + pub rta_config: PhoenixRtaConfig, + #[serde(default)] + pub frontend_presets: BTreeMap, + #[serde(default)] + pub frontend_layouts: BTreeMap, + #[serde(default)] + pub kiosk_browser_storage: BTreeMap, +} + +#[derive(Clone, Debug, Default, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct KioskBrowserConfig { + #[serde(default)] + pub revision: u64, + #[serde(default)] + pub storage: BTreeMap, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct KioskBrowserConfigUpdate { + pub base_revision: u64, + #[serde(default)] + pub storage: BTreeMap, +} + pub async fn health() -> Json { Json(serde_json::json!({ "ok": true, @@ -197,6 +235,218 @@ pub async fn set_frontend_layout( } } +pub async fn export_device_backup(State(state): State) -> Response { + let presets = match read_frontend_presets(&state.config).await { + Ok(value) => value, + Err(err) => return device_backup_error(StatusCode::INTERNAL_SERVER_ERROR, err.to_string()), + }; + let layouts = match read_frontend_layouts(&state.config).await { + Ok(value) => value, + Err(err) => return device_backup_error(StatusCode::INTERNAL_SERVER_ERROR, err.to_string()), + }; + let exported_at_unix_ms = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_millis(); + let kiosk_browser_storage = match read_kiosk_browser_config(&state.config).await { + Ok(value) => value.storage, + Err(err) => return device_backup_error(StatusCode::INTERNAL_SERVER_ERROR, err.to_string()), + }; + Json(DeviceBackup { + kind: DEVICE_BACKUP_KIND.to_string(), + schema_version: DEVICE_BACKUP_SCHEMA_VERSION, + exported_at_unix_ms, + global_config: state.global_config().await, + rta_config: state.rta_config().await, + frontend_presets: presets.into_iter().collect(), + frontend_layouts: layouts.into_iter().collect(), + kiosk_browser_storage, + }) + .into_response() +} + +pub async fn restore_device_backup( + State(state): State, + Json(backup): Json, +) -> Response { + if let Err(message) = validate_device_backup(&backup) { + return device_backup_error(StatusCode::BAD_REQUEST, message); + } + + let old_presets = match read_frontend_presets(&state.config).await { + Ok(value) => value, + Err(err) => return device_backup_error(StatusCode::INTERNAL_SERVER_ERROR, err.to_string()), + }; + let old_layouts = match read_frontend_layouts(&state.config).await { + Ok(value) => value, + Err(err) => return device_backup_error(StatusCode::INTERNAL_SERVER_ERROR, err.to_string()), + }; + let old_global = state.global_config().await; + let old_rta = state.rta_config().await; + let old_kiosk = match read_kiosk_browser_config(&state.config).await { + Ok(value) => value, + Err(err) => return device_backup_error(StatusCode::INTERNAL_SERVER_ERROR, err.to_string()), + }; + let new_presets: serde_json::Map = + backup.frontend_presets.clone().into_iter().collect(); + let new_layouts: serde_json::Map = + backup.frontend_layouts.clone().into_iter().collect(); + + let result: anyhow::Result<()> = async { + persist_frontend_presets(&state.config, &new_presets).await?; + persist_frontend_layouts(&state.config, &new_layouts).await?; + persist_kiosk_browser_config( + &state.config, + &KioskBrowserConfig { + revision: old_kiosk.revision.saturating_add(1), + storage: backup.kiosk_browser_storage.clone(), + }, + ) + .await?; + state.set_rta_config(backup.rta_config.clone()).await; + state.persist_current_rta_config().await?; + state + .set_global_config(backup.global_config.clone()) + .await?; + Ok(()) + } + .await; + + if let Err(err) = result { + let _ = persist_frontend_presets(&state.config, &old_presets).await; + let _ = persist_frontend_layouts(&state.config, &old_layouts).await; + let _ = persist_kiosk_browser_config(&state.config, &old_kiosk).await; + state.set_rta_config(old_rta).await; + let _ = state.set_global_config(old_global).await; + return device_backup_error( + StatusCode::INTERNAL_SERVER_ERROR, + format!("restore failed; previous settings were restored ({err})"), + ); + } + + ( + StatusCode::OK, + Json(serde_json::json!({ + "ok": true, + "schemaVersion": DEVICE_BACKUP_SCHEMA_VERSION, + "presets": backup.frontend_presets.len(), + "layouts": backup.frontend_layouts.len(), + "kioskBrowserValues": backup.kiosk_browser_storage.len(), + })), + ) + .into_response() +} + +fn validate_device_backup(backup: &DeviceBackup) -> Result<(), String> { + if backup.kind != DEVICE_BACKUP_KIND { + return Err("not a Phoenix device backup".to_string()); + } + if backup.schema_version != DEVICE_BACKUP_SCHEMA_VERSION { + return Err(format!( + "unsupported backup schema {}; expected {}", + backup.schema_version, DEVICE_BACKUP_SCHEMA_VERSION + )); + } + for (id, value) in &backup.frontend_presets { + if normalize_frontend_preset_id(id).is_empty() || !value.is_object() { + return Err(format!("invalid software preset '{id}'")); + } + } + for (id, value) in &backup.frontend_layouts { + if normalize_frontend_layout_id(id).is_empty() || !value.is_object() { + return Err(format!("invalid layout preset '{id}'")); + } + } + validate_kiosk_browser_storage(&backup.kiosk_browser_storage)?; + Ok(()) +} + +pub async fn get_kiosk_browser_config(State(state): State) -> Response { + match read_kiosk_browser_config(&state.config).await { + Ok(value) => (StatusCode::OK, Json(value)).into_response(), + Err(err) => device_backup_error(StatusCode::INTERNAL_SERVER_ERROR, err.to_string()), + } +} + +pub async fn set_kiosk_browser_config( + State(state): State, + Json(update): Json, +) -> Response { + if let Err(message) = validate_kiosk_browser_storage(&update.storage) { + return device_backup_error(StatusCode::BAD_REQUEST, message); + } + let current = match read_kiosk_browser_config(&state.config).await { + Ok(value) => value, + Err(err) => return device_backup_error(StatusCode::INTERNAL_SERVER_ERROR, err.to_string()), + }; + if update.base_revision != current.revision { + return ( + StatusCode::CONFLICT, + Json(serde_json::json!({ + "ok": false, + "error": "kiosk browser configuration changed", + "revision": current.revision, + })), + ) + .into_response(); + } + let next = KioskBrowserConfig { + revision: current.revision.saturating_add(1), + storage: update.storage, + }; + match persist_kiosk_browser_config(&state.config, &next).await { + Ok(()) => ( + StatusCode::OK, + Json(serde_json::json!({ + "ok": true, + "revision": next.revision, + })), + ) + .into_response(), + Err(err) => device_backup_error(StatusCode::INTERNAL_SERVER_ERROR, err.to_string()), + } +} + +fn validate_kiosk_browser_storage(storage: &BTreeMap) -> Result<(), String> { + if storage.len() > 128 { + return Err("too many kiosk browser values".to_string()); + } + let mut total = 0usize; + for (key, value) in storage { + let allowed = key.starts_with("analyzer_") + || key.starts_with("MIGRATE_") + || matches!( + key.as_str(), + "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" + ); + if !allowed || key.len() > 128 { + return Err(format!("invalid kiosk browser key '{key}'")); + } + total = total.saturating_add(key.len()).saturating_add(value.len()); + } + if total > 2 * 1024 * 1024 { + return Err("kiosk browser configuration is too large".to_string()); + } + Ok(()) +} + +fn device_backup_error(status: StatusCode, message: String) -> Response { + ( + status, + Json(serde_json::json!({ + "ok": false, + "error": message, + })), + ) + .into_response() +} + pub async fn download_online_update() -> Response { let phoenix_root = match resolve_phoenix_root_dir() { Ok(path) => path, @@ -814,6 +1064,32 @@ async fn read_frontend_layouts( Ok(parsed.as_object().cloned().unwrap_or_default()) } +async fn read_kiosk_browser_config( + config: &crate::config::PhoenixConfig, +) -> anyhow::Result { + let path = config.kiosk_browser_config_path.clone(); + let raw = match fs::read_to_string(&path).await { + Ok(raw) => raw, + Err(err) if err.kind() == std::io::ErrorKind::NotFound => { + return Ok(KioskBrowserConfig::default()) + } + Err(err) => { + return Err(anyhow::anyhow!( + "failed to read kiosk browser config {} ({})", + path.display(), + err + )) + } + }; + serde_json::from_str::(&raw).map_err(|err| { + anyhow::anyhow!( + "failed to parse kiosk browser config {} ({})", + path.display(), + err + ) + }) +} + async fn persist_frontend_presets( config: &crate::config::PhoenixConfig, presets: &serde_json::Map, @@ -824,11 +1100,7 @@ async fn persist_frontend_presets( format!("failed to create frontend preset dir {}", parent.display()) })?; } - let json = serde_json::to_vec_pretty(&serde_json::Value::Object(presets.clone()))?; - fs::write(&path, json) - .await - .with_context(|| format!("failed to write frontend presets {}", path.display()))?; - Ok(()) + persist_json_atomic(&path, &serde_json::Value::Object(presets.clone())).await } async fn persist_frontend_layouts( @@ -841,10 +1113,44 @@ async fn persist_frontend_layouts( format!("failed to create frontend layout dir {}", parent.display()) })?; } - let json = serde_json::to_vec_pretty(&serde_json::Value::Object(layouts.clone()))?; - fs::write(&path, json) + persist_json_atomic(&path, &serde_json::Value::Object(layouts.clone())).await +} + +async fn persist_kiosk_browser_config( + config: &crate::config::PhoenixConfig, + kiosk: &KioskBrowserConfig, +) -> anyhow::Result<()> { + let path = config.kiosk_browser_config_path.clone(); + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).await.with_context(|| { + format!( + "failed to create kiosk browser config dir {}", + parent.display() + ) + })?; + } + persist_json_atomic(&path, kiosk).await +} + +async fn persist_json_atomic(path: &PathBuf, value: &T) -> anyhow::Result<()> { + let json = serde_json::to_vec_pretty(value)?; + let token = JSON_WRITE_TOKEN.fetch_add(1, Ordering::Relaxed); + let temp_name = format!( + ".{}.tmp-{}-{}", + path.file_name() + .and_then(|name| name.to_str()) + .unwrap_or("phoenix"), + std::process::id(), + token + ); + let temp_path = path.with_file_name(temp_name); + fs::write(&temp_path, json) .await - .with_context(|| format!("failed to write frontend layouts {}", path.display()))?; + .with_context(|| format!("failed to write temporary JSON {}", temp_path.display()))?; + if let Err(err) = fs::rename(&temp_path, path).await { + let _ = fs::remove_file(&temp_path).await; + return Err(err).with_context(|| format!("failed to replace JSON {}", path.display())); + } Ok(()) } @@ -1446,4 +1752,58 @@ mod tests { json.len() ); } + + #[test] + fn device_backup_round_trip_preserves_all_sections() { + let backup = DeviceBackup { + kind: DEVICE_BACKUP_KIND.to_string(), + schema_version: DEVICE_BACKUP_SCHEMA_VERSION, + exported_at_unix_ms: 123_456, + global_config: PhoenixGlobalConfig::default(), + rta_config: PhoenixRtaConfig::default(), + frontend_presets: BTreeMap::from([( + "michael".to_string(), + serde_json::json!({"RTA_BPO_MODE": "1_6"}), + )]), + frontend_layouts: BTreeMap::from([( + "1".to_string(), + serde_json::json!({"UI_LAST_STYLE": "quad-view"}), + )]), + kiosk_browser_storage: BTreeMap::from([( + "analyzer_style".to_string(), + "quad-view".to_string(), + )]), + }; + let json = serde_json::to_string(&backup).unwrap(); + let restored: DeviceBackup = serde_json::from_str(&json).unwrap(); + assert!(validate_device_backup(&restored).is_ok()); + assert_eq!(restored.kind, backup.kind); + assert_eq!(restored.frontend_presets, backup.frontend_presets); + assert_eq!(restored.frontend_layouts, backup.frontend_layouts); + assert_eq!(restored.kiosk_browser_storage, backup.kiosk_browser_storage); + assert_eq!( + serde_json::to_value(restored.global_config).unwrap(), + serde_json::to_value(backup.global_config).unwrap() + ); + } + + #[test] + fn device_backup_rejects_wrong_schema_and_invalid_store_entries() { + let mut backup = DeviceBackup { + kind: DEVICE_BACKUP_KIND.to_string(), + schema_version: DEVICE_BACKUP_SCHEMA_VERSION + 1, + exported_at_unix_ms: 0, + global_config: PhoenixGlobalConfig::default(), + rta_config: PhoenixRtaConfig::default(), + frontend_presets: BTreeMap::new(), + frontend_layouts: BTreeMap::new(), + kiosk_browser_storage: BTreeMap::new(), + }; + assert!(validate_device_backup(&backup).is_err()); + backup.schema_version = DEVICE_BACKUP_SCHEMA_VERSION; + backup + .frontend_layouts + .insert("99".to_string(), serde_json::json!({})); + assert!(validate_device_backup(&backup).is_err()); + } } diff --git a/src/state.rs b/src/state.rs index b1c8e78..b6fba0f 100644 --- a/src/state.rs +++ b/src/state.rs @@ -55,6 +55,7 @@ pub(crate) struct NativeWavRecorder { } const NATIVE_RECORDING_EDGE_FADE_FRAMES: usize = 128; +static CONFIG_WRITE_TOKEN: AtomicU64 = AtomicU64::new(1); fn apply_native_recording_edge_fades(pcm_bytes: &mut [u8], channels: u16) { let ch = usize::from(channels.max(1)); @@ -100,7 +101,7 @@ impl AppState { let (spectro_tx, _) = broadcast::channel(8); let initial_global_config = load_global_config(&config); let initial_rta_config = - apply_global_to_rta(config.default_rta_config(), &initial_global_config); + apply_global_to_rta(load_rta_config(&config), &initial_global_config); let configured_sample_rate = config.sample_rate; Self { config, @@ -163,6 +164,7 @@ impl AppState { pub async fn set_rta_config(&self, config: PhoenixRtaConfig) -> PhoenixRtaConfig { let normalized = normalize_rta_config(config); *self.rta_config.write().await = normalized.clone(); + let _ = persist_rta_config(&self.config, &normalized).await; let current = self.global_config.read().await.clone(); let updated_global = PhoenixGlobalConfig { fft_size: normalized.fft_size, @@ -245,6 +247,10 @@ impl AppState { self.global_config.read().await.clone() } + pub async fn persist_current_rta_config(&self) -> anyhow::Result<()> { + persist_rta_config(&self.config, &self.rta_config().await).await + } + pub fn global_config_revision(&self) -> u64 { self.global_config_rev.load(Ordering::SeqCst) } @@ -768,20 +774,60 @@ fn load_global_config(runtime: &PhoenixConfig) -> PhoenixGlobalConfig { normalize_global_config(parsed, runtime) } +fn load_rta_config(runtime: &PhoenixConfig) -> PhoenixRtaConfig { + let fallback = normalize_rta_config(runtime.default_rta_config()); + let Ok(raw) = std::fs::read_to_string(&runtime.rta_config_path) else { + return fallback; + }; + let Ok(parsed) = serde_json::from_str::(&raw) else { + return fallback; + }; + normalize_rta_config(parsed) +} + +async fn persist_rta_config( + runtime: &PhoenixConfig, + config: &PhoenixRtaConfig, +) -> anyhow::Result<()> { + persist_config_atomic(&runtime.rta_config_path, config, "Phoenix RTA config").await +} + async fn persist_global_config( runtime: &PhoenixConfig, config: &PhoenixGlobalConfig, ) -> anyhow::Result<()> { - let path = runtime.global_config_path.clone(); + persist_config_atomic(&runtime.global_config_path, config, "Phoenix config").await +} + +async fn persist_config_atomic( + path: &std::path::Path, + config: &T, + label: &str, +) -> anyhow::Result<()> { + let path = path.to_path_buf(); if let Some(parent) = path.parent() { fs::create_dir_all(parent) .await - .with_context(|| format!("failed to create Phoenix config dir {}", parent.display()))?; + .with_context(|| format!("failed to create {label} dir {}", parent.display()))?; } let json = serde_json::to_vec_pretty(config)?; - fs::write(&path, json) + let token = CONFIG_WRITE_TOKEN.fetch_add(1, Ordering::Relaxed); + let temp_name = format!( + ".{}.tmp-{}-{}", + path.file_name() + .and_then(|name| name.to_str()) + .unwrap_or("global-config.json"), + std::process::id(), + token + ); + let temp_path = path.with_file_name(temp_name); + fs::write(&temp_path, json) .await - .with_context(|| format!("failed to write Phoenix config {}", path.display()))?; + .with_context(|| format!("failed to write temporary {label} {}", temp_path.display()))?; + if let Err(err) = fs::rename(&temp_path, &path).await { + let _ = fs::remove_file(&temp_path).await; + return Err(err).with_context(|| format!("failed to replace {label} {}", path.display())); + } Ok(()) } diff --git a/www/changelog.html b/www/changelog.html index 6a6aa01..fe4030b 100644 --- a/www/changelog.html +++ b/www/changelog.html @@ -1,6 +1,6 @@
- 3.8 (Phoenix 1.0) + 3.8.5 (Phoenix 1.0)

Veröffentlichung: 21.07.2026

@@ -21,6 +21,10 @@
  • Phasenrad: Bandpass, Hilbert-Transformation und energiegewichtete L/R-Phasenmittelung laufen nun samplekontinuierlich im Audiokern statt auf ausgedünnten Browser-XY-Punkten. Paketgrenzen beeinflussen den Winkel nicht mehr; Glättung und AGC arbeiten zeitbasiert, und die doppelte AGC-Verstärkung wurde entfernt.
  • True Peak, RMS und VU: True Peak verwendet den vierphasigen Referenz-FIR aus ITU-R BS.1770, besteht die EBU-Testfälle 15 bis 19 und zeigt Übersteuerungen in allen Ansichten bis +6 dBTP an. True RMS integriert samplekontinuierlich im Leistungsbereich mit Fast (125 ms), Slow (1 s) oder einem gleitenden 300-ms-Fenster; Browser-Doppelglättung und der RMS-fremde Impulse-Modus wurden entfernt. VU verwendet ein Moving-Coil-Modell mit 300-ms-Sprungantwort und 1 bis 1,5 % Überschwingen; der fälschliche Standardoffset von +1,92 dB wurde auf 0 dB korrigiert.
  • Qualitätssicherung: Automatische Regressionstests für PPM, RTA, A/C/Z, mehrere Sampleraten, Korrelationssignale, Goniometertaktung, Binärprotokolle sowie Spektrogramm-Zeitbasis und Langlauf ergänzt.
  • +
  • Gerätesicherung: Versioniertes Gesamtbackup und geprüfte Wiederherstellung für globale Geräteeinstellungen, vollständige RTA-Konfiguration, Software-Presets und Layout-Slots ergänzt. Konfigurationsdateien werden atomar ersetzt; bei einem Restore-Fehler wird der vorherige Zustand wiederhergestellt.
  • +
  • Internes Pi-Display: Die Phoenix-eigenen lokalen Einstellungen des Chromium-Kiosks werden gezielt und cachefrei mit dem Gerät synchronisiert, im Gesamtbackup gesichert und nach einem Restore ausschließlich auf dem internen Display wiederhergestellt. Externe Browserprofile bleiben unberührt.
  • +
  • GUI-Aufräumarbeiten: Split- und Quad-View teilen sich gemeinsame interne Bausteine; Canvas-Caches sind begrenzt, doppelte Zeichenhelfer vereinheitlicht, Optionscode aufgeteilt, direkte Stildefinitionen reduziert und der Changelog wird erst beim Öffnen geladen. Das sichtbare Layout bleibt erhalten.
  • +
  • Sicht-Auswahl: Die dynamische Breite des Sicht-Dropdowns wiederhergestellt, damit Meter-Slots und weitere Bedienelemente nicht mehr unnötig in eine zweite Zeile umbrechen.
  • diff --git a/www/index.html b/www/index.html index 71e7172..611c96c 100644 --- a/www/index.html +++ b/www/index.html @@ -13,7 +13,7 @@
    - @@ -995,6 +995,15 @@
    Speichert nur Ansicht und Layout. Layouts können per URL, z. B. ?layout=1, oder über die Aktivieren-Tasten geladen werden.
    +
    + +
    + + + +
    + Enthält globale Geräteeinstellungen, RTA-Laufzeitkonfiguration, Software-Presets und Layout-Slots. Browserlokale Ansichten sind nicht enthalten. +
    diff --git a/www/main.js b/www/main.js index 2c04b79..b373d27 100644 --- a/www/main.js +++ b/www/main.js @@ -51,6 +51,7 @@ import * as stopwatch from './meters/stopwatch.js'; // Options UI import { setupOptions } from './ui/options.js'; +import { setupKioskBrowserBackup } from './ui/kiosk_browser_backup.js'; // --- Canvas & Context --------------------------------------------------------- const C = document.getElementById('cv'); @@ -2734,6 +2735,7 @@ async function loop(now){ (async function boot(){ try{ const start = async () => { + if (!await setupKioskBrowserBackup()) return; await new Promise((resolve) => setTimeout(resolve, 2000)); const layoutParam = (() => { try { diff --git a/www/ui/device_backup.js b/www/ui/device_backup.js new file mode 100644 index 0000000..0087c94 --- /dev/null +++ b/www/ui/device_backup.js @@ -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 = ''; + } + }); +} diff --git a/www/ui/kiosk_browser_backup.js b/www/ui/kiosk_browser_backup.js new file mode 100644 index 0000000..4974529 --- /dev/null +++ b/www/ui/kiosk_browser_backup.js @@ -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; +} diff --git a/www/ui/options.js b/www/ui/options.js index d0beca7..7bed35a 100644 --- a/www/ui/options.js +++ b/www/ui/options.js @@ -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