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
+34
View File
@@ -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")
}
+8
View File
@@ -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
View File
@@ -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
View File
@@ -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(())
}