Add complete device and kiosk backup
This commit is contained in:
@@ -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(())
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user