use crate::storage; use anyhow::{Context, Result}; use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _}; use qrcode::{render::svg, QrCode}; use rand::{rngs::OsRng, RngCore}; use serde_json::{json, Value}; use std::{ net::{IpAddr, Ipv4Addr, SocketAddr, UdpSocket}, path::{Path, PathBuf}, }; #[derive(Debug, Clone)] pub struct RuntimeConfig { pub config_path: PathBuf, pub default_bind: SocketAddr, pub phone_access_available: bool, pub ollama_enabled: bool, pub ollama_url: Option, pub llama_cpp_enabled: bool, pub llama_cpp_url: Option, } pub fn load_runtime_config(root: &Path, config_path: &str) -> Result { let path = if Path::new(config_path).is_absolute() { PathBuf::from(config_path) } else { root.join(config_path) }; let text = std::fs::read_to_string(&path) .with_context(|| format!("cannot read config {}", path.display()))?; let value: toml::Value = toml::from_str(&text).with_context(|| format!("cannot parse config {}", path.display()))?; let bind_text = value .get("bind") .and_then(|v| v.as_str()) .map(str::to_string) .or_else(|| { let host = value.get("server")?.get("host")?.as_str()?; let port = value.get("server")?.get("port")?.as_integer()?; Some(format!("{host}:{port}")) }) .unwrap_or_else(|| "127.0.0.1:4891".to_string()); let default_bind = bind_text .parse::() .with_context(|| format!("invalid bind address: {bind_text}"))?; let phone_access_available = value .get("phone_access_available") .and_then(|v| v.as_bool()) .or_else(|| { value .get("security")? .get("phone_access_available")? .as_bool() }) .unwrap_or(false); let (ollama_enabled, ollama_url) = backend_settings(&value, &["ollama"], true); let (llama_cpp_enabled, llama_cpp_url) = backend_settings(&value, &["llamacpp", "llama_cpp"], true); Ok(RuntimeConfig { config_path: path, default_bind, phone_access_available, ollama_enabled, ollama_url, llama_cpp_enabled, llama_cpp_url, }) } pub fn effective_bind(root: &Path, config: &RuntimeConfig) -> SocketAddr { let settings = storage::read_settings(root); let enabled = settings .get("phone_access_enabled") .and_then(|v| v.as_bool()) .unwrap_or(false); if enabled && config.phone_access_available { SocketAddr::new( IpAddr::V4(Ipv4Addr::UNSPECIFIED), config.default_bind.port(), ) } else { config.default_bind } } pub fn ensure_phone_token(root: &Path) -> Result { let mut settings = storage::read_settings(root); if let Some(token) = settings.get("phone_access_token").and_then(|v| v.as_str()) { if token.len() >= 32 { return Ok(token.to_string()); } } let mut bytes = [0_u8; 24]; OsRng.fill_bytes(&mut bytes); let token = URL_SAFE_NO_PAD.encode(bytes); if let Some(object) = settings.as_object_mut() { object.insert("phone_access_token".to_string(), json!(token)); } storage::write_settings(root, &settings)?; Ok(token) } pub fn set_phone_access( root: &Path, config: &RuntimeConfig, enabled: bool, active_bind: SocketAddr, ) -> Result { if enabled && !config.phone_access_available { anyhow::bail!("phone access is disabled by the runtime configuration"); } let mut settings = storage::read_settings(root); if let Some(object) = settings.as_object_mut() { object.insert("phone_access_enabled".to_string(), json!(enabled)); } storage::write_settings(root, &settings)?; if enabled { ensure_phone_token(root)?; } Ok(status(root, config, active_bind)) } pub fn status(root: &Path, config: &RuntimeConfig, active_bind: SocketAddr) -> Value { let settings = storage::read_settings(root); let enabled = settings .get("phone_access_enabled") .and_then(|v| v.as_bool()) .unwrap_or(false); let token = settings .get("phone_access_token") .and_then(|v| v.as_str()) .unwrap_or(""); let active = enabled && !active_bind.ip().is_loopback(); let local_ip = local_ipv4(); let url = if enabled && !token.is_empty() { local_ip.map(|ip| format!("http://{ip}:{}?pair={token}", active_bind.port())) } else { None }; let qr_svg = url.as_deref().and_then(qr_svg); json!({ "available": config.phone_access_available, "enabled": enabled, "active": active, "bind": active_bind.to_string(), "url": url, "qr_svg": qr_svg, "pairing_required": true, "restart_required": enabled != active, "config": config.config_path.to_string_lossy() }) } pub fn configured_token(root: &Path) -> Option { storage::read_settings(root) .get("phone_access_token") .and_then(|v| v.as_str()) .map(str::to_string) } fn local_ipv4() -> Option { let socket = UdpSocket::bind("0.0.0.0:0").ok()?; socket.connect("192.0.2.1:80").ok()?; match socket.local_addr().ok()?.ip() { IpAddr::V4(ip) if !ip.is_loopback() && !ip.is_unspecified() => Some(ip), _ => None, } } fn qr_svg(value: &str) -> Option { let code = QrCode::new(value.as_bytes()).ok()?; Some( code.render::() .min_dimensions(220, 220) .dark_color(svg::Color("#0b111a")) .light_color(svg::Color("#ffffff")) .build(), ) } fn backend_settings( value: &toml::Value, names: &[&str], default_enabled: bool, ) -> (bool, Option) { let backend = value .get("backends") .and_then(|backends| names.iter().find_map(|name| backends.get(*name))); let enabled = backend .and_then(|entry| entry.get("enabled")) .and_then(|entry| entry.as_bool()) .unwrap_or(default_enabled); let url = backend .and_then(|entry| entry.get("url").or_else(|| entry.get("base_url"))) .and_then(|entry| entry.as_str()) .map(str::to_string); (enabled, url) }