use std::collections::HashMap;
use iced::widget::{button, column, container, row, text};
use iced::{Element, Length, Subscription, Task, Theme, time, window};
use crate::tailscale::{self, ExitNode, NetcheckReport, PeerStatus, Prefs, Status};
use crate::tray;
use crate::ui;
#[derive(Debug, Default, Clone)]
pub struct TsState {
pub backend_state: String,
pub self_node: Option<PeerStatus>,
pub peers: Option<Vec<PeerStatus>>,
pub exit_nodes: Option<Vec<ExitNode>>,
pub ping_output: HashMap<String, String>,
pub pinging: Option<String>,
pub netcheck_report: Option<NetcheckReport>,
pub netcheck_running: bool,
pub prefs: Option<Prefs>,
pub install_status: Option<String>,
pub last_error: Option<String>,
}
#[derive(Debug, Clone, Default)]
pub enum Tab {
#[default]
Peers,
ExitNodes,
Netcheck,
Options,
}
pub struct App {
state: TsState,
tab: Tab,
window_visible: bool,
window_id: Option<window::Id>,
show_close_dialog: bool,
pending_close_id: Option<window::Id>,
}
#[derive(Debug, Clone)]
pub enum Message {
Refresh,
StatusLoaded(Result<Status, String>),
Connect,
Disconnect,
ConnectDone(Result<(), String>),
PingPeer(String),
PingDone(String, Result<String, String>),
CopyIp(String),
LoadExitNodes,
ExitNodesLoaded(Result<Vec<ExitNode>, String>),
SetExitNode(String),
ClearExitNode,
ExitNodeSet(Result<(), String>),
RunNetcheck,
NetcheckDone(Result<NetcheckReport, String>),
LoadPrefs,
PrefsLoaded(Result<Prefs, String>),
SetOption(String, bool),
OptionSet(Result<(), String>),
InstallToPath,
InstallDone(Result<(), String>),
SetTab(Tab),
WindowCloseRequest(window::Id),
HideToTray,
TrayEvent,
Tick,
Quit,
}
impl App {
pub fn new() -> (Self, Task<Message>) {
let (window_id, open_task) = window::open(window::Settings {
size: iced::Size::new(720.0, 520.0),
min_size: Some(iced::Size::new(500.0, 380.0)),
exit_on_close_request: false,
..Default::default()
});
(
Self {
state: TsState::default(),
tab: Tab::default(),
window_visible: true,
window_id: Some(window_id),
show_close_dialog: false,
pending_close_id: None,
},
Task::batch([
open_task.discard(),
Task::perform(tailscale::status(), Message::StatusLoaded),
]),
)
}
pub fn title(&self) -> String {
"Tailscale".to_string()
}
pub fn update(&mut self, message: Message) -> Task<Message> {
match message {
Message::Tick | Message::Refresh => {
Task::perform(tailscale::status(), Message::StatusLoaded)
}
Message::StatusLoaded(Ok(status)) => {
self.state.backend_state = status.backend_state.clone();
self.state.self_node = status.self_node;
self.state.peers = status.peer.map(|map| {
let mut peers: Vec<PeerStatus> = map.into_values().collect();
peers.sort_by(|a, b| {
b.online.cmp(&a.online).then(a.host_name.cmp(&b.host_name))
});
peers
});
self.state.last_error = None;
Task::none()
}
Message::StatusLoaded(Err(e)) => {
self.state.last_error = Some(e);
Task::none()
}
Message::Connect => {
Task::perform(tailscale::connect(), Message::ConnectDone)
}
Message::Disconnect => {
Task::perform(tailscale::disconnect(), Message::ConnectDone)
}
Message::ConnectDone(result) => {
if let Err(e) = result {
self.state.last_error = Some(e);
}
Task::perform(tailscale::status(), Message::StatusLoaded)
}
Message::PingPeer(ip) => {
self.state.pinging = Some(ip.clone());
let ip_clone = ip.clone();
Task::perform(
async move { tailscale::ping(&ip_clone).await },
move |r| Message::PingDone(ip.clone(), r),
)
}
Message::PingDone(ip, result) => {
self.state.pinging = None;
match result {
Ok(out) => {
let summary = out
.lines()
.filter(|l| l.contains("ms") || l.contains("pong"))
.take(3)
.collect::<Vec<_>>()
.join(" | ");
self.state.ping_output.insert(ip, summary);
}
Err(e) => {
self.state.ping_output.insert(ip, format!("Error: {e}"));
}
}
Task::none()
}
Message::CopyIp(ip) => {
iced::clipboard::write(ip)
}
Message::LoadExitNodes => {
Task::perform(tailscale::exit_nodes(), Message::ExitNodesLoaded)
}
Message::ExitNodesLoaded(result) => {
match result {
Ok(nodes) => self.state.exit_nodes = Some(nodes),
Err(e) => self.state.last_error = Some(e),
}
Task::none()
}
Message::SetExitNode(name) => {
Task::perform(
async move { tailscale::set_exit_node(&name).await },
Message::ExitNodeSet,
)
}
Message::ClearExitNode => {
Task::perform(tailscale::clear_exit_node(), Message::ExitNodeSet)
}
Message::ExitNodeSet(result) => {
if let Err(e) = result {
self.state.last_error = Some(e);
}
Task::perform(tailscale::exit_nodes(), Message::ExitNodesLoaded)
}
Message::RunNetcheck => {
self.state.netcheck_running = true;
Task::perform(tailscale::netcheck(), Message::NetcheckDone)
}
Message::NetcheckDone(result) => {
self.state.netcheck_running = false;
match result {
Ok(report) => self.state.netcheck_report = Some(report),
Err(e) => self.state.last_error = Some(e),
}
Task::none()
}
Message::LoadPrefs => {
Task::perform(tailscale::prefs(), Message::PrefsLoaded)
}
Message::PrefsLoaded(result) => {
match result {
Ok(p) => self.state.prefs = Some(p),
Err(e) => self.state.last_error = Some(e),
}
Task::none()
}
Message::SetOption(flag, value) => {
Task::perform(
async move { tailscale::set_bool(&flag, value).await },
Message::OptionSet,
)
}
Message::OptionSet(result) => {
if let Err(e) = result {
self.state.last_error = Some(e);
}
Task::perform(tailscale::prefs(), Message::PrefsLoaded)
}
Message::InstallToPath => {
Task::perform(
async { crate::install().map_err(|e| e.to_string()) },
Message::InstallDone,
)
}
Message::InstallDone(result) => {
match result {
Ok(()) => self.state.install_status = Some("Installed successfully.".into()),
Err(e) => self.state.install_status = Some(format!("Install failed: {e}")),
}
Task::none()
}
Message::SetTab(tab) => {
let load = matches!(tab, Tab::Options) && self.state.prefs.is_none();
self.tab = tab;
if load {
Task::perform(tailscale::prefs(), Message::PrefsLoaded)
} else {
Task::none()
}
}
Message::WindowCloseRequest(id) => {
self.show_close_dialog = true;
self.pending_close_id = Some(id);
Task::none()
}
Message::HideToTray => {
self.show_close_dialog = false;
if let Some(id) = self.pending_close_id.take() {
self.window_visible = false;
self.window_id = None;
window::close(id)
} else {
Task::none()
}
}
Message::Quit => {
return iced::exit();
}
Message::TrayEvent => {
while gtk::events_pending() {
gtk::main_iteration_do(false);
}
while let Some(id) = tray::try_recv() {
if Some(&id) == crate::QUIT_ID.get() {
return iced::exit();
}
if Some(&id) == crate::SHOW_ID.get() {
if self.window_visible {
if let Some(win_id) = self.window_id {
self.window_visible = false;
self.window_id = None;
return window::close(win_id);
}
} else {
let (new_id, task) = window::open(window::Settings {
size: iced::Size::new(720.0, 520.0),
min_size: Some(iced::Size::new(500.0, 380.0)),
exit_on_close_request: false,
..Default::default()
});
self.window_id = Some(new_id);
self.window_visible = true;
return task.discard();
}
}
}
Task::none()
}
}
}
pub fn view(&self, _window: window::Id) -> Element<'_, Message> {
let tab_bar = row![
tab_btn("Peers", matches!(self.tab, Tab::Peers), Message::SetTab(Tab::Peers)),
tab_btn("Exit Nodes", matches!(self.tab, Tab::ExitNodes), Message::SetTab(Tab::ExitNodes)),
tab_btn("Netcheck", matches!(self.tab, Tab::Netcheck), Message::SetTab(Tab::Netcheck)),
tab_btn("Options", matches!(self.tab, Tab::Options), Message::SetTab(Tab::Options)),
iced::widget::Space::new().width(Length::Fill),
button(text("Quit").size(13)).on_press(Message::Quit).style(button::danger),
]
.spacing(4)
.align_y(iced::Alignment::Center);
let content: Element<Message> = match self.tab {
Tab::Peers => ui::peers::view(&self.state),
Tab::ExitNodes => ui::exit_node::view(&self.state),
Tab::Netcheck => ui::netcheck::view(&self.state),
Tab::Options => ui::options::view(&self.state),
};
let main: Element<Message> = container(
column![
ui::dashboard::view(&self.state),
tab_bar,
container(content)
.width(Length::Fill)
.height(Length::Fill)
.style(|theme| {
let palette = iced::Theme::palette(theme);
iced::widget::container::Style {
background: Some(iced::Background::Color(palette.background)),
border: iced::Border {
color: iced::Color::from_rgb(0.85, 0.85, 0.85),
width: 1.0,
radius: 8.0.into(),
},
..Default::default()
}
}),
]
.spacing(8)
.padding(12),
)
.width(Length::Fill)
.height(Length::Fill)
.into();
if self.show_close_dialog {
let dialog: Element<Message> = container(
column![
text("Close Tuxscale?").size(15),
text("Keep it running in the system tray, or quit entirely.")
.size(12)
.color(iced::Color::from_rgb(0.6, 0.6, 0.6)),
row![
button(text("Minimize to Tray").size(13))
.on_press(Message::HideToTray)
.style(button::secondary),
button(text("Quit").size(13))
.on_press(Message::Quit)
.style(button::danger),
]
.spacing(8),
]
.spacing(12)
.padding(24),
)
.style(|theme: &Theme| {
let palette = theme.palette();
iced::widget::container::Style {
background: Some(iced::Background::Color(palette.background)),
border: iced::Border {
color: iced::Color::from_rgb(0.5, 0.5, 0.5),
width: 1.0,
radius: 8.0.into(),
},
..Default::default()
}
})
.into();
let backdrop: Element<Message> = container(dialog)
.center_x(Length::Fill)
.center_y(Length::Fill)
.style(|_| iced::widget::container::Style {
background: Some(iced::Background::Color(iced::Color::from_rgba(
0.0, 0.0, 0.0, 0.45,
))),
..Default::default()
})
.into();
iced::widget::stack([main, backdrop]).into()
} else {
main
}
}
pub fn subscription(&self) -> Subscription<Message> {
Subscription::batch([
time::every(std::time::Duration::from_secs(5)).map(|_| Message::Tick),
time::every(std::time::Duration::from_millis(100)).map(|_| Message::TrayEvent),
window::close_requests().map(Message::WindowCloseRequest),
])
}
pub fn theme(&self, _window: window::Id) -> Theme {
Theme::TokyoNight
}
}
fn tab_btn(label: &str, active: bool, msg: Message) -> Element<'_, Message> {
let btn = button(text(label).size(13)).on_press(msg);
if active {
btn.style(button::primary)
} else {
btn.style(button::secondary)
}
.into()
}