agent_first_http/cli/cmd/
ui.rs1use std::time::{Duration, Instant};
25
26use agent_first_ui::{UiUpstream, UiWindow, UiWindowConfig};
27use serde::Serialize;
28
29use crate::cli::connect::Connection;
30use crate::cli::output;
31use crate::shared::error::{Error, ErrorCode};
32
33#[derive(Debug)]
34pub struct Args {
35 pub sub: UiSub,
36 pub delivery: Delivery,
37}
38
39#[derive(Debug)]
40pub enum UiSub {
41 Takeover(TakeoverArgs),
42}
43
44#[derive(Debug, Clone, Copy, PartialEq, Eq)]
46pub enum Delivery {
47 Window,
49 Listed,
52}
53
54#[derive(Debug)]
55pub enum TakeoverArgs {
56 Mint { connection: Connection },
58 Open { takeover_url_secret: String },
61}
62
63#[derive(Serialize)]
69struct UiReady {
70 panel_url: String,
73 session: &'static str,
76 delivery: &'static str,
78 #[serde(skip_serializing_if = "Option::is_none")]
81 session_id: Option<String>,
82 #[serde(skip_serializing_if = "Option::is_none")]
83 takeover_url_expires_at_rfc3339: Option<String>,
84 #[serde(skip_serializing_if = "Option::is_none")]
85 takeover_url_ttl_s: Option<u64>,
86}
87
88#[derive(Serialize)]
90struct UiTakeoverResult {
91 panel_url: String,
92 session: &'static str,
93 delivery: &'static str,
94 outcome: &'static str,
95 open_s: u64,
96}
97
98const SESSION_KIND: &str = "watch";
99
100const PROVIDER_ID: &str = "afhttp";
104const UI_KIND: &str = "takeover";
105
106pub async fn run(args: Args) -> Result<(), Error> {
107 match args.sub {
108 UiSub::Takeover(takeover) => takeover_run(takeover, args.delivery).await,
109 }
110}
111
112async fn takeover_run(args: TakeoverArgs, delivery: Delivery) -> Result<(), Error> {
113 let panel = resolve_panel(args).await?;
114 let panel_url = credential_free_panel_url(&panel.url)?;
115
116 let announced = UiUpstream::new(PROVIDER_ID, UI_KIND, &panel.url)
128 .and_then(|upstream| upstream.with_subject(&panel_url).announce());
129 let announced = match (announced, delivery) {
130 (Ok(announced), _) => Some(announced),
131 (Err(error), Delivery::Listed) => {
132 return Err(Error::new(
133 ErrorCode::InternalError,
134 format!(
135 "could not publish the takeover panel as a UI session: {error}. \
136 Without a window there is nothing else to deliver it with."
137 ),
138 ));
139 }
140 (Err(_), Delivery::Window) => None,
141 };
142
143 output::emit_progress(
144 "ui_takeover",
145 &UiReady {
146 panel_url: panel_url.clone(),
147 session: SESSION_KIND,
148 delivery: delivery.as_str(),
149 session_id: announced
150 .as_ref()
151 .map(|announced| announced.metadata().session_id.to_string()),
152 takeover_url_expires_at_rfc3339: panel.expires_at_rfc3339,
153 takeover_url_ttl_s: panel.ttl_s,
154 },
155 )?;
156
157 let opened_at = Instant::now();
158 let outcome = match delivery {
159 Delivery::Window => wait_on_window(&panel.url).await?,
160 Delivery::Listed => wait_while_listed(panel.ttl_s).await,
161 };
162 drop(announced);
163
164 output::emit(
165 "ui_takeover",
166 &UiTakeoverResult {
167 panel_url,
168 session: SESSION_KIND,
169 delivery: delivery.as_str(),
170 outcome,
171 open_s: opened_at.elapsed().as_secs(),
172 },
173 )
174}
175
176impl Delivery {
177 fn as_str(self) -> &'static str {
178 match self {
179 Self::Window => "window",
180 Self::Listed => "listed",
181 }
182 }
183}
184
185async fn wait_on_window(url_secret: &str) -> Result<&'static str, Error> {
187 let mut window = UiWindow::launch(url_secret, &UiWindowConfig::default()).map_err(|error| {
191 Error::new(
192 ErrorCode::BrowserLaunchFailed,
193 format!(
194 "could not open a takeover window: {error}. \
195 This delivery needs a display and a Chromium-family browser on this machine; \
196 use `--takeover-no-window` to hand the panel to a phone through \
197 `afui session serve`, or `afhttp panel` to hand the URL to someone else."
198 ),
199 )
200 })?;
201 window.wait_closed().await.map_err(|error| {
202 Error::new(
203 ErrorCode::InternalError,
204 format!("waiting on the takeover window: {error}"),
205 )
206 })?;
207 Ok("closed")
208}
209
210async fn wait_while_listed(ttl_s: Option<u64>) -> &'static str {
221 let stopped = stop_requested();
222 match ttl_s {
223 Some(ttl_s) => {
224 let expiry = tokio::time::sleep(Duration::from_secs(ttl_s));
225 tokio::select! {
226 () = expiry => "expired",
227 () = stopped => "stopped",
228 }
229 }
230 None => {
231 stopped.await;
232 "stopped"
233 }
234 }
235}
236
237async fn stop_requested() {
239 let interrupt = async {
240 let _ignored = tokio::signal::ctrl_c().await;
241 };
242 #[cfg(unix)]
243 let terminate = async {
244 if let Ok(mut signal) =
245 tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())
246 {
247 signal.recv().await;
248 }
249 };
250 #[cfg(not(unix))]
251 let terminate = std::future::pending::<()>();
252 tokio::select! {
253 () = interrupt => {},
254 () = terminate => {},
255 }
256}
257
258struct Panel {
261 url: String,
262 expires_at_rfc3339: Option<String>,
263 ttl_s: Option<u64>,
264}
265
266async fn resolve_panel(args: TakeoverArgs) -> Result<Panel, Error> {
267 match args {
268 TakeoverArgs::Open {
269 takeover_url_secret,
270 } => Ok(Panel {
271 url: takeover_url_secret,
272 expires_at_rfc3339: None,
273 ttl_s: None,
274 }),
275 TakeoverArgs::Mint { connection } => {
276 let client = connection.client().await?;
277 let handoff = client.takeover_handoff(None, None).await?;
278 Ok(Panel {
279 url: handoff.takeover_url_secret,
280 expires_at_rfc3339: Some(handoff.takeover_url_expires_at_rfc3339),
281 ttl_s: Some(handoff.takeover_url_ttl_s),
282 })
283 }
284 }
285}
286
287fn credential_free_panel_url(url: &str) -> Result<String, Error> {
292 let mut panel = url::Url::parse(url).map_err(|error| {
293 Error::new(
294 ErrorCode::InvalidEndpoint,
295 format!("--takeover-url-secret: {url:?} is not a URL: {error}"),
296 )
297 })?;
298 panel.set_query(None);
299 panel.set_fragment(None);
300 Ok(panel.to_string())
301}
302
303#[cfg(test)]
304mod tests {
305 use super::*;
306
307 #[test]
308 fn the_reported_panel_url_drops_the_credential() {
309 let panel = credential_free_panel_url(
310 "http://127.0.0.1:9222/takeover/panel?handoff_secret=deadbeef#frag",
311 )
312 .unwrap();
313 assert_eq!(panel, "http://127.0.0.1:9222/takeover/panel");
314 }
315
316 #[test]
317 fn a_non_url_panel_is_an_argument_error_not_a_launch() {
318 let error = credential_free_panel_url("not a url").unwrap_err();
319 assert_eq!(error.error_code, ErrorCode::InvalidEndpoint);
320 }
321
322 #[test]
323 fn neither_emitted_event_carries_the_secret() {
324 const SECRET: &str = "0123456789abcdef";
325 let url = format!("http://127.0.0.1:9222/takeover/panel?handoff_secret={SECRET}");
326 let panel = credential_free_panel_url(&url).unwrap();
327 let ready = serde_json::to_string(&UiReady {
328 panel_url: panel.clone(),
329 session: SESSION_KIND,
330 delivery: Delivery::Listed.as_str(),
331 session_id: Some("aabbccdd".to_string()),
332 takeover_url_expires_at_rfc3339: Some("2026-06-11T00:00:00Z".into()),
333 takeover_url_ttl_s: Some(900),
334 })
335 .unwrap();
336 let done = serde_json::to_string(&UiTakeoverResult {
337 panel_url: panel,
338 session: SESSION_KIND,
339 delivery: Delivery::Listed.as_str(),
340 outcome: "expired",
341 open_s: 12,
342 })
343 .unwrap();
344 assert!(!ready.contains(SECRET), "{ready}");
345 assert!(!ready.contains("handoff_secret"), "{ready}");
346 assert!(!done.contains(SECRET), "{done}");
347 assert!(!done.contains("handoff_secret"), "{done}");
348 }
349
350 #[test]
353 fn the_panel_is_announced_under_identifiers_afui_accepts() {
354 let announced = UiUpstream::new(
355 PROVIDER_ID,
356 UI_KIND,
357 "http://127.0.0.1:9222/takeover/panel?handoff_secret=deadbeef",
358 );
359 assert!(announced.is_ok());
360 }
361}