jackailocal / src /phone.rs
jackboy70's picture
Deploy: accurate lite-builder note
f25362a
Raw
History Blame Contribute Delete
6.42 kB
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)
}