Add complete device and kiosk backup
This commit is contained in:
@@ -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
@@ -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 ==="
|
||||
|
||||
+7
-1
@@ -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" \
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
+369
-9
@@ -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<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> {
|
||||
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<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 {
|
||||
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<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(
|
||||
config: &crate::config::PhoenixConfig,
|
||||
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())
|
||||
})?;
|
||||
}
|
||||
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<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
|
||||
.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());
|
||||
}
|
||||
}
|
||||
|
||||
+51
-5
@@ -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::<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(
|
||||
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<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() {
|
||||
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(())
|
||||
}
|
||||
|
||||
|
||||
+5
-1
@@ -1,6 +1,6 @@
|
||||
<div class="opt-group">
|
||||
<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">
|
||||
<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>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>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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+10
-1
@@ -13,7 +13,7 @@
|
||||
|
||||
<div class="hud">
|
||||
<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="peak-history">Peak History</option>
|
||||
<option value="spectrogram">Spectrogram</option>
|
||||
@@ -995,6 +995,15 @@
|
||||
</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>
|
||||
</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>
|
||||
</details>
|
||||
<details>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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