Add complete device and kiosk backup

This commit is contained in:
Mikei386
2026-07-22 11:58:58 +02:00
parent 890a47a272
commit 22444ff5bd
13 changed files with 767 additions and 18 deletions
+13
View File
@@ -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(/<select\b[^>]*\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');
+7 -1
View File
@@ -33,6 +33,8 @@ PHOENIX_SAMPLE_RATE="${PHOENIX_SAMPLE_RATE:-48000}"
PHOENIX_PERIOD_SIZE="${PHOENIX_PERIOD_SIZE:-128}" PHOENIX_PERIOD_SIZE="${PHOENIX_PERIOD_SIZE:-128}"
PHOENIX_BUFFER_SIZE="${PHOENIX_BUFFER_SIZE:-512}" PHOENIX_BUFFER_SIZE="${PHOENIX_BUFFER_SIZE:-512}"
PHOENIX_GLOBAL_CONFIG_PATH="${PHOENIX_GLOBAL_CONFIG_PATH:-/home/analyzer/.config/phoenix/global-config.json}" 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_ANALYZER="${PHOENIX_RECORDINGS_DIR_ANALYZER:-/home/analyzer/Aufnahmen}"
PHOENIX_RECORDINGS_DIR_VOLUMIO="${PHOENIX_RECORDINGS_DIR_VOLUMIO:-/home/volumio/Aufnahmen}" PHOENIX_RECORDINGS_DIR_VOLUMIO="${PHOENIX_RECORDINGS_DIR_VOLUMIO:-/home/volumio/Aufnahmen}"
PHOENIX_LR_FRAC_DELAY_ENABLED="${PHOENIX_LR_FRAC_DELAY_ENABLED:-true}" PHOENIX_LR_FRAC_DELAY_ENABLED="${PHOENIX_LR_FRAC_DELAY_ENABLED:-true}"
@@ -83,6 +85,8 @@ else
PHOENIX_PERIOD_SIZE="$PHOENIX_PERIOD_SIZE" \ PHOENIX_PERIOD_SIZE="$PHOENIX_PERIOD_SIZE" \
PHOENIX_BUFFER_SIZE="$PHOENIX_BUFFER_SIZE" \ PHOENIX_BUFFER_SIZE="$PHOENIX_BUFFER_SIZE" \
PHOENIX_GLOBAL_CONFIG_PATH="$PHOENIX_GLOBAL_CONFIG_PATH" \ 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_ANALYZER="$PHOENIX_RECORDINGS_DIR_ANALYZER" \
PHOENIX_RECORDINGS_DIR_VOLUMIO="$PHOENIX_RECORDINGS_DIR_VOLUMIO" \ PHOENIX_RECORDINGS_DIR_VOLUMIO="$PHOENIX_RECORDINGS_DIR_VOLUMIO" \
PHOENIX_LR_FRAC_DELAY_ENABLED="$PHOENIX_LR_FRAC_DELAY_ENABLED" \ PHOENIX_LR_FRAC_DELAY_ENABLED="$PHOENIX_LR_FRAC_DELAY_ENABLED" \
@@ -97,6 +101,8 @@ else
PHOENIX_PERIOD_SIZE="$PHOENIX_PERIOD_SIZE" \ PHOENIX_PERIOD_SIZE="$PHOENIX_PERIOD_SIZE" \
PHOENIX_BUFFER_SIZE="$PHOENIX_BUFFER_SIZE" \ PHOENIX_BUFFER_SIZE="$PHOENIX_BUFFER_SIZE" \
PHOENIX_GLOBAL_CONFIG_PATH="$PHOENIX_GLOBAL_CONFIG_PATH" \ 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_ANALYZER="$PHOENIX_RECORDINGS_DIR_ANALYZER" \
PHOENIX_RECORDINGS_DIR_VOLUMIO="$PHOENIX_RECORDINGS_DIR_VOLUMIO" \ PHOENIX_RECORDINGS_DIR_VOLUMIO="$PHOENIX_RECORDINGS_DIR_VOLUMIO" \
PHOENIX_LR_FRAC_DELAY_ENABLED="$PHOENIX_LR_FRAC_DELAY_ENABLED" \ 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 \ --lang=de-DE --disable-translate --disable-features=Translate,TranslateUI \
--memory-pressure-off \ --memory-pressure-off \
--max-active-webgl-contexts=1 \ --max-active-webgl-contexts=1 \
http://localhost/index.html \ 'http://localhost/index.html?kiosk=1' \
>>"$LOG" 2>&1 >>"$LOG" 2>&1
echo "=== $(date): session.sh END ===" echo "=== $(date): session.sh END ==="
+7 -1
View File
@@ -24,6 +24,8 @@ PHOENIX_SAMPLE_RATE="${PHOENIX_SAMPLE_RATE:-48000}"
PHOENIX_PERIOD_SIZE="${PHOENIX_PERIOD_SIZE:-128}" PHOENIX_PERIOD_SIZE="${PHOENIX_PERIOD_SIZE:-128}"
PHOENIX_BUFFER_SIZE="${PHOENIX_BUFFER_SIZE:-512}" PHOENIX_BUFFER_SIZE="${PHOENIX_BUFFER_SIZE:-512}"
PHOENIX_GLOBAL_CONFIG_PATH="${PHOENIX_GLOBAL_CONFIG_PATH:-/home/volumio/.config/phoenix/global-config.json}" 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_ANALYZER="${PHOENIX_RECORDINGS_DIR_ANALYZER:-/home/analyzer/Aufnahmen}"
PHOENIX_RECORDINGS_DIR_VOLUMIO="${PHOENIX_RECORDINGS_DIR_VOLUMIO:-/home/volumio/Aufnahmen}" PHOENIX_RECORDINGS_DIR_VOLUMIO="${PHOENIX_RECORDINGS_DIR_VOLUMIO:-/home/volumio/Aufnahmen}"
PHOENIX_LR_FRAC_DELAY_ENABLED="${PHOENIX_LR_FRAC_DELAY_ENABLED:-true}" 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}" CHROME_BIN="${CHROME_BIN:-/usr/bin/chromium-browser}"
EXT_VK="${EXT_VK:-/data/volumiokioskextensions/VirtualKeyboard}" EXT_VK="${EXT_VK:-/data/volumiokioskextensions/VirtualKeyboard}"
EXT_TOGGLE="${EXT_TOGGLE:-/opt/analyzer/toggle-ext}" 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}" VOLUMIO_URL="${VOLUMIO_URL:-http://localhost:3000}"
mkdir -p "$(dirname "$LOG")" mkdir -p "$(dirname "$LOG")"
@@ -96,6 +98,8 @@ else
PHOENIX_PERIOD_SIZE="$PHOENIX_PERIOD_SIZE" \ PHOENIX_PERIOD_SIZE="$PHOENIX_PERIOD_SIZE" \
PHOENIX_BUFFER_SIZE="$PHOENIX_BUFFER_SIZE" \ PHOENIX_BUFFER_SIZE="$PHOENIX_BUFFER_SIZE" \
PHOENIX_GLOBAL_CONFIG_PATH="$PHOENIX_GLOBAL_CONFIG_PATH" \ 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_ANALYZER="$PHOENIX_RECORDINGS_DIR_ANALYZER" \
PHOENIX_RECORDINGS_DIR_VOLUMIO="$PHOENIX_RECORDINGS_DIR_VOLUMIO" \ PHOENIX_RECORDINGS_DIR_VOLUMIO="$PHOENIX_RECORDINGS_DIR_VOLUMIO" \
PHOENIX_LR_FRAC_DELAY_ENABLED="$PHOENIX_LR_FRAC_DELAY_ENABLED" \ PHOENIX_LR_FRAC_DELAY_ENABLED="$PHOENIX_LR_FRAC_DELAY_ENABLED" \
@@ -112,6 +116,8 @@ else
PHOENIX_PERIOD_SIZE="$PHOENIX_PERIOD_SIZE" \ PHOENIX_PERIOD_SIZE="$PHOENIX_PERIOD_SIZE" \
PHOENIX_BUFFER_SIZE="$PHOENIX_BUFFER_SIZE" \ PHOENIX_BUFFER_SIZE="$PHOENIX_BUFFER_SIZE" \
PHOENIX_GLOBAL_CONFIG_PATH="$PHOENIX_GLOBAL_CONFIG_PATH" \ 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_ANALYZER="$PHOENIX_RECORDINGS_DIR_ANALYZER" \
PHOENIX_RECORDINGS_DIR_VOLUMIO="$PHOENIX_RECORDINGS_DIR_VOLUMIO" \ PHOENIX_RECORDINGS_DIR_VOLUMIO="$PHOENIX_RECORDINGS_DIR_VOLUMIO" \
PHOENIX_LR_FRAC_DELAY_ENABLED="$PHOENIX_LR_FRAC_DELAY_ENABLED" \ PHOENIX_LR_FRAC_DELAY_ENABLED="$PHOENIX_LR_FRAC_DELAY_ENABLED" \
+34
View File
@@ -14,8 +14,10 @@ pub struct PhoenixConfig {
#[cfg_attr(not(target_os = "linux"), allow(dead_code))] #[cfg_attr(not(target_os = "linux"), allow(dead_code))]
pub buffer_size: u32, pub buffer_size: u32,
pub global_config_path: PathBuf, pub global_config_path: PathBuf,
pub rta_config_path: PathBuf,
pub frontend_presets_path: PathBuf, pub frontend_presets_path: PathBuf,
pub frontend_layouts_path: PathBuf, pub frontend_layouts_path: PathBuf,
pub kiosk_browser_config_path: PathBuf,
pub recordings_dir_analyzer: PathBuf, pub recordings_dir_analyzer: PathBuf,
pub recordings_dir_volumio: PathBuf, pub recordings_dir_volumio: PathBuf,
pub max_recording_upload_bytes: u64, pub max_recording_upload_bytes: u64,
@@ -53,6 +55,11 @@ impl PhoenixConfig {
.map(|v| PathBuf::from(v.trim())) .map(|v| PathBuf::from(v.trim()))
.filter(|v| !v.as_os_str().is_empty()) .filter(|v| !v.as_os_str().is_empty())
.unwrap_or_else(default_global_config_path), .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") frontend_presets_path: std::env::var("PHOENIX_FRONTEND_PRESETS_PATH")
.ok() .ok()
.map(|v| PathBuf::from(v.trim())) .map(|v| PathBuf::from(v.trim()))
@@ -63,6 +70,11 @@ impl PhoenixConfig {
.map(|v| PathBuf::from(v.trim())) .map(|v| PathBuf::from(v.trim()))
.filter(|v| !v.as_os_str().is_empty()) .filter(|v| !v.as_os_str().is_empty())
.unwrap_or_else(default_frontend_layouts_path), .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") recordings_dir_analyzer: std::env::var("PHOENIX_RECORDINGS_DIR_ANALYZER")
.ok() .ok()
.map(|v| PathBuf::from(v.trim())) .map(|v| PathBuf::from(v.trim()))
@@ -128,6 +140,17 @@ fn default_global_config_path() -> PathBuf {
PathBuf::from("phoenix-global-config.json") 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 { fn default_frontend_presets_path() -> PathBuf {
if let Some(home) = std::env::var_os("HOME") { if let Some(home) = std::env::var_os("HOME") {
let mut path = PathBuf::from(home); let mut path = PathBuf::from(home);
@@ -150,6 +173,17 @@ fn default_frontend_layouts_path() -> PathBuf {
PathBuf::from("phoenix-frontend-layouts.json") 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 { fn default_recordings_dir_analyzer() -> PathBuf {
PathBuf::from("/home/analyzer/Aufnahmen") PathBuf::from("/home/analyzer/Aufnahmen")
} }
+8
View File
@@ -53,6 +53,14 @@ async fn main() -> anyhow::Result<()> {
"/api/v1/frontend-layouts", "/api/v1/frontend-layouts",
get(routes::get_frontend_layouts).post(routes::set_frontend_layout), 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( .route(
"/api/v1/update/download", "/api/v1/update/download",
post(routes::download_online_update), post(routes::download_online_update),
+369 -9
View File
@@ -7,10 +7,12 @@ use axum::{
Json, Json,
}; };
use http_body_util::BodyExt; use http_body_util::BodyExt;
use serde::Deserialize; use serde::{Deserialize, Serialize};
use std::{ use std::{
collections::BTreeMap,
path::PathBuf, path::PathBuf,
process::Stdio, process::Stdio,
sync::atomic::{AtomicU64, Ordering},
sync::Arc, sync::Arc,
time::{SystemTime, UNIX_EPOCH}, time::{SystemTime, UNIX_EPOCH},
}; };
@@ -23,6 +25,9 @@ use crate::{
}; };
const ONLINE_UPDATE_ZIP_URL: &str = "https://webshare.casaderoll.de/share/Phoenix.zip"; 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 { fn stable_script_command(program: &str) -> Command {
let mut command = Command::new(program); let mut command = Command::new(program);
@@ -42,6 +47,39 @@ pub struct FrontendPresetSavePayload {
pub config: serde_json::Value, 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<String, serde_json::Value>,
#[serde(default)]
pub frontend_layouts: BTreeMap<String, serde_json::Value>,
#[serde(default)]
pub kiosk_browser_storage: BTreeMap<String, String>,
}
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct KioskBrowserConfig {
#[serde(default)]
pub revision: u64,
#[serde(default)]
pub storage: BTreeMap<String, String>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct KioskBrowserConfigUpdate {
pub base_revision: u64,
#[serde(default)]
pub storage: BTreeMap<String, String>,
}
pub async fn health() -> Json<serde_json::Value> { pub async fn health() -> Json<serde_json::Value> {
Json(serde_json::json!({ Json(serde_json::json!({
"ok": true, "ok": true,
@@ -197,6 +235,218 @@ pub async fn set_frontend_layout(
} }
} }
pub async fn export_device_backup(State(state): State<AppState>) -> 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<AppState>,
Json(backup): Json<DeviceBackup>,
) -> 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<String, serde_json::Value> =
backup.frontend_presets.clone().into_iter().collect();
let new_layouts: serde_json::Map<String, serde_json::Value> =
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<AppState>) -> 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<AppState>,
Json(update): Json<KioskBrowserConfigUpdate>,
) -> 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<String, String>) -> 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 { pub async fn download_online_update() -> Response {
let phoenix_root = match resolve_phoenix_root_dir() { let phoenix_root = match resolve_phoenix_root_dir() {
Ok(path) => path, Ok(path) => path,
@@ -814,6 +1064,32 @@ async fn read_frontend_layouts(
Ok(parsed.as_object().cloned().unwrap_or_default()) Ok(parsed.as_object().cloned().unwrap_or_default())
} }
async fn read_kiosk_browser_config(
config: &crate::config::PhoenixConfig,
) -> anyhow::Result<KioskBrowserConfig> {
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::<KioskBrowserConfig>(&raw).map_err(|err| {
anyhow::anyhow!(
"failed to parse kiosk browser config {} ({})",
path.display(),
err
)
})
}
async fn persist_frontend_presets( async fn persist_frontend_presets(
config: &crate::config::PhoenixConfig, config: &crate::config::PhoenixConfig,
presets: &serde_json::Map<String, serde_json::Value>, presets: &serde_json::Map<String, serde_json::Value>,
@@ -824,11 +1100,7 @@ async fn persist_frontend_presets(
format!("failed to create frontend preset dir {}", parent.display()) format!("failed to create frontend preset dir {}", parent.display())
})?; })?;
} }
let json = serde_json::to_vec_pretty(&serde_json::Value::Object(presets.clone()))?; persist_json_atomic(&path, &serde_json::Value::Object(presets.clone())).await
fs::write(&path, json)
.await
.with_context(|| format!("failed to write frontend presets {}", path.display()))?;
Ok(())
} }
async fn persist_frontend_layouts( async fn persist_frontend_layouts(
@@ -841,10 +1113,44 @@ async fn persist_frontend_layouts(
format!("failed to create frontend layout dir {}", parent.display()) format!("failed to create frontend layout dir {}", parent.display())
})?; })?;
} }
let json = serde_json::to_vec_pretty(&serde_json::Value::Object(layouts.clone()))?; persist_json_atomic(&path, &serde_json::Value::Object(layouts.clone())).await
fs::write(&path, json) }
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<T: Serialize>(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 .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(()) Ok(())
} }
@@ -1446,4 +1752,58 @@ mod tests {
json.len() 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());
}
} }
+51 -5
View File
@@ -55,6 +55,7 @@ pub(crate) struct NativeWavRecorder {
} }
const NATIVE_RECORDING_EDGE_FADE_FRAMES: usize = 128; 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) { fn apply_native_recording_edge_fades(pcm_bytes: &mut [u8], channels: u16) {
let ch = usize::from(channels.max(1)); let ch = usize::from(channels.max(1));
@@ -100,7 +101,7 @@ impl AppState {
let (spectro_tx, _) = broadcast::channel(8); let (spectro_tx, _) = broadcast::channel(8);
let initial_global_config = load_global_config(&config); let initial_global_config = load_global_config(&config);
let initial_rta_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; let configured_sample_rate = config.sample_rate;
Self { Self {
config, config,
@@ -163,6 +164,7 @@ impl AppState {
pub async fn set_rta_config(&self, config: PhoenixRtaConfig) -> PhoenixRtaConfig { pub async fn set_rta_config(&self, config: PhoenixRtaConfig) -> PhoenixRtaConfig {
let normalized = normalize_rta_config(config); let normalized = normalize_rta_config(config);
*self.rta_config.write().await = normalized.clone(); *self.rta_config.write().await = normalized.clone();
let _ = persist_rta_config(&self.config, &normalized).await;
let current = self.global_config.read().await.clone(); let current = self.global_config.read().await.clone();
let updated_global = PhoenixGlobalConfig { let updated_global = PhoenixGlobalConfig {
fft_size: normalized.fft_size, fft_size: normalized.fft_size,
@@ -245,6 +247,10 @@ impl AppState {
self.global_config.read().await.clone() 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 { pub fn global_config_revision(&self) -> u64 {
self.global_config_rev.load(Ordering::SeqCst) self.global_config_rev.load(Ordering::SeqCst)
} }
@@ -768,20 +774,60 @@ fn load_global_config(runtime: &PhoenixConfig) -> PhoenixGlobalConfig {
normalize_global_config(parsed, runtime) 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::<PhoenixRtaConfig>(&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( async fn persist_global_config(
runtime: &PhoenixConfig, runtime: &PhoenixConfig,
config: &PhoenixGlobalConfig, config: &PhoenixGlobalConfig,
) -> anyhow::Result<()> { ) -> 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<T: serde::Serialize>(
path: &std::path::Path,
config: &T,
label: &str,
) -> anyhow::Result<()> {
let path = path.to_path_buf();
if let Some(parent) = path.parent() { if let Some(parent) = path.parent() {
fs::create_dir_all(parent) fs::create_dir_all(parent)
.await .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)?; 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 .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(()) Ok(())
} }
+5 -1
View File
@@ -1,6 +1,6 @@
<div class="opt-group"> <div class="opt-group">
<details class="changelog-version" open> <details class="changelog-version" open>
<summary>3.8 (Phoenix 1.0)</summary> <summary>3.8.5 (Phoenix 1.0)</summary>
<div class="opt-group"> <div class="opt-group">
<div class="opt"> <div class="opt">
<p class="changelog-date">Veröffentlichung: 21.07.2026</p> <p class="changelog-date">Veröffentlichung: 21.07.2026</p>
@@ -21,6 +21,10 @@
<li><strong>Phasenrad:</strong> 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.</li> <li><strong>Phasenrad:</strong> 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.</li>
<li><strong>True Peak, RMS und VU:</strong> 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.</li> <li><strong>True Peak, RMS und VU:</strong> 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.</li>
<li><strong>Qualitätssicherung:</strong> Automatische Regressionstests für PPM, RTA, A/C/Z, mehrere Sampleraten, Korrelationssignale, Goniometertaktung, Binärprotokolle sowie Spektrogramm-Zeitbasis und Langlauf ergänzt.</li> <li><strong>Qualitätssicherung:</strong> Automatische Regressionstests für PPM, RTA, A/C/Z, mehrere Sampleraten, Korrelationssignale, Goniometertaktung, Binärprotokolle sowie Spektrogramm-Zeitbasis und Langlauf ergänzt.</li>
<li><strong>Gerätesicherung:</strong> 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.</li>
<li><strong>Internes Pi-Display:</strong> 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.</li>
<li><strong>GUI-Aufräumarbeiten:</strong> 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.</li>
<li><strong>Sicht-Auswahl:</strong> Die dynamische Breite des Sicht-Dropdowns wiederhergestellt, damit Meter-Slots und weitere Bedienelemente nicht mehr unnötig in eine zweite Zeile umbrechen.</li>
</ul> </ul>
</div> </div>
</div> </div>
+10 -1
View File
@@ -13,7 +13,7 @@
<div class="hud"> <div class="hud">
<label for="styleSel">Sicht</label> <label for="styleSel">Sicht</label>
<select class="opt-w-220" id="styleSel"> <select id="styleSel" style="width:220px">
<option value="realtime">Real Time Analyzer</option> <option value="realtime">Real Time Analyzer</option>
<option value="peak-history">Peak History</option> <option value="peak-history">Peak History</option>
<option value="spectrogram">Spectrogram</option> <option value="spectrogram">Spectrogram</option>
@@ -995,6 +995,15 @@
</div> </div>
<small>Speichert nur Ansicht und Layout. Layouts können per URL, z. B. <code>?layout=1</code>, oder über die Aktivieren-Tasten geladen werden.</small> <small>Speichert nur Ansicht und Layout. Layouts können per URL, z. B. <code>?layout=1</code>, oder über die Aktivieren-Tasten geladen werden.</small>
</div> </div>
<div class="opt">
<label>Gerätesicherung</label>
<div class="row" style="gap:8px;align-items:center;flex-wrap:wrap">
<button id="btnDeviceBackupExport" class="btn btn-compact" type="button">Gesamtbackup herunterladen</button>
<label for="impDeviceBackup" class="btn btn-compact" style="cursor:pointer">Gesamtbackup einlesen</label>
<input id="impDeviceBackup" type="file" accept="application/json" style="display:none">
</div>
<small id="deviceBackupStatus">Enthält globale Geräteeinstellungen, RTA-Laufzeitkonfiguration, Software-Presets und Layout-Slots. Browserlokale Ansichten sind nicht enthalten.</small>
</div>
</div> </div>
</details> </details>
<details> <details>
+2
View File
@@ -51,6 +51,7 @@ import * as stopwatch from './meters/stopwatch.js';
// Options UI // Options UI
import { setupOptions } from './ui/options.js'; import { setupOptions } from './ui/options.js';
import { setupKioskBrowserBackup } from './ui/kiosk_browser_backup.js';
// --- Canvas & Context --------------------------------------------------------- // --- Canvas & Context ---------------------------------------------------------
const C = document.getElementById('cv'); const C = document.getElementById('cv');
@@ -2734,6 +2735,7 @@ async function loop(now){
(async function boot(){ (async function boot(){
try{ try{
const start = async () => { const start = async () => {
if (!await setupKioskBrowserBackup()) return;
await new Promise((resolve) => setTimeout(resolve, 2000)); await new Promise((resolve) => setTimeout(resolve, 2000));
const layoutParam = (() => { const layoutParam = (() => {
try { try {
+92
View File
@@ -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 = '';
}
});
}
+162
View File
@@ -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;
}
+7
View File
@@ -5,6 +5,7 @@ import { CONFIG, saveConfig, loadConfig, applyAlignmentProfile, applyRtaBpoSelec
import { clamp, clampPow2 } from '../core/utils.js'; import { clamp, clampPow2 } from '../core/utils.js';
import { bindOptionsViewportAssist } from './options_viewport.js'; import { bindOptionsViewportAssist } from './options_viewport.js';
import { setupLazyChangelog } from './changelog.js'; import { setupLazyChangelog } from './changelog.js';
import { setupDeviceBackup } from './device_backup.js';
// DOM helper // DOM helper
const E = (id) => document.getElementById(id); const E = (id) => document.getElementById(id);
@@ -90,6 +91,12 @@ export function setupOptions(env) {
wireHandlers(env); wireHandlers(env);
bindOptionsViewportAssist(E('optionsPanelNew')); bindOptionsViewportAssist(E('optionsPanelNew'));
setupLazyChangelog(E('changelogDetails'), E('changelogContent')); setupLazyChangelog(E('changelogDetails'), E('changelogContent'));
setupDeviceBackup({
exportButton: E('btnDeviceBackupExport'),
importInput: E('impDeviceBackup'),
status: E('deviceBackupStatus'),
resolveApiBaseUrl: resolvePhoenixApiBaseUrl,
});
bindAlignmentToggle(env); bindAlignmentToggle(env);
bindVuRefToggle(env); bindVuRefToggle(env);
// Sichtbarkeit nach View steuern übernimmt main.js — hier nur Export der Sync-Funktion // Sichtbarkeit nach View steuern übernimmt main.js — hier nur Export der Sync-Funktion