Vectrum
Sign In
1213 lines · 51.5 KB · Rust
//! 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 client's Genome map shows the host's Empryo
//! Genome, read there on [`ClientMessage::Genome`].
//!
//! 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.
//!
//! The welcome also says the host's version and whether it can update
//! itself. A client that's newer asks it to with [`ClientMessage::Update`]:
//! the host downloads the same signed release the client's updater would,
//! reports how it goes in [`ServerMessage::UpdateStatus`], and restarts.

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 },
    /// Asks for Empryo's Genome of a thread's checkout (the project's, for a
    /// thread not created yet), answered with [`ServerMessage::GenomeReply`].
    /// `since` is when the Genome the client has was written, so an
    /// unchanged one isn't sent again. Older hosts don't answer.
    Genome { request: u64, thread: Option<String>, project: PathBuf, since: Option<u64> },
    /// Updates the host's app to the newest release and restarts it, all in
    /// one; it answers with [`ServerMessage::UpdateStatus`] until it goes
    /// down. Only hosts that say they're `updatable` take it.
    Update,
}

#[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>,
        /// Whether the host can update itself when asked with
        /// [`ClientMessage::Update`]: it runs as an AppImage. Older hosts
        /// can't.
        #[serde(default)]
        updatable: bool,
    },
    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> },
    /// The answer to a [`ClientMessage::Genome`].
    GenomeReply { request: u64, answer: GenomeAnswer },
    /// How an update a client asked for goes: `checking`, `downloading`
    /// (with `progress`, 0 to 1), `restarting`, or `failed` with `error`.
    /// The connection drops when the host restarts.
    UpdateStatus { state: String, progress: Option<f32>, error: Option<String> },
}

/// What a host found of a checkout's Genome.
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct GenomeAnswer {
    /// When Empryo last wrote it, in milliseconds since the epoch; `None`
    /// when there's no Genome yet.
    pub written: Option<u64>,
    /// The map, unless it's unchanged since the client's or missing.
    pub genome: Option<Box<crate::genome::Genome>>,
    /// It's the one the client has.
    pub unchanged: bool,
    pub 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::Update).unwrap();
        assert_eq!(text, r#"{"type":"update"}"#);
        // An older host's welcome says nothing of updating.
        let welcome = r#"{"type":"welcome","host":"h","version":"0.2.4","protocol":1}"#;
        assert!(matches!(
            serde_json::from_str::<ServerMessage>(welcome).unwrap(),
            ServerMessage::Welcome { updatable: false, .. }
        ));

        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 genome_messages_round_trip() {
        let message = ClientMessage::Genome { request: 3, thread: Some("t1".into()), project: "/repo".into(), since: Some(42) };
        let text = serde_json::to_string(&message).unwrap();
        assert_eq!(serde_json::from_str::<ClientMessage>(&text).unwrap(), message);
        let genome = crate::genome::Genome {
            total_files: 1,
            total_symbols: 1,
            total_edges: 0,
            files: vec![crate::genome::GenomeFile {
                path: "src/a.rs".into(),
                language: "rust".into(),
                lines: 3,
                symbol_count: 1,
                rank: 1.,
                dependents: 0,
                dependencies: 0,
                recent: true,
                symbols: vec![crate::genome::GenomeSymbol {
                    name: "a".into(),
                    kind: "function".into(),
                    line: 1,
                    exported: true,
                    signature: Some("pub fn a()".into()),
                }],
            }],
            edges: Vec::new(),
            layout: vec![(0.5, 0.5)],
        };
        let answer = GenomeAnswer { written: Some(42), genome: Some(Box::new(genome)), ..GenomeAnswer::default() };
        let text = serde_json::to_string(&ServerMessage::GenomeReply { request: 3, answer: answer.clone() }).unwrap();
        match serde_json::from_str::<ServerMessage>(&text).unwrap() {
            ServerMessage::GenomeReply { request: 3, answer: received } => assert_eq!(received, answer),
            _ => panic!("not a Genome reply"),
        }
        // An unchanged Genome comes back as just that.
        let text = r#"{"type": "genome_reply", "request": 4, "answer": {"written": 42, "unchanged": true}}"#;
        match serde_json::from_str::<ServerMessage>(text).unwrap() {
            ServerMessage::GenomeReply { answer, .. } => assert!(answer.unchanged && answer.genome.is_none()),
            _ => panic!("not a Genome 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<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::Message(ServerMessage::Welcome { version, .. })) => {
                assert_eq!(version, env!("CARGO_PKG_VERSION"))
            }
            _ => panic!("didn't pass on the welcome"),
        }
        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(_)))));
    }
}