use std::io;
use std::time::{Duration, Instant};
use crossterm::{
event::{self, Event, KeyCode, KeyEventKind, KeyModifiers},
execute,
terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
};
use ratatui::{
backend::CrosstermBackend,
layout::{Alignment, Constraint, Direction, Layout},
style::{Color, Modifier, Style},
text::{Line, Span},
widgets::{Block, Borders, Paragraph},
Frame, Terminal,
};
use crate::credentials;
use crate::init::{decide, Decision};
pub const ENVS: &[(&str, &str)] = &[
("Production", credentials::PROD_API_URL),
("Staging", credentials::STAGING_API_URL),
];
pub struct DeviceStart {
pub device_code: String,
pub user_code: String,
pub verification_uri: String,
pub interval: u64,
pub expires_in: u64,
}
pub fn parse_device_start(body: &str) -> Option<DeviceStart> {
let v: serde_json::Value = serde_json::from_str(body).ok()?;
Some(DeviceStart {
device_code: v["device_code"].as_str()?.to_string(),
user_code: v["user_code"].as_str()?.to_string(),
verification_uri: v["verification_uri"].as_str()?.to_string(),
interval: v["interval"].as_u64().unwrap_or(5).clamp(1, 30),
expires_in: v["expires_in"].as_u64().unwrap_or(600),
})
}
pub fn device_start(api_url: &str) -> Result<DeviceStart, String> {
let url = format!("{}/api/auth/cli/start", api_url.trim_end_matches('/'));
let resp = ureq::post(&url)
.config()
.http_status_as_error(false)
.timeout_global(Some(Duration::from_secs(15)))
.build()
.send("")
.map_err(|e| format!("could not reach {api_url}: {e}"))?;
let status = resp.status().as_u16();
if status == 404 {
return Err(format!("browser sign-in isn't available on {api_url}"));
}
if status != 200 {
return Err(format!(
"sign-in could not start ({api_url}: HTTP {status})"
));
}
let body = resp
.into_body()
.read_to_string()
.map_err(|e| e.to_string())?;
parse_device_start(&body).ok_or_else(|| "malformed start response".into())
}
pub fn device_poll(api_url: &str, device_code: &str) -> Decision {
let url = format!("{}/api/auth/cli/poll", api_url.trim_end_matches('/'));
let payload = serde_json::json!({ "device_code": device_code }).to_string();
match ureq::post(&url)
.config()
.http_status_as_error(false)
.timeout_global(Some(Duration::from_secs(15)))
.build()
.header("Content-Type", "application/json")
.send(payload.as_str())
{
Ok(r) => {
let s = r.status().as_u16();
let b = r.into_body().read_to_string().unwrap_or_default();
decide(s, &b)
}
Err(_) => Decision::Pending,
}
}
enum Step {
ChooseEnv,
Await {
user_code: String,
device_code: String,
verification_uri: String,
deadline: Instant,
next_poll: Instant,
},
Connected {
summary: String,
},
Failed(String),
}
struct Wizard {
step: Step,
env_idx: usize,
notice: Option<String>,
pending: Option<Pending>,
quit: bool,
connected: bool,
}
impl Wizard {
fn new() -> Self {
Self {
step: Step::ChooseEnv,
env_idx: 0,
notice: None,
pending: None,
quit: false,
connected: false,
}
}
fn api_url(&self) -> &'static str {
ENVS[self.env_idx].1
}
}
pub fn run() -> bool {
use std::io::IsTerminal;
if !io::stdout().is_terminal() {
crate::init::print_connect_guidance();
return false;
}
match run_inner() {
Ok(connected) => connected,
Err(e) => {
eprintln!("onboarding error: {e}");
false
}
}
}
fn run_inner() -> io::Result<bool> {
let default_panic = std::panic::take_hook();
std::panic::set_hook(Box::new(move |info| {
let _ = disable_raw_mode();
let _ = execute!(io::stdout(), LeaveAlternateScreen);
default_panic(info);
}));
enable_raw_mode()?;
let mut stdout = io::stdout();
execute!(stdout, EnterAlternateScreen)?;
let mut terminal = Terminal::new(CrosstermBackend::new(stdout))?;
let mut w = Wizard::new();
while !w.quit {
terminal.draw(|f| draw(f, &w))?;
if let Some(action) = w.pending.take() {
action.run(&mut w);
continue;
}
if let Step::Await {
device_code,
deadline,
next_poll,
..
} = &w.step
{
let now = Instant::now();
if now >= *deadline {
w.step = Step::Failed("the code expired — press Enter to try again".into());
continue;
}
if now >= *next_poll {
let url = w.api_url();
let dc = device_code.clone();
match device_poll(url, &dc) {
Decision::Pending => {
if let Step::Await { next_poll, .. } = &mut w.step {
*next_poll = Instant::now() + Duration::from_secs(5);
}
}
Decision::Approved(token) => {
let _ = credentials::save(&token, Some(url));
std::env::set_var("ZAKURO_API_KEY", &token);
std::env::set_var("ZAKURO_API_URL", url);
w.notice = Some("Approved — connecting to the mesh…".into());
w.pending = Some(Pending::Connect);
}
Decision::Failed(msg) => {
w.step = Step::Failed(msg);
}
}
continue;
}
}
if !event::poll(Duration::from_millis(120))? {
continue;
}
if let Event::Key(k) = event::read()? {
if k.kind == KeyEventKind::Press {
handle_key(&mut w, k.code, k.modifiers);
}
}
}
disable_raw_mode()?;
execute!(terminal.backend_mut(), LeaveAlternateScreen)?;
terminal.show_cursor()?;
if w.connected {
if let Step::Connected { summary } = &w.step {
println!(" ✓ Signed in · {summary}");
}
}
Ok(w.connected)
}
enum Pending {
Start,
Connect,
}
impl Pending {
fn run(self, w: &mut Wizard) {
match self {
Pending::Start => match device_start(w.api_url()) {
Ok(ds) => {
let _ = crate::init::open_browser(&ds.verification_uri);
w.notice = Some("Opened the dashboard — approve the code there.".into());
w.step = Step::Await {
user_code: ds.user_code,
device_code: ds.device_code,
verification_uri: ds.verification_uri,
deadline: Instant::now() + Duration::from_secs(ds.expires_in),
next_poll: Instant::now() + Duration::from_secs(ds.interval),
};
}
Err(e) => w.step = Step::Failed(e),
},
Pending::Connect => {
match crate::vpn::connect(crate::vpn::connector::Preference::Auto) {
Ok(info) => {
w.connected = true;
w.step = Step::Connected {
summary: format!(
"mesh up — {} · {} peer(s)",
info.address,
info.peers.len()
),
};
w.notice = None;
}
Err(e) => {
w.step = Step::Failed(format!("{e}"));
w.notice = None;
}
}
}
}
}
}
fn handle_key(w: &mut Wizard, code: KeyCode, mods: KeyModifiers) {
if code == KeyCode::Esc || (code == KeyCode::Char('c') && mods.contains(KeyModifiers::CONTROL))
{
w.quit = true;
return;
}
match &w.step {
Step::ChooseEnv => match code {
KeyCode::Up | KeyCode::Char('k') => {
w.env_idx = (w.env_idx + ENVS.len() - 1) % ENVS.len()
}
KeyCode::Down | KeyCode::Char('j') => w.env_idx = (w.env_idx + 1) % ENVS.len(),
KeyCode::Char('q') => w.quit = true,
KeyCode::Enter => {
w.notice = Some("Starting browser sign-in…".into());
w.pending = Some(Pending::Start);
}
_ => {}
},
Step::Await {
verification_uri, ..
} => {
if code == KeyCode::Char('o') {
let _ = crate::init::open_browser(verification_uri);
}
}
Step::Connected { .. } => w.quit = true,
Step::Failed(_) => match code {
KeyCode::Enter | KeyCode::Char('r') => {
w.step = Step::ChooseEnv;
w.notice = None;
}
_ => w.quit = true,
},
}
}
fn draw(f: &mut Frame, w: &Wizard) {
let area = f.size();
let accent = Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD);
let dim = Style::default().fg(Color::DarkGray);
let ok = Style::default().fg(Color::Green);
let err = Style::default().fg(Color::Red);
let chunks = Layout::default()
.direction(Direction::Vertical)
.margin(1)
.constraints([
Constraint::Length(3),
Constraint::Min(6),
Constraint::Length(2),
Constraint::Length(1),
])
.split(area);
let step_no = match w.step {
Step::ChooseEnv => "Step 1/2",
Step::Await { .. } => "Step 2/2",
Step::Connected { .. } => "Done",
Step::Failed(_) => "Problem",
};
f.render_widget(
Paragraph::new(Line::from(vec![
Span::styled(" Connect to Zakuro ", accent),
Span::styled(format!("· {step_no}"), dim),
]))
.block(Block::default().borders(Borders::ALL).border_style(accent)),
chunks[0],
);
let body: Vec<Line> = match &w.step {
Step::ChooseEnv => {
let mut lines = vec![Line::from("Choose your environment:"), Line::from("")];
for (i, (name, url)) in ENVS.iter().enumerate() {
let sel = i == w.env_idx;
lines.push(Line::from(vec![
Span::styled(if sel { " › " } else { " " }, accent),
Span::styled(
format!("{name} "),
if sel { accent } else { Style::default() },
),
Span::styled(format!("({url})"), dim),
]));
}
lines
}
Step::Await {
user_code,
verification_uri,
..
} => vec![
Line::from(vec![
Span::raw("Environment: "),
Span::styled(ENVS[w.env_idx].0, accent),
]),
Line::from(""),
Line::from("In the browser tab that opened, enter this code and approve:"),
Line::from(""),
Line::from(Span::styled(format!(" {user_code}"), accent)),
Line::from(""),
Line::from(vec![
Span::styled(" ", dim),
Span::styled(verification_uri.clone(), dim),
]),
Line::from(""),
Line::from(Span::styled(" Waiting for approval…", dim)),
],
Step::Connected { summary } => vec![
Line::from(vec![Span::styled(" ✓ ", ok), Span::raw(summary.clone())]),
Line::from(""),
Line::from("You're on the mesh. Press any key to open the zc terminal."),
],
Step::Failed(msg) => vec![
Line::from(vec![Span::styled(" ✗ ", err), Span::raw(msg.clone())]),
Line::from(""),
Line::from("Press Enter to start over, or Esc to quit."),
],
};
f.render_widget(
Paragraph::new(body).block(Block::default().borders(Borders::ALL)),
chunks[1],
);
let notice = w.notice.clone().unwrap_or_default();
f.render_widget(
Paragraph::new(Line::from(Span::styled(notice, dim))),
chunks[2],
);
let hint = match w.step {
Step::ChooseEnv => "↑/↓ select · ⏎ sign in · q quit",
Step::Await { .. } => "o re-open browser · Esc cancel",
Step::Connected { .. } => "press any key to continue",
Step::Failed(_) => "⏎ start over · Esc quit",
};
f.render_widget(
Paragraph::new(Line::from(Span::styled(hint, dim))).alignment(Alignment::Center),
chunks[3],
);
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_a_full_start_response() {
let body = r#"{"device_code":"abc","user_code":"WXYZ-1234","verification_uri":"https://x/activate","interval":5,"expires_in":600}"#;
let ds = parse_device_start(body).expect("parse");
assert_eq!(ds.user_code, "WXYZ-1234");
assert_eq!(ds.device_code, "abc");
assert_eq!(ds.interval, 5);
}
#[test]
fn start_defaults_interval_and_expiry() {
let body = r#"{"device_code":"d","user_code":"c","verification_uri":"u"}"#;
let ds = parse_device_start(body).expect("parse");
assert_eq!(ds.interval, 5);
assert_eq!(ds.expires_in, 600);
}
#[test]
fn start_none_on_missing_fields() {
assert!(parse_device_start(r#"{"user_code":"c"}"#).is_none());
assert!(parse_device_start("garbage").is_none());
}
#[test]
fn envs_are_prod_then_staging() {
assert_eq!(ENVS[0].1, credentials::PROD_API_URL);
assert_eq!(ENVS[1].1, credentials::STAGING_API_URL);
}
}