use std::{
net::{Ipv4Addr, SocketAddr},
path::Path,
sync::Arc,
};
use alix::{
assemble::{self, open_store},
config::Config,
recent::{self, RecentDecks},
serve, workspace,
};
use anyhow::{Context, Result, bail};
use crate::LaunchArgs;
fn serve_addr(port: Option<u16>, lan: bool, config: &Config) -> SocketAddr {
let ip = if lan {
Ipv4Addr::UNSPECIFIED } else {
Ipv4Addr::LOCALHOST };
SocketAddr::from((ip, port.unwrap_or(config.serve.port)))
}
fn resolve_serve_token(cli: Option<String>, lan: bool, config: &Config) -> Result<Option<String>> {
if let Some(t) = cli
.or_else(|| config.serve.token.clone())
.filter(|t| !t.is_empty())
{
return Ok(Some(t));
}
if lan {
return Ok(Some(generate_token()?));
}
Ok(None)
}
fn generate_token() -> Result<String> {
let mut buf = [0u8; 16];
getrandom::getrandom(&mut buf)
.map_err(|e| anyhow::anyhow!("could not generate a serve pairing token: {e}"))?;
Ok(buf.iter().map(|b| format!("{b:02x}")).collect())
}
pub(crate) fn launch(args: LaunchArgs) -> Result<()> {
let config = Config::load(args.config.as_deref())?;
let scoped = args.dir.is_some();
let (decks_dir, instance_store, recent_path) = match &args.dir {
None => (
config.decks_dir().context("cannot determine ~/decks")?,
None,
recent::default_recent_path().context("cannot determine the data directory")?,
),
Some(path) if path.is_file() => bail!(
"`alix <deck>` was removed — run `alix` and pick the deck there, \
or serve its folder: `alix {}`",
path.parent().unwrap_or_else(|| Path::new(".")).display()
),
Some(path) if !path.is_dir() => bail!("`{}` is not a folder", path.display()),
Some(path) => {
let store = if workspace::is_workspace(path) {
workspace::store_path(path)
} else {
path.join(workspace::STORE_FILE)
};
(path.clone(), Some(store), path.join("recent.json"))
}
};
let recent = RecentDecks::load(recent_path);
let store = open_store(instance_store.clone())?;
let addr = serve_addr(args.port, args.lan, &config);
let server = Arc::new(serve::bind(addr)?);
let pacing = assemble::Pacing {
max_new: args.new.or(config.review.max_new).unwrap_or(10),
limit: args.limit.or(config.review.limit),
};
let token = resolve_serve_token(args.token.clone(), args.lan, &config)?;
let pair = announce(addr, args.lan, token.as_deref(), &decks_dir);
let opts = serve::ReviewOptions {
keys: config.keys.clone(),
picker: config.picker.clone(),
browse: config.browse.clone(),
exam: config.exam.clone(),
ai: config.ai.clone(),
generate: config.generate.clone(),
audience: config.serve.audience,
auth: token,
config_path: args.config.clone(),
pair,
scoped,
cfg: assemble::AssembleConfig {
review: config.review,
ask: config.ask.clone(),
trace_auto_grade: config.trace.auto_grade,
pacing,
instance_store: instance_store.clone(),
},
};
serve::run_review(store, recent, decks_dir, server, opts)
}
fn announce(addr: SocketAddr, lan: bool, token: Option<&str>, root: &Path) -> serve::PairInfo {
let root = abbreviate_home(root);
let port = addr.port();
let lan_ip = if lan { local_lan_ip() } else { None };
let pair = match (token, lan_ip) {
(Some(t), Some(ip)) => serve::PairInfo {
url: format!("http://{ip}:{port}/?token={t}"),
lan: true,
},
_ => serve::PairInfo {
url: format!("http://127.0.0.1:{port}/"),
lan: false,
},
};
match (lan, token) {
(true, Some(t)) => match lan_ip {
Some(ip) => {
println!("Serving {root} at http://{ip}:{port}");
println!("On another device, open in a browser (or scan):");
println!(" {}", pair.url);
print_qr(&pair.url);
println!("Or pair the app with: host {ip} port {port} token {t}");
}
None => {
println!("Serving {root} on all interfaces, port {port}.");
println!("On another device, open in a browser:");
println!(" http://<this-machine's-IP>:{port}/?token={t}");
println!("Or pair the app with: host <this-machine's-IP> port {port} token {t}");
}
},
(true, None) => {
println!("Serving {root} on all interfaces, port {port}.");
println!("warning: no authentication — anyone on your network can reach this.");
}
(false, _) => {
println!("Serving {root} at http://127.0.0.1:{port} — open it in your browser.")
}
}
println!("Press Ctrl-C to stop.");
pair
}
fn abbreviate_home(path: &Path) -> String {
if let Some(dirs) = directories::BaseDirs::new()
&& let Ok(rest) = path.strip_prefix(dirs.home_dir())
{
return format!("~/{}", rest.display());
}
path.display().to_string()
}
#[cfg_attr(coverage_nightly, coverage(off))]
fn local_lan_ip() -> Option<std::net::IpAddr> {
let socket = std::net::UdpSocket::bind(("0.0.0.0", 0)).ok()?;
socket.connect(("8.8.8.8", 80)).ok()?;
Some(socket.local_addr().ok()?.ip())
}
#[cfg_attr(coverage_nightly, coverage(off))]
fn print_qr(text: &str) {
if let Some(q) = alix::qr::terminal_blocks(text) {
print!("{q}");
}
}
#[cfg(test)]
mod tests {
use std::path::PathBuf;
use super::*;
#[test]
fn serve_token_is_generated_only_when_exposed() {
let cfg = Config::default();
assert_eq!(resolve_serve_token(None, false, &cfg).unwrap(), None);
assert_eq!(
resolve_serve_token(Some("abc".into()), true, &cfg).unwrap(),
Some("abc".into())
);
assert!(
resolve_serve_token(None, true, &cfg)
.unwrap()
.is_some_and(|t| !t.is_empty())
);
}
#[test]
fn announce_local_only_returns_a_loopback_pair_regardless_of_token() {
let addr = SocketAddr::from((Ipv4Addr::LOCALHOST, 4321));
let root = Path::new("/tmp/does-not-need-to-exist");
let no_token = announce(addr, false, None, root);
assert_eq!(no_token.url, "http://127.0.0.1:4321/");
assert!(!no_token.lan);
let with_token = announce(addr, false, Some("abc"), root);
assert_eq!(with_token.url, "http://127.0.0.1:4321/");
assert!(!with_token.lan);
}
#[test]
fn abbreviate_home_prefixes_a_path_under_home_with_tilde() {
let Some(dirs) = directories::BaseDirs::new() else {
return;
};
let path = dirs.home_dir().join("decks").join("rust.txt");
let expected = format!("~/{}", Path::new("decks").join("rust.txt").display());
assert_eq!(abbreviate_home(&path), expected);
}
#[test]
fn abbreviate_home_leaves_a_path_outside_home_unchanged() {
let outside = PathBuf::from("/definitely-not-the-home-dir-xyz/decks/rust.txt");
if let Some(dirs) = directories::BaseDirs::new()
&& outside.starts_with(dirs.home_dir())
{
return;
}
assert_eq!(abbreviate_home(&outside), outside.display().to_string());
}
}