use std::{
net::{Ipv4Addr, SocketAddr},
path::Path,
sync::Arc,
};
use alix::{
assemble, config::Config, recent::RecentDecks, serve, state::UserFiles, tutorial, 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)
}
pub(crate) 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())
}
fn log_settings(config: &Config, targets: &[alix::log::Target]) -> alix::log::Settings {
alix::log::Settings {
max_bytes: config.log.max_bytes,
verbose: config.log.verbose || !targets.is_empty(),
stderr: alix::log::Targets::from_slice(targets),
}
}
pub(crate) fn launch(args: LaunchArgs, instance: &str) -> Result<()> {
let config = Config::load(args.config.as_deref())?;
let logging = log_settings(&config, &args.log);
if let Err(error) = alix::log::init(instance, logging) {
eprintln!("warning: could not open the server log: {error}");
}
let log_path = alix::log::log_path(instance);
let scoped = args.dir.is_some();
let (decks_dir, user_root) = match &args.dir {
None => {
let dir = config.decks_dir().context("cannot determine ~/decks")?;
tutorial::seed_new_decks_dir(&dir);
let user_root = workspace::root_store_path(&dir);
(dir, user_root)
}
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) => (path.clone(), workspace::root_store_path(path)),
};
let recent = RecentDecks::load(UserFiles::new(&user_root).recent());
let instance_store = Some(user_root);
let store = assemble::open_store_tolerant(instance_store.clone())?;
let addr = serve_addr(args.port, args.lan, &config);
let server = Arc::new(serve::bind(addr)?);
let addr = server.server_addr().to_ip().unwrap_or(addr);
let stopper = Arc::clone(&server);
ctrlc::set_handler(move || stopper.unblock()).context("cannot install the shutdown handler")?;
let pacing = assemble::Pacing {
max_session: args
.session
.or(config.review.max_session)
.unwrap_or(alix::session::DEFAULT_MAX_SESSION),
new_cards_percent: config
.review
.new_cards_percent
.unwrap_or(alix::session::DEFAULT_NEW_CARDS_PERCENT),
};
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(),
log_path,
pair,
scoped,
cfg: assemble::AssembleConfig {
review: config.review,
ask: config.ask.clone(),
pacing,
instance_store: instance_store.clone(),
},
};
serve::run_review(store, recent, decks_dir, server, opts)
}
macro_rules! say {
($($arg:tt)*) => {{
use std::io::Write;
let _ = writeln!(std::io::stdout(), $($arg)*);
}};
}
fn announce(addr: SocketAddr, lan: bool, token: Option<&str>, root: &Path) -> serve::PairInfo {
announce_with(addr, lan, lan.then(local_lan_ip).flatten(), token, root)
}
fn announce_with(
addr: SocketAddr,
lan: bool,
lan_ip: Option<std::net::IpAddr>,
token: Option<&str>,
root: &Path,
) -> serve::PairInfo {
let root = abbreviate_home(root);
let port = addr.port();
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) => {
say!("Serving {root} at http://{ip}:{port}");
say!("On another device, open in a browser (or scan):");
say!(" {}", pair.url);
print_qr(&pair.url);
say!("Or pair the app with: host {ip} port {port} token {t}");
}
None => {
say!("Serving {root} on all interfaces, port {port}.");
say!("On another device, open in a browser:");
say!(" http://<this-machine's-IP>:{port}/?token={t}");
say!("Or pair the app with: host <this-machine's-IP> port {port} token {t}");
}
},
(true, None) => {
say!("Serving {root} on all interfaces, port {port}.");
say!("warning: no authentication — anyone on your network can reach this.");
}
(false, _) => {
say!("Serving {root} at http://127.0.0.1:{port}. Open it in your browser.")
}
}
say!("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))]
pub(crate) 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) {
use std::io::Write;
let _ = write!(std::io::stdout(), "{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 generated_tokens_are_distinct_128_bit_lowercase_hex() {
let first = generate_token().unwrap();
let second = generate_token().unwrap();
for token in [&first, &second] {
assert_eq!(32, token.len(), "{token}");
assert!(
token
.chars()
.all(|ch| ch.is_ascii_digit() || ('a'..='f').contains(&ch)),
"{token}"
);
}
assert_ne!(first, second);
}
#[test]
fn naming_a_live_log_target_also_enables_verbose_file_records() {
let config = Config::default();
assert!(!log_settings(&config, &[]).verbose);
assert!(log_settings(&config, &[alix::log::Target::Http]).verbose);
}
#[test]
fn qr_output_child() {
if std::env::var_os("ALIX_QR_OUTPUT_CHILD").is_none() {
return;
}
print_qr("http://127.0.0.1:4321/?token=0123456789abcdef");
}
#[test]
fn print_qr_emits_the_terminal_rendering() {
let output = std::process::Command::new(std::env::current_exe().unwrap())
.args(["--exact", "launch::tests::qr_output_child", "--nocapture"])
.env("ALIX_QR_OUTPUT_CHILD", "1")
.output()
.unwrap();
assert!(output.status.success(), "{output:?}");
let stdout = String::from_utf8(output.stdout).unwrap();
assert!(stdout.contains('█'), "{stdout}");
}
#[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.md");
let expected = format!("~/{}", Path::new("decks").join("rust.md").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.md");
if let Some(dirs) = directories::BaseDirs::new()
&& outside.starts_with(dirs.home_dir())
{
return;
}
assert_eq!(abbreviate_home(&outside), outside.display().to_string());
}
#[test]
fn the_pairing_url_follows_the_supplied_host_address() {
use std::net::{IpAddr, Ipv4Addr, SocketAddr};
let addr = SocketAddr::from(([127, 0, 0, 1], 7780));
let root = std::path::Path::new("/tmp/decks");
let lan_ip = Some(IpAddr::V4(Ipv4Addr::new(192, 168, 1, 42)));
let paired = announce_with(addr, true, lan_ip, Some("tok"), root);
assert_eq!("http://192.168.1.42:7780/?token=tok", paired.url);
assert!(paired.lan);
let unresolved = announce_with(addr, true, None, Some("tok"), root);
assert_eq!("http://127.0.0.1:7780/", unresolved.url);
assert!(!unresolved.lan);
let tokenless = announce_with(addr, true, lan_ip, None, root);
assert_eq!("http://127.0.0.1:7780/", tokenless.url);
assert!(!tokenless.lan);
}
}