use std::collections::BTreeMap;
use std::fs;
use std::ops::ControlFlow::{self, Break, Continue};
use std::path::{Path, PathBuf};
use std::sync::mpsc::{self, Receiver};
use std::time::Duration;
use ratatui::crossterm::event::{self, Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers};
use serde::{Deserialize, Serialize};
use super::{Theme, stepped};
use crate::ports::{self, Listener};
mod view;
const FRAME: Duration = Duration::from_millis(100);
const RESCAN: Duration = Duration::from_secs(1);
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct Choice {
pub ports: Vec<u16>,
pub local: bool,
pub widget: bool,
pub calm: bool,
}
impl Choice {
pub fn command(&self) -> String {
let mut words = vec![program()];
words.extend(self.ports.iter().map(u16::to_string));
words.extend(self.local.then(|| "--local".to_string()));
words.extend((!self.widget).then(|| "--no-widget".to_string()));
words.extend(self.calm.then(|| "--calm".to_string()));
words.join(" ")
}
fn describe(&self) -> String {
let ports: Vec<String> = self.ports.iter().map(u16::to_string).collect();
let place = if self.local {
"on this machine"
} else {
"public link"
};
let mut words = vec![ports.join(", "), place.to_string()];
words.extend((!self.widget).then(|| "no feedback button".to_string()));
words.extend(self.calm.then(|| "no animations".to_string()));
words.join(" · ")
}
}
fn memory_file() -> Option<PathBuf> {
Some(crate::os::data_dir()?.join("home").join("last.json"))
}
fn memory() -> BTreeMap<String, Choice> {
memory_file()
.and_then(|path| fs::read(path).ok())
.and_then(|json| serde_json::from_slice(&json).ok())
.unwrap_or_default()
}
fn remember(here: &Path, choice: &Choice) {
let Some(path) = memory_file() else {
return;
};
let mut memory = memory();
memory.insert(here.to_string_lossy().into_owned(), choice.clone());
if let (Some(dir), Ok(json)) = (path.parent(), serde_json::to_vec_pretty(&memory)) {
let _ = fs::create_dir_all(dir).and_then(|_| fs::write(&path, json));
}
}
fn program() -> String {
std::env::args_os()
.next()
.as_deref()
.map(Path::new)
.and_then(Path::file_stem)
.map_or("bunflared".into(), |name| {
name.to_string_lossy().into_owned()
})
}
#[derive(Clone, Copy, PartialEq)]
enum Row {
Last,
Port(u16),
Widget,
Animations,
}
struct Home {
apps: Vec<Listener>,
here: PathBuf,
checked: Vec<u16>,
cursor: Row,
local: bool,
widget: bool,
calm: bool,
fresh: bool,
last: Option<Choice>,
}
pub fn run(theme: &Theme, start: Choice) -> Option<Choice> {
let here = std::env::current_dir().unwrap_or_default();
let last = memory().remove(here.to_string_lossy().as_ref());
let mut home = Home {
apps: Vec::new(),
checked: Vec::new(),
cursor: Row::Widget,
local: start.local || last.as_ref().is_some_and(|last| last.local),
widget: start.widget,
calm: start.calm,
fresh: true,
here,
last,
};
home.update(ports::scan());
if home
.last
.as_ref()
.is_some_and(|last| home.missing(last).is_empty() && (last.local || !start.local))
{
home.cursor = Row::Last;
}
let scans = watch();
let mut terminal = ratatui::init();
let result = loop {
if let Some(apps) = scans.try_iter().last() {
home.update(apps);
}
if terminal.draw(|frame| home.render(frame, theme)).is_err() {
break None;
}
match event::poll(FRAME) {
Err(_) => break None,
Ok(false) => continue,
Ok(true) => {}
}
if let Ok(Event::Key(key)) = event::read()
&& key.kind == KeyEventKind::Press
&& let Break(result) = home.key(key)
{
break result;
}
};
ratatui::restore();
if let Some(choice) = &result {
remember(&home.here, choice);
}
result
}
fn watch() -> Receiver<Vec<Listener>> {
let (tx, rx) = mpsc::channel();
std::thread::spawn(move || {
loop {
std::thread::sleep(RESCAN);
if tx.send(ports::scan()).is_err() {
break;
}
}
});
rx
}
impl Home {
fn update(&mut self, mut apps: Vec<Listener>) {
let here = &self.here;
apps.sort_by_key(|app| (distance(app, here), app.age.unwrap_or(u64::MAX), app.port));
self.apps = apps;
if self.fresh
&& let Some(first) = self.apps.first().map(|app| app.port)
{
self.checked.push(first);
self.fresh = false;
self.cursor = Row::Port(first);
}
if let Row::Port(port) = self.cursor
&& !self.listening(port)
{
self.cursor = self.rows()[0];
}
}
fn listening(&self, port: u16) -> bool {
self.apps.iter().any(|app| app.port == port)
}
fn missing(&self, choice: &Choice) -> Vec<String> {
let ports = choice.ports.iter().filter(|&&port| !self.listening(port));
ports.map(u16::to_string).collect()
}
fn ports(&self) -> Vec<u16> {
let ports = self.checked.iter().copied();
ports.filter(|&port| self.listening(port)).collect()
}
fn rows(&self) -> Vec<Row> {
let mut rows: Vec<Row> = self.last.iter().map(|_| Row::Last).collect();
rows.extend(self.apps.iter().map(|app| Row::Port(app.port)));
rows.extend([Row::Widget, Row::Animations]);
rows
}
fn pending(&self) -> Option<Choice> {
if self.cursor == Row::Last {
let last = self.last.as_ref();
return last.filter(|last| self.missing(last).is_empty()).cloned();
}
let ports = self.ports();
(!ports.is_empty()).then_some(Choice {
ports,
local: self.local,
widget: self.widget,
calm: self.calm,
})
}
fn key(&mut self, key: KeyEvent) -> ControlFlow<Option<Choice>> {
match key.code {
KeyCode::Char('q') | KeyCode::Esc => return Break(None),
KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => {
return Break(None);
}
KeyCode::Enter => {
if let Some(choice) = self.pending() {
return Break(Some(choice));
}
}
KeyCode::Up | KeyCode::Char('k') => self.step(false),
KeyCode::Down | KeyCode::Char('j') => self.step(true),
KeyCode::Tab | KeyCode::BackTab => {
if self.cursor == Row::Last {
self.cursor = self.rows()[1];
}
self.local = !self.local;
}
KeyCode::Char(' ') => self.toggle(),
_ => {}
}
Continue(())
}
fn step(&mut self, down: bool) {
if let Some(row) = stepped(&self.rows(), Some(&self.cursor), down) {
self.cursor = row;
}
}
fn toggle(&mut self) {
match self.cursor {
Row::Port(port) => {
match self.checked.iter().position(|&p| p == port) {
Some(at) => {
self.checked.remove(at);
}
None => self.checked.push(port),
}
self.fresh = false;
}
Row::Widget => self.widget = !self.widget,
Row::Animations => self.calm = !self.calm,
Row::Last => {}
}
}
}
fn distance(app: &Listener, here: &Path) -> u8 {
match app.folder.as_deref() {
Some(folder) if folder == here => 0,
Some(folder) if folder.starts_with(here) => 1,
_ => 2,
}
}