//! 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<String>,
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<u64>,
pub created: u64,
pub updated: u64,
pub last_message: u64,
/// The thread's worktree branch, if it runs in one.
pub branch: Option<String>,
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<String>,
}
/// 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<String>,
pub thinking: bool,
pub preparing: bool,
/// How long the running turn has been going.
pub turn_elapsed_ms: Option<u64>,
pub context_tokens: Option<u64>,
pub context_window: Option<u64>,
pub queue: Vec<QueuedMessage>,
pub limit_resets: Option<u64>,
pub resume_at: Option<u64>,
/// Bound for a worktree made with the first message.
pub pending_worktree: bool,
pub pull_request: Option<PullRequest>,
pub turn_changes: Vec<(usize, TurnChanges)>,
pub background_tasks: Vec<RemoteTask>,
}
/// 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<String>,
pub branches: Vec<String>,
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<String>,
}
/// 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<bool>, agent_access: Option<bool>, onboarding: Option<bool> },
Retry,
Open { thread: String, device: String, boot: bool },
Close { thread: String, device: Option<String>, 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<Upload> },
Queue { text: String, attachments: Vec<Upload> },
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<String> },
AskSkip { ix: usize },
/// Sends a running command or subagent to the background.
Background { ix: usize },
StopTask { task_id: String },
SetModel { model: Option<String> },
SetPermissionMode { mode: String },
SetReasoning { reasoning: String },
SetFastMode { on: bool },
Settle,
Unsettle,
Pin { pinned: bool },
Snooze { until: Option<u64> },
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<String>,
permission_mode: String,
reasoning: String,
fast_mode: bool,
worktree: bool,
/// The branch a worktree starts from.
base: Option<String>,
text: String,
attachments: Vec<Upload>,
},
/// 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<String>, 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<String>, 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<Route>,
},
Denied { reason: String },
State { projects: Vec<ProjectInfo>, threads: Vec<ThreadSummary> },
/// 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<SavedItem> },
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<String, serde_json::Value> },
/// The subfolders of a folder a client asked for, or why it can't have them.
Folders { path: String, folders: Vec<Folder>, truncated: bool, error: Option<String> },
/// 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<String> },
/// The answer to a [`ClientMessage::Device`]: its result or what went wrong.
DeviceReply { request: u64, ok: Option<serde_json::Value>, error: Option<String> },
/// 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<String> },
/// The host's addresses changed, as when it joined another Wi-Fi.
/// Older hosts don't send it.
Routes { routes: Vec<Route> },
/// 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<RemoteInstance>, favorites: Vec<String> },
/// The answer to a [`ClientMessage::Diff`]: the changed files, or what
/// went wrong.
DiffReply { request: u64, files: Vec<RemoteFileDiff>, error: Option<String> },
}
/// 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<String>,
/// `ready`, `checking`, `missing`, `disabled` or `failed`.
pub status: String,
pub account: Option<String>,
pub plan: Option<String>,
/// The model Claude's `default` entry resolves to (`claude-opus-5-5`), when it reports one.
pub default_model: Option<String>,
pub models: Vec<RemoteModel>,
pub commands: Vec<RemoteCommand>,
}
/// 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<String>,
pub default_effort: Option<String>,
pub ultracode: bool,
pub fast_mode: bool,
pub auto_mode: bool,
pub context_window: Option<u64>,
/// 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<RemoteDiffLine>,
}
#[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<u32>,
pub new_line: Option<u32>,
}
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 }
}
}
impl From<RemoteFileDiff> for FileDiff {
fn from(file: RemoteFileDiff) -> Self {
let lines = file
.lines
.into_iter()
.map(|line| crate::git::DiffLine {
kind: match line.kind.as_str() {
"hunk" => LineKind::Hunk,
"added" => LineKind::Added,
"removed" => LineKind::Removed,
_ => LineKind::Context,
},
text: line.text,
old_line: line.old_line,
new_line: line.new_line,
})
.collect();
Self { path: file.path, 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::<std::net::SocketAddr>() {
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<Route> {
let mut all: Vec<Route> = 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<Vec<Route>> {
let mut next: Vec<Route> = 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/<key>`.
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<String>, Option<String>) {
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::<u16>().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<Route> {
routes_from(&interface_addresses(), port)
}
fn routes_from(interfaces: &[(String, std::net::IpAddr)], port: u16) -> Vec<Route> {
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<Route> = 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::<u32>().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<RouteKind> {
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<String>,
pub addresses: Vec<std::net::Ipv4Addr>,
/// 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<TailnetPeer> {
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<Route> {
let wanted: Vec<String> = 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<TailnetPeer> {
let Ok(status) = serde_json::from_str::<serde_json::Value>(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<String> {
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<String> {
let names: Vec<String> = 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<String> = 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::<ClientMessage>(&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::<ClientMessage>(&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::<ClientMessage>(&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::<ClientMessage>(&text).unwrap(), message);
let text = serde_json::to_string(&ServerMessage::TerminalExited { terminal: 3, error: None }).unwrap();
assert!(matches!(
serde_json::from_str::<ServerMessage>(&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::<ClientMessage>(&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::<ClientMessage>(&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::<ServerMessage>(&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::<ServerMessage>(&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::<ClientMessage>(&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::<ServerMessage>(&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::<ClientMessage>(&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::<ServerMessage>(&text).unwrap() {
ServerMessage::DiffReply { request: 6, files: received, error: None } => assert_eq!(received, files),
_ => panic!("not a diff reply"),
}
let back = crate::git::FileDiff::from(files[0].clone());
assert_eq!((back.path.as_str(), back.binary, back.lines.len()), ("src/a.rs", false, 2));
assert_eq!((back.lines[0].kind, back.lines[1].kind), (LineKind::Hunk, LineKind::Added));
assert_eq!((back.lines[1].text.as_str(), back.lines[1].new_line), ("new", Some(1)));
}
#[::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<T>(receiver: &smol::channel::Receiver<T>) -> Option<T> {
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(_)))));
}
}