diff --git a/Cargo.lock b/Cargo.lock index 8b3e5bd..bd2c29b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1682,6 +1682,7 @@ dependencies = [ "async-trait", "clap", "dotenv", + "hex", "hickory-proto", "hickory-server", "humantime", diff --git a/README.md b/README.md index 5c5805f..db00dee 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,17 @@ CLI, GUI app, and shared library for exposing local environments to the internet brew install datum-cloud/tap/desktop ``` +**nix** + +``` +# GUI app +nix run github:datum-cloud/app#desktop + +# CLI +nix run github:datum-cloud/app#cli -- auth login +nix run github:datum-cloud/app#cli -- tunnel list +``` + **Direct download:** [![Download for macOS](https://img.shields.io/badge/Download-macOS-000000?logo=apple&logoColor=white)](https://github.com/datum-cloud/datum-connect/releases/latest/download/Datum.dmg) diff --git a/cli/Cargo.toml b/cli/Cargo.toml index 51d3ccd..abf2dc7 100644 --- a/cli/Cargo.toml +++ b/cli/Cargo.toml @@ -21,4 +21,5 @@ hickory-proto = "0.25.2" iroh-base.workspace = true z32 = "1.0.3" rand.workspace = true +hex.workspace = true sentry.workspace = true \ No newline at end of file diff --git a/cli/src/main.rs b/cli/src/main.rs index f53e45a..0bcb5bd 100644 --- a/cli/src/main.rs +++ b/cli/src/main.rs @@ -4,9 +4,10 @@ mod dns_dev; mod tunnel_dev; use lib::{ - Advertisment, AdvertismentTicket, ConnectNode, ListenNode, ProxyState, Repo, TcpProxyData, - datum_cloud::{ApiEnv, DatumCloudClient}, + Advertisment, AdvertismentTicket, ConnectNode, HeartbeatAgent, ListenNode, ProxyState, Repo, + TcpProxyData, TunnelService, datum_cloud::DatumCloudClient, }; +use n0_error::StdResultExt; use std::{net::SocketAddr, path::PathBuf}; use tracing::info; use tracing_subscriber::prelude::*; @@ -35,12 +36,12 @@ enum Commands { /// Local entrypoint that tunnels traffic through the gateway using CONNECT. TunnelDev(TunnelDevArgs), - /// List configured proxies. - List, - /// Add proxies. #[clap(subcommand, alias = "ls")] Add(AddCommands), + + /// Manage tunnels (create, list, update, delete) that expose local services to public hostnames. + Tunnel(TunnelArgs), } #[derive(Debug, clap::Parser)] @@ -132,9 +133,61 @@ pub struct ConnectArgs { pub ticket: AdvertismentTicket, } +#[derive(Parser, Debug)] +pub struct TunnelArgs { + /// Project ID to use for this command (overrides the currently selected project). + #[clap(long)] + project: Option, + #[clap(subcommand)] + command: TunnelCommands, +} + +#[derive(Subcommand, Debug)] +pub enum TunnelCommands { + /// List all tunnels in the current project. + List, + + /// Start a tunnel that exposes a local service to a public hostname. + Listen { + /// Display name for the tunnel (auto-generated if not provided). + #[clap(long)] + label: Option, + /// Local address to expose (host:port, e.g. 127.0.0.1:8080). + #[clap(long)] + endpoint: String, + /// Skip confirmation prompt if tunnel already exists. + #[clap(long, default_value = "false")] + yes: bool, + }, + + /// Update an existing tunnel. + Update { + /// Tunnel ID (resource name). + #[clap(long)] + id: String, + /// New display name for the tunnel. + #[clap(long)] + label: Option, + /// New local address to expose (host:port, e.g. 127.0.0.1:8080). + #[clap(long)] + endpoint: Option, + }, + + /// Delete a tunnel. + Delete { + /// Tunnel ID (resource name) to delete. + #[clap(long)] + id: String, + }, +} + #[tokio::main] async fn main() -> n0_error::Result<()> { tracing_subscriber::registry() + .with( + tracing_subscriber::EnvFilter::try_from_default_env() + .unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("warn")), + ) .with(tracing_subscriber::fmt::layer()) .with(sentry::integrations::tracing::layer()) .init(); @@ -163,28 +216,6 @@ async fn main() -> n0_error::Result<()> { let repo = Repo::open_or_create(path).await?; match args.command { - Commands::List => { - let datum = DatumCloudClient::with_repo(ApiEnv::default(), repo.clone()).await?; - let orgs = datum.orgs_and_projects().await?; - for org in orgs { - println!("org: {} {}", org.org.resource_id, org.org.display_name); - for project in org.projects { - println!( - " project: {} {}", - project.resource_id, project.display_name - ); - } - } - - println!(); - let state = repo.load_state().await?; - for p in state.get().proxies.iter() { - println!( - "{} -> {}:{} (enabled: {})", - p.info.resource_id, p.info.data.host, p.info.data.port, p.enabled - ) - } - } Commands::Add(AddCommands::TcpProxy { host, label }) => { let service = TcpProxyData::from_host_port_str(&host)?; let advertisment = Advertisment::new(service, label); @@ -280,6 +311,139 @@ async fn main() -> n0_error::Result<()> { Commands::TunnelDev(args) => { tunnel_dev::serve(args).await?; } + Commands::Tunnel(TunnelArgs { project, command: args }) => { + let datum = DatumCloudClient::with_datumctl(project).await?; + + let node = ListenNode::new(repo.clone()).await?; + let service = TunnelService::new(datum.clone(), node.clone()); + let heartbeat = HeartbeatAgent::new(datum.clone(), node.clone()); + + match args { + TunnelCommands::List => { + let tunnels = service.list_active().await?; + if tunnels.is_empty() { + println!("No tunnels found in current project."); + } else { + for t in tunnels { + let status = if t.accepted && t.programmed { + "ready" + } else if t.accepted { + "accepted" + } else { + "pending" + }; + let enabled = if t.enabled { "enabled" } else { "disabled" }; + println!("{} [{}] {} -> {}", t.id, status, t.label, t.endpoint); + if !t.hostnames.is_empty() { + for h in &t.hostnames { + println!(" hostname: {}", h); + } + } + println!(" status: {}, {}", enabled, status); + } + } + } + TunnelCommands::Listen { label, endpoint, yes } => { + let endpoint_id = node.endpoint_id(); + + let existing = service.get_active_by_endpoint(&endpoint).await?; + let tunnel_id = if let Some(t) = existing { + println!("Found existing tunnel for {}:", endpoint); + println!(" id: {}", t.id); + println!(" label: {}", t.label); + println!(" endpoint: {}", t.endpoint); + println!(); + + // Only update if an explicit label was given and it differs. + if let Some(label) = label.filter(|l| l != &t.label) { + if yes { + println!("Updating tunnel (--yes specified)"); + } else { + print!("Update tunnel label to '{}'? [y/N] ", label); + std::io::Write::flush(&mut std::io::stdout())?; + let mut input = String::new(); + std::io::stdin().read_line(&mut input)?; + if !input.trim().eq_ignore_ascii_case("y") { + println!("Aborted."); + return Ok(()); + } + } + let updated = service.update_active(&t.id, &label, &endpoint).await?; + println!("Updated tunnel:"); + println!(" id: {}", updated.id); + updated.id + } else { + println!("Tunnel already configured correctly."); + t.id + } + } else { + let label = label.unwrap_or_else(|| { + let bytes: [u8; 6] = rand::random(); + hex::encode(bytes) + }); + let tunnel = service.create_active(&label, &endpoint).await?; + println!("Created tunnel:"); + println!(" id: {}", tunnel.id); + println!(" label: {}", tunnel.label); + tunnel.id + }; + + heartbeat.start().await; + if let Some(ctx) = datum.selected_context() { + heartbeat.register_project(ctx.project_id).await; + } + + service.set_enabled_active(&tunnel_id, true).await?; + println!(); + println!("Your endpoint ID: {}", endpoint_id); + println!("Setting up tunnel..."); + let setup_start = std::time::Instant::now(); + + let tunnel = loop { + let t = service.get_active(&tunnel_id).await?; + let Some(t) = t else { + n0_error::bail_any!("Tunnel {} not found", tunnel_id); + }; + if t.accepted && t.programmed && !t.hostnames.is_empty() { + break t; + } + tokio::time::sleep(std::time::Duration::from_secs(2)).await; + }; + + let elapsed = setup_start.elapsed().as_secs(); + for hostname in &tunnel.hostnames { + println!("Tunnel ready after {} sec: https://{}", elapsed, hostname); + } + println!("Press Ctrl+C to stop..."); + + tokio::signal::ctrl_c().await?; + println!(); + println!("Disabling tunnel..."); + service.set_enabled_active(&tunnel_id, false).await?; + println!("Tunnel disabled."); + } + TunnelCommands::Update { id, label, endpoint } => { + let current = service.get_active(&id).await?; + let current = current.std_context("Tunnel not found")?; + let new_label = label.unwrap_or(current.label); + let new_endpoint = endpoint.unwrap_or(current.endpoint); + let tunnel = service.update_active(&id, &new_label, &new_endpoint).await?; + println!("Updated tunnel {}:", tunnel.id); + println!(" label: {}", tunnel.label); + println!(" endpoint: {}", tunnel.endpoint); + if !tunnel.hostnames.is_empty() { + println!(" hostnames:"); + for h in &tunnel.hostnames { + println!(" {}", h); + } + } + } + TunnelCommands::Delete { id } => { + service.delete_active(&id).await?; + println!("Deleted tunnel {}", id); + } + } + } } Ok(()) } diff --git a/flake.nix b/flake.nix index fb5869a..433f964 100644 --- a/flake.nix +++ b/flake.nix @@ -20,6 +20,22 @@ targets = [ "wasm32-unknown-unknown" ]; }; + # Toolchain used for the static (musl) CLI build. Kept separate from + # the dev toolchain so the dev shell isn't forced to download musl + # targets it doesn't need. + muslTarget = + if pkgs.stdenv.hostPlatform.isAarch64 then "aarch64-unknown-linux-musl" + else "x86_64-unknown-linux-musl"; + + rustMuslToolchain = pkgs.rust-bin.stable.latest.default.override { + targets = [ muslTarget ]; + }; + + rustMuslPlatform = pkgs.makeRustPlatform { + cargo = rustMuslToolchain; + rustc = rustMuslToolchain; + }; + # Platform-specific packages darwinPackages = with pkgs; lib.optionals stdenv.isDarwin [ apple-sdk_15 @@ -27,10 +43,11 @@ linuxPackages = with pkgs; lib.optionals stdenv.isLinux [ # For web/desktop rendering - webkitgtk + webkitgtk_4_1 gtk3 - libsoup + libsoup_3 # X11 dependencies + xdotool xorg.libX11 xorg.libXcursor xorg.libXrandr @@ -38,8 +55,7 @@ ]; cargoOutputHashes = { - "iroh-proxy-utils-0.1.0" = "sha256-tI26vv7fvNR18KsUJvBTXZ0c7Wc/63Qq88NAWuWMoHs="; - "dioxus-primitives-0.0.1" = "sha256-tI26vv7fvNR18KsUJvBTXZ0c7Wc/63Qq88NAWuWMoHs="; + "dioxus-primitives-0.0.1" = "sha256-T/ZdVqgWDLpdNzf3GlBeQVLbs4eJbqdgDkrUSzMycR4="; }; in @@ -106,6 +122,87 @@ }; }; + # Fully statically linked Linux CLI (musl + static openssl). + # Build with: nix build .#cli-static + # + # We host-build with the default gnu stdenv and cross-target musl + # via cargo's --target flag. Earlier versions of this derivation + # tried `.override { stdenv = muslPkgs.stdenv }` so that + # cargoBuildHook would auto-pick --target=...-musl, but the + # combination of makeRustPlatform + stdenv override + the install + # hook ended up writing the gnu-built binary into $out/bin (only + # the store path name carried the "-musl" suffix). Driving the + # build and install phases ourselves removes that ambiguity. + packages.cli-static = let + muslPkgs = + if pkgs.stdenv.hostPlatform.isAarch64 then pkgs.pkgsCross.aarch64-multiplatform-musl + else pkgs.pkgsCross.musl64; + muslCC = muslPkgs.stdenv.cc; + muslOpenssl = muslPkgs.pkgsStatic.openssl; + linkerEnvVar = + "CARGO_TARGET_" + (pkgs.lib.toUpper (builtins.replaceStrings [ "-" ] [ "_" ] muslTarget)) + "_LINKER"; + in rustMuslPlatform.buildRustPackage { + pname = "datum-connect-cli-static"; + version = "0.1.0"; + + src = ./.; + + cargoLock = { + lockFile = ./Cargo.lock; + outputHashes = cargoOutputHashes; + }; + + nativeBuildInputs = [ + pkgs.pkg-config + muslCC + ]; + + # openssl-sys is pulled in transitively (sentry -> native-tls). + # Provide the musl-built static openssl so the C link succeeds. + buildInputs = [ muslOpenssl ]; + + # Tell openssl-sys + pkg-config to statically link, and point them + # at the musl-built openssl (not the host one). + OPENSSL_STATIC = "1"; + OPENSSL_LIB_DIR = "${muslOpenssl.out}/lib"; + OPENSSL_INCLUDE_DIR = "${muslOpenssl.dev}/include"; + PKG_CONFIG_ALL_STATIC = "1"; + + # Force the C runtime to be linked statically against musl. + CARGO_BUILD_RUSTFLAGS = "-C target-feature=+crt-static"; + + # Use the musl gcc as the linker for the musl target. + ${linkerEnvVar} = "${muslCC}/bin/${muslCC.targetPrefix}cc"; + + buildPhase = '' + runHook preBuild + cargo build -j $NIX_BUILD_CORES \ + --target ${muslTarget} \ + --frozen \ + --release \ + -p datum-connect + runHook postBuild + ''; + + installPhase = '' + runHook preInstall + install -Dm755 target/${muslTarget}/release/datum-connect \ + $out/bin/datum-connect + runHook postInstall + ''; + + doCheck = false; + + # The musl-static binary has no nix-store deps to rewrite. + dontPatchELF = true; + + meta = with pkgs.lib; { + description = "Datum Connect CLI (statically linked)"; + mainProgram = "datum-connect"; + platforms = platforms.linux; + }; + }; + devShells.default = pkgs.mkShell { buildInputs = with pkgs; [ # Rust toolchain with WASM support @@ -132,8 +229,8 @@ # Environment variables RUST_SRC_PATH = "${rustToolchain}/lib/rustlib/src/rust/library"; - # For OpenSSL on macOS - PKG_CONFIG_PATH = "${pkgs.openssl.dev}/lib/pkgconfig"; + RUSTFLAGS = pkgs.lib.optionalString pkgs.stdenv.isLinux + "-L${pkgs.xdotool}/lib"; shellHook = '' echo "🚀 Dioxus development environment loaded" @@ -151,7 +248,7 @@ formatter = pkgs.nixpkgs-fmt; apps.desktop = let - script = pkgs.writeShellScriptBin "desktop-app" '' + script = pkgs.writeShellScriptBin "datum-desktop" '' cd "$PWD/ui" export DATUM_CONNECT_PUBLISH_TICKETS=1 export RUST_LOG=info,lib::heartbeat=debug,lib::tunnels=debug @@ -159,7 +256,16 @@ ''; in { type = "app"; - program = "${script}/bin/desktop-app"; + program = "${script}/bin/datum-desktop"; + }; + + apps.cli = let + script = pkgs.writeShellScriptBin "datum-connect-cli" '' + exec ${self.packages.${system}.cli}/bin/datum-connect "$@" + ''; + in { + type = "app"; + program = "${script}/bin/datum-connect-cli"; }; } ); diff --git a/lib/src/config.rs b/lib/src/config.rs index 386d7bb..e35e886 100644 --- a/lib/src/config.rs +++ b/lib/src/config.rs @@ -7,6 +7,8 @@ use std::{ use n0_error::{Result, StackResultExt, StdResultExt}; use serde::{Deserialize, Serialize}; +use crate::SelectedContext; + #[derive(Debug, Clone, Copy, Serialize, Deserialize, Default)] #[serde(rename_all = "snake_case")] pub enum DiscoveryMode { @@ -49,6 +51,10 @@ pub struct Config { /// Useful for local development (e.g. 127.0.0.1:53535). #[serde(default)] pub dns_resolver: Option, + + /// The currently selected org/project context. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub selected_context: Option, } impl Config { diff --git a/lib/src/datum_cloud.rs b/lib/src/datum_cloud.rs index 61a8a2f..e650cb2 100644 --- a/lib/src/datum_cloud.rs +++ b/lib/src/datum_cloud.rs @@ -19,6 +19,7 @@ pub use self::{ }; mod auth; +pub mod datumctl; mod env; const ORGS_PROJECTS_DEDUP_WINDOW: StdDuration = StdDuration::from_secs(2); @@ -35,7 +36,7 @@ pub struct DatumCloudClient { impl DatumCloudClient { pub async fn with_repo(env: ApiEnv, repo: Repo) -> Result { - let auth = AuthClient::with_repo(env, repo.clone()).await?; + let auth = AuthClient::with_repo(env.clone(), repo.clone()).await?; let session = SessionStateWrapper::from_repo(Some(repo)).await?; let http = reqwest::Client::builder() .user_agent(datum_http_user_agent()) @@ -54,7 +55,7 @@ impl DatumCloudClient { } pub async fn new(env: ApiEnv) -> Result { - let auth = AuthClient::new(env).await?; + let auth = AuthClient::new(env.clone()).await?; let session = SessionStateWrapper::empty(); let http = reqwest::Client::builder() .user_agent(datum_http_user_agent()) @@ -72,15 +73,48 @@ impl DatumCloudClient { Ok(client) } + /// Build a client that delegates auth + selected-project lookup to a sibling `datumctl` + /// install. Reads `~/.datumctl/config` for the active session and selected context, and + /// shells out to `datumctl auth get-token` per API call. Does not run its own OAuth + /// flow and does not write any state to disk. + /// + /// `project_override` pins a specific project (e.g. CLI `--project` flag) instead of + /// using whatever is set in datumctl's current-context. + pub async fn with_datumctl(project_override: Option) -> Result { + let resolved = datumctl::resolve(project_override).await?; + let env = ApiEnv::from_api_url(&resolved.api_url); + let auth = AuthClient::with_datumctl( + env.clone(), + resolved.profile, + Some(resolved.session_name), + ); + let session = SessionStateWrapper::empty(); + session + .set_selected_context(Some(resolved.selected_context)) + .await?; + let http = reqwest::Client::builder() + .user_agent(datum_http_user_agent()) + .build() + .anyerr()?; + Ok(Self { + env, + auth, + http, + session, + orgs_projects_fetch_gate: Arc::new(Mutex::new(None)), + _session_task: None, + }) + } + pub fn login_state(&self) -> LoginState { self.auth.login_state() } - pub fn api_url(&self) -> &'static str { + pub fn api_url(&self) -> &str { self.env.api_url() } - pub fn web_url(&self) -> &'static str { + pub fn web_url(&self) -> &str { self.env.web_url() } @@ -96,6 +130,19 @@ impl DatumCloudClient { self.auth.load() } + pub async fn is_authenticated(&self) -> Result { + let state = self.auth.load_refreshed().await?; + Ok(state.get().is_ok()) + } + + pub async fn login(&self) -> Result<()> { + self.auth.login().await + } + + pub async fn logout(&self) -> Result<()> { + self.auth.logout().await + } + pub fn selected_context(&self) -> Option { self.session.selected_context() } diff --git a/lib/src/datum_cloud/auth.rs b/lib/src/datum_cloud/auth.rs index 66fbe7a..a0e1860 100644 --- a/lib/src/datum_cloud/auth.rs +++ b/lib/src/datum_cloud/auth.rs @@ -31,6 +31,7 @@ const LOGIN_TIMEOUT: Duration = Duration::from_secs(60); /// Refresh auth or relogin if access token is valid for less than 30min const REFRESH_AUTH_WHEN: Duration = Duration::from_secs(60 * 30); +#[derive(Debug, Clone, Serialize, Deserialize)] pub struct AuthProvider { pub issuer_url: String, pub client_id: String, @@ -136,7 +137,8 @@ pub struct StatelessClient { impl StatelessClient { pub async fn new(env: ApiEnv) -> Result { - Self::with_provider(env, env.auth_provider()).await + let provider = env.auth_provider(); + Self::with_provider(env, provider).await } pub async fn with_provider(env: ApiEnv, provider: AuthProvider) -> Result { @@ -190,6 +192,7 @@ impl StatelessClient { .add_scope(Scope::new("profile".to_string())) .add_scope(Scope::new("email".to_string())) .add_scope(Scope::new("offline_access".to_string())) + .add_extra_param("prompt", "select_account") .set_pkce_challenge(pkce_challenge) .url(); debug!(auth_uri=%self.oidc.auth_uri(), "attempting login"); @@ -531,19 +534,28 @@ pub struct AuthClient { env: ApiEnv, /// OIDC client with JWKs. Swapped before each login/refresh so we always have fresh keys /// (avoids "No matching key found" when Datum Cloud rotates signing keys; datum-cloud/app#121). - client: Arc>, + /// `None` for datumctl-backed clients, which never run their own OAuth flow. + client: Option>>, _refresh_task: Option>>, + datumctl: Option>, +} + +#[derive(Debug, Clone)] +pub(super) struct DatumctlAuth { + pub profile: UserProfile, + pub session_name: Option, } impl AuthClient { pub async fn with_repo(env: ApiEnv, repo: Repo) -> Result { let auth = AuthStateWrapper::from_repo(repo, env.oauth_storage_key()).await?; - let auth_client = Arc::new(StatelessClient::new(env).await?); + let auth_client = Arc::new(StatelessClient::new(env.clone()).await?); let mut client = Self { state: auth, env, - client: Arc::new(ArcSwap::new(auth_client)), + client: Some(Arc::new(ArcSwap::new(auth_client))), _refresh_task: None, + datumctl: None, }; client.start_refresh_loop(); Ok(client) @@ -551,23 +563,51 @@ impl AuthClient { pub async fn new(env: ApiEnv) -> Result { let auth = AuthStateWrapper::empty(); - let auth_client = Arc::new(StatelessClient::new(env).await?); + let auth_client = Arc::new(StatelessClient::new(env.clone()).await?); let mut client = Self { state: auth, env, - client: Arc::new(ArcSwap::new(auth_client)), + client: Some(Arc::new(ArcSwap::new(auth_client))), _refresh_task: None, + datumctl: None, }; client.start_refresh_loop(); Ok(client) } + /// Build an AuthClient that sources tokens from a sibling `datumctl` install. Does not + /// run its own OAuth flow; `login`/`logout`/`refresh`/`refresh_profile` will error. + pub(super) fn with_datumctl( + env: ApiEnv, + profile: UserProfile, + session_name: Option, + ) -> Self { + Self { + state: AuthStateWrapper::empty(), + env, + client: None, + _refresh_task: None, + datumctl: Some(Arc::new(DatumctlAuth { + profile, + session_name, + })), + } + } + + fn is_datumctl(&self) -> bool { + self.datumctl.is_some() + } + /// Fetch fresh OIDC provider metadata (including JWKs) and swap in a new client. /// Call before login/refresh to avoid "No matching key found" when keys rotate. async fn ensure_fresh_client(&self) -> Result> { - let fresh = - Arc::new(StatelessClient::with_provider(self.env, self.env.auth_provider()).await?); - self.client.store(fresh.clone()); + let client = self.client.as_ref().std_context( + "OIDC flow is disabled on datumctl-backed AuthClient (manage credentials with datumctl)", + )?; + let fresh = Arc::new( + StatelessClient::with_provider(self.env.clone(), self.env.auth_provider()).await?, + ); + client.store(fresh.clone()); Ok(fresh) } @@ -642,6 +682,23 @@ impl AuthClient { } pub async fn load_refreshed(&self) -> Result> { + if let Some(datumctl) = self.datumctl.clone() { + let token = + super::datumctl::fetch_access_token(datumctl.session_name.as_deref()).await?; + let new_auth = AuthState { + tokens: AuthTokens { + access_token: AccessToken::new(token), + refresh_token: None, + issued_at: chrono::Utc::now(), + // datumctl handles refresh; we always fetch fresh, so the expiry is + // only used to keep `login_state()` reporting `Valid`. + expires_in: Duration::from_secs(3600), + }, + profile: datumctl.profile.clone(), + }; + self.state.set(Some(new_auth)).await?; + return Ok(self.state.load()); + } let state = self.state.load(); match state.get() { Err(_) => Ok(state), @@ -654,11 +711,21 @@ impl AuthClient { } pub async fn logout(&self) -> Result<()> { + if self.is_datumctl() { + n0_error::bail_any!( + "logout is managed by datumctl. Run `datumctl auth logout`." + ); + } self.state.set(None).await?; Ok(()) } pub async fn login(&self) -> Result<()> { + if self.is_datumctl() { + n0_error::bail_any!( + "login is managed by datumctl. Run `datumctl auth login`." + ); + } let auth = self.state.load(); let auth = match auth.get() { Err(_) => { @@ -699,6 +766,11 @@ impl AuthClient { } pub async fn refresh(&self) -> Result<()> { + if self.is_datumctl() { + n0_error::bail_any!( + "token refresh is managed by datumctl. Tokens are re-fetched on demand." + ); + } let auth = self.state.load(); let auth = auth.get()?; let client = self.ensure_fresh_client().await?; @@ -716,11 +788,18 @@ impl AuthClient { /// Refresh the user profile from the API without refreshing tokens pub async fn refresh_profile(&self) -> Result<()> { + if self.is_datumctl() { + n0_error::bail_any!( + "user profile is managed by datumctl. Re-run `datumctl auth login` to update." + ); + } let auth = self.state.load(); let auth = auth.get()?; let user_id = auth.profile.user_id.clone(); let new_profile = self .client + .as_ref() + .std_context("OIDC client unavailable")? .load() .fetch_user_profile(&auth.tokens, &user_id) .await?; @@ -815,8 +894,6 @@ mod redirect_server { static LOGIN_SUCCESS_PNG: &[u8] = include_bytes!("../../../ui/assets/images/login-success.png"); static ALLIANCE_NO1_REGULAR_TTF: &[u8] = include_bytes!("../../../ui/assets/fonts/AllianceNo1-Regular.ttf"); - static FAVICON_LIGHT_32: &[u8] = - include_bytes!("../../../ui/assets/icons/favicon-light-32x32.png"); static FAVICON_DARK_32: &[u8] = include_bytes!("../../../ui/assets/icons/favicon-dark-32x32.png"); diff --git a/lib/src/datum_cloud/datumctl.rs b/lib/src/datum_cloud/datumctl.rs new file mode 100644 index 0000000..8237dab --- /dev/null +++ b/lib/src/datum_cloud/datumctl.rs @@ -0,0 +1,333 @@ +//! Bridge for sourcing auth + selected project from a colocated `datumctl` install. +//! +//! `datum-connect` doesn't run its own OAuth flow when this bridge is used; it reads the +//! datumctl config file for the active session and selected context, and shells out to +//! `datumctl auth get-token` for access tokens. + +use std::path::PathBuf; +use std::process::Stdio; + +use data_encoding::BASE64URL_NOPAD; +use n0_error::{Result, StdResultExt}; +use serde::{Deserialize, Serialize}; +use tokio::process::Command; + +use crate::SelectedContext; +use crate::datum_cloud::auth::UserProfile; + +const DATUMCTL_BIN: &str = "datumctl"; +const CONFIG_DIR: &str = ".datumctl"; +const CONFIG_FILE: &str = "config"; + +/// Subset of the datumctl YAML config we depend on. Unknown fields are ignored so schema +/// additions on the datumctl side don't break us. +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct DatumctlConfig { + #[serde(default, rename = "active-session")] + pub active_session: Option, + #[serde(default, rename = "current-context")] + pub current_context: Option, + #[serde(default)] + pub sessions: Vec, + #[serde(default)] + pub contexts: Vec, + #[serde(default)] + pub cache: Cache, +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct Session { + pub name: String, + pub endpoint: Endpoint, + #[serde(default, rename = "user-email")] + pub user_email: Option, + #[serde(default, rename = "user-name")] + pub user_name: Option, + #[serde(default, rename = "user-key")] + pub user_key: Option, +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct Endpoint { + pub server: String, + #[serde(default, rename = "auth-hostname")] + pub auth_hostname: Option, +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct Context { + pub name: String, + #[serde(default, rename = "organization-id")] + pub organization_id: Option, + #[serde(default, rename = "project-id")] + pub project_id: Option, + #[serde(default)] + pub session: Option, +} + +#[derive(Debug, Clone, Default, Deserialize, Serialize)] +pub struct Cache { + #[serde(default)] + pub organizations: Vec, + #[serde(default)] + pub projects: Vec, +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct CachedOrg { + pub id: String, + #[serde(default, rename = "display-name")] + pub display_name: Option, + #[serde(default, rename = "type")] + pub org_type: Option, +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct CachedProject { + pub id: String, + #[serde(default, rename = "display-name")] + pub display_name: Option, + #[serde(default, rename = "org-id")] + pub org_id: Option, +} + +/// Everything `datum-connect` needs to talk to the Datum API: an active session, the +/// resolved selected context, and (optionally) a project override. +#[derive(Debug, Clone)] +pub struct ResolvedSession { + pub session_name: String, + pub api_url: String, + pub profile: UserProfile, + pub selected_context: SelectedContext, +} + +fn config_path() -> Result { + let home = dirs_next::home_dir() + .std_context("Could not determine home directory for datumctl config")?; + Ok(home.join(CONFIG_DIR).join(CONFIG_FILE)) +} + +/// Read and parse the datumctl config file. Fails with a friendly message if the file is +/// missing (user hasn't run `datumctl auth login` yet). +pub async fn read_config() -> Result { + let path = config_path()?; + let data = match tokio::fs::read_to_string(&path).await { + Ok(data) => data, + Err(err) if err.kind() == std::io::ErrorKind::NotFound => { + n0_error::bail_any!( + "datumctl config not found at {}. Run `datumctl auth login` first.", + path.display() + ); + } + Err(err) => { + return Err(err) + .with_std_context(|_| format!("Failed to read datumctl config at {}", path.display())); + } + }; + let config: DatumctlConfig = serde_yml::from_str(&data) + .with_std_context(|_| format!("Failed to parse datumctl config at {}", path.display()))?; + Ok(config) +} + +/// Resolve the active session and selected context from the datumctl config. +/// +/// `project_override` lets the caller pin a specific project (e.g. `datum-connect tunnel +/// --project=...`). When set, the org is looked up via the cache; if the project isn't in +/// the cache we trust the user and pass through with the project_id alone. +pub async fn resolve(project_override: Option) -> Result { + let config = read_config().await?; + let mut resolved = resolve_from_config(&config, project_override)?; + // datumctl's session.user-key holds the user's email, but the IAM API at + // /apis/iam.miloapis.com/v1alpha1/users/{user_id} requires the OIDC subject. + // Pull a fresh access token and read its `sub` claim. + let token = fetch_access_token(Some(&resolved.session_name)).await?; + resolved.profile.user_id = decode_jwt_sub(&token) + .std_context("Failed to extract user ID from datumctl access token")?; + Ok(resolved) +} + +pub fn resolve_from_config( + config: &DatumctlConfig, + project_override: Option, +) -> Result { + let current_name = config + .current_context + .as_deref() + .filter(|s| !s.is_empty()) + .std_context( + "No current context set in datumctl. Run `datumctl project switch` to pick one.", + )?; + let context = config + .contexts + .iter() + .find(|c| c.name == current_name) + .with_std_context(|_| { + format!( + "datumctl current-context '{current_name}' not present in contexts list. \ + Re-run `datumctl project switch`." + ) + })?; + + let session_name = context + .session + .as_deref() + .or(config.active_session.as_deref()) + .filter(|s| !s.is_empty()) + .std_context("datumctl current context is not bound to a session. Run `datumctl auth login`.")?; + let session = config + .sessions + .iter() + .find(|s| s.name == session_name) + .with_std_context(|_| { + format!("datumctl session '{session_name}' is referenced but missing. Re-run `datumctl auth login`.") + })?; + + let org_id = context + .organization_id + .clone() + .filter(|s| !s.is_empty()) + .std_context("Selected context has no organization. Use `datumctl project switch` to pick a project.")?; + + let project_id = match project_override { + Some(pid) => pid, + None => context + .project_id + .clone() + .filter(|s| !s.is_empty()) + .std_context( + "Selected context has no project. Use `datumctl project switch` to pick one, \ + or pass `--project`.", + )?, + }; + + let org = config.cache.organizations.iter().find(|o| o.id == org_id); + let project = config.cache.projects.iter().find(|p| p.id == project_id); + + let selected_context = SelectedContext { + org_id: org_id.clone(), + org_name: org + .and_then(|o| o.display_name.clone()) + .unwrap_or_else(|| org_id.clone()), + project_id: project_id.clone(), + project_name: project + .and_then(|p| p.display_name.clone()) + .unwrap_or_else(|| project_id.clone()), + org_type: org + .and_then(|o| o.org_type.clone()) + .unwrap_or_else(|| "personal".to_string()), + }; + + let profile = UserProfile { + user_id: session.user_key.clone().unwrap_or_default(), + email: session.user_email.clone().unwrap_or_default(), + first_name: session.user_name.clone(), + last_name: None, + avatar_url: None, + registration_approval: None, + }; + + Ok(ResolvedSession { + session_name: session.name.clone(), + api_url: session.endpoint.server.trim_end_matches('/').to_string(), + profile, + selected_context, + }) +} + +/// Shell out to `datumctl auth get-token [--session=NAME]` and return the trimmed token. +/// Hard fails if the binary is missing or exits non-zero. +pub async fn fetch_access_token(session_name: Option<&str>) -> Result { + let mut cmd = Command::new(DATUMCTL_BIN); + cmd.arg("auth").arg("get-token"); + if let Some(name) = session_name { + cmd.arg("--session").arg(name); + } + cmd.stdin(Stdio::null()); + cmd.stdout(Stdio::piped()); + cmd.stderr(Stdio::piped()); + + let output = cmd.output().await.map_err(|err| { + if err.kind() == std::io::ErrorKind::NotFound { + n0_error::anyerr!( + "`datumctl` binary not found on PATH. Install datumctl to use datum-connect." + ) + } else { + n0_error::anyerr!("Failed to spawn datumctl: {err}") + } + })?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + let stderr = stderr.trim(); + if stderr.is_empty() { + n0_error::bail_any!( + "datumctl auth get-token exited with status {}. Try `datumctl auth login`.", + output.status + ); + } else { + n0_error::bail_any!("datumctl auth get-token failed: {stderr}"); + } + } + + let token = String::from_utf8(output.stdout) + .std_context("datumctl returned non-UTF8 token output")? + .trim() + .to_string(); + if token.is_empty() { + n0_error::bail_any!("datumctl returned an empty access token"); + } + Ok(token) +} + +/// Extract the `sub` claim from a JWT access token without verifying its signature. +/// We rely on datumctl to have already validated the token; we only need the subject +/// to build IAM API paths like `/apis/iam.miloapis.com/v1alpha1/users/{sub}`. +fn decode_jwt_sub(token: &str) -> Result { + let parts: Vec<&str> = token.split('.').collect(); + if parts.len() != 3 { + n0_error::bail_any!( + "access token is not a JWT (expected 3 dot-separated segments, got {})", + parts.len() + ); + } + let payload_bytes = BASE64URL_NOPAD + .decode(parts[1].as_bytes()) + .std_context("Failed to base64url-decode JWT payload")?; + let payload: serde_json::Value = serde_json::from_slice(&payload_bytes) + .std_context("Failed to parse JWT payload as JSON")?; + payload + .get("sub") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()) + .std_context("JWT payload is missing `sub` claim") +} + +#[cfg(test)] +mod tests { + use super::*; + + fn make_jwt(payload_json: &str) -> String { + let header = BASE64URL_NOPAD.encode(br#"{"alg":"none","typ":"JWT"}"#); + let payload = BASE64URL_NOPAD.encode(payload_json.as_bytes()); + let sig = BASE64URL_NOPAD.encode(b"sig"); + format!("{header}.{payload}.{sig}") + } + + #[test] + fn decode_jwt_sub_reads_subject_claim() { + let token = make_jwt(r#"{"sub":"user-abc123","email":"a@b.com"}"#); + assert_eq!(decode_jwt_sub(&token).unwrap(), "user-abc123"); + } + + #[test] + fn decode_jwt_sub_rejects_non_jwt() { + assert!(decode_jwt_sub("not-a-jwt").is_err()); + assert!(decode_jwt_sub("a.b").is_err()); + } + + #[test] + fn decode_jwt_sub_rejects_missing_sub() { + let token = make_jwt(r#"{"email":"a@b.com"}"#); + assert!(decode_jwt_sub(&token).is_err()); + } +} diff --git a/lib/src/datum_cloud/env.rs b/lib/src/datum_cloud/env.rs index 2efc635..9168c2f 100644 --- a/lib/src/datum_cloud/env.rs +++ b/lib/src/datum_cloud/env.rs @@ -15,10 +15,19 @@ const PROD_CLIENT_ID: &str = "360628348109527815"; const PROD_WEB_URL: &str = "https://cloud.datum.net"; /// Environment for Datum API and auth. Use [`ApiEnv::from_env()`] or `ApiEnv::default()` to respect `DATUM_API_ENV`. -#[derive(Debug, Copy, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize)] pub enum ApiEnv { Staging, Production, + /// Custom endpoint resolved at runtime (e.g. from datumctl's session config). + /// `auth_provider` is best-effort; embedded OAuth flows will use it but datumctl-backed + /// clients never trigger those. + Custom { + api_url: String, + web_url: String, + auth_provider: AuthProvider, + oauth_storage_key: String, + }, } impl ApiEnv { @@ -30,25 +39,53 @@ impl ApiEnv { } } + /// Map a known API URL to a built-in variant, falling back to a `Custom` env with a + /// stubbed auth provider. The stubbed provider is only valid for clients that never + /// trigger embedded OIDC (i.e. datumctl-backed clients). + pub fn from_api_url(api_url: &str) -> Self { + let trimmed = api_url.trim_end_matches('/'); + if trimmed == PROD_API_URL { + ApiEnv::Production + } else if trimmed == STAGING_API_URL { + ApiEnv::Staging + } else { + ApiEnv::Custom { + api_url: trimmed.to_string(), + web_url: trimmed.to_string(), + auth_provider: AuthProvider { + issuer_url: trimmed.to_string(), + client_id: String::new(), + client_secret: None, + }, + oauth_storage_key: "custom".to_string(), + } + } + } + /// Storage key for per-env OAuth state (e.g. "staging", "production"). - pub fn oauth_storage_key(&self) -> &'static str { + pub fn oauth_storage_key(&self) -> &str { match self { ApiEnv::Staging => "staging", ApiEnv::Production => "production", + ApiEnv::Custom { + oauth_storage_key, .. + } => oauth_storage_key, } } - pub fn api_url(&self) -> &'static str { + pub fn api_url(&self) -> &str { match self { ApiEnv::Staging => STAGING_API_URL, ApiEnv::Production => PROD_API_URL, + ApiEnv::Custom { api_url, .. } => api_url, } } - pub fn web_url(&self) -> &'static str { + pub fn web_url(&self) -> &str { match self { ApiEnv::Staging => STAGING_WEB_URL, ApiEnv::Production => PROD_WEB_URL, + ApiEnv::Custom { web_url, .. } => web_url, } } @@ -64,6 +101,7 @@ impl ApiEnv { client_id: PROD_CLIENT_ID.to_string(), client_secret: None, }, + ApiEnv::Custom { auth_provider, .. } => auth_provider.clone(), } } } diff --git a/lib/src/node.rs b/lib/src/node.rs index 2d43425..0d18d5e 100644 --- a/lib/src/node.rs +++ b/lib/src/node.rs @@ -177,9 +177,11 @@ impl StateWrapper { fn tcp_proxy_exists(&self, host: &str, port: u16) -> bool { // Strip scheme from incoming host (e.g., "http://127.0.0.1" -> "127.0.0.1") // The gateway may send the host with scheme, but local state stores without - let normalized_host = strip_host_scheme(host); + let normalized_host = normalize_loopback(strip_host_scheme(host)); let exists = self.get().proxies.iter().any(|a| { - a.enabled && a.info.service().host == normalized_host && a.info.service().port == port + a.enabled + && normalize_loopback(&a.info.service().host) == normalized_host + && a.info.service().port == port }); if !exists { debug!( @@ -198,6 +200,15 @@ fn strip_host_scheme(host: &str) -> &str { .unwrap_or(host) } +/// Normalize loopback hostnames so that "localhost", "127.0.0.1", and "::1" compare equal. +/// The gateway may use either form regardless of how the tunnel was registered. +fn normalize_loopback(host: &str) -> &str { + match host { + "localhost" | "::1" => "127.0.0.1", + _ => host, + } +} + impl AuthHandler for StateWrapper { async fn authorize<'a>( &'a self, diff --git a/lib/src/repo.rs b/lib/src/repo.rs index c3975ab..bbd4159 100644 --- a/lib/src/repo.rs +++ b/lib/src/repo.rs @@ -21,7 +21,6 @@ impl Repo { const OAUTH_FILE: &str = "oauth.yml"; const AUTH_FILE: &str = "auth.yml"; const STATE_FILE: &str = "state.yml"; - const SELECTED_CONTEXT_FILE: &str = "selected_context.yml"; pub fn default_location() -> PathBuf { match std::env::var("DATUM_CONNECT_REPO") { @@ -75,21 +74,28 @@ impl Repo { &self, selected: Option<&crate::SelectedContext>, ) -> Result<()> { - let path = self.0.join(Self::SELECTED_CONTEXT_FILE); - let data = serde_yml::to_string(&selected).anyerr()?; - tokio::fs::write(path, data).await?; - Ok(()) + let path = self.0.join(Self::CONFIG_FILE); + let mut config = if path.exists() { + let data = tokio::fs::read_to_string(&path) + .await + .context("reading config file")?; + serde_yml::from_str(&data).std_context("parsing config file")? + } else { + crate::config::Config::default() + }; + config.selected_context = selected.cloned(); + config.write(path).await } pub async fn read_selected_context(&self) -> Result> { - let path = self.0.join(Self::SELECTED_CONTEXT_FILE); + let path = self.0.join(Self::CONFIG_FILE); if path.exists() { let data = tokio::fs::read_to_string(path) .await - .context("failed to read selected context file")?; - let selected: Option = - serde_yml::from_str(&data).std_context("failed to parse selected context file")?; - return Ok(selected); + .context("reading config file")?; + let config: crate::config::Config = + serde_yml::from_str(&data).std_context("parsing config file")?; + return Ok(config.selected_context); } Ok(None) } diff --git a/lib/src/tunnels.rs b/lib/src/tunnels.rs index 5dc92f3..cf3661a 100644 --- a/lib/src/tunnels.rs +++ b/lib/src/tunnels.rs @@ -138,6 +138,12 @@ impl TunnelService { Ok(tunnels.into_iter().find(|tunnel| tunnel.id == tunnel_id)) } + pub async fn get_active_by_endpoint(&self, endpoint: &str) -> Result> { + let tunnels = self.list_active().await?; + let normalized = normalize_endpoint(endpoint); + Ok(tunnels.into_iter().find(|tunnel| tunnel.endpoint == normalized)) + } + pub async fn create_active(&self, label: &str, endpoint: &str) -> Result { let Some(selected) = self.datum.selected_context() else { n0_error::bail_any!("No project selected"); @@ -179,7 +185,7 @@ impl TunnelService { } pub async fn list_project(&self, project_id: &str) -> Result> { - let connector = self.find_connector(project_id).await?; + let connector = self.find_connector_readonly(project_id).await?; let Some(connector) = connector else { return Ok(Vec::new()); }; @@ -688,13 +694,7 @@ impl TunnelService { tunnel_id: &str, ) -> Result { let connector = self.find_connector(project_id).await?; - let Some(connector) = connector else { - return Ok(TunnelDeleteOutcome { - project_id: project_id.to_string(), - connector_deleted: false, - }); - }; - let connector_name = connector.name_any(); + let connector_name = connector.as_ref().map(|c| c.name_any()); let pcp = self.datum.project_control_plane_client(project_id).await?; let client = pcp.client(); @@ -748,41 +748,43 @@ impl TunnelService { warn!(%tunnel_id, "Failed to remove proxy state: {err:#}"); } - let remaining = proxies - .list(&ListParams::default()) - .await - .std_context("Failed to list remaining HTTPProxy objects")?; let mut connector_deleted = false; - let mut remaining_for_connector = remaining - .items - .into_iter() - .filter(|proxy| proxy_uses_connector(proxy, &connector_name)) - .peekable(); - if remaining_for_connector.peek().is_none() { - let ad_selector = format!("{ADVERTISEMENT_CONNECTOR_FIELD}={connector_name}"); - let ads_list = ads - .list(&ListParams::default().fields(&ad_selector)) + if let Some(connector_name) = connector_name { + let remaining = proxies + .list(&ListParams::default()) .await - .std_context("Failed to list remaining ConnectorAdvertisements")?; - for ad in ads_list.items { - if let Some(name) = ad.metadata.name.clone() - && let Err(err) = ads.delete(&name, &DeleteParams::default()).await - { - warn!(%name, "Failed to delete connector advertisement: {err:#}"); + .std_context("Failed to list remaining HTTPProxy objects")?; + let mut remaining_for_connector = remaining + .items + .into_iter() + .filter(|proxy| proxy_uses_connector(proxy, &connector_name)) + .peekable(); + if remaining_for_connector.peek().is_none() { + let ad_selector = format!("{ADVERTISEMENT_CONNECTOR_FIELD}={connector_name}"); + let ads_list = ads + .list(&ListParams::default().fields(&ad_selector)) + .await + .std_context("Failed to list remaining ConnectorAdvertisements")?; + for ad in ads_list.items { + if let Some(name) = ad.metadata.name.clone() + && let Err(err) = ads.delete(&name, &DeleteParams::default()).await + { + warn!(%name, "Failed to delete connector advertisement: {err:#}"); + } } - } - if connectors - .get_opt(&connector_name) - .await - .std_context("Failed to load Connector")? - .is_some() - { - connectors - .delete(&connector_name, &DeleteParams::default()) + if connectors + .get_opt(&connector_name) .await - .std_context("Failed to delete Connector")?; - connector_deleted = true; + .std_context("Failed to load Connector")? + .is_some() + { + connectors + .delete(&connector_name, &DeleteParams::default()) + .await + .std_context("Failed to delete Connector")?; + connector_deleted = true; + } } } @@ -792,6 +794,29 @@ impl TunnelService { }) } + async fn find_connector_readonly(&self, project_id: &str) -> Result> { + let pcp = self.datum.project_control_plane_client(project_id).await?; + let client = pcp.client(); + let connectors: Api = Api::namespaced(client, DEFAULT_PCP_NAMESPACE); + let endpoint_id = self.listen.endpoint_id().to_string(); + let selector = format!("{CONNECTOR_SELECTOR_FIELD}={endpoint_id}"); + let list = connectors + .list(&ListParams::default().fields(&selector)) + .await + .std_context("Failed to list connectors")?; + if list.items.is_empty() { + return Ok(None); + } + if list.items.len() > 1 { + debug!( + %selector, + count = list.items.len(), + "Multiple connectors found for endpoint, using first" + ); + } + Ok(Some(list.items.into_iter().next().unwrap())) + } + async fn find_connector(&self, project_id: &str) -> Result> { let pcp = self.datum.project_control_plane_client(project_id).await?; let client = pcp.client(); diff --git a/ui/src/components/select/component.rs b/ui/src/components/select/component.rs index b20e967..e11e3bf 100644 --- a/ui/src/components/select/component.rs +++ b/ui/src/components/select/component.rs @@ -17,6 +17,7 @@ pub enum SelectAlign { #[derive(Clone, Copy, PartialEq, Eq)] pub enum SelectSize { Default, + #[allow(dead_code)] Small, } diff --git a/ui/src/main.rs b/ui/src/main.rs index d3e1d7c..9f2ce1b 100644 --- a/ui/src/main.rs +++ b/ui/src/main.rs @@ -13,7 +13,7 @@ use crate::views::{ #[cfg(feature = "desktop")] use dioxus_desktop::{ trayicon::{ - menu::{IconMenuItem, Menu, MenuItem, NativeIcon, PredefinedMenuItem}, + menu::{IconMenuItem, Menu, MenuItem, PredefinedMenuItem}, Icon, TrayIcon, TrayIconBuilder, }, use_muda_event_handler, use_window, @@ -88,8 +88,10 @@ pub struct DiagnosticsContext { pub prompted: Signal, } -// Assets for favicons +// Assets for favicons (macOS title bar only) +#[cfg(target_os = "macos")] const FAVICON_DARK_196: Asset = asset!("/assets/icons/favicon-dark-196x196.png"); +#[cfg(target_os = "macos")] const FAVICON_LIGHT_196: Asset = asset!("/assets/icons/favicon-light-196x196.png"); #[cfg(all(feature = "desktop", target_os = "macos"))] @@ -171,7 +173,7 @@ fn main() { .with_has_shadow(true) .with_fullsize_content_view(true); - let mut config = Config::new() + let config = Config::new() // Make "close" behave like hide, so the tray icon can restore it. .with_close_behaviour(WindowCloseBehaviour::WindowHides) .with_window(window_builder); @@ -210,14 +212,13 @@ fn init_tracing() { #[component] fn TitleBar() -> Element { let IsLoginPageSignal(is_login_page) = consume_context::(); - let title_bar_bg = if is_login_page() { - "h-[32px] flex items-center pl-20 z-50 cursor-default bg-[var(--midnight-fjord)]" - } else { - "h-[32px] flex items-center pl-20 z-50 cursor-default bg-background" - }; - #[cfg(target_os = "macos")] { + let title_bar_bg = if is_login_page() { + "h-[32px] flex items-center pl-20 z-50 cursor-default bg-[var(--midnight-fjord)]" + } else { + "h-[32px] flex items-center pl-20 z-50 cursor-default bg-background" + }; rsx! { div { class: "{title_bar_bg}", @@ -254,13 +255,13 @@ fn TitleBar() -> Element { #[component] fn App() -> Element { let mut app_state_ready = use_signal(|| false); - let mut installing_update = + let installing_update = use_signal(|| std::env::var("DATUM_UPDATE_FAKE_INSTALLING").as_deref() == Ok("1")); - let mut manual_update_check = use_signal(|| false); - let mut update_check_in_progress = use_signal(|| false); - let mut update_downloading = use_signal(|| false); + let manual_update_check = use_signal(|| false); + let update_check_in_progress = use_signal(|| false); + let update_downloading = use_signal(|| false); let mut update_ready = use_signal(|| None::); - let mut has_pending_install = use_signal(|| false); + let has_pending_install = use_signal(|| false); let mut pending_update_info = use_signal(|| None::); let mut install_now_trigger = use_signal(|| false); let update_channel = use_signal(|| lib::UpdateChannel::infer_from_installed_version()); @@ -391,8 +392,8 @@ fn App() -> Element { tracing::info!("DATUM_UPDATE_FAKE=1: injected fake update ready"); // Still run the loop to handle Later/Install now, but skip real updates - let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel::>(); - let checker = lib::UpdateChecker::new(repo.clone()); + let (_tx, mut rx) = tokio::sync::mpsc::unbounded_channel::>(); + let _checker = lib::UpdateChecker::new(repo.clone()); let mut last_periodic_check = std::time::Instant::now(); loop { tokio::select! { diff --git a/ui/src/state.rs b/ui/src/state.rs index b0da2d8..c2a10e5 100644 --- a/ui/src/state.rs +++ b/ui/src/state.rs @@ -48,10 +48,6 @@ impl AppState { &self.heartbeat } - pub fn listen_node(&self) -> &ListenNode { - &self.node().listen - } - pub fn tunnel_service(&self) -> TunnelService { TunnelService::new(self.datum.clone(), self.node.listen.clone()) }