| 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<String>, |
| pub llama_cpp_enabled: bool, |
| pub llama_cpp_url: Option<String>, |
| } |
|
|
| pub fn load_runtime_config(root: &Path, config_path: &str) -> Result<RuntimeConfig> { |
| 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::<SocketAddr>() |
| .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<String> { |
| 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<Value> { |
| 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<String> { |
| storage::read_settings(root) |
| .get("phone_access_token") |
| .and_then(|v| v.as_str()) |
| .map(str::to_string) |
| } |
|
|
| fn local_ipv4() -> Option<Ipv4Addr> { |
| 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<String> { |
| let code = QrCode::new(value.as_bytes()).ok()?; |
| Some( |
| code.render::<svg::Color>() |
| .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<String>) { |
| 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) |
| } |
|
|