//! Remote connections, like T3 Code's environments: one copy of the app can //! let others on the network see and drive its projects and threads. The //! host runs a small WebSocket server guarded by a key; a client connects //! with the host's address and that key, and its threads show up in the //! client's sidebar under the host's name. //! //! The protocol is JSON over WebSocket text messages. A client says //! [`ClientMessage::Hello`] with the key, gets the host's projects and //! threads in [`ServerMessage::State`], subscribes to the threads it opens, //! and receives each change as a [`ServerMessage::Thread`] carrying the //! transcript from the first item that changed. The host also shares its //! environment settings in [`ServerMessage::Settings`], which clients can //! change with [`ClientMessage::UpdateSettings`]. The Add project palette //! browses and adds the host's folders with [`ClientMessage::ListFolders`] //! and [`ClientMessage::AddProject`]. A client's terminal drawer runs its //! shells on the host: [`ClientMessage::TerminalOpen`] starts one in a //! thread's folder there, keys go over as [`ClientMessage::TerminalInput`], //! and the shell's output comes back as [`ServerMessage::TerminalOutput`]. //! A client's Device panel uses the host's simulators and emulators: it asks //! with [`ClientMessage::Device`], gets [`ServerMessage::DeviceReply`], and //! follows [`ServerMessage::DeviceState`]; the video and touches go through //! the host's [`device_proxy`] on the same port. Clients without a catalog //! of their own, like the Android app, ask for the host's agents and models //! with [`ClientMessage::Catalog`] and for a turn's diff with //! [`ClientMessage::Diff`]. //! //! A host can be reached more than one way, like T3 Code's routes: over the //! LAN at home and over Tailscale or another VPN away from it. The host //! reports the [`Route`]s it listens on in [`ServerMessage::Welcome`] and //! again in [`ServerMessage::Routes`] when they change; the client saves //! them with the connection, tries them fastest first, and moves back to a //! faster one as soon as it answers. pub mod client; pub mod device_proxy; pub mod server; pub mod shell; use std::path::PathBuf; use serde::{Deserialize, Serialize}; use crate::git::{FileDiff, LineKind, TurnChanges}; use crate::projects::ProjectSource; use crate::ui::folder_browser::Folder; use crate::models::{Catalog, InstanceState, ModelEntry, ProbeStatus, Provider}; use crate::session::{QueuedMessage, SavedItem}; use crate::settings::SettingsPatch; use crate::worktree::PullRequest; /// The port a host listens on unless told otherwise. pub const DEFAULT_PORT: u16 = 47_823; /// Bumped when the messages change incompatibly. pub const PROTOCOL: u32 = 1; /// A thread as the sidebar needs it. #[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] #[serde(default)] pub struct ThreadSummary { pub id: String, pub project: PathBuf, pub title: String, pub provider: Provider, pub instance: String, pub model: Option, pub permission_mode: String, pub reasoning: String, pub fast_mode: bool, /// `starting`, `idle`, `working` or `exited`. pub status: String, /// A tool call or question waits for the user. pub attention: bool, pub settled: bool, pub pinned: bool, /// Milliseconds since the epoch, like every time here. pub snoozed_until: Option, pub created: u64, pub updated: u64, pub last_message: u64, /// The thread's worktree branch, if it runs in one. pub branch: Option, pub total_cost: f64, /// Whether anything was sent in it yet. pub has_items: bool, /// For a subagent's thread, the thread that delegated its task. #[serde(skip_serializing_if = "Option::is_none")] pub parent: Option, } /// The rest of an open thread's state, sent with its transcript. #[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] #[serde(default)] pub struct ThreadDetail { pub cwd: PathBuf, pub active_model: Option, pub thinking: bool, pub preparing: bool, /// How long the running turn has been going. pub turn_elapsed_ms: Option, pub context_tokens: Option, pub context_window: Option, pub queue: Vec, pub limit_resets: Option, pub resume_at: Option, /// Bound for a worktree made with the first message. pub pending_worktree: bool, pub pull_request: Option, pub turn_changes: Vec<(usize, TurnChanges)>, pub background_tasks: Vec, } /// A command, monitor or subagent the host's agent left running. #[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] #[serde(default)] pub struct RemoteTask { pub task_id: String, pub description: String, pub elapsed_ms: u64, } #[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] #[serde(default)] pub struct ProjectInfo { pub path: PathBuf, pub name: String, /// `None` outside a git repository. pub branch: Option, pub branches: Vec, pub git: bool, pub added: u64, pub removed: u64, /// The `origin` remote's URL, so a device can find the same repository /// among its own projects whatever folder it's in. pub origin: Option, } /// A file sent along with a message. #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub struct Upload { pub name: String, /// Base64. pub data: String, } /// What a client asks of the host's devices. Threads are the host's ids. #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] #[serde(tag = "call", rename_all = "snake_case")] pub enum DeviceCall { /// Send the device state now and whenever it changes. Watch, /// Refresh the devices, starting the hub once device support is on. List, Configure { enabled: Option, agent_access: Option, onboarding: Option }, Retry, Open { thread: String, device: String, boot: bool }, Close { thread: String, device: Option, shutdown: bool }, Detail { device: String }, Action { device: String, action: crate::device::Action }, /// A PNG of the screen, base64. Screenshot { device: String }, Accessibility { device: String }, } /// What a client asks of a thread on the host. #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] #[serde(tag = "action", rename_all = "snake_case")] pub enum ThreadAction { Send { text: String, attachments: Vec }, Queue { text: String, attachments: Vec }, SteerQueued { ix: usize }, RemoveQueued { ix: usize }, Interrupt, /// `allow`, `always` or `deny`. Permission { ix: usize, decision: String }, Answer { ix: usize, option: usize }, /// Answers an AskUserQuestion form, one answer per question. AskSubmit { ix: usize, answers: Vec }, AskSkip { ix: usize }, /// Sends a running command or subagent to the background. Background { ix: usize }, StopTask { task_id: String }, SetModel { model: Option }, SetPermissionMode { mode: String }, SetReasoning { reasoning: String }, SetFastMode { on: bool }, Settle, Unsettle, Pin { pinned: bool }, Snooze { until: Option }, Rename { title: String }, CancelResume, ScheduleResume { at: u64 }, Delete, } #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] #[serde(tag = "type", rename_all = "snake_case")] pub enum ClientMessage { Hello { key: String, name: String, protocol: u32 }, Subscribe { thread: String }, Unsubscribe { thread: String }, Action { thread: String, action: ThreadAction }, /// Starts a thread in one of the host's projects with its first message. Create { reference: u64, project: PathBuf, instance: String, provider: Provider, model: Option, permission_mode: String, reasoning: String, fast_mode: bool, worktree: bool, /// The branch a worktree starts from. base: Option, text: String, attachments: Vec, }, /// Changes the host's environment settings or its projects' overrides. UpdateSettings { patch: SettingsPatch }, /// Lists a folder on the host for the Add project palette, as typed /// there: `~/Work/`. Older hosts don't answer. ListFolders { path: String }, /// Adds a project on the host, cloning or creating it as asked. AddProject { source: ProjectSource }, /// Starts a shell on the host in a thread's folder (the project's, for /// a thread not created yet). `terminal` is the client's id for it. /// Older hosts don't answer. TerminalOpen { terminal: u64, thread: Option, project: PathBuf, columns: u16, lines: u16 }, /// What was typed in a terminal, base64. TerminalInput { terminal: u64, data: String }, TerminalResize { terminal: u64, columns: u16, lines: u16 }, /// Hangs up on a terminal's shell. TerminalClose { terminal: u64 }, /// Something for the host's devices; `request` pairs it with its reply. /// Older hosts don't answer. Device { request: u64, call: DeviceCall }, /// Asks for the host's agents, models and favorites, answered with /// [`ServerMessage::Catalog`]. Older hosts don't answer. Catalog, /// Asks for the diff between two of a thread's snapshots (a turn's /// `base` and `head`), for one file or all of them. Older hosts don't /// answer. Diff { request: u64, thread: String, base: String, head: String, path: Option, ignore_whitespace: bool }, } #[derive(Clone, Debug, Serialize, Deserialize)] #[serde(tag = "type", rename_all = "snake_case")] pub enum ServerMessage { Welcome { host: String, version: String, protocol: u32, /// Where else the host listens. Older hosts don't say. #[serde(default)] routes: Vec, }, Denied { reason: String }, State { projects: Vec, threads: Vec }, /// A subscribed thread changed: its state, and its transcript from item /// `from` on (earlier items are as last sent); `len` items in all. Thread { summary: ThreadSummary, detail: ThreadDetail, from: usize, len: usize, items: Vec }, Created { reference: u64, thread: String }, Error { message: String }, /// The host's environment settings and project overrides, sent on /// connecting and whenever they change. Older hosts don't send it. Settings { values: serde_json::Map }, /// The subfolders of a folder a client asked for, or why it can't have them. Folders { path: String, folders: Vec, truncated: bool, error: Option }, /// The project a client asked for is in the host's list now. ProjectAdded { path: PathBuf }, /// A terminal's shell printed something, base64. TerminalOutput { terminal: u64, data: String }, /// A terminal's shell exited, or with `error`, couldn't start. TerminalExited { terminal: u64, error: Option }, /// The answer to a [`ClientMessage::Device`]: its result or what went wrong. DeviceReply { request: u64, ok: Option, error: Option }, /// The host's devices, for clients that watch them. DeviceState { state: crate::device::State }, /// An agent on the host opened a device in one of its threads. DeviceOpened { thread: String, device: crate::device::DeviceSummary }, /// An agent closed a thread's device, or all of them without one. DeviceClosed { thread: String, device: Option }, /// The host's addresses changed, as when it joined another Wi-Fi. /// Older hosts don't send it. Routes { routes: Vec }, /// The host's agents and the models they offer, as its pickers show /// them, and its favorite models by key (`instance:id`). Catalog { instances: Vec, favorites: Vec }, /// The answer to a [`ClientMessage::Diff`]: the changed files, or what /// went wrong. DiffReply { request: u64, files: Vec, error: Option }, } /// A provider instance on the host and what its last health check found. #[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] #[serde(default)] pub struct RemoteInstance { pub id: String, pub provider: Provider, pub name: String, /// A hex color (`#3e9bfa`) marking the instance, if it has one. pub accent: Option, /// `ready`, `checking`, `missing`, `disabled` or `failed`. pub status: String, pub account: Option, pub plan: Option, /// The model Claude's `default` entry resolves to (`claude-opus-5-5`), when it reports one. pub default_model: Option, pub models: Vec, pub commands: Vec, } /// A model as the host's picker knows it; see [`ModelEntry`]. #[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] #[serde(default)] pub struct RemoteModel { pub id: String, pub resolved: String, pub name: String, pub detail: String, pub legacy: bool, /// Reasoning levels it accepts, as the CLI spells them (`high`). pub efforts: Vec, pub default_effort: Option, pub ultracode: bool, pub fast_mode: bool, pub auto_mode: bool, pub context_window: Option, /// Hidden from the model picker in the host's settings. pub hidden: bool, } /// A `/` command or skill an instance offers. #[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] #[serde(default)] pub struct RemoteCommand { pub name: String, pub description: String, pub argument_hint: String, pub skill: bool, } /// One file of a diff. #[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] #[serde(default)] pub struct RemoteFileDiff { pub path: String, pub binary: bool, pub lines: Vec, } #[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] #[serde(default)] pub struct RemoteDiffLine { /// `hunk`, `context`, `added` or `removed`. pub kind: String, pub text: String, pub old_line: Option, pub new_line: Option, } impl RemoteInstance { pub fn from_state(state: &InstanceState, catalog: &Catalog) -> Self { let status = match state.status { ProbeStatus::Ready => "ready", ProbeStatus::Checking => "checking", ProbeStatus::Missing => "missing", ProbeStatus::Disabled => "disabled", ProbeStatus::Failed(_) => "failed", }; let accent = state.config.accent.trim(); Self { id: state.config.id.clone(), provider: state.config.kind, name: state.label(), accent: (!accent.is_empty()).then(|| accent.to_string()), status: status.into(), account: state.account.clone(), plan: state.plan.clone(), default_model: state.default.as_ref().map(|model| model.resolved.clone()), models: state.models.iter().map(|model| RemoteModel::from_entry(model, catalog)).collect(), commands: state .commands .iter() .map(|command| RemoteCommand { name: command.name.clone(), description: command.description.clone(), argument_hint: command.argument_hint.clone(), skill: command.skill, }) .collect(), } } } impl RemoteModel { pub fn from_entry(model: &ModelEntry, catalog: &Catalog) -> Self { let profile = model.claude_profile(); Self { id: model.id.clone(), resolved: model.resolved.clone(), name: model.name.clone(), detail: model.detail.clone(), legacy: model.legacy, efforts: model.efforts.iter().map(|effort| effort.as_cli().to_string()).collect(), default_effort: profile.default_effort.map(|effort| effort.as_cli().to_string()), ultracode: profile.ultracode, fast_mode: model.fast_mode, auto_mode: model.auto_mode, context_window: model.context_window, hidden: catalog.is_hidden(model), } } } impl From<&FileDiff> for RemoteFileDiff { fn from(file: &FileDiff) -> Self { let lines = file .lines .iter() .map(|line| RemoteDiffLine { kind: match line.kind { LineKind::Hunk => "hunk", LineKind::Context => "context", LineKind::Added => "added", LineKind::Removed => "removed", } .into(), text: line.text.clone(), old_line: line.old_line, new_line: line.new_line, }) .collect(); Self { path: file.path.clone(), binary: file.binary, lines } } } /// What kind of network a route goes over, which says how fast it likely is. #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum RouteKind { Loopback, #[default] Lan, Tailnet, /// Another VPN, or Tailscale's range on a host that didn't say so: /// Cloudflare WARP and others share it. Vpn, /// A name or public address. Other, } impl RouteKind { /// Guesses from an address alone, for addresses the user typed. pub fn of(address: &str) -> Self { let address = with_port(address); if let Ok(socket) = address.parse::() { return match socket.ip() { std::net::IpAddr::V4(ip) => kind_of_v4(ip).unwrap_or(Self::Other), std::net::IpAddr::V6(ip) if ip.is_loopback() => Self::Loopback, std::net::IpAddr::V6(ip) if is_tailscale_v6(ip) => Self::Tailnet, std::net::IpAddr::V6(_) => Self::Other, }; } let name = address.rsplit_once(':').map_or(address.as_str(), |(name, _)| name).to_ascii_lowercase(); if name == "localhost" { Self::Loopback } else if name.ends_with(".ts.net") { Self::Tailnet } else if name.ends_with(".local") || !name.contains('.') { Self::Lan } else { Self::Other } } pub fn label(self) -> &'static str { match self { Self::Loopback => "This device", Self::Lan => "LAN", Self::Tailnet => "Tailscale", Self::Vpn => "VPN", Self::Other => "Direct", } } /// Faster first. fn rank(self) -> u8 { match self { Self::Loopback => 0, Self::Lan => 1, Self::Tailnet | Self::Vpn => 2, Self::Other => 3, } } } /// One way to reach a host: `host:port`, and the network it goes over. #[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] #[serde(default)] pub struct Route { pub address: String, pub kind: RouteKind, /// Reported by the host rather than saved by the user, so the next /// report replaces it. #[serde(skip_serializing_if = "std::ops::Not::not")] pub learned: bool, } impl Route { pub fn guess(address: &str) -> Self { Self { address: with_port(address), kind: RouteKind::of(address), learned: false } } /// `LAN · 192.168.2.10:47823`. pub fn describe(&self) -> String { format!("{} · {}", self.kind.label(), self.address) } } /// Every route to a host, fastest first: the address the user saved and /// the routes it reported. One the host reported keeps the host's kind, /// which knows Tailscale from other VPNs. pub fn ordered_routes(address: &str, routes: &[Route]) -> Vec { let mut all: Vec = Vec::new(); for route in routes.iter().cloned().chain((!address.trim().is_empty()).then(|| Route::guess(address))) { let route = Route { address: with_port(&route.address), ..route }; if route.address.is_empty() || all.iter().any(|known| known.address == route.address) { continue; } all.push(route); } // The user's address goes first among equals. let saved = with_port(address); all.sort_by_key(|route| (route.kind.rank(), route.address != saved)); all } /// The routes saved after the host reports `reported`: the user's own stay, /// the learned ones are replaced. `None` when nothing changed. pub fn merge_routes(saved: &[Route], reported: &[Route]) -> Option> { let mut next: Vec = saved.iter().filter(|route| !route.learned).cloned().collect(); for route in reported { // A loopback address names whichever device opens it. if route.kind == RouteKind::Loopback || route.address.trim().is_empty() { continue; } let address = with_port(&route.address); match next.iter_mut().find(|known| known.address == address) { // A saved route the host reports takes the host's word for its kind. Some(known) => known.kind = route.kind, None => next.push(Route { address, kind: route.kind, learned: true }), } } (next != saved).then_some(next) } /// A new random key: 24 bytes, hex. pub fn new_key() -> String { let mut bytes = [0u8; 24]; let read = std::fs::File::open("/dev/urandom").and_then(|mut file| std::io::Read::read_exact(&mut file, &mut bytes)); if read.is_err() { // Not as good, but never empty. let seed = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap_or_default().as_nanos(); for (ix, byte) in bytes.iter_mut().enumerate() { *byte = (seed >> ((ix % 16) * 8)) as u8 ^ (ix as u8).wrapping_mul(151); } } bytes.iter().map(|byte| format!("{byte:02x}")).collect() } /// Compares keys in time independent of where they differ. pub fn keys_match(a: &str, b: &str) -> bool { a.len() == b.len() && a.bytes().zip(b.bytes()).fold(0u8, |acc, (x, y)| acc | (x ^ y)) == 0 } /// A pairing code bundles a host's addresses and key, fastest first: /// `192.168.2.10:47823,100.101.4.2:47823/`. pub fn pairing_code(addresses: &[String], key: &str) -> String { format!("{}/{key}", addresses.join(",")) } /// Splits a pairing code, or an address alone, into addresses and key. pub fn parse_pairing(code: &str) -> (Vec, Option) { let code = code.trim().trim_start_matches("ws://").trim_start_matches("vectrum://"); let (addresses, key) = match code.split_once('/') { Some((addresses, key)) => (addresses, Some(key.trim().to_string()).filter(|key| !key.is_empty())), None => (code, None), }; let addresses = addresses.split(',').map(str::trim).filter(|address| !address.is_empty()).map(str::to_string).collect(); (addresses, key) } /// `host` or `host:port`, with the default port filled in. pub fn with_port(address: &str) -> String { let address = address.trim(); let has_port = match address.rsplit_once(':') { // An IPv6 address without brackets has colons but no port. Some((host, port)) => !host.is_empty() && port.parse::().is_ok() && (!host.contains(':') || host.ends_with(']')), None => false, }; if has_port { address.to_string() } else { format!("{address}:{DEFAULT_PORT}") } } /// Where this machine listens on `port`, LAN first: its private IPv4 /// addresses, with those on Tailscale's interface marked as such. Public /// addresses are left out, since connections aren't encrypted, and so are /// container and VM networks only this machine reaches. pub fn local_routes(port: u16) -> Vec { routes_from(&interface_addresses(), port) } fn routes_from(interfaces: &[(String, std::net::IpAddr)], port: u16) -> Vec { use std::net::IpAddr; // `tailscale0` on Linux; macOS names every tunnel `utunN`, so there it's // the one that also has an address in Tailscale's IPv6 range. let tailscale = |name: &str| { name.to_ascii_lowercase().starts_with("tailscale") || interfaces.iter().any(|(other, ip)| other == name && matches!(ip, IpAddr::V6(ip) if is_tailscale_v6(*ip))) }; const VIRTUAL: [&str; 14] = [ "docker", "br-", "veth", "virbr", "vmnet", "vboxnet", "vEthernet", "podman", "cni", "flannel", "cali", "lxcbr", "lxdbr", "waydroid", ]; let mut routes: Vec = Vec::new(); for (name, ip) in interfaces { let IpAddr::V4(ip) = ip else { continue }; // macOS's `bridge100` and up are its VMs' networks. let mac_bridge = name.strip_prefix("bridge").and_then(|n| n.parse::().ok()).is_some_and(|n| n >= 100); if VIRTUAL.iter().any(|prefix| name.starts_with(prefix)) || mac_bridge { continue; } let kind = match kind_of_v4(*ip) { Some(RouteKind::Lan) => RouteKind::Lan, Some(RouteKind::Vpn) if tailscale(name) => RouteKind::Tailnet, Some(RouteKind::Vpn) => RouteKind::Vpn, _ => continue, }; let address = format!("{ip}:{port}"); if !routes.iter().any(|route| route.address == address) { routes.push(Route { address, kind, learned: false }); } } routes.sort_by_key(|route| route.kind.rank()); routes } /// `Lan` for private ranges, `Vpn` for the shared 100.64.0.0/10 that /// Tailscale and other VPNs use, `Loopback`, or `None` for anything else. fn kind_of_v4(ip: std::net::Ipv4Addr) -> Option { let [a, b, ..] = ip.octets(); if ip.is_loopback() { Some(RouteKind::Loopback) } else if ip.is_private() { Some(RouteKind::Lan) } else if a == 100 && (64..128).contains(&b) { Some(RouteKind::Vpn) } else { None } } /// Tailscale's IPv6 range, fd7a:115c:a1e0::/48. fn is_tailscale_v6(ip: std::net::Ipv6Addr) -> bool { let [a, b, c, ..] = ip.segments(); (a, b, c) == (0xfd7a, 0x115c, 0xa1e0) } /// Each network interface's name and addresses. #[cfg(unix)] fn interface_addresses() -> Vec<(String, std::net::IpAddr)> { use std::net::{Ipv4Addr, Ipv6Addr}; let mut list = Vec::new(); let mut head: *mut libc::ifaddrs = std::ptr::null_mut(); if unsafe { libc::getifaddrs(&mut head) } != 0 { return list; } let mut cursor = head; while !cursor.is_null() { // The list stays valid until it's freed below. let entry = unsafe { &*cursor }; cursor = entry.ifa_next; if entry.ifa_addr.is_null() || entry.ifa_name.is_null() { continue; } let name = unsafe { std::ffi::CStr::from_ptr(entry.ifa_name) }.to_string_lossy().into_owned(); match i32::from(unsafe { (*entry.ifa_addr).sa_family }) { libc::AF_INET => { let address = unsafe { &*(entry.ifa_addr as *const libc::sockaddr_in) }; list.push((name, Ipv4Addr::from(u32::from_be(address.sin_addr.s_addr)).into())); } libc::AF_INET6 => { let address = unsafe { &*(entry.ifa_addr as *const libc::sockaddr_in6) }; list.push((name, Ipv6Addr::from(address.sin6_addr.s6_addr).into())); } _ => {} } } unsafe { libc::freeifaddrs(head) }; list } #[cfg(not(unix))] fn interface_addresses() -> Vec<(String, std::net::IpAddr)> { Vec::new() } /// Another device on this one's tailnet, as `tailscale status` lists it. #[derive(Debug, PartialEq)] pub struct TailnetPeer { /// Its machine name and its MagicDNS name's first label, which differ /// once it's renamed in Tailscale's admin console. pub names: Vec, pub addresses: Vec, /// What Tailscale's coordination server last heard, which lags. pub online: bool, } /// The other devices on this device's tailnet; none when Tailscale isn't /// installed, running or logged in. pub fn tailnet_peers() -> Vec { tailscale_status().map(|json| parse_tailnet_peers(&json)).unwrap_or_default() } /// Routes over Tailscale to the peers called one of `names`, as a host's /// own name or the one the user gave it, online ones first. This finds a /// host that was only ever reached over the LAN, before it could report its /// Tailscale address. pub fn tailnet_routes(peers: &[TailnetPeer], names: &[String], port: u16) -> Vec { let wanted: Vec = names.iter().map(|name| machine_name(name)).filter(|name| !name.is_empty()).collect(); let mut found: Vec<&TailnetPeer> = peers.iter().filter(|peer| peer.names.iter().any(|name| wanted.contains(&machine_name(name)))).collect(); found.sort_by_key(|peer| !peer.online); found .into_iter() .flat_map(|peer| &peer.addresses) .map(|ip| Route { address: format!("{ip}:{port}"), kind: RouteKind::Tailnet, learned: true }) .collect() } /// A name as Tailscale makes a DNS label of it: `My Phone` is `my-phone`. fn machine_name(name: &str) -> String { name.trim().to_lowercase().chars().map(|c| if c.is_alphanumeric() || c == '-' { c } else { '-' }).collect() } fn parse_tailnet_peers(json: &str) -> Vec { let Ok(status) = serde_json::from_str::(json) else { return Vec::new() }; let Some(peers) = status.get("Peer").and_then(|peers| peers.as_object()) else { return Vec::new() }; peers .values() .map(|peer| { let text = |key: &str| peer.get(key).and_then(|value| value.as_str()).unwrap_or_default(); let dns_label = text("DNSName").split('.').next().unwrap_or_default(); TailnetPeer { names: [text("HostName"), dns_label].into_iter().filter(|name| !name.is_empty()).map(str::to_string).collect(), addresses: peer .get("TailscaleIPs") .and_then(|ips| ips.as_array()) .into_iter() .flatten() .filter_map(|ip| ip.as_str()?.parse().ok()) .collect(), online: peer.get("Online").and_then(|online| online.as_bool()).unwrap_or(false), } }) .filter(|peer| !peer.names.is_empty() && !peer.addresses.is_empty()) .collect() } /// `tailscale status --json`, or `None` when it's missing, fails, or takes /// longer than a moment. fn tailscale_status() -> Option { use std::{io::Read, process::{Command, Stdio}, time::{Duration, Instant}}; // macOS's app doesn't put its CLI on the PATH. const PROGRAMS: [&str; 3] = ["tailscale", "tailscale.exe", "/Applications/Tailscale.app/Contents/MacOS/Tailscale"]; for program in PROGRAMS { let Ok(mut child) = Command::new(program) .args(["status", "--json"]) .stdin(Stdio::null()) .stdout(Stdio::piped()) .stderr(Stdio::null()) .spawn() else { continue; }; let mut stdout = child.stdout.take()?; let reader = std::thread::spawn(move || { let mut text = String::new(); stdout.read_to_string(&mut text).ok().map(|_| text) }); let deadline = Instant::now() + Duration::from_millis(1_500); loop { match child.try_wait() { Ok(Some(status)) => return reader.join().ok().flatten().filter(|_| status.success()), Ok(None) if Instant::now() < deadline => std::thread::sleep(Duration::from_millis(20)), _ => { let _ = child.kill(); let _ = child.wait(); return None; } } } } None } #[cfg(test)] mod tests { use super::*; #[::core::prelude::v1::test] fn pairing_codes() { assert_eq!(parse_pairing("192.168.2.10:47823/abc"), (vec!["192.168.2.10:47823".into()], Some("abc".into()))); assert_eq!(parse_pairing(" 10.0.0.2 "), (vec!["10.0.0.2".into()], None)); let code = pairing_code(&["192.168.2.10:47823".into(), "100.101.4.2:47823".into()], "abc"); assert_eq!(parse_pairing(&code), (vec!["192.168.2.10:47823".into(), "100.101.4.2:47823".into()], Some("abc".into()))); assert_eq!(with_port("10.0.0.2"), format!("10.0.0.2:{DEFAULT_PORT}")); assert_eq!(with_port("10.0.0.2:9000"), "10.0.0.2:9000"); assert_eq!(with_port("box.local"), format!("box.local:{DEFAULT_PORT}")); assert_eq!(with_port("[::1]:9000"), "[::1]:9000"); } #[::core::prelude::v1::test] fn tailnet_lookup() { let status = r#"{ "Self": { "HostName": "laptop", "DNSName": "laptop.example.ts.net.", "TailscaleIPs": ["100.100.0.1"] }, "Peer": { "a": { "HostName": "Desktop", "DNSName": "desktop.example.ts.net.", "TailscaleIPs": ["100.100.0.2", "fd7a:115c:a1e0::2"], "Online": false }, "b": { "HostName": "raspberrypi", "DNSName": "nas.example.ts.net.", "TailscaleIPs": ["100.100.0.3"], "Online": true }, "c": { "HostName": "My Phone", "DNSName": "my-phone.example.ts.net.", "TailscaleIPs": ["100.100.0.4"], "Online": true } } }"#; let peers = parse_tailnet_peers(status); assert_eq!(peers.len(), 3); let lookup = |names: &[&str]| -> Vec { let names: Vec = names.iter().map(|name| name.to_string()).collect(); tailnet_routes(&peers, &names, 47823).into_iter().map(|route| route.describe()).collect() }; // The name the user gave it, in another case; offline as far as the coordination server knows. assert_eq!(lookup(&["DESKTOP"]), ["Tailscale · 100.100.0.2:47823"]); // Renamed in the admin console: its DNS name still finds it. assert_eq!(lookup(&["nas"]), ["Tailscale · 100.100.0.3:47823"]); assert_eq!(lookup(&["My Phone"]), ["Tailscale · 100.100.0.4:47823"]); // Online ones first when several match. assert_eq!(lookup(&["desktop", "raspberrypi"]), ["Tailscale · 100.100.0.3:47823", "Tailscale · 100.100.0.2:47823"]); // This device itself, and names that aren't there, find nothing. assert!(lookup(&["laptop", "", "printer"]).is_empty()); assert!(parse_tailnet_peers("not json").is_empty()); } #[::core::prelude::v1::test] fn routes() { use std::net::IpAddr; let interfaces: Vec<(String, IpAddr)> = [ ("lo", "127.0.0.1"), ("tailscale0", "100.101.4.2"), ("wlan0", "192.168.2.10"), ("wlan0", "fe80::1"), ("docker0", "172.17.0.1"), ("warp0", "100.96.0.3"), ("eth9", "8.8.8.8"), ("utun4", "100.80.1.1"), ("utun4", "fd7a:115c:a1e0::1"), ] .into_iter() .map(|(name, ip)| (name.to_string(), ip.parse().unwrap())) .collect(); let found = routes_from(&interfaces, 9000); let found: Vec<(&str, RouteKind)> = found.iter().map(|route| (route.address.as_str(), route.kind)).collect(); assert_eq!( found, [ ("192.168.2.10:9000", RouteKind::Lan), ("100.101.4.2:9000", RouteKind::Tailnet), ("100.96.0.3:9000", RouteKind::Vpn), ("100.80.1.1:9000", RouteKind::Tailnet), ] ); assert_eq!(RouteKind::of("192.168.2.10"), RouteKind::Lan); assert_eq!(RouteKind::of("100.101.4.2:47823"), RouteKind::Vpn); assert_eq!(RouteKind::of("box.tail1234.ts.net"), RouteKind::Tailnet); assert_eq!(RouteKind::of("box.local"), RouteKind::Lan); assert_eq!(RouteKind::of("example.com"), RouteKind::Other); assert_eq!(RouteKind::of("127.0.0.1:9000"), RouteKind::Loopback); // Paired over Tailscale; the host then reports its LAN address too. let reported = [ Route { address: "192.168.2.10:47823".into(), kind: RouteKind::Lan, learned: false }, Route { address: "100.101.4.2:47823".into(), kind: RouteKind::Tailnet, learned: false }, Route { address: "127.0.0.1:47823".into(), kind: RouteKind::Loopback, learned: false }, ]; let saved = merge_routes(&[], &reported).unwrap(); assert_eq!(saved.len(), 2); assert!(saved.iter().all(|route| route.learned)); assert_eq!(merge_routes(&saved, &reported), None); let order: Vec = ordered_routes("100.101.4.2", &saved).into_iter().map(|route| route.describe()).collect(); assert_eq!(order, ["LAN · 192.168.2.10:47823", "Tailscale · 100.101.4.2:47823"]); // The host joined another Wi-Fi: its old LAN address goes, the user's own route stays. let mut saved = saved; saved.push(Route::guess("10.0.0.5")); let moved = [Route { address: "192.168.7.20:47823".into(), kind: RouteKind::Lan, learned: false }]; let next = merge_routes(&saved, &moved).unwrap(); let next: Vec<&str> = next.iter().map(|route| route.address.as_str()).collect(); assert_eq!(next, ["10.0.0.5:47823", "192.168.7.20:47823"]); } #[::core::prelude::v1::test] fn keys() { let key = new_key(); assert_eq!(key.len(), 48); assert_ne!(key, new_key()); assert!(keys_match(&key, &key.clone())); assert!(!keys_match(&key, &new_key())); assert!(!keys_match("a", "ab")); } #[::core::prelude::v1::test] fn messages_round_trip() { let message = ClientMessage::Action { thread: "t1".into(), action: ThreadAction::Send { text: "hi".into(), attachments: Vec::new() }, }; let text = serde_json::to_string(&message).unwrap(); assert_eq!(serde_json::from_str::(&text).unwrap(), message); let patch: SettingsPatch = serde_json::from_str(r#"{"values": {"workspace": "worktree"}}"#).unwrap(); let message = ClientMessage::UpdateSettings { patch }; let text = serde_json::to_string(&message).unwrap(); assert_eq!(serde_json::from_str::(&text).unwrap(), message); let message = ClientMessage::AddProject { source: ProjectSource::Forge { forge: crate::projects::Forge::GitHub, repo: "owner/repo".into() }, }; let text = serde_json::to_string(&message).unwrap(); assert_eq!(serde_json::from_str::(&text).unwrap(), message); let message = ClientMessage::TerminalOpen { terminal: 3, thread: None, project: "/p".into(), columns: 80, lines: 24 }; let text = serde_json::to_string(&message).unwrap(); assert_eq!(serde_json::from_str::(&text).unwrap(), message); let text = serde_json::to_string(&ServerMessage::TerminalExited { terminal: 3, error: None }).unwrap(); assert!(matches!( serde_json::from_str::(&text).unwrap(), ServerMessage::TerminalExited { terminal: 3, error: None } )); let message = ClientMessage::Device { request: 4, call: DeviceCall::Action { device: "emulator-5554".into(), action: crate::device::Action::SetPermission { app_id: "com.example".into(), permission: "camera".into(), decision: "grant".into(), }, }, }; let text = serde_json::to_string(&message).unwrap(); assert_eq!(serde_json::from_str::(&text).unwrap(), message); let message = ClientMessage::Device { request: 5, call: DeviceCall::Action { device: "d".into(), action: crate::device::Action::SetAppearance(true) } }; let text = serde_json::to_string(&message).unwrap(); assert_eq!(serde_json::from_str::(&text).unwrap(), message); let state = crate::device::State { enabled: true, host_status: crate::device::HostStatus::Ready, ..Default::default() }; let text = serde_json::to_string(&ServerMessage::DeviceState { state }).unwrap(); match serde_json::from_str::(&text).unwrap() { ServerMessage::DeviceState { state } => assert_eq!(state.host_status, crate::device::HostStatus::Ready), _ => panic!("not a device state"), } let values = crate::settings::Settings::default().environment_values(); let text = serde_json::to_string(&ServerMessage::Settings { values: values.clone() }).unwrap(); match serde_json::from_str::(&text).unwrap() { ServerMessage::Settings { values: received } => assert_eq!(received, values), _ => panic!("not settings"), } let text = serde_json::to_string(&ClientMessage::Catalog).unwrap(); assert_eq!(text, r#"{"type":"catalog"}"#); assert_eq!(serde_json::from_str::(&text).unwrap(), ClientMessage::Catalog); let instance = RemoteInstance { id: "claude".into(), name: "Claude".into(), status: "ready".into(), default_model: Some("default".into()), models: vec![RemoteModel { id: "opus".into(), efforts: vec!["low".into(), "high".into()], default_effort: Some("medium".into()), ..Default::default() }], commands: vec![RemoteCommand { name: "compact".into(), ..Default::default() }], ..Default::default() }; let message = ServerMessage::Catalog { instances: vec![instance.clone()], favorites: vec!["claude:opus".into()] }; let text = serde_json::to_string(&message).unwrap(); match serde_json::from_str::(&text).unwrap() { ServerMessage::Catalog { instances, favorites } => { assert_eq!(instances, [instance]); assert_eq!(favorites, ["claude:opus"]); } _ => panic!("not a catalog"), } // Clients may leave out what they don't know. let model: RemoteModel = serde_json::from_str(r#"{"id": "sonnet"}"#).unwrap(); assert_eq!((model.id.as_str(), model.hidden), ("sonnet", false)); let message = ClientMessage::Diff { request: 6, thread: "t1".into(), base: "abc".into(), head: "def".into(), path: Some("src/a.rs".into()), ignore_whitespace: true, }; let text = serde_json::to_string(&message).unwrap(); assert_eq!(serde_json::from_str::(&text).unwrap(), message); let file = crate::git::FileDiff { path: "src/a.rs".into(), binary: false, lines: vec![ crate::git::DiffLine { kind: LineKind::Hunk, text: "@@ -1 +1 @@".into(), old_line: None, new_line: None }, crate::git::DiffLine { kind: LineKind::Added, text: "new".into(), old_line: None, new_line: Some(1) }, ], }; let files = vec![RemoteFileDiff::from(&file)]; assert_eq!(files[0].lines[1].kind, "added"); let text = serde_json::to_string(&ServerMessage::DiffReply { request: 6, files: files.clone(), error: None }).unwrap(); match serde_json::from_str::(&text).unwrap() { ServerMessage::DiffReply { request: 6, files: received, error: None } => assert_eq!(received, files), _ => panic!("not a diff reply"), } } #[::core::prelude::v1::test] fn client_and_server_talk() { use std::time::Duration; use super::client::{ClientEvent, ConnectionStatus, RemoteClient}; use super::server::{RemoteServer, ServerEvent}; let port = 47_900 + (std::process::id() % 90) as u16; let key = new_key(); let server = RemoteServer::start(port, key.clone(), "test-host".into()).unwrap(); fn wait(receiver: &smol::channel::Receiver) -> Option { smol::block_on(async { smol::future::or(async { receiver.recv().await.ok() }, async { smol::Timer::after(Duration::from_secs(10)).await; None }) .await }) } let client = RemoteClient::connect(vec![Route::guess(&format!("127.0.0.1:{port}"))], key, "test-client".into(), Vec::new()); assert!(matches!(wait(&client.events), Some(ClientEvent::Status(ConnectionStatus::Connecting)))); match wait(&client.events) { Some(ClientEvent::Route(route)) => assert_eq!(route.address, format!("127.0.0.1:{port}")), _ => panic!("didn't say which route"), } assert!(matches!(wait(&client.events), Some(ClientEvent::Message(ServerMessage::Routes { .. })))); match wait(&client.events) { Some(ClientEvent::Status(ConnectionStatus::Connected(host))) => assert_eq!(host, "test-host"), _ => panic!("didn't connect"), } let conn = match wait(&server.events) { Some(ServerEvent::Connected { conn, name, .. }) => { assert_eq!(name, "test-client"); conn } _ => panic!("server didn't see the client"), }; client.send(&ClientMessage::Subscribe { thread: "t1".into() }); match wait(&server.events) { Some(ServerEvent::Message { message: ClientMessage::Subscribe { thread }, .. }) => assert_eq!(thread, "t1"), _ => panic!("server didn't get the message"), } server.send(conn, &ServerMessage::Created { reference: 7, thread: "t2".into() }); match wait(&client.events) { Some(ClientEvent::Message(ServerMessage::Created { reference, thread })) => { assert_eq!((reference, thread.as_str()), (7, "t2")) } _ => panic!("client didn't get the reply"), } let intruder = RemoteClient::connect(vec![Route::guess(&format!("127.0.0.1:{port}"))], "wrong".into(), "intruder".into(), Vec::new()); assert!(matches!(wait(&intruder.events), Some(ClientEvent::Status(ConnectionStatus::Connecting)))); assert!(matches!(wait(&intruder.events), Some(ClientEvent::Status(ConnectionStatus::Denied(_))))); } }