From ce4f3ee93409f989aedc41ee45a354c3d6173722 Mon Sep 17 00:00:00 2001 From: restitux Date: Wed, 4 Mar 2026 19:11:59 -0700 Subject: [PATCH] impl: refactor config logic to use generic interface --- .dockerignore | 1 + gui/src/app.rs | 21 +++--- gui/src/imp/android.rs | 91 ++++++++++++++++++++++++++ gui/src/imp/desktop.rs | 51 +-------------- gui/src/imp/mobile.rs | 17 +---- gui/src/imp/mod.rs | 53 ++++++++++----- gui/src/imp/native_config.rs | 121 +++++++++++++++++++++++++++++++++++ gui/src/imp/stub.rs | 39 ++++++----- gui/src/imp/web.rs | 88 +++++++++++++++++-------- 9 files changed, 348 insertions(+), 134 deletions(-) create mode 100644 .dockerignore create mode 100644 gui/src/imp/android.rs create mode 100644 gui/src/imp/native_config.rs diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..eb5a316 --- /dev/null +++ b/.dockerignore @@ -0,0 +1 @@ +target diff --git a/gui/src/app.rs b/gui/src/app.rs index 5de295b..16ceaa6 100644 --- a/gui/src/app.rs +++ b/gui/src/app.rs @@ -6,7 +6,7 @@ use mumble_web2_common::{ProxyOverrides, ServerStatus}; use ordermap::OrderSet; use std::collections::{HashMap, HashSet}; -use crate::imp::{Platform, PlatformInterface as _}; +use crate::imp::{ConfigSystem, ConfigSystemInterface as _, Platform, PlatformInterface as _}; pub type ChannelId = u32; pub type UserId = u32; @@ -645,7 +645,7 @@ pub fn ControlView(overrides: Resource) -> Element { } #[component] -pub fn ServerView(overrides: Resource) -> Element { +pub fn ServerView(overrides: Resource, user_config: ConfigSystem) -> Element { let net: Coroutine = use_coroutine_handle(); let server = STATE.server.read(); let Some(&UserState { @@ -683,7 +683,7 @@ pub fn ServerView(overrides: Resource) -> Element { } #[component] -pub fn LoginView(overrides: Resource) -> Element { +pub fn LoginView(overrides: Resource, user_config: ConfigSystem) -> Element { let net: Coroutine = use_coroutine_handle(); let last_status = use_signal(|| None::>); @@ -695,7 +695,7 @@ pub fn LoginView(overrides: Resource) -> Element { } }); - let mut address_input = use_signal(|| Platform::load_server_url()); + let mut address_input = use_signal(|| user_config.config_get::("server_url")); let address = use_memo(move || { if let Some(addr) = address_input() { addr.clone() @@ -706,14 +706,13 @@ pub fn LoginView(overrides: Resource) -> Element { } }); - let previous_username = Platform::load_username(); + let previous_username = user_config.config_get::("username"); let mut username = use_signal(|| previous_username.unwrap_or(String::new())); let do_connect = move |_| { - //let _ = set_default_username(&username.read()); - let _ = Platform::set_default_username(&username.read()); + let _ = user_config.config_set::("username", &username.read()); if overrides.read().as_ref().is_some_and(|cfg| cfg.any_server) { - Platform::set_default_server(&address.read()); + user_config.config_set::("server_url", &address.read()); } net.send(Connect { address: address.read().clone(), @@ -866,6 +865,8 @@ pub fn app() -> Element { } }); + let user_config = ConfigSystem::new().unwrap(); + Platform::request_permissions(); rsx!( @@ -874,8 +875,8 @@ pub fn app() -> Element { document::Link{ rel: "stylesheet", href: STYLE } match *STATE.status.read() { - Connected => rsx!(ServerView { overrides }), - _ => rsx!(LoginView { overrides }), + Connected => rsx!(ServerView { overrides, user_config }), + _ => rsx!(LoginView { overrides, user_config }), } ) } diff --git a/gui/src/imp/android.rs b/gui/src/imp/android.rs new file mode 100644 index 0000000..cac7b86 --- /dev/null +++ b/gui/src/imp/android.rs @@ -0,0 +1,91 @@ +use crate::app::Command; +use color_eyre::eyre::Error; +use dioxus::hooks::UnboundedReceiver; +use mumble_web2_common::{ClientConfig, ServerStatus}; +use std::future::Future; +use std::time::Duration; + +/// Mobile platform implementation using Tokio, native audio, and Android permissions. +pub struct MobilePlatform; + +impl super::PlatformInterface for MobilePlatform { + type AudioSystem = super::native_audio::NativeAudioSystem; + + async fn load_config() -> color_eyre::Result { + Ok(ClientConfig { + proxy_url: None, + cert_hash: None, + any_server: true, + }) + } + + fn load_username() -> Option { + None + } + + fn load_server_url() -> Option { + None + } + + fn set_default_username(_username: &str) -> Option<()> { + None + } + + fn set_default_server(server: &str) -> Option<()> { + None + } + + async fn network_connect( + address: String, + username: String, + event_rx: &mut UnboundedReceiver, + gui_config: &ClientConfig, + ) -> Result<(), Error> { + super::connect::network_connect(address, username, event_rx, gui_config).await + } + + async fn get_status(client: &reqwest::Client) -> color_eyre::Result { + super::connect::get_status(client).await + } + + fn init_logging() { + use tracing::level_filters::LevelFilter; + use tracing_subscriber::filter::EnvFilter; + + let env_filter = EnvFilter::builder() + .with_default_directive(LevelFilter::INFO.into()) + .from_env_lossy(); + + tracing_subscriber::fmt() + .with_target(true) + .with_level(true) + .with_env_filter(env_filter) + .init(); + } + + fn request_permissions() { + request_recording_permission(); + } + + async fn sleep(duration: Duration) { + tokio::time::sleep(duration).await; + } +} + +#[cfg(not(target_os = "android"))] +pub fn request_recording_permission() {} + +#[cfg(target_os = "android")] +pub fn request_recording_permission() { + use android_permissions::{PermissionManager, RECORD_AUDIO}; + use jni::{objects::JObject, JavaVM}; + + let ctx = ndk_context::android_context(); + let vm = unsafe { JavaVM::from_raw(ctx.vm().cast()).unwrap() }; + let activity = unsafe { JObject::from_raw(ctx.context().cast()) }; + + let manager = PermissionManager::create(vm, activity).unwrap(); + if !manager.check(&RECORD_AUDIO).unwrap() { + manager.request(&[&RECORD_AUDIO]).unwrap(); + } +} diff --git a/gui/src/imp/desktop.rs b/gui/src/imp/desktop.rs index 28a5e9f..8ea7ee6 100644 --- a/gui/src/imp/desktop.rs +++ b/gui/src/imp/desktop.rs @@ -11,6 +11,7 @@ pub struct DesktopPlatform; impl super::PlatformInterface for DesktopPlatform { type AudioSystem = super::native_audio::NativeAudioSystem; + type ConfigSystem = super::native_config::NativeConfigSystem; async fn sleep(duration: Duration) { tokio::time::sleep(duration).await; @@ -24,28 +25,6 @@ impl super::PlatformInterface for DesktopPlatform { }) } - fn load_username() -> Option { - let config = load_config_map(); - config.get("username").cloned() - } - - fn load_server_url() -> Option { - let config = load_config_map(); - config.get("server").cloned() - } - - fn set_default_username(username: &str) -> Option<()> { - let mut config = load_config_map(); - config.insert("username".to_string(), username.to_string()); - save_config_map(&config).ok() - } - - fn set_default_server(server: &str) -> Option<()> { - let mut config = load_config_map(); - config.insert("server".to_string(), server.to_string()); - save_config_map(&config).ok() - } - async fn network_connect( address: String, username: String, @@ -78,31 +57,3 @@ impl super::PlatformInterface for DesktopPlatform { // No-op on desktop } } - -fn get_config_path() -> std::path::PathBuf { - let strategy = choose_app_strategy(AppStrategyArgs { - top_level_domain: "com".to_string(), - author: "Ohea Corp".to_string(), - app_name: "Mumble Web2".to_string(), - }) - .expect("failed to choose app strategy"); - strategy.config_dir().join("config.json") -} - -fn load_config_map() -> HashMap { - let config_path = get_config_path(); - match std::fs::read_to_string(&config_path) { - Ok(contents) => serde_json::from_str(&contents).unwrap_or_default(), - Err(_) => HashMap::new(), - } -} - -fn save_config_map(config: &HashMap) -> color_eyre::Result<()> { - let config_path = get_config_path(); - if let Some(parent) = config_path.parent() { - std::fs::create_dir_all(parent)?; - } - let contents = serde_json::to_string_pretty(config)?; - std::fs::write(&config_path, contents)?; - Ok(()) -} diff --git a/gui/src/imp/mobile.rs b/gui/src/imp/mobile.rs index 667a0d8..8ff89f6 100644 --- a/gui/src/imp/mobile.rs +++ b/gui/src/imp/mobile.rs @@ -9,6 +9,7 @@ pub struct MobilePlatform; impl super::PlatformInterface for MobilePlatform { type AudioSystem = super::native_audio::NativeAudioSystem; + type ConfigSystem = super::native_config::NativeConfigSystem; async fn load_proxy_overrides() -> color_eyre::Result { Ok(ProxyOverrides { @@ -18,22 +19,6 @@ impl super::PlatformInterface for MobilePlatform { }) } - fn load_username() -> Option { - None - } - - fn load_server_url() -> Option { - None - } - - fn set_default_username(_username: &str) -> Option<()> { - None - } - - fn set_default_server(server: &str) -> Option<()> { - None - } - async fn network_connect( address: String, username: String, diff --git a/gui/src/imp/mod.rs b/gui/src/imp/mod.rs index 120f7ce..06fe510 100644 --- a/gui/src/imp/mod.rs +++ b/gui/src/imp/mod.rs @@ -8,6 +8,7 @@ use crate::{app::Command, effects::AudioProcessor}; use color_eyre::eyre::Error; use dioxus::hooks::UnboundedReceiver; use mumble_web2_common::{ProxyOverrides, ServerStatus}; +use std::collections::HashMap; use std::future::Future; use std::time::Duration; @@ -50,11 +51,24 @@ pub trait AudioPlayerInterface { fn play_opus(&mut self, payload: &[u8]); } +pub trait ConfigSystemInterface: Sized { + fn new() -> Result; + + fn config_get(&self, key: &str) -> Option + where + T: serde::de::DeserializeOwned; + + fn config_set(&self, key: &str, value: &T) + where + T: serde::Serialize; +} + /// This is the main trait that each platform must implement. It combines all /// platform-specific functionality into a single interface, providing compile-time /// verification that all platforms implement the required functionality. pub trait PlatformInterface { type AudioSystem: AudioSystemInterface; + type ConfigSystem: ConfigSystemInterface; /// Initialize logging for the platform. fn init_logging(); @@ -78,18 +92,6 @@ pub trait PlatformInterface { /// Load the proxy overrides (proxy URL, cert hash, etc.). fn load_proxy_overrides() -> impl Future>; - /// Load saved username. - fn load_username() -> Option; - - /// Load saved server URL. - fn load_server_url() -> Option; - - /// Save the default username. - fn set_default_username(username: &str) -> Option<()>; - - /// Save the default server URL. - fn set_default_server(server: &str) -> Option<()>; - /// Async sleep for the given duration. fn sleep(duration: Duration) -> impl Future; } @@ -98,15 +100,21 @@ pub trait PlatformInterface { // Platform Modules // ============================================================================ +mod stub; + #[cfg(any(feature = "desktop", feature = "mobile"))] mod connect; -#[cfg(feature = "desktop")] -mod desktop; -#[cfg(feature = "mobile")] -mod mobile; #[cfg(any(feature = "desktop", feature = "mobile"))] mod native_audio; -mod stub; +#[cfg(any(feature = "desktop", feature = "mobile"))] +mod native_config; + +#[cfg(feature = "desktop")] +mod desktop; + +#[cfg(feature = "mobile")] +mod mobile; + #[cfg(feature = "web")] mod web; @@ -133,6 +141,8 @@ pub type Platform = stub::StubPlatform; pub type AudioSystem = ::AudioSystem; pub type AudioPlayer = ::AudioPlayer; +pub type ConfigSystem = ::ConfigSystem; + // ======================== // Platform Async Runtime // ======================== @@ -164,3 +174,12 @@ const _: () = { let _ = assert_platform::; let _ = assert_platform::; }; + +fn global_default_config() -> HashMap { + serde_json::json!({}) + .as_object() + .unwrap() + .clone() + .into_iter() + .collect() +} diff --git a/gui/src/imp/native_config.rs b/gui/src/imp/native_config.rs new file mode 100644 index 0000000..bf471d9 --- /dev/null +++ b/gui/src/imp/native_config.rs @@ -0,0 +1,121 @@ +use crate::app::Command; +use color_eyre::eyre::Error; +use dioxus::hooks::UnboundedReceiver; +use mumble_web2_common::ServerStatus; +use std::collections::HashMap; +use std::time::Duration; +use tracing::{error, info, warn}; + +#[derive(Clone, PartialEq)] +pub struct NativeConfigSystem { + config_path: std::path::PathBuf, +} + +impl super::ConfigSystemInterface for NativeConfigSystem { + fn new() -> color_eyre::Result { + return Ok(NativeConfigSystem { + config_path: get_config_path()?, + }); + } + + fn config_get(&self, key: &str) -> Option + where + T: serde::de::DeserializeOwned, + { + let config = load_config_map(&self.config_path); + + let Some(value_untyped) = config.get(key).cloned().or_else(|| config_get_default(key)) + else { + return None; + }; + + match serde_json::from_value::(value_untyped) { + Ok(v) => Some(v), + Err(_) => { + let default_value = config_get_default(key) + .expect("Default value required after config parse failure"); + Some( + serde_json::from_value::(default_value) + .expect("Default value could not be parsed"), + ) + } + } + } + + fn config_set(&self, key: &str, value: &T) + where + T: serde::Serialize, + { + let mut config = load_config_map(&self.config_path); + let json_value = serde_json::to_value(value).expect("failed to serialize config value"); + config.insert(key.to_string(), json_value); + save_config_map(&config).expect("failed to set config") + } +} + +#[cfg(any(feature = "desktop"))] +fn get_config_path() -> color_eyre::Result { + use etcetera::{choose_app_strategy, AppStrategy, AppStrategyArgs}; + + let strategy = choose_app_strategy(AppStrategyArgs { + top_level_domain: "xyz".to_string(), + author: "ohea".to_string(), + app_name: "Mumble Web2".to_string(), + }) + .expect("failed to choose app strategy"); + Ok(strategy.config_dir().join("config.json")) +} + +#[cfg(target_os = "android")] +fn get_config_path() -> color_eyre::Result { + let ctx = ndk_context::android_context(); + let vm = unsafe { jni::JavaVM::from_raw(ctx.vm().cast()) }?; + let mut env = vm.attach_current_thread()?; + let ctx = unsafe { jni::objects::JObject::from_raw(ctx.context().cast()) }; + let cache_dir = env + .call_method(ctx, "getFilesDir", "()Ljava/io/File;", &[])? + .l()?; + let cache_dir: jni::objects::JString = env + .call_method(&cache_dir, "toString", "()Ljava/lang/String;", &[])? + .l()? + .try_into()?; + let cache_dir = env.get_string(&cache_dir)?; + let cache_dir = cache_dir.to_str()?; + Ok(std::path::PathBuf::from(cache_dir).join("config.json")) +} + +fn load_config_map(config_path: &std::path::PathBuf) -> HashMap { + match std::fs::read_to_string(config_path) { + Ok(contents) => serde_json::from_str(&contents).unwrap_or_default(), + Err(_) => HashMap::new(), + } +} + +fn save_config_map(config: &HashMap) -> color_eyre::Result<()> { + let config_path = get_config_path().expect("Could not get config file path."); + if let Some(parent) = config_path.parent() { + info!("Creating config directory: {}", parent.display()); + std::fs::create_dir_all(parent)?; + } + let contents = serde_json::to_string_pretty(config)?; + info!("Writing config to {}", config_path.display()); + std::fs::write(&config_path, contents)?; + Ok(()) +} + +fn config_get_default(key: &str) -> Option { + let default_config = platform_default_config(); + default_config + .get(key) + .cloned() + .or(super::global_default_config().get(key).cloned()) +} + +fn platform_default_config() -> HashMap { + serde_json::json!({}) + .as_object() + .unwrap() + .clone() + .into_iter() + .collect() +} diff --git a/gui/src/imp/stub.rs b/gui/src/imp/stub.rs index 8f68d81..4bf1973 100644 --- a/gui/src/imp/stub.rs +++ b/gui/src/imp/stub.rs @@ -10,6 +10,7 @@ pub struct StubPlatform; impl super::PlatformInterface for StubPlatform { type AudioSystem = StubAudioSystem; + type ConfigSystem = StubConfigSystem; fn init_logging() { panic!("stubbed platform") @@ -38,22 +39,6 @@ impl super::PlatformInterface for StubPlatform { async { panic!("stubbed platform") } } - fn load_username() -> Option { - panic!("stubbed platform") - } - - fn load_server_url() -> Option { - panic!("stubbed platform") - } - - fn set_default_username(_username: &str) -> Option<()> { - panic!("stubbed platform") - } - - fn set_default_server(_server: &str) -> Option<()> { - panic!("stubbed platform") - } - fn sleep(_duration: std::time::Duration) -> impl Future { async { panic!("stubbed platform") } } @@ -92,6 +77,28 @@ impl super::AudioPlayerInterface for StubAudioPlayer { } } +pub struct StubConfigSystem; + +impl super::ConfigSystemInterface for StubConfigSystem { + fn new() -> Result { + panic!("stubbed platform") + } + + fn config_get(&self, key: &str) -> Option + where + T: serde::de::DeserializeOwned, + { + panic!("stubbed platform") + } + + fn config_set(&self, key: &str, value: &T) + where + T: serde::Serialize, + { + panic!("stubbed platform") + } +} + #[allow(unused)] pub struct SpawnHandle; diff --git a/gui/src/imp/web.rs b/gui/src/imp/web.rs index 7fd90ac..432c593 100644 --- a/gui/src/imp/web.rs +++ b/gui/src/imp/web.rs @@ -8,6 +8,7 @@ use js_sys::Float32Array; use mumble_protocol::control::ClientControlCodec; use mumble_web2_common::{ProxyOverrides, ServerStatus}; use reqwest::Url; +use std::collections::HashMap; use std::future::Future; use std::sync::Arc; use std::time::Duration; @@ -62,6 +63,7 @@ pub struct WebPlatform; impl super::PlatformInterface for WebPlatform { type AudioSystem = WebAudioSystem; + type ConfigSystem = WebConfigSystem; fn init_logging() { // copied from tracing_web example usage @@ -104,31 +106,6 @@ impl super::PlatformInterface for WebPlatform { Ok(config) } - fn load_username() -> Option { - web_sys::window() - .unwrap() - .local_storage() - .ok()?? - .get_item("username") - .ok()? - } - - fn load_server_url() -> Option { - None - } - - fn set_default_username(username: &str) -> Option<()> { - web_sys::window()? - .local_storage() - .ok()?? - .set_item("username", username) - .ok() - } - - fn set_default_server(_server: &str) -> Option<()> { - None - } - async fn network_connect( address: String, username: String, @@ -523,3 +500,64 @@ pub fn absolute_url(path: &str) -> Result { let location = window.location(); Ok(Url::parse(&location.href().ey()?)?.join(path)?) } + +#[derive(Clone, PartialEq)] +pub struct WebConfigSystem {} + +impl super::ConfigSystemInterface for WebConfigSystem { + fn new() -> Result { + return Ok(WebConfigSystem {}); + } + + fn config_get(&self, key: &str) -> Option + where + T: serde::de::DeserializeOwned, + { + // Get Storage + let storage = web_sys::window()?.local_storage().ok()??; + + // Try localStorage first + if let Ok(Some(raw)) = storage.get_item(key) { + if let Ok(parsed) = serde_json::from_str::(&raw) { + return Some(parsed); + } + } + + // Fallback to default if deserialization fails or key missing + let default_value = config_get_default(key)?; + serde_json::from_value::(default_value).ok() + } + + fn config_set(&self, key: &str, value: &T) + where + T: serde::Serialize, + { + let storage = window() + .and_then(|w| w.local_storage().ok().flatten()) + .expect("localStorage not available"); + + let json_value = + serde_json::to_string(value).expect("failed to serialize config value to JSON string"); + + storage + .set_item(key, &json_value) + .expect("failed to write to localStorage"); + } +} + +fn config_get_default(key: &str) -> Option { + let default_config = platform_default_config(); + default_config + .get(key) + .cloned() + .or(super::global_default_config().get(key).cloned()) +} + +fn platform_default_config() -> HashMap { + serde_json::json!({}) + .as_object() + .unwrap() + .clone() + .into_iter() + .collect() +}