From 97c569fb35688a5b89f0a20f938a7bb6d1afe8d7 Mon Sep 17 00:00:00 2001 From: Conrad Kramer Date: Sun, 3 May 2026 17:36:55 -0700 Subject: [PATCH] Align GTK app with Apple home surface Add the GTK home screen, local account store, daemon gRPC wrapper, and embedded Linux daemon startup path so the Linux app follows the Apple client UX and daemon boundary. Document the GTK parity expectations and update the daemon IPC and Tailnet BEPs with the cross-platform client model. --- burrow-gtk/Cargo.toml | 2 + burrow-gtk/src/account_store.rs | 139 ++ burrow-gtk/src/components/app.rs | 139 +- burrow-gtk/src/components/home_screen.rs | 1178 +++++++++++++++++ burrow-gtk/src/components/mod.rs | 10 +- burrow-gtk/src/daemon_api.rs | 420 ++++++ burrow-gtk/src/main.rs | 6 +- burrow/src/daemon/apple.rs | 39 +- burrow/src/lib.rs | 8 +- docs/GTK_APP.md | 22 +- .../BEP-0005-daemon-ipc-and-apple-boundary.md | 3 + ...6-tailnet-authority-first-control-plane.md | 5 +- 12 files changed, 1861 insertions(+), 110 deletions(-) create mode 100644 burrow-gtk/src/account_store.rs create mode 100644 burrow-gtk/src/components/home_screen.rs create mode 100644 burrow-gtk/src/daemon_api.rs diff --git a/burrow-gtk/Cargo.toml b/burrow-gtk/Cargo.toml index 21cb52e..b12577a 100644 --- a/burrow-gtk/Cargo.toml +++ b/burrow-gtk/Cargo.toml @@ -11,6 +11,8 @@ relm4 = { version = "0.6", features = ["libadwaita", "gnome_44"]} burrow = { version = "*", path = "../burrow/" } tokio = { version = "1.35.0", features = ["time", "sync"] } gettext-rs = { version = "0.7.0", features = ["gettext-system"] } +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" [build-dependencies] anyhow = "1.0" diff --git a/burrow-gtk/src/account_store.rs b/burrow-gtk/src/account_store.rs new file mode 100644 index 0000000..6aee78b --- /dev/null +++ b/burrow-gtk/src/account_store.rs @@ -0,0 +1,139 @@ +use anyhow::{Context, Result}; +use serde::{Deserialize, Serialize}; +use std::{ + path::PathBuf, + time::{SystemTime, UNIX_EPOCH}, +}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AccountRecord { + pub id: String, + pub kind: AccountKind, + pub title: String, + pub authority: Option, + pub account: String, + pub identity: String, + pub hostname: Option, + pub tailnet: Option, + pub note: Option, + pub created_at: u64, + pub updated_at: u64, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum AccountKind { + WireGuard, + Tor, + Tailnet, +} + +impl AccountKind { + pub fn title(self) -> &'static str { + match self { + Self::WireGuard => "WireGuard", + Self::Tor => "Tor", + Self::Tailnet => "Tailnet", + } + } + + fn sort_rank(self) -> u8 { + match self { + Self::Tailnet => 0, + Self::Tor => 1, + Self::WireGuard => 2, + } + } +} + +pub fn load() -> Result> { + let path = storage_path()?; + if !path.exists() { + return Ok(Vec::new()); + } + let data = + std::fs::read(&path).with_context(|| format!("failed to read {}", path.display()))?; + serde_json::from_slice(&data).with_context(|| format!("failed to parse {}", path.display())) +} + +pub fn upsert(mut record: AccountRecord) -> Result> { + let mut accounts = load()?; + let now = timestamp(); + record.updated_at = now; + if record.created_at == 0 { + record.created_at = now; + } + + if let Some(index) = accounts.iter().position(|account| account.id == record.id) { + accounts[index] = record; + } else { + accounts.push(record); + } + accounts.sort_by(|lhs, rhs| { + lhs.kind + .sort_rank() + .cmp(&rhs.kind.sort_rank()) + .then_with(|| lhs.title.to_lowercase().cmp(&rhs.title.to_lowercase())) + }); + persist(&accounts)?; + Ok(accounts) +} + +pub fn new_record( + kind: AccountKind, + title: String, + authority: Option, + account: String, + identity: String, + hostname: Option, + tailnet: Option, + note: Option, +) -> AccountRecord { + let now = timestamp(); + AccountRecord { + id: format!("{}-{now}", kind.title().to_ascii_lowercase()), + kind, + title, + authority, + account, + identity, + hostname, + tailnet, + note, + created_at: now, + updated_at: now, + } +} + +fn persist(accounts: &[AccountRecord]) -> Result<()> { + let path = storage_path()?; + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent) + .with_context(|| format!("failed to create {}", parent.display()))?; + } + let data = serde_json::to_vec_pretty(accounts).context("failed to encode account store")?; + std::fs::write(&path, data).with_context(|| format!("failed to write {}", path.display())) +} + +fn storage_path() -> Result { + if let Some(data_home) = std::env::var_os("XDG_DATA_HOME") { + return Ok(PathBuf::from(data_home) + .join("burrow") + .join("accounts.json")); + } + if let Some(home) = std::env::var_os("HOME") { + return Ok(PathBuf::from(home) + .join(".local") + .join("share") + .join("burrow") + .join("accounts.json")); + } + Ok(std::env::temp_dir().join("burrow-accounts.json")) +} + +fn timestamp() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|duration| duration.as_secs()) + .unwrap_or_default() +} diff --git a/burrow-gtk/src/components/app.rs b/burrow-gtk/src/components/app.rs index 62c98c0..7354825 100644 --- a/burrow-gtk/src/components/app.rs +++ b/burrow-gtk/src/components/app.rs @@ -1,24 +1,19 @@ use super::*; use anyhow::Context; -use std::time::Duration; - -const RECONNECT_POLL_TIME: Duration = Duration::from_secs(5); pub struct App { - daemon_client: Arc>>, - settings_screen: Controller, - switch_screen: AsyncController, + _home_screen: AsyncController, } #[derive(Debug)] pub enum AppMsg { None, - PostInit, } impl App { pub fn run() { let app = RelmApp::new(config::ID); + relm4::set_global_css(APP_CSS); Self::setup_gresources().unwrap(); Self::setup_i18n().unwrap(); @@ -49,7 +44,7 @@ impl AsyncComponent for App { view! { adw::Window { set_title: Some("Burrow"), - set_default_size: (640, 480), + set_default_size: (900, 760), } } @@ -58,100 +53,84 @@ impl AsyncComponent for App { root: Self::Root, sender: AsyncComponentSender, ) -> AsyncComponentParts { - let daemon_client = Arc::new(Mutex::new(DaemonClient::new().await.ok())); - - let switch_screen = switch_screen::SwitchScreen::builder() - .launch(switch_screen::SwitchScreenInit { - daemon_client: Arc::clone(&daemon_client), - }) - .forward(sender.input_sender(), |_| AppMsg::None); - - let settings_screen = settings_screen::SettingsScreen::builder() - .launch(settings_screen::SettingsScreenInit { - daemon_client: Arc::clone(&daemon_client), - }) + let home_screen = home_screen::HomeScreen::builder() + .launch(()) .forward(sender.input_sender(), |_| AppMsg::None); let widgets = view_output!(); - let view_stack = adw::ViewStack::new(); - view_stack.add_titled(switch_screen.widget(), None, "Switch"); - view_stack.add_titled(settings_screen.widget(), None, "Settings"); - - let view_switcher_bar = adw::ViewSwitcherBar::builder().stack(&view_stack).build(); - view_switcher_bar.set_reveal(true); - - // When libadwaita 1.4 support becomes more avaliable, this approach is more appropriate - // - // let toolbar = adw::ToolbarView::new(); - // toolbar.add_top_bar( - // &adw::HeaderBar::builder() - // .title_widget(>k::Label::new(Some("Burrow"))) - // .build(), - // ); - // toolbar.add_bottom_bar(&view_switcher_bar); - // toolbar.set_content(Some(&view_stack)); - // root.set_content(Some(&toolbar)); - let content = gtk::Box::new(gtk::Orientation::Vertical, 0); content.append( &adw::HeaderBar::builder() .title_widget(>k::Label::new(Some("Burrow"))) .build(), ); - content.append(&view_stack); - content.append(&view_switcher_bar); + content.append(home_screen.widget()); root.set_content(Some(&content)); - sender.input(AppMsg::PostInit); - - let model = App { - daemon_client, - switch_screen, - settings_screen, - }; + let model = App { _home_screen: home_screen }; AsyncComponentParts { model, widgets } } async fn update( &mut self, - _msg: Self::Input, + msg: Self::Input, _sender: AsyncComponentSender, _root: &Self::Root, ) { - loop { - tokio::time::sleep(RECONNECT_POLL_TIME).await; - { - let mut daemon_client = self.daemon_client.lock().await; - let mut disconnected_daemon_client = false; - - if let Some(daemon_client) = daemon_client.as_mut() { - if let Err(_e) = daemon_client.send_command(DaemonCommand::ServerInfo).await { - disconnected_daemon_client = true; - self.switch_screen - .emit(switch_screen::SwitchScreenMsg::DaemonDisconnect); - self.settings_screen - .emit(settings_screen::SettingsScreenMsg::DaemonStateChange) - } - } - - if disconnected_daemon_client || daemon_client.is_none() { - match DaemonClient::new().await { - Ok(new_daemon_client) => { - *daemon_client = Some(new_daemon_client); - self.switch_screen - .emit(switch_screen::SwitchScreenMsg::DaemonReconnect); - self.settings_screen - .emit(settings_screen::SettingsScreenMsg::DaemonStateChange) - } - Err(_e) => { - // TODO: Handle Error - } - } - } - } + match msg { + AppMsg::None => {} } } } + +const APP_CSS: &str = r#" +.empty-state { + border-radius: 18px; + padding: 22px; + background: alpha(@card_bg_color, 0.72); +} + +.summary-card { + border-radius: 18px; + padding: 14px; + background: alpha(@card_bg_color, 0.72); +} + +.network-card { + border-radius: 10px; + padding: 16px; + box-shadow: 0 2px 6px alpha(black, 0.14); +} + +.wireguard-card { + background: linear-gradient(135deg, #3277d8, #174ea6); +} + +.tailnet-card { + background: linear-gradient(135deg, #31b891, #147d69); +} + +.network-card-kind, +.network-card-title, +.network-card-detail { + color: white; +} + +.network-card-kind { + opacity: 0.86; + font-weight: 700; +} + +.network-card-title { + font-size: 1.22em; + font-weight: 700; +} + +.network-card-detail { + opacity: 0.92; + font-family: monospace; +} +"#; diff --git a/burrow-gtk/src/components/home_screen.rs b/burrow-gtk/src/components/home_screen.rs new file mode 100644 index 0000000..0bfdda2 --- /dev/null +++ b/burrow-gtk/src/components/home_screen.rs @@ -0,0 +1,1178 @@ +use super::*; +use crate::account_store::{self, AccountKind, AccountRecord}; +use std::time::Duration; + +pub struct HomeScreen { + daemon_banner: adw::Banner, + network_status: gtk::Label, + network_cards: gtk::Box, + account_status: gtk::Label, + account_rows: gtk::Box, + tunnel_status: gtk::Label, + tunnel_button: gtk::Button, + tunnel_state: Option, + tailnet_session_id: Option, + tailnet_running: bool, +} + +#[derive(Debug)] +pub enum HomeScreenMsg { + EnsureDaemon, + Refresh, + TunnelAction, + OpenWireGuard, + OpenTor, + OpenTailnet, + AddWireGuard { + title: String, + account: String, + identity: String, + config: String, + }, + SaveTor { + title: String, + account: String, + identity: String, + note: String, + }, + DiscoverTailnet(String), + ProbeTailnet(String), + StartTailnetLogin { + authority: String, + account: String, + identity: String, + hostname: Option, + }, + PollTailnetLogin, + CancelTailnetLogin, + AddTailnet { + authority: String, + account: String, + identity: String, + hostname: Option, + tailnet: Option, + }, +} + +#[relm4::component(pub, async)] +impl AsyncComponent for HomeScreen { + type Init = (); + type Input = HomeScreenMsg; + type Output = (); + type CommandOutput = (); + + view! { + gtk::ScrolledWindow { + set_vexpand: true, + + adw::Clamp { + set_maximum_size: 900, + + gtk::Box { + set_orientation: gtk::Orientation::Vertical, + set_spacing: 24, + set_margin_all: 24, + + gtk::Box { + set_orientation: gtk::Orientation::Horizontal, + set_spacing: 16, + + gtk::Box { + set_orientation: gtk::Orientation::Vertical, + set_spacing: 6, + set_hexpand: true, + + gtk::Label { + add_css_class: "title-1", + set_xalign: 0.0, + set_label: "Burrow", + }, + + gtk::Label { + add_css_class: "heading", + add_css_class: "dim-label", + set_xalign: 0.0, + set_label: "Networks and accounts", + }, + }, + + #[name(add_button)] + gtk::MenuButton { + add_css_class: "flat", + set_icon_name: "list-add-symbolic", + set_tooltip_text: Some("Add"), + set_valign: Align::Start, + }, + }, + + #[name(daemon_banner)] + adw::Banner { + set_title: "Starting Burrow daemon", + set_revealed: false, + }, + + gtk::Box { + set_orientation: gtk::Orientation::Vertical, + set_spacing: 12, + + gtk::Box { + set_orientation: gtk::Orientation::Vertical, + set_spacing: 4, + + gtk::Label { + add_css_class: "title-2", + set_xalign: 0.0, + set_label: "Networks", + }, + + #[name(network_status)] + gtk::Label { + add_css_class: "dim-label", + set_xalign: 0.0, + set_wrap: true, + set_label: "Stored daemon networks and their active account selectors", + }, + }, + + gtk::ScrolledWindow { + set_policy: (gtk::PolicyType::Automatic, gtk::PolicyType::Never), + set_min_content_height: 190, + + #[name(network_cards)] + gtk::Box { + set_orientation: gtk::Orientation::Horizontal, + set_spacing: 14, + }, + }, + }, + + gtk::Box { + set_orientation: gtk::Orientation::Vertical, + set_spacing: 12, + + gtk::Box { + set_orientation: gtk::Orientation::Vertical, + set_spacing: 4, + + gtk::Label { + add_css_class: "title-2", + set_xalign: 0.0, + set_label: "Accounts", + }, + + gtk::Label { + add_css_class: "dim-label", + set_xalign: 0.0, + set_wrap: true, + set_label: "Per-network identities and sign-in state", + }, + }, + + #[name(account_rows)] + gtk::Box { + set_orientation: gtk::Orientation::Vertical, + set_spacing: 8, + set_margin_all: 0, + set_valign: Align::Center, + }, + + #[name(account_status)] + gtk::Label { + add_css_class: "dim-label", + set_xalign: 0.0, + set_wrap: true, + set_label: "", + }, + }, + + gtk::Box { + set_orientation: gtk::Orientation::Vertical, + set_spacing: 8, + + gtk::Box { + set_orientation: gtk::Orientation::Vertical, + set_spacing: 4, + + gtk::Label { + add_css_class: "title-2", + set_xalign: 0.0, + set_label: "Tunnel", + }, + + gtk::Label { + add_css_class: "dim-label", + set_xalign: 0.0, + set_label: "Current daemon tunnel state", + }, + }, + + #[name(tunnel_status)] + gtk::Label { + set_xalign: 0.0, + set_label: "Checking daemon status", + }, + + #[name(tunnel_button)] + gtk::Button { + add_css_class: "suggested-action", + set_label: "Start", + set_halign: Align::Start, + connect_clicked => HomeScreenMsg::TunnelAction, + }, + }, + } + } + } + } + + async fn init( + _: Self::Init, + _root: Self::Root, + sender: AsyncComponentSender, + ) -> AsyncComponentParts { + let widgets = view_output!(); + configure_add_popover(&widgets.add_button, &sender); + + let refresh_sender = sender.input_sender().clone(); + relm4::spawn(async move { + loop { + tokio::time::sleep(Duration::from_secs(5)).await; + refresh_sender.emit(HomeScreenMsg::Refresh); + } + }); + + let model = HomeScreen { + daemon_banner: widgets.daemon_banner.clone(), + network_status: widgets.network_status.clone(), + network_cards: widgets.network_cards.clone(), + account_status: widgets.account_status.clone(), + account_rows: widgets.account_rows.clone(), + tunnel_status: widgets.tunnel_status.clone(), + tunnel_button: widgets.tunnel_button.clone(), + tunnel_state: None, + tailnet_session_id: None, + tailnet_running: false, + }; + + sender.input(HomeScreenMsg::EnsureDaemon); + + AsyncComponentParts { model, widgets } + } + + async fn update( + &mut self, + msg: Self::Input, + sender: AsyncComponentSender, + root: &Self::Root, + ) { + match msg { + HomeScreenMsg::EnsureDaemon => self.ensure_daemon().await, + HomeScreenMsg::Refresh => self.refresh().await, + HomeScreenMsg::TunnelAction => self.perform_tunnel_action().await, + HomeScreenMsg::OpenWireGuard => open_wireguard_window(root, &sender), + HomeScreenMsg::OpenTor => open_tor_window(root, &sender), + HomeScreenMsg::OpenTailnet => open_tailnet_window(root, &sender), + HomeScreenMsg::AddWireGuard { + title, + account, + identity, + config, + } => self.add_wireguard(title, account, identity, config).await, + HomeScreenMsg::SaveTor { title, account, identity, note } => { + self.save_tor(title, account, identity, note) + } + HomeScreenMsg::DiscoverTailnet(email) => self.discover_tailnet(email).await, + HomeScreenMsg::ProbeTailnet(authority) => self.probe_tailnet(authority).await, + HomeScreenMsg::StartTailnetLogin { + authority, + account, + identity, + hostname, + } => { + self.start_tailnet_login(authority, account, identity, hostname, sender) + .await; + } + HomeScreenMsg::PollTailnetLogin => self.poll_tailnet_login(sender).await, + HomeScreenMsg::CancelTailnetLogin => self.cancel_tailnet_login().await, + HomeScreenMsg::AddTailnet { + authority, + account, + identity, + hostname, + tailnet, + } => { + self.add_tailnet(authority, account, identity, hostname, tailnet) + .await; + } + } + } +} + +impl HomeScreen { + async fn ensure_daemon(&mut self) { + self.daemon_banner.set_title("Starting Burrow daemon"); + self.daemon_banner.set_revealed(true); + match daemon_api::ensure_daemon().await { + Ok(()) => { + self.daemon_banner.set_revealed(false); + self.refresh().await; + } + Err(error) => { + self.daemon_banner + .set_title(&format!("Burrow daemon is not reachable: {error}")); + self.daemon_banner.set_revealed(true); + self.tunnel_state = None; + self.tunnel_status.set_label("Daemon unavailable"); + self.tunnel_button.set_label("Enable"); + self.tunnel_button.set_sensitive(true); + self.network_status + .set_label("Stored daemon networks are unavailable until the daemon starts."); + self.render_networks(&[]); + } + } + } + + async fn refresh(&mut self) { + match daemon_api::tunnel_state().await { + Ok(state) => { + self.daemon_banner.set_revealed(false); + self.tunnel_state = Some(state); + match state { + daemon_api::TunnelState::Running => { + self.tunnel_status.set_label("Connected"); + self.tunnel_button.set_label("Stop"); + } + daemon_api::TunnelState::Stopped => { + self.tunnel_status.set_label("Disconnected"); + self.tunnel_button.set_label("Start"); + } + } + self.tunnel_button.set_sensitive(true); + } + Err(error) => { + self.tunnel_state = None; + self.daemon_banner + .set_title(&format!("Burrow daemon is not reachable: {error}")); + self.daemon_banner.set_revealed(true); + self.tunnel_status.set_label("Unknown"); + self.tunnel_button.set_label("Enable"); + self.tunnel_button.set_sensitive(true); + } + } + + match daemon_api::list_networks().await { + Ok(networks) => { + self.render_networks(&networks); + self.network_status.set_label(if networks.is_empty() { + "Stored daemon networks and their active account selectors" + } else { + "Stored daemon networks and their active account selectors" + }); + } + Err(error) => { + self.render_networks(&[]); + self.network_status + .set_label(&format!("Unable to read daemon networks: {error}")); + } + } + + match account_store::load() { + Ok(accounts) => { + self.account_status.set_label(""); + self.render_accounts(&accounts); + } + Err(error) => { + self.render_accounts(&[]); + self.account_status + .set_label(&format!("Unable to read account store: {error}")); + } + } + } + + async fn perform_tunnel_action(&mut self) { + match self.tunnel_state { + Some(daemon_api::TunnelState::Running) => { + self.tunnel_button.set_sensitive(false); + self.tunnel_status.set_label("Disconnecting..."); + if let Err(error) = daemon_api::stop_tunnel().await { + self.tunnel_status + .set_label(&format!("Stop failed: {error}")); + } + self.refresh().await; + } + Some(daemon_api::TunnelState::Stopped) => { + self.tunnel_button.set_sensitive(false); + self.tunnel_status.set_label("Connecting..."); + if let Err(error) = daemon_api::start_tunnel().await { + self.tunnel_status + .set_label(&format!("Start failed: {error}")); + } + self.refresh().await; + } + None => self.ensure_daemon().await, + } + } + + async fn add_wireguard( + &mut self, + title: String, + account: String, + identity: String, + config: String, + ) { + if config.trim().is_empty() { + self.network_status + .set_label("Paste a WireGuard configuration before adding a network."); + return; + } + match daemon_api::add_wireguard(config).await { + Ok(id) => { + let title = daemon_api::normalized(&title, &format!("WireGuard {id}")); + let record = account_store::new_record( + AccountKind::WireGuard, + title, + None, + daemon_api::normalized(&account, "default"), + daemon_api::normalized(&identity, &format!("network-{id}")), + None, + None, + Some(format!("Linked to daemon network #{id}.")), + ); + match account_store::upsert(record) { + Ok(accounts) => self.render_accounts(&accounts), + Err(error) => self + .account_status + .set_label(&format!("WireGuard account save failed: {error}")), + } + self.network_status + .set_label(&format!("Added WireGuard network #{id}.")); + self.refresh().await; + } + Err(error) => self + .network_status + .set_label(&format!("Unable to add WireGuard network: {error}")), + } + } + + fn save_tor(&mut self, title: String, account: String, identity: String, note: String) { + let record = account_store::new_record( + AccountKind::Tor, + daemon_api::normalized( + &title, + &format!("Tor {}", daemon_api::normalized(&identity, "linux")), + ), + Some("arti://local".to_owned()), + daemon_api::normalized(&account, "default"), + daemon_api::normalized(&identity, "linux"), + None, + None, + Some(note), + ); + match account_store::upsert(record) { + Ok(accounts) => { + self.account_status.set_label("Saved Tor account."); + self.render_accounts(&accounts); + } + Err(error) => self + .account_status + .set_label(&format!("Unable to save Tor account: {error}")), + } + } + + async fn discover_tailnet(&mut self, email: String) { + let Ok(email) = daemon_api::require_value(&email, "Email address") else { + self.account_status + .set_label("Enter an email address before Tailnet discovery."); + return; + }; + + self.account_status.set_label("Finding Tailnet server..."); + match daemon_api::discover_tailnet(email).await { + Ok(discovery) => { + let kind = if discovery.managed { + "managed authority" + } else { + "custom authority" + }; + let issuer = discovery + .oidc_issuer + .map(|issuer| format!(" OIDC: {issuer}.")) + .unwrap_or_default(); + self.account_status.set_label(&format!( + "Discovered {kind}: {}.{issuer}", + discovery.authority + )); + } + Err(error) => self + .account_status + .set_label(&format!("Tailnet discovery failed: {error}")), + } + } + + async fn probe_tailnet(&mut self, authority: String) { + let Ok(authority) = daemon_api::require_value(&authority, "Tailnet server URL") else { + self.account_status + .set_label("Enter a Tailnet server URL before checking it."); + return; + }; + + self.account_status.set_label("Checking Tailnet server..."); + match daemon_api::probe_tailnet(authority).await { + Ok(probe) => { + let detail = probe + .detail + .unwrap_or_else(|| format!("HTTP {}", probe.status_code)); + self.account_status + .set_label(&format!("{}: {detail}", probe.summary)); + } + Err(error) => self + .account_status + .set_label(&format!("Tailnet probe failed: {error}")), + } + } + + async fn start_tailnet_login( + &mut self, + authority: String, + account: String, + identity: String, + hostname: Option, + sender: AsyncComponentSender, + ) { + let Ok(authority) = daemon_api::require_value(&authority, "Tailnet server URL") else { + self.account_status + .set_label("Enter a Tailnet server URL before sign-in."); + return; + }; + + self.account_status.set_label("Starting Tailnet sign-in..."); + match daemon_api::start_tailnet_login(authority, account, identity, hostname).await { + Ok(status) => { + self.apply_login_status(&status); + if let Some(auth_url) = status.auth_url.as_deref() { + if let Err(error) = open_auth_url(auth_url) { + self.account_status.set_label(&format!( + "{} Open this URL manually: {auth_url}. Browser launch failed: {error}", + self.account_status.text() + )); + } + } + if !status.running { + sender.input(HomeScreenMsg::PollTailnetLogin); + } + } + Err(error) => self + .account_status + .set_label(&format!("Tailnet sign-in failed: {error}")), + } + } + + async fn poll_tailnet_login(&mut self, sender: AsyncComponentSender) { + let Some(session_id) = self.tailnet_session_id.clone() else { + return; + }; + if self.tailnet_running { + return; + } + + tokio::time::sleep(Duration::from_secs(1)).await; + match daemon_api::tailnet_login_status(session_id).await { + Ok(status) => { + self.apply_login_status(&status); + if !status.running { + sender.input(HomeScreenMsg::PollTailnetLogin); + } + } + Err(error) => { + self.account_status + .set_label(&format!("Tailnet sign-in status failed: {error}")); + self.tailnet_session_id = None; + } + } + } + + async fn cancel_tailnet_login(&mut self) { + let Some(session_id) = self.tailnet_session_id.clone() else { + self.account_status + .set_label("No Tailnet sign-in is active."); + return; + }; + match daemon_api::cancel_tailnet_login(session_id).await { + Ok(()) => { + self.tailnet_session_id = None; + self.tailnet_running = false; + self.account_status.set_label("Tailnet sign-in cancelled."); + } + Err(error) => self + .account_status + .set_label(&format!("Unable to cancel Tailnet sign-in: {error}")), + } + } + + async fn add_tailnet( + &mut self, + authority: String, + account: String, + identity: String, + hostname: Option, + tailnet: Option, + ) { + let Ok(authority) = daemon_api::require_value(&authority, "Tailnet server URL") else { + self.account_status + .set_label("Enter a Tailnet server URL before saving."); + return; + }; + if self.tailnet_session_id.is_some() && !self.tailnet_running { + self.account_status + .set_label("Finish browser sign-in before saving this Tailnet account."); + return; + } + + let stored_authority = daemon_api::normalized_optional(&authority) + .unwrap_or_else(|| daemon_api::default_tailnet_authority().to_owned()); + let stored_account = daemon_api::normalized(&account, "default"); + let stored_identity = daemon_api::normalized(&identity, "linux"); + let stored_hostname = hostname.clone(); + let stored_tailnet = tailnet.clone(); + + match daemon_api::add_tailnet(authority, account, identity, hostname, tailnet).await { + Ok(id) => { + let title = stored_tailnet + .clone() + .or(stored_hostname.clone()) + .unwrap_or_else(|| format!("Tailnet {id}")); + let record = account_store::new_record( + AccountKind::Tailnet, + title, + Some(stored_authority), + stored_account, + stored_identity, + stored_hostname, + stored_tailnet, + Some(format!("Linked to daemon network #{id}.")), + ); + match account_store::upsert(record) { + Ok(accounts) => self.render_accounts(&accounts), + Err(error) => self + .account_status + .set_label(&format!("Tailnet account save failed: {error}")), + } + self.account_status + .set_label(&format!("Saved Tailnet account and network #{id}.")); + self.refresh().await; + } + Err(error) => self + .account_status + .set_label(&format!("Unable to save Tailnet account: {error}")), + } + } + + fn apply_login_status(&mut self, status: &daemon_api::TailnetLoginStatus) { + self.tailnet_session_id = Some(status.session_id.clone()); + self.tailnet_running = status.running; + + let mut parts = Vec::new(); + if status.running { + parts.push("Signed In".to_owned()); + } else if status.needs_login { + parts.push("Browser Sign-In Required".to_owned()); + } else { + parts.push("Checking Sign-In".to_owned()); + } + if !status.backend_state.is_empty() { + parts.push(format!("State: {}", status.backend_state)); + } + if let Some(tailnet_name) = &status.tailnet_name { + parts.push(format!("Tailnet: {tailnet_name}")); + } + if let Some(self_dns_name) = &status.self_dns_name { + parts.push(self_dns_name.clone()); + } + if !status.tailnet_ips.is_empty() { + parts.push(status.tailnet_ips.join(", ")); + } + if !status.health.is_empty() { + parts.push(status.health.join(" / ")); + } + self.account_status.set_label(&parts.join("\n")); + } + + fn render_networks(&self, networks: &[daemon_api::NetworkSummary]) { + while let Some(child) = self.network_cards.first_child() { + self.network_cards.remove(&child); + } + + if networks.is_empty() { + self.network_cards.append(&empty_networks_view()); + return; + } + + for network in networks { + self.network_cards.append(&network_card(network)); + } + } + + fn render_accounts(&self, accounts: &[AccountRecord]) { + while let Some(child) = self.account_rows.first_child() { + self.account_rows.remove(&child); + } + + if accounts.is_empty() { + self.account_rows.append(&empty_accounts_view()); + return; + } + + for account in accounts { + self.account_rows.append(&account_card(account)); + } + } +} + +fn configure_add_popover(button: >k::MenuButton, sender: &AsyncComponentSender) { + let popover = gtk::Popover::new(); + let box_ = gtk::Box::new(gtk::Orientation::Vertical, 4); + box_.set_margin_all(6); + + for (label, msg) in [ + ("Add WireGuard Network", HomeScreenMsg::OpenWireGuard), + ("Save Tor Account", HomeScreenMsg::OpenTor), + ("Add Tailnet Account", HomeScreenMsg::OpenTailnet), + ] { + let item = gtk::Button::with_label(label); + item.add_css_class("flat"); + item.set_halign(Align::Fill); + let input = sender.input_sender().clone(); + item.connect_clicked(move |_| input.emit(msg_from_template(&msg))); + box_.append(&item); + } + + popover.set_child(Some(&box_)); + button.set_popover(Some(&popover)); +} + +fn msg_from_template(msg: &HomeScreenMsg) -> HomeScreenMsg { + match msg { + HomeScreenMsg::OpenWireGuard => HomeScreenMsg::OpenWireGuard, + HomeScreenMsg::OpenTor => HomeScreenMsg::OpenTor, + HomeScreenMsg::OpenTailnet => HomeScreenMsg::OpenTailnet, + _ => unreachable!(), + } +} + +fn network_card(network: &daemon_api::NetworkSummary) -> gtk::Box { + let card = gtk::Box::new(gtk::Orientation::Vertical, 10); + card.add_css_class("network-card"); + if network.title.to_ascii_lowercase().contains("wireguard") { + card.add_css_class("wireguard-card"); + } else { + card.add_css_class("tailnet-card"); + } + card.set_size_request(360, 175); + card.set_margin_bottom(8); + + let kind = if network.title.to_ascii_lowercase().contains("wireguard") { + "WireGuard" + } else { + "Tailnet" + }; + let kind_label = gtk::Label::new(Some(kind)); + kind_label.add_css_class("network-card-kind"); + kind_label.set_xalign(0.0); + + let title = gtk::Label::new(Some(&network.title)); + title.add_css_class("network-card-title"); + title.set_xalign(0.0); + title.set_wrap(true); + + let spacer = gtk::Box::new(gtk::Orientation::Vertical, 0); + spacer.set_vexpand(true); + + let detail = gtk::Label::new(Some(&network.detail)); + detail.add_css_class("network-card-detail"); + detail.set_xalign(0.0); + detail.set_wrap(true); + detail.set_lines(4); + + card.append(&kind_label); + card.append(&title); + card.append(&spacer); + card.append(&detail); + card +} + +fn empty_networks_view() -> gtk::Box { + let box_ = gtk::Box::new(gtk::Orientation::Vertical, 6); + box_.add_css_class("empty-state"); + box_.set_size_request(520, 175); + box_.set_hexpand(true); + + let title = gtk::Label::new(Some("No Networks Yet")); + title.add_css_class("title-3"); + title.set_xalign(0.0); + let detail = gtk::Label::new(Some( + "Add a WireGuard network, or save a Tailnet account so Burrow can store a managed network when the daemon is reachable.", + )); + detail.add_css_class("dim-label"); + detail.set_wrap(true); + detail.set_xalign(0.0); + + box_.append(&title); + box_.append(&detail); + box_ +} + +fn empty_accounts_view() -> gtk::Box { + let box_ = gtk::Box::new(gtk::Orientation::Vertical, 6); + box_.add_css_class("empty-state"); + box_.set_hexpand(true); + + let title = gtk::Label::new(Some("No Accounts Yet")); + title.add_css_class("title-3"); + title.set_justify(gtk::Justification::Center); + let detail = gtk::Label::new(Some( + "Save a Tor account or sign in to Tailnet to keep network identities ready on this device.", + )); + detail.add_css_class("dim-label"); + detail.set_wrap(true); + detail.set_justify(gtk::Justification::Center); + + box_.append(&title); + box_.append(&detail); + box_ +} + +fn account_card(account: &AccountRecord) -> gtk::Box { + let card = gtk::Box::new(gtk::Orientation::Vertical, 8); + card.add_css_class("summary-card"); + card.set_hexpand(true); + + let header = gtk::Box::new(gtk::Orientation::Horizontal, 8); + let title = gtk::Label::new(Some(&account.title)); + title.add_css_class("title-3"); + title.set_xalign(0.0); + title.set_hexpand(true); + let kind = gtk::Label::new(Some(account.kind.title())); + kind.add_css_class("dim-label"); + header.append(&title); + header.append(&kind); + card.append(&header); + + append_account_value(&card, "Account", &account.account); + append_account_value(&card, "Identity", &account.identity); + if let Some(authority) = &account.authority { + append_account_value(&card, "Authority", authority); + } + if let Some(hostname) = &account.hostname { + append_account_value(&card, "Hostname", hostname); + } + if let Some(tailnet) = &account.tailnet { + append_account_value(&card, "Tailnet", tailnet); + } + if let Some(note) = &account.note { + let note_label = gtk::Label::new(Some(note)); + note_label.add_css_class("dim-label"); + note_label.set_wrap(true); + note_label.set_xalign(0.0); + card.append(¬e_label); + } + + card +} + +fn append_account_value(card: >k::Box, label: &str, value: &str) { + let row = gtk::Box::new(gtk::Orientation::Horizontal, 8); + let key = gtk::Label::new(Some(label)); + key.add_css_class("dim-label"); + key.set_xalign(0.0); + key.set_width_chars(9); + let value = gtk::Label::new(Some(value)); + value.set_xalign(0.0); + value.set_wrap(true); + value.set_hexpand(true); + row.append(&key); + row.append(&value); + card.append(&row); +} + +fn open_wireguard_window(root: >k::ScrolledWindow, sender: &AsyncComponentSender) { + let window = sheet_window(root, "WireGuard", 560, 620); + let content = sheet_content( + &window, + "Import WireGuard", + "Import a tunnel and optional account metadata.", + ); + + let title = gtk::Entry::new(); + title.set_placeholder_text(Some("Title")); + let account = gtk::Entry::new(); + account.set_placeholder_text(Some("Account")); + let identity = gtk::Entry::new(); + identity.set_placeholder_text(Some("Identity")); + let text = gtk::TextView::new(); + text.set_monospace(true); + text.set_wrap_mode(gtk::WrapMode::WordChar); + + let editor = gtk::ScrolledWindow::new(); + editor.set_min_content_height(220); + editor.set_child(Some(&text)); + + content.append(§ion_label("Identity")); + content.append(&title); + content.append(&account); + content.append(&identity); + content.append(§ion_label("WireGuard Configuration")); + content.append(&editor); + + let add = gtk::Button::with_label("Add Network"); + add.add_css_class("suggested-action"); + let input = sender.input_sender().clone(); + let window_for_click = window.clone(); + add.connect_clicked(move |_| { + input.emit(HomeScreenMsg::AddWireGuard { + title: title.text().to_string(), + account: account.text().to_string(), + identity: identity.text().to_string(), + config: text_view_text(&text), + }); + window_for_click.close(); + }); + content.append(&add); + + window.set_child(Some(&content)); + window.present(); +} + +fn open_tor_window(root: >k::ScrolledWindow, sender: &AsyncComponentSender) { + let window = sheet_window(root, "Tor", 520, 540); + let content = sheet_content( + &window, + "Configure Tor", + "Store Arti account and identity preferences.", + ); + + let title = entry_with_text("Title", "Default Tor"); + let account = entry_with_text("Account", "default"); + let identity = entry_with_text("Identity", "linux"); + let addresses = entry_with_text("Virtual Addresses", "100.64.0.2/32"); + let dns = entry_with_text("DNS Resolvers", "1.1.1.1, 1.0.0.1"); + let mtu = entry_with_text("MTU", "1400"); + let listen = entry_with_text("Transparent Listener", "127.0.0.1:9040"); + + content.append(§ion_label("Identity")); + content.append(&title); + content.append(&account); + content.append(&identity); + content.append(§ion_label("Tor Preferences")); + content.append(&addresses); + content.append(&dns); + content.append(&mtu); + content.append(&listen); + + let save = gtk::Button::with_label("Save Account"); + save.add_css_class("suggested-action"); + let input = sender.input_sender().clone(); + let window_for_click = window.clone(); + save.connect_clicked(move |_| { + let note = [ + format!( + "Addresses: {}", + normalized_entry(&addresses, "100.64.0.2/32") + ), + format!("DNS: {}", normalized_entry(&dns, "1.1.1.1, 1.0.0.1")), + format!("MTU: {}", normalized_entry(&mtu, "1400")), + format!("Listen: {}", normalized_entry(&listen, "127.0.0.1:9040")), + ] + .join(" - "); + input.emit(HomeScreenMsg::SaveTor { + title: normalized_entry(&title, "Default Tor"), + account: normalized_entry(&account, "default"), + identity: normalized_entry(&identity, "linux"), + note, + }); + window_for_click.close(); + }); + content.append(&save); + + window.set_child(Some(&content)); + window.present(); +} + +fn open_tailnet_window(root: >k::ScrolledWindow, sender: &AsyncComponentSender) { + let window = sheet_window(root, "Tailnet", 560, 680); + let content = sheet_content( + &window, + "Connect Tailnet", + "Save Tailnet authority, identity defaults, and login material.", + ); + + let email = gtk::Entry::new(); + email.set_placeholder_text(Some("Email address")); + let authority = entry_with_text("Server URL", daemon_api::default_tailnet_authority()); + let tailnet = gtk::Entry::new(); + tailnet.set_placeholder_text(Some("Tailnet")); + let account = entry_with_text("Account", "default"); + let identity = entry_with_text("Identity", "linux"); + let hostname = entry_with_text("Hostname", &hostname_fallback()); + + content.append(§ion_label("Connection")); + content.append(&email); + content.append(&authority); + content.append(&tailnet); + content.append(§ion_label("Identity")); + content.append(&account); + content.append(&identity); + content.append(&hostname); + + let actions = gtk::Box::new(gtk::Orientation::Horizontal, 8); + let discover = gtk::Button::with_label("Refresh Server Lookup"); + let probe = gtk::Button::with_label("Check Server"); + let sign_in = gtk::Button::with_label("Start Sign-In"); + actions.append(&discover); + actions.append(&probe); + actions.append(&sign_in); + content.append(§ion_label("Authentication")); + content.append(&actions); + + let input = sender.input_sender().clone(); + let email_for_click = email.clone(); + discover.connect_clicked(move |_| { + input.emit(HomeScreenMsg::DiscoverTailnet( + email_for_click.text().to_string(), + )); + }); + + let input = sender.input_sender().clone(); + let authority_for_probe = authority.clone(); + probe.connect_clicked(move |_| { + input.emit(HomeScreenMsg::ProbeTailnet( + authority_for_probe.text().to_string(), + )); + }); + + let input = sender.input_sender().clone(); + let authority_for_login = authority.clone(); + let account_for_login = account.clone(); + let identity_for_login = identity.clone(); + let hostname_for_login = hostname.clone(); + sign_in.connect_clicked(move |_| { + input.emit(HomeScreenMsg::StartTailnetLogin { + authority: authority_for_login.text().to_string(), + account: normalized_entry(&account_for_login, "default"), + identity: normalized_entry(&identity_for_login, "linux"), + hostname: daemon_api::normalized_optional(&hostname_for_login.text()), + }); + }); + + let save = gtk::Button::with_label("Save Account"); + save.add_css_class("suggested-action"); + let input = sender.input_sender().clone(); + let window_for_click = window.clone(); + save.connect_clicked(move |_| { + input.emit(HomeScreenMsg::AddTailnet { + authority: authority.text().to_string(), + account: normalized_entry(&account, "default"), + identity: normalized_entry(&identity, "linux"), + hostname: daemon_api::normalized_optional(&hostname.text()), + tailnet: daemon_api::normalized_optional(&tailnet.text()), + }); + window_for_click.close(); + }); + + let cancel = gtk::Button::with_label("Cancel Sign-In"); + let input = sender.input_sender().clone(); + cancel.connect_clicked(move |_| { + input.emit(HomeScreenMsg::CancelTailnetLogin); + }); + + content.append(&save); + content.append(&cancel); + + window.set_child(Some(&content)); + window.present(); +} + +fn sheet_window(root: >k::ScrolledWindow, title: &str, width: i32, height: i32) -> gtk::Window { + let window = gtk::Window::builder() + .title(title) + .default_width(width) + .default_height(height) + .modal(true) + .build(); + if let Some(root) = root.root() { + if let Ok(parent) = root.downcast::() { + window.set_transient_for(Some(&parent)); + } + } + window +} + +fn sheet_content(window: >k::Window, title: &str, detail: &str) -> gtk::Box { + let content = gtk::Box::new(gtk::Orientation::Vertical, 12); + content.set_margin_all(18); + + let summary = gtk::Box::new(gtk::Orientation::Horizontal, 12); + summary.add_css_class("summary-card"); + + let copy = gtk::Box::new(gtk::Orientation::Vertical, 4); + copy.set_hexpand(true); + + let title_label = gtk::Label::new(Some(title)); + title_label.add_css_class("title-3"); + title_label.set_xalign(0.0); + + let detail_label = gtk::Label::new(Some(detail)); + detail_label.add_css_class("dim-label"); + detail_label.set_wrap(true); + detail_label.set_xalign(0.0); + + copy.append(&title_label); + copy.append(&detail_label); + summary.append(©); + + let close = gtk::Button::builder() + .icon_name("window-close-symbolic") + .tooltip_text("Close") + .valign(Align::Start) + .build(); + close.add_css_class("flat"); + let window_for_click = window.clone(); + close.connect_clicked(move |_| window_for_click.close()); + summary.append(&close); + + content.append(&summary); + content +} + +fn section_label(label: &str) -> gtk::Label { + let section = gtk::Label::new(Some(label)); + section.add_css_class("heading"); + section.set_xalign(0.0); + section +} + +fn entry_with_text(placeholder: &str, value: &str) -> gtk::Entry { + let entry = gtk::Entry::new(); + entry.set_placeholder_text(Some(placeholder)); + entry.set_text(value); + entry +} + +fn normalized_entry(entry: >k::Entry, fallback: &str) -> String { + daemon_api::normalized(&entry.text(), fallback) +} + +fn hostname_fallback() -> String { + std::env::var("HOSTNAME").unwrap_or_else(|_| "linux".to_owned()) +} + +fn text_view_text(text_view: >k::TextView) -> String { + let buffer = text_view.buffer(); + buffer + .text(&buffer.start_iter(), &buffer.end_iter(), true) + .to_string() +} + +fn open_auth_url(url: &str) -> anyhow::Result<()> { + gtk::gio::AppInfo::launch_default_for_uri(url, None::<>k::gio::AppLaunchContext>) + .map_err(anyhow::Error::from) +} diff --git a/burrow-gtk/src/components/mod.rs b/burrow-gtk/src/components/mod.rs index b134809..8e60fa7 100644 --- a/burrow-gtk/src/components/mod.rs +++ b/burrow-gtk/src/components/mod.rs @@ -1,6 +1,6 @@ use super::*; +use crate::daemon_api; use adw::prelude::*; -use burrow::{DaemonClient, DaemonCommand, DaemonResponseData}; use gtk::Align; use relm4::{ component::{ @@ -9,13 +9,9 @@ use relm4::{ }, prelude::*, }; -use std::sync::Arc; -use tokio::sync::Mutex; mod app; -mod settings; -mod settings_screen; -mod switch_screen; +mod home_screen; pub use app::*; -pub use settings::{DaemonGroupMsg, DiagGroupMsg}; +pub use home_screen::{HomeScreen, HomeScreenMsg}; diff --git a/burrow-gtk/src/daemon_api.rs b/burrow-gtk/src/daemon_api.rs new file mode 100644 index 0000000..4ff8bf5 --- /dev/null +++ b/burrow-gtk/src/daemon_api.rs @@ -0,0 +1,420 @@ +use anyhow::{anyhow, Context, Result}; +use burrow::{ + control::{TailnetConfig, TailnetProvider}, + grpc_defs::{ + Empty, Network, NetworkType, State, TailnetDiscoverRequest, TailnetLoginCancelRequest, + TailnetLoginStartRequest, TailnetLoginStatusRequest, TailnetProbeRequest, + }, + BurrowClient, +}; +use std::{path::PathBuf, sync::OnceLock}; +use tokio::time::{timeout, Duration}; + +const RPC_TIMEOUT: Duration = Duration::from_secs(3); +const MANAGED_TAILSCALE_AUTHORITY: &str = "https://controlplane.tailscale.com"; +static EMBEDDED_DAEMON_STARTED: OnceLock<()> = OnceLock::new(); + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum TunnelState { + Running, + Stopped, +} + +#[derive(Debug, Clone)] +pub struct NetworkSummary { + pub id: i32, + pub title: String, + pub detail: String, +} + +#[derive(Debug, Clone)] +pub struct TailnetDiscovery { + pub authority: String, + pub managed: bool, + pub oidc_issuer: Option, +} + +#[derive(Debug, Clone)] +pub struct TailnetProbe { + pub summary: String, + pub detail: Option, + pub status_code: i32, +} + +#[derive(Debug, Clone)] +pub struct TailnetLoginStatus { + pub session_id: String, + pub backend_state: String, + pub auth_url: Option, + pub running: bool, + pub needs_login: bool, + pub tailnet_name: Option, + pub self_dns_name: Option, + pub tailnet_ips: Vec, + pub health: Vec, +} + +pub fn default_tailnet_authority() -> &'static str { + MANAGED_TAILSCALE_AUTHORITY +} + +pub fn configure_client_paths() -> Result<()> { + if std::env::var_os("BURROW_SOCKET_PATH").is_none() { + std::env::set_var("BURROW_SOCKET_PATH", default_socket_path()?); + } + Ok(()) +} + +pub async fn ensure_daemon() -> Result<()> { + configure_client_paths()?; + if daemon_available().await { + return Ok(()); + } + + let socket_path = socket_path()?; + let db_path = database_path()?; + ensure_parent(&socket_path)?; + ensure_parent(&db_path)?; + + if EMBEDDED_DAEMON_STARTED.get().is_none() { + tokio::task::spawn_blocking(move || { + burrow::spawn_in_process_with_paths(Some(socket_path), Some(db_path)); + }) + .await + .context("failed to join embedded daemon startup")?; + let _ = EMBEDDED_DAEMON_STARTED.set(()); + } + + tunnel_state() + .await + .map(|_| ()) + .context("Burrow daemon started but did not accept tunnel status RPCs") +} + +pub fn infer_tailnet_provider(authority: &str) -> TailnetProvider { + let normalized = authority.trim().trim_end_matches('/').to_ascii_lowercase(); + if normalized == "controlplane.tailscale.com" + || normalized == "http://controlplane.tailscale.com" + || normalized == MANAGED_TAILSCALE_AUTHORITY + { + TailnetProvider::Tailscale + } else { + TailnetProvider::Headscale + } +} + +pub async fn daemon_available() -> bool { + tunnel_state().await.is_ok() +} + +fn socket_path() -> Result { + if let Some(path) = std::env::var_os("BURROW_SOCKET_PATH") { + return Ok(PathBuf::from(path)); + } + default_socket_path() +} + +fn default_socket_path() -> Result { + if let Some(runtime_dir) = std::env::var_os("XDG_RUNTIME_DIR") { + return Ok(PathBuf::from(runtime_dir).join("burrow.sock")); + } + let uid = std::env::var("UID").unwrap_or_else(|_| "1000".to_owned()); + Ok(PathBuf::from(format!("/tmp/burrow-{uid}.sock"))) +} + +fn database_path() -> Result { + if let Some(path) = std::env::var_os("BURROW_DB_PATH") { + return Ok(PathBuf::from(path)); + } + if let Some(data_home) = std::env::var_os("XDG_DATA_HOME") { + return Ok(PathBuf::from(data_home).join("burrow").join("burrow.db")); + } + if let Some(home) = std::env::var_os("HOME") { + return Ok(PathBuf::from(home) + .join(".local") + .join("share") + .join("burrow") + .join("burrow.db")); + } + Ok(std::env::temp_dir().join("burrow.db")) +} + +fn ensure_parent(path: &PathBuf) -> Result<()> { + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent) + .with_context(|| format!("failed to create {}", parent.display()))?; + } + Ok(()) +} + +pub async fn tunnel_state() -> Result { + let mut client = BurrowClient::from_uds().await?; + let mut stream = timeout(RPC_TIMEOUT, client.tunnel_client.tunnel_status(Empty {})) + .await + .context("timed out connecting to Burrow daemon")?? + .into_inner(); + let status = timeout(RPC_TIMEOUT, stream.message()) + .await + .context("timed out reading Burrow tunnel status")?? + .context("Burrow daemon ended the status stream without a state")?; + Ok(match status.state() { + State::Running => TunnelState::Running, + State::Stopped => TunnelState::Stopped, + }) +} + +pub async fn start_tunnel() -> Result<()> { + let mut client = BurrowClient::from_uds().await?; + timeout(RPC_TIMEOUT, client.tunnel_client.tunnel_start(Empty {})) + .await + .context("timed out starting Burrow tunnel")??; + Ok(()) +} + +pub async fn stop_tunnel() -> Result<()> { + let mut client = BurrowClient::from_uds().await?; + timeout(RPC_TIMEOUT, client.tunnel_client.tunnel_stop(Empty {})) + .await + .context("timed out stopping Burrow tunnel")??; + Ok(()) +} + +pub async fn list_networks() -> Result> { + let mut client = BurrowClient::from_uds().await?; + let mut stream = timeout(RPC_TIMEOUT, client.networks_client.network_list(Empty {})) + .await + .context("timed out connecting to Burrow network list")?? + .into_inner(); + let response = timeout(RPC_TIMEOUT, stream.message()) + .await + .context("timed out reading Burrow network list")?? + .context("Burrow daemon ended the network stream without a snapshot")?; + Ok(response.network.iter().map(summarize_network).collect()) +} + +pub async fn add_wireguard(config: String) -> Result { + add_network(NetworkType::WireGuard, config.into_bytes()).await +} + +pub async fn add_tailnet( + authority: String, + account: String, + identity: String, + hostname: Option, + tailnet: Option, +) -> Result { + let provider = infer_tailnet_provider(&authority); + let config = TailnetConfig { + provider, + authority: Some(authority), + account: Some(account), + identity: Some(identity), + hostname, + tailnet, + }; + let payload = serde_json::to_vec_pretty(&config)?; + add_network(NetworkType::Tailnet, payload).await +} + +pub async fn discover_tailnet(email: String) -> Result { + let mut client = BurrowClient::from_uds().await?; + let response = timeout( + RPC_TIMEOUT, + client + .tailnet_client + .discover(TailnetDiscoverRequest { email }), + ) + .await + .context("timed out discovering Tailnet authority")?? + .into_inner(); + + Ok(TailnetDiscovery { + authority: response.authority, + managed: response.managed, + oidc_issuer: optional(response.oidc_issuer), + }) +} + +pub async fn probe_tailnet(authority: String) -> Result { + let mut client = BurrowClient::from_uds().await?; + let response = timeout( + RPC_TIMEOUT, + client + .tailnet_client + .probe(TailnetProbeRequest { authority }), + ) + .await + .context("timed out probing Tailnet authority")?? + .into_inner(); + + Ok(TailnetProbe { + summary: response.summary, + detail: optional(response.detail), + status_code: response.status_code, + }) +} + +pub async fn start_tailnet_login( + authority: String, + account_name: String, + identity_name: String, + hostname: Option, +) -> Result { + let mut client = BurrowClient::from_uds().await?; + let response = timeout( + RPC_TIMEOUT, + client.tailnet_client.login_start(TailnetLoginStartRequest { + account_name, + identity_name, + hostname: hostname.unwrap_or_default(), + authority, + }), + ) + .await + .context("timed out starting Tailnet sign-in")?? + .into_inner(); + Ok(decode_tailnet_status(response)) +} + +pub async fn tailnet_login_status(session_id: String) -> Result { + let mut client = BurrowClient::from_uds().await?; + let response = timeout( + RPC_TIMEOUT, + client + .tailnet_client + .login_status(TailnetLoginStatusRequest { session_id }), + ) + .await + .context("timed out reading Tailnet sign-in status")?? + .into_inner(); + Ok(decode_tailnet_status(response)) +} + +pub async fn cancel_tailnet_login(session_id: String) -> Result<()> { + let mut client = BurrowClient::from_uds().await?; + timeout( + RPC_TIMEOUT, + client + .tailnet_client + .login_cancel(TailnetLoginCancelRequest { session_id }), + ) + .await + .context("timed out cancelling Tailnet sign-in")??; + Ok(()) +} + +async fn add_network(network_type: NetworkType, payload: Vec) -> Result { + let id = next_network_id().await?; + let mut client = BurrowClient::from_uds().await?; + timeout( + RPC_TIMEOUT, + client.networks_client.network_add(Network { + id, + r#type: network_type.into(), + payload, + }), + ) + .await + .context("timed out saving network to Burrow daemon")??; + Ok(id) +} + +async fn next_network_id() -> Result { + let networks = list_networks().await?; + Ok(networks.iter().map(|network| network.id).max().unwrap_or(0) + 1) +} + +fn summarize_network(network: &Network) -> NetworkSummary { + match network.r#type() { + NetworkType::WireGuard => summarize_wireguard(network), + NetworkType::Tailnet => summarize_tailnet(network), + } +} + +fn summarize_wireguard(network: &Network) -> NetworkSummary { + let payload = String::from_utf8_lossy(&network.payload); + let detail = payload + .lines() + .map(str::trim) + .find(|line| !line.is_empty() && !line.starts_with('[')) + .unwrap_or("Stored WireGuard configuration") + .to_owned(); + NetworkSummary { + id: network.id, + title: format!("WireGuard {}", network.id), + detail, + } +} + +fn summarize_tailnet(network: &Network) -> NetworkSummary { + match TailnetConfig::from_slice(&network.payload) { + Ok(config) => { + let title = config + .tailnet + .clone() + .or(config.hostname.clone()) + .unwrap_or_else(|| "Tailnet".to_owned()); + let authority = config + .authority + .unwrap_or_else(|| "default authority".to_owned()); + let account = config.account.unwrap_or_else(|| "default".to_owned()); + NetworkSummary { + id: network.id, + title, + detail: format!("{authority} - account {account}"), + } + } + Err(error) => NetworkSummary { + id: network.id, + title: "Tailnet".to_owned(), + detail: format!("Unable to read Tailnet payload: {error}"), + }, + } +} + +fn decode_tailnet_status( + response: burrow::grpc_defs::TailnetLoginStatusResponse, +) -> TailnetLoginStatus { + TailnetLoginStatus { + session_id: response.session_id, + backend_state: response.backend_state, + auth_url: optional(response.auth_url), + running: response.running, + needs_login: response.needs_login, + tailnet_name: optional(response.tailnet_name), + self_dns_name: optional(response.self_dns_name), + tailnet_ips: response.tailnet_ips, + health: response.health, + } +} + +fn optional(value: String) -> Option { + let trimmed = value.trim(); + if trimmed.is_empty() { + None + } else { + Some(trimmed.to_owned()) + } +} + +pub fn normalized(value: &str, fallback: &str) -> String { + let trimmed = value.trim(); + if trimmed.is_empty() { + fallback.to_owned() + } else { + trimmed.to_owned() + } +} + +pub fn normalized_optional(value: &str) -> Option { + let trimmed = value.trim(); + if trimmed.is_empty() { + None + } else { + Some(trimmed.to_owned()) + } +} + +pub fn require_value(value: &str, label: &str) -> Result { + normalized_optional(value).ok_or_else(|| anyhow!("{label} is required")) +} diff --git a/burrow-gtk/src/main.rs b/burrow-gtk/src/main.rs index 6f91e2a..b47b63e 100644 --- a/burrow-gtk/src/main.rs +++ b/burrow-gtk/src/main.rs @@ -1,11 +1,15 @@ use anyhow::Result; pub mod components; -mod diag; +mod account_store; +mod daemon_api; // Generated using meson mod config; fn main() { + if let Err(error) = daemon_api::configure_client_paths() { + eprintln!("failed to configure Burrow daemon paths: {error}"); + } components::App::run(); } diff --git a/burrow/src/daemon/apple.rs b/burrow/src/daemon/apple.rs index c60f131..f369ea9 100644 --- a/burrow/src/daemon/apple.rs +++ b/burrow/src/daemon/apple.rs @@ -1,11 +1,11 @@ use std::{ ffi::{c_char, CStr}, path::PathBuf, - sync::Arc, + sync::{Arc, Mutex}, thread, }; -use once_cell::sync::OnceCell; +use once_cell::sync::{Lazy, OnceCell}; use tokio::{ runtime::{Builder, Handle}, sync::Notify, @@ -14,25 +14,35 @@ use tracing::error; use crate::daemon::daemon_main; -static BURROW_NOTIFY: OnceCell> = OnceCell::new(); static BURROW_HANDLE: OnceCell = OnceCell::new(); +static BURROW_READY: OnceCell<()> = OnceCell::new(); +static BURROW_SPAWN_LOCK: Lazy> = Lazy::new(|| Mutex::new(())); #[no_mangle] pub unsafe extern "C" fn spawn_in_process(path: *const c_char, db_path: *const c_char) { + let path_buf = if path.is_null() { + None + } else { + Some(PathBuf::from(CStr::from_ptr(path).to_str().unwrap())) + }; + let db_path_buf = if db_path.is_null() { + None + } else { + Some(PathBuf::from(CStr::from_ptr(db_path).to_str().unwrap())) + }; + spawn_in_process_with_paths(path_buf, db_path_buf); +} + +pub fn spawn_in_process_with_paths(path_buf: Option, db_path_buf: Option) { crate::tracing::initialize(); - let notify = BURROW_NOTIFY.get_or_init(|| Arc::new(Notify::new())); + let _guard = BURROW_SPAWN_LOCK.lock().unwrap(); + if BURROW_READY.get().is_some() { + return; + } + + let notify = Arc::new(Notify::new()); let handle = BURROW_HANDLE.get_or_init(|| { - let path_buf = if path.is_null() { - None - } else { - Some(PathBuf::from(CStr::from_ptr(path).to_str().unwrap())) - }; - let db_path_buf = if db_path.is_null() { - None - } else { - Some(PathBuf::from(CStr::from_ptr(db_path).to_str().unwrap())) - }; let sender = notify.clone(); let (handle_tx, handle_rx) = tokio::sync::oneshot::channel(); @@ -62,4 +72,5 @@ pub unsafe extern "C" fn spawn_in_process(path: *const c_char, db_path: *const c let receiver = notify.clone(); handle.block_on(async move { receiver.notified().await }); + let _ = BURROW_READY.set(()); } diff --git a/burrow/src/lib.rs b/burrow/src/lib.rs index 15b6a19..7867d18 100644 --- a/burrow/src/lib.rs +++ b/burrow/src/lib.rs @@ -16,10 +16,10 @@ pub(crate) mod tracing; #[cfg(target_os = "linux")] pub mod usernet; -#[cfg(target_vendor = "apple")] -pub use daemon::apple::spawn_in_process; +#[cfg(any(target_os = "linux", target_vendor = "apple"))] +pub use daemon::apple::{spawn_in_process, spawn_in_process_with_paths}; #[cfg(any(target_os = "linux", target_vendor = "apple"))] pub use daemon::{ - rpc::DaemonResponse, rpc::ServerInfo, DaemonClient, DaemonCommand, DaemonResponseData, - DaemonStartOptions, + rpc::grpc_defs, rpc::BurrowClient, rpc::DaemonResponse, rpc::ServerInfo, DaemonClient, + DaemonCommand, DaemonResponseData, DaemonStartOptions, }; diff --git a/docs/GTK_APP.md b/docs/GTK_APP.md index ef73d2b..582b0a2 100644 --- a/docs/GTK_APP.md +++ b/docs/GTK_APP.md @@ -15,7 +15,7 @@ Note that the flatpak version can compile but will not run properly! 1. Install build dependencies ``` - sudo apt install -y clang meson cmake pkg-config libgtk-4-dev libadwaita-1-dev gettext desktop-file-utils + sudo apt install -y clang meson cmake pkg-config libssl-dev libgtk-4-dev libadwaita-1-dev gettext desktop-file-utils ``` 2. Install flatpak builder (Optional) @@ -38,7 +38,7 @@ Note that the flatpak version can compile but will not run properly! 1. Install build dependencies ``` - sudo dnf install -y clang ninja-build cmake meson gtk4-devel glib2-devel libadwaita-devel desktop-file-utils libappstream-glib + sudo dnf install -y clang ninja-build cmake meson openssl-devel gtk4-devel glib2-devel libadwaita-devel desktop-file-utils libappstream-glib ``` 2. Install flatpak builder (Optional) @@ -61,7 +61,7 @@ Note that the flatpak version can compile but will not run properly! 1. Install build dependencies ``` - sudo xbps-install -Sy gcc clang meson cmake pkg-config gtk4-devel gettext desktop-file-utils gtk4-update-icon-cache appstream-glib + sudo xbps-install -Sy gcc clang meson cmake pkg-config openssl-devel gtk4-devel gettext desktop-file-utils gtk4-update-icon-cache appstream-glib ``` 2. Install flatpak builder (Optional) @@ -88,6 +88,12 @@ flatpak install --user \ ## Building +With Nix, enter the focused GTK shell before running the Meson build: + +```bash +nix develop .#gtk +``` +
General @@ -139,6 +145,16 @@ flatpak install --user \ ## Running +The GTK app mirrors the Apple home surface: a Burrow header, Networks carousel, +Accounts section, Tunnel action, and the same add flows for WireGuard, Tor, and +Tailnet. It talks to the daemon over the same gRPC API used by Apple clients for +network storage, tunnel state, Tailnet discovery, authority probing, browser +sign-in, and Tailnet payloads. + +On Linux the GTK app first looks for a daemon on the configured gRPC socket. If +none is reachable, it starts an embedded user-scoped daemon with a socket under +`XDG_RUNTIME_DIR` and a database under `XDG_DATA_HOME` before refreshing the UI. +
General diff --git a/evolution/proposals/BEP-0005-daemon-ipc-and-apple-boundary.md b/evolution/proposals/BEP-0005-daemon-ipc-and-apple-boundary.md index 1227444..a34a609 100644 --- a/evolution/proposals/BEP-0005-daemon-ipc-and-apple-boundary.md +++ b/evolution/proposals/BEP-0005-daemon-ipc-and-apple-boundary.md @@ -44,6 +44,7 @@ Burrow should formalize one Apple/runtime boundary: Apple clients speak only to - Keeping control-plane I/O out of Swift UI reduces accidental secret, token, and callback sprawl across app code. - The daemon boundary makes testing and kill-switch behavior tractable because runtime integration is localized. - Apple daemon lifecycle ownership must be explicit: either the app ensures the daemon is running before RPC or the extension owns it and the UI surfaces daemon-unavailable state clearly. +- Non-Apple presentation clients should follow the same daemon-first lifecycle pattern: connect to a managed daemon when present, or start a user-scoped embedded daemon before issuing RPCs, without adding platform-local control-plane paths. ## Contributor Playbook @@ -54,6 +55,7 @@ Burrow should formalize one Apple/runtime boundary: Apple clients speak only to - daemon unavailable behavior - successful RPC path - error propagation through the UI +- Keep Linux GTK and Apple clients visually and functionally aligned around the same daemon-backed home surface: Networks, Accounts, Tunnel, and add flows should remain corresponding views over the daemon API. ## Alternatives Considered @@ -63,6 +65,7 @@ Burrow should formalize one Apple/runtime boundary: Apple clients speak only to ## Impact on Other Work - Governs the Tailnet refactor and future Apple runtime work. +- Governs Linux GTK daemon startup parity where the same daemon API is reused from a user-scoped presentation process. - Interacts with BEP-0002 control-plane bootstrap and BEP-0003 transport refactoring. ## Decision diff --git a/evolution/proposals/BEP-0006-tailnet-authority-first-control-plane.md b/evolution/proposals/BEP-0006-tailnet-authority-first-control-plane.md index fea4aba..36458ef 100644 --- a/evolution/proposals/BEP-0006-tailnet-authority-first-control-plane.md +++ b/evolution/proposals/BEP-0006-tailnet-authority-first-control-plane.md @@ -37,6 +37,7 @@ Burrow should treat Tailnet as one protocol family. Tailscale-managed and self-h - Burrow-owned authority when explicitly applicable - Discovery returns authority and related metadata; editing the authority is the mechanism that moves a configuration from managed default to custom control server. - The daemon and control layer own provider inference; the UI should primarily present “Tailnet” plus the selected authority. +- Platform clients consume the same daemon gRPC surface for Tailnet discovery, authority probing, browser sign-in, and saved network payloads. macOS/iOS SwiftUI and Linux GTK may differ in presentation and local credential stores, but neither should introduce a second control-plane path. ## Security and Operational Considerations @@ -48,6 +49,7 @@ Burrow should treat Tailnet as one protocol family. Tailscale-managed and self-h - Remove provider pickers from Tailnet UI unless a concrete protocol difference requires one. - Store the authority explicitly in payloads and infer provider internally only when needed. +- Keep Linux GTK and Apple clients at functional parity by routing Tailnet add/discover/probe/login through `TailnetControl` and `Networks` RPCs instead of platform-local HTTP or legacy JSON daemon commands. - Prefer tests that validate authority normalization and discovery behavior over UI-provider branching. ## Alternatives Considered @@ -58,7 +60,7 @@ Burrow should treat Tailnet as one protocol family. Tailscale-managed and self-h ## Impact on Other Work - Refines BEP-0002’s Tailscale-shaped control-plane work. -- Constrains the Tailnet Apple refactor and future daemon control-plane storage. +- Constrains the Tailnet Apple and Linux GTK refactors plus future daemon control-plane storage. ## Decision @@ -68,4 +70,5 @@ Pending. - `burrow/src/control/` - `Apple/UI/Networks/` +- `burrow-gtk/src/` - `proto/burrow.proto`