use std::time::{Duration, Instant};
use agent_first_ui::{UiUpstream, UiWindow, UiWindowConfig};
use serde::Serialize;
use crate::cli::connect::Connection;
use crate::cli::output;
use crate::shared::error::{Error, ErrorCode};
#[derive(Debug)]
pub struct Args {
pub sub: UiSub,
pub delivery: Delivery,
}
#[derive(Debug)]
pub enum UiSub {
Takeover(TakeoverArgs),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Delivery {
Window,
Listed,
}
#[derive(Debug)]
pub enum TakeoverArgs {
Mint { connection: Connection },
Open { takeover_url_secret: String },
}
#[derive(Serialize)]
struct UiReady {
panel_url: String,
session: &'static str,
delivery: &'static str,
#[serde(skip_serializing_if = "Option::is_none")]
session_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
takeover_url_expires_at_rfc3339: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
takeover_url_ttl_s: Option<u64>,
}
#[derive(Serialize)]
struct UiTakeoverResult {
panel_url: String,
session: &'static str,
delivery: &'static str,
outcome: &'static str,
open_s: u64,
}
const SESSION_KIND: &str = "watch";
const PROVIDER_ID: &str = "afhttp";
const UI_KIND: &str = "takeover";
pub async fn run(args: Args) -> Result<(), Error> {
match args.sub {
UiSub::Takeover(takeover) => takeover_run(takeover, args.delivery).await,
}
}
async fn takeover_run(args: TakeoverArgs, delivery: Delivery) -> Result<(), Error> {
let panel = resolve_panel(args).await?;
let panel_url = credential_free_panel_url(&panel.url)?;
let announced = UiUpstream::new(PROVIDER_ID, UI_KIND, &panel.url)
.and_then(|upstream| upstream.with_subject(&panel_url).announce());
let announced = match (announced, delivery) {
(Ok(announced), _) => Some(announced),
(Err(error), Delivery::Listed) => {
return Err(Error::new(
ErrorCode::InternalError,
format!(
"could not publish the takeover panel as a UI session: {error}. \
Without a window there is nothing else to deliver it with."
),
));
}
(Err(_), Delivery::Window) => None,
};
output::emit_progress(
"ui_takeover",
&UiReady {
panel_url: panel_url.clone(),
session: SESSION_KIND,
delivery: delivery.as_str(),
session_id: announced
.as_ref()
.map(|announced| announced.metadata().session_id.to_string()),
takeover_url_expires_at_rfc3339: panel.expires_at_rfc3339,
takeover_url_ttl_s: panel.ttl_s,
},
)?;
let opened_at = Instant::now();
let outcome = match delivery {
Delivery::Window => wait_on_window(&panel.url).await?,
Delivery::Listed => wait_while_listed(panel.ttl_s).await,
};
drop(announced);
output::emit(
"ui_takeover",
&UiTakeoverResult {
panel_url,
session: SESSION_KIND,
delivery: delivery.as_str(),
outcome,
open_s: opened_at.elapsed().as_secs(),
},
)
}
impl Delivery {
fn as_str(self) -> &'static str {
match self {
Self::Window => "window",
Self::Listed => "listed",
}
}
}
async fn wait_on_window(url_secret: &str) -> Result<&'static str, Error> {
let mut window = UiWindow::launch(url_secret, &UiWindowConfig::default()).map_err(|error| {
Error::new(
ErrorCode::BrowserLaunchFailed,
format!(
"could not open a takeover window: {error}. \
This delivery needs a display and a Chromium-family browser on this machine; \
use `--takeover-no-window` to hand the panel to a phone through \
`afui session serve`, or `afhttp panel` to hand the URL to someone else."
),
)
})?;
window.wait_closed().await.map_err(|error| {
Error::new(
ErrorCode::InternalError,
format!("waiting on the takeover window: {error}"),
)
})?;
Ok("closed")
}
async fn wait_while_listed(ttl_s: Option<u64>) -> &'static str {
let stopped = stop_requested();
match ttl_s {
Some(ttl_s) => {
let expiry = tokio::time::sleep(Duration::from_secs(ttl_s));
tokio::select! {
() = expiry => "expired",
() = stopped => "stopped",
}
}
None => {
stopped.await;
"stopped"
}
}
}
async fn stop_requested() {
let interrupt = async {
let _ignored = tokio::signal::ctrl_c().await;
};
#[cfg(unix)]
let terminate = async {
if let Ok(mut signal) =
tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())
{
signal.recv().await;
}
};
#[cfg(not(unix))]
let terminate = std::future::pending::<()>();
tokio::select! {
() = interrupt => {},
() = terminate => {},
}
}
struct Panel {
url: String,
expires_at_rfc3339: Option<String>,
ttl_s: Option<u64>,
}
async fn resolve_panel(args: TakeoverArgs) -> Result<Panel, Error> {
match args {
TakeoverArgs::Open {
takeover_url_secret,
} => Ok(Panel {
url: takeover_url_secret,
expires_at_rfc3339: None,
ttl_s: None,
}),
TakeoverArgs::Mint { connection } => {
let client = connection.client().await?;
let handoff = client.takeover_handoff(None, None).await?;
Ok(Panel {
url: handoff.takeover_url_secret,
expires_at_rfc3339: Some(handoff.takeover_url_expires_at_rfc3339),
ttl_s: Some(handoff.takeover_url_ttl_s),
})
}
}
}
fn credential_free_panel_url(url: &str) -> Result<String, Error> {
let mut panel = url::Url::parse(url).map_err(|error| {
Error::new(
ErrorCode::InvalidEndpoint,
format!("--takeover-url-secret: {url:?} is not a URL: {error}"),
)
})?;
panel.set_query(None);
panel.set_fragment(None);
Ok(panel.to_string())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_reported_panel_url_drops_the_credential() {
let panel = credential_free_panel_url(
"http://127.0.0.1:9222/takeover/panel?handoff_secret=deadbeef#frag",
)
.unwrap();
assert_eq!(panel, "http://127.0.0.1:9222/takeover/panel");
}
#[test]
fn a_non_url_panel_is_an_argument_error_not_a_launch() {
let error = credential_free_panel_url("not a url").unwrap_err();
assert_eq!(error.error_code, ErrorCode::InvalidEndpoint);
}
#[test]
fn neither_emitted_event_carries_the_secret() {
const SECRET: &str = "0123456789abcdef";
let url = format!("http://127.0.0.1:9222/takeover/panel?handoff_secret={SECRET}");
let panel = credential_free_panel_url(&url).unwrap();
let ready = serde_json::to_string(&UiReady {
panel_url: panel.clone(),
session: SESSION_KIND,
delivery: Delivery::Listed.as_str(),
session_id: Some("aabbccdd".to_string()),
takeover_url_expires_at_rfc3339: Some("2026-06-11T00:00:00Z".into()),
takeover_url_ttl_s: Some(900),
})
.unwrap();
let done = serde_json::to_string(&UiTakeoverResult {
panel_url: panel,
session: SESSION_KIND,
delivery: Delivery::Listed.as_str(),
outcome: "expired",
open_s: 12,
})
.unwrap();
assert!(!ready.contains(SECRET), "{ready}");
assert!(!ready.contains("handoff_secret"), "{ready}");
assert!(!done.contains(SECRET), "{done}");
assert!(!done.contains("handoff_secret"), "{done}");
}
#[test]
fn the_panel_is_announced_under_identifiers_afui_accepts() {
let announced = UiUpstream::new(
PROVIDER_ID,
UI_KIND,
"http://127.0.0.1:9222/takeover/panel?handoff_secret=deadbeef",
);
assert!(announced.is_ok());
}
}