use std::time::Duration;
use ff_rdp_core::{
COMPATIBLE_FIREFOX_MAX, COMPATIBLE_FIREFOX_MIN, RdpConnection, RootActor, TabInfo,
};
use serde_json::{Value, json};
use crate::cli::args::Cli;
use crate::connection_meta::is_loopback;
use crate::daemon::{client::find_running_daemon, registry};
use crate::error::AppError;
use crate::output;
use crate::output_pipeline::OutputPipeline;
use crate::port_owner::{self, PortOwner};
use crate::tab_target::format_uptime_short as format_uptime;
#[derive(Debug, Clone)]
struct Probe {
name: &'static str,
status: Status,
detail: String,
hint: Option<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Status {
Pass,
Warn,
Fail,
}
impl Status {
fn as_str(self) -> &'static str {
match self {
Self::Pass => "pass",
Self::Warn => "warn",
Self::Fail => "fail",
}
}
fn glyph(self) -> &'static str {
match self {
Self::Pass => "✓",
Self::Warn => "⚠",
Self::Fail => "✗",
}
}
}
pub fn run(cli: &Cli) -> Result<(), AppError> {
let host = cli.host.as_str();
let port = cli.port;
let mut probes: Vec<Probe> = Vec::new();
probes.push(probe_daemon(host, port));
let local = is_loopback(host);
let owner = if local {
port_owner::find_listener(port).ok().flatten()
} else {
None
};
let port_in_use = if local {
owner.is_some() || port_owner::is_port_in_use(port)
} else {
true
};
probes.push(probe_port_owner(port, port_in_use, owner.as_ref(), local));
let mut firefox_version: Option<u32> = None;
if port_in_use {
match RdpConnection::connect(host, port, Duration::from_millis(cli.timeout)) {
Ok(mut conn) => {
firefox_version = conn.firefox_version();
probes.push(Probe {
name: "rdp_handshake",
status: Status::Pass,
detail: format!("greeting received from {host}:{port}"),
hint: None,
});
match RootActor::list_tabs(conn.transport_mut()) {
Ok(t) => {
probes.push(probe_tabs(&t));
}
Err(e) => probes.push(Probe {
name: "tabs",
status: Status::Fail,
detail: format!("listTabs failed: {e}"),
hint: Some(
"the tab list could not be retrieved; reload Firefox or relaunch with `ff-rdp launch --temp-profile`".to_owned(),
),
}),
}
}
Err(e) => probes.push(Probe {
name: "rdp_handshake",
status: Status::Fail,
detail: format!("RDP handshake failed: {e}"),
hint: Some(
"the listener accepted TCP but did not return a Firefox greeting — check that --start-debugger-server matches --port and that the listener is Firefox"
.to_owned(),
),
}),
}
} else {
probes.push(Probe {
name: "rdp_handshake",
status: Status::Fail,
detail: "skipped — no listener on the port".to_owned(),
hint: Some(format!(
"run `ff-rdp launch` to start Firefox with debugging on port {port}"
)),
});
probes.push(Probe {
name: "tabs",
status: Status::Fail,
detail: "skipped — no listener on the port".to_owned(),
hint: None,
});
}
probes.push(probe_version(firefox_version));
let any_failed = probes.iter().any(|p| p.status == Status::Fail);
let results = build_results_json(&probes);
let mut meta = json!({
"host": host,
"port": port,
});
crate::connection_meta::merge_into(&mut meta, host, port, firefox_version);
let envelope = output::envelope(&results, probes.len(), &meta);
OutputPipeline::from_cli(cli)?
.finalize(&envelope)
.map_err(AppError::from)?;
if any_failed {
Err(AppError::Exit(1))
} else {
Ok(())
}
}
fn probe_daemon(host: &str, port: u16) -> Probe {
match find_running_daemon(host, port) {
Ok(Some(info)) => Probe {
name: "daemon",
status: Status::Pass,
detail: format!(
"daemon running (PID {}, proxy port {}, started {})",
info.pid, info.proxy_port, info.started_at
),
hint: None,
},
Ok(None) => {
match registry::read_registry() {
Ok(Some(_)) => Probe {
name: "daemon",
status: Status::Warn,
detail: "daemon registry exists but PID is dead — stale entry was cleaned up"
.to_owned(),
hint: Some(
"no daemon is running; commands will connect directly to Firefox"
.to_owned(),
),
},
Ok(None) => Probe {
name: "daemon",
status: Status::Pass,
detail: "no daemon running (commands will connect directly)".to_owned(),
hint: None,
},
Err(e) => Probe {
name: "daemon",
status: Status::Warn,
detail: format!("could not read daemon registry: {e:#}"),
hint: None,
},
}
}
Err(e) => Probe {
name: "daemon",
status: Status::Warn,
detail: format!("daemon registry read error: {e:#}"),
hint: None,
},
}
}
fn probe_port_owner(port: u16, in_use: bool, owner: Option<&PortOwner>, local: bool) -> Probe {
if !local {
return Probe {
name: "port_owner",
status: Status::Pass,
detail: format!("port {port} is on a non-loopback host; skipping local OS probe"),
hint: None,
};
}
if !in_use {
return Probe {
name: "port_owner",
status: Status::Fail,
detail: format!("nothing is listening on port {port}"),
hint: Some(format!(
"run `ff-rdp launch --port {port}` to start Firefox with debugging enabled"
)),
};
}
match owner {
Some(o) => {
let uptime = match o.uptime_s {
Some(s) => format!(", uptime {}", format_uptime(s)),
None => String::new(),
};
let process = if o.process_name.is_empty() {
String::new()
} else {
format!(" ({})", o.process_name)
};
Probe {
name: "port_owner",
status: Status::Pass,
detail: format!("PID {}{process} is listening on port {port}{uptime}", o.pid),
hint: None,
}
}
None => Probe {
name: "port_owner",
status: Status::Warn,
detail: format!("port {port} is in use but the owner could not be identified"),
hint: Some(
"install `lsof` (Unix) so doctor can identify the listener process".to_owned(),
),
},
}
}
fn probe_tabs(tabs: &[TabInfo]) -> Probe {
if tabs.is_empty() {
return Probe {
name: "tabs",
status: Status::Fail,
detail: "Firefox is connected but exposes 0 tabs".to_owned(),
hint: Some(
"open a tab in Firefox, or relaunch with `ff-rdp launch --temp-profile` for a clean session".to_owned(),
),
};
}
let selected = tabs.iter().filter(|t| t.selected).count();
Probe {
name: "tabs",
status: Status::Pass,
detail: format!("{} tab(s) available, {selected} selected", tabs.len()),
hint: None,
}
}
fn probe_version(version: Option<u32>) -> Probe {
match version {
None => Probe {
name: "firefox_version",
status: Status::Warn,
detail: "Firefox version not advertised in the RDP greeting".to_owned(),
hint: None,
},
Some(v) if (COMPATIBLE_FIREFOX_MIN..=COMPATIBLE_FIREFOX_MAX).contains(&v) => Probe {
name: "firefox_version",
status: Status::Pass,
detail: format!(
"Firefox {v} (within tested range {COMPATIBLE_FIREFOX_MIN}–{COMPATIBLE_FIREFOX_MAX})"
),
hint: None,
},
Some(v) => Probe {
name: "firefox_version",
status: Status::Warn,
detail: format!(
"Firefox {v} is outside the tested range {COMPATIBLE_FIREFOX_MIN}–{COMPATIBLE_FIREFOX_MAX}"
),
hint: Some(
"some commands may misbehave on this version; report regressions at https://github.com/ractive/ff-rdp/issues".to_owned(),
),
},
}
}
fn build_results_json(probes: &[Probe]) -> Value {
let arr: Vec<Value> = probes
.iter()
.map(|p| {
let mut obj = serde_json::Map::new();
obj.insert("name".to_string(), Value::String(p.name.to_string()));
obj.insert(
"status".to_string(),
Value::String(p.status.as_str().to_string()),
);
obj.insert("detail".to_string(), Value::String(p.detail.clone()));
obj.insert(
"glyph".to_string(),
Value::String(p.status.glyph().to_string()),
);
if let Some(hint) = &p.hint {
obj.insert("hint".to_string(), Value::String(hint.clone()));
}
Value::Object(obj)
})
.collect();
Value::Array(arr)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn probe_version_in_range_is_pass() {
let p = probe_version(Some(149));
assert_eq!(p.status, Status::Pass);
}
#[test]
fn probe_version_out_of_range_is_warn() {
let p = probe_version(Some(99));
assert_eq!(p.status, Status::Warn);
}
#[test]
fn probe_version_unknown_is_warn() {
let p = probe_version(None);
assert_eq!(p.status, Status::Warn);
}
#[test]
fn build_results_includes_hint_when_present() {
let probes = vec![Probe {
name: "x",
status: Status::Fail,
detail: "bad".into(),
hint: Some("try y".into()),
}];
let json = build_results_json(&probes);
assert_eq!(json[0]["hint"], "try y");
assert_eq!(json[0]["status"], "fail");
}
#[test]
fn probe_tabs_zero_is_fail() {
let p = probe_tabs(&[]);
assert_eq!(p.status, Status::Fail);
}
}