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_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
@@ -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" \
|
||||||
|
|||||||
@@ -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")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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>
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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 { 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
|
||||||
|
|||||||
Reference in New Issue
Block a user