1use agentmux_common::AUTH_KEY_HEADER;
26use std::sync::{Arc, OnceLock};
27
28use futures_util::{SinkExt, StreamExt};
29use serde::Deserialize;
30use tokio::sync::{mpsc, watch};
31use tokio_tungstenite::tungstenite::{ClientRequestBuilder, Message};
32
33#[cfg(target_os = "windows")]
34mod windows;
35#[cfg(target_os = "linux")]
36mod linux;
37
38#[derive(Clone, Debug, Deserialize, PartialEq, Eq)]
41pub struct Notification {
42 pub id: String,
43 pub kind: String,
44 pub priority: String,
45 pub title: String,
46 #[serde(default)]
47 pub body: Option<String>,
48 #[serde(default)]
52 pub summary: Option<String>,
53 pub tag: String,
54}
55
56impl Notification {
57 pub fn is_attention(&self) -> bool {
58 self.priority == "attention"
59 }
60}
61
62#[derive(Clone, Debug, PartialEq, Eq)]
65pub enum UserAction {
66 Clicked(String),
67 Dismissed(String),
68 SetPause(i64),
70 SetStartAtLogin(bool),
72}
73
74pub trait Presenter: Send + Sync {
76 fn show(&self, n: &Notification);
77 fn retract(&self, tag: &str);
78 fn clear_all(&self);
79}
80
81struct NullPresenter;
84impl Presenter for NullPresenter {
85 fn show(&self, n: &Notification) {
86 crate::logging::log(&format!("notify: no OS backend on this platform; dropping '{}'", n.kind));
87 }
88 fn retract(&self, _tag: &str) {}
89 fn clear_all(&self) {}
90}
91
92#[derive(Clone, Debug, PartialEq, Eq)]
93struct Endpoint {
94 ws: String,
95 auth_key: String,
96}
97
98struct Handle {
99 endpoint: watch::Sender<Option<Endpoint>>,
100 presenter: Arc<dyn Presenter>,
101}
102
103pub type LauncherState = Arc<tokio::sync::Mutex<crate::state::State>>;
106
107static HANDLE: OnceLock<Handle> = OnceLock::new();
108
109type ActionChannel = (mpsc::UnboundedSender<UserAction>, std::sync::Mutex<Option<mpsc::UnboundedReceiver<UserAction>>>);
114static ACTIONS: OnceLock<ActionChannel> = OnceLock::new();
115
116fn actions() -> &'static ActionChannel {
117 ACTIONS.get_or_init(|| {
118 let (tx, rx) = mpsc::unbounded_channel();
119 (tx, std::sync::Mutex::new(Some(rx)))
120 })
121}
122
123fn make_presenter(actions: mpsc::UnboundedSender<UserAction>, data_dir: &std::path::Path) -> Arc<dyn Presenter> {
124 #[cfg(target_os = "windows")]
125 {
126 match windows::WindowsPresenter::spawn(actions, data_dir) {
127 Ok(p) => return Arc::new(p),
128 Err(e) => crate::logging::log(&format!("notify: Windows toast backend unavailable: {e}")),
129 }
130 }
131 #[cfg(target_os = "linux")]
132 {
133 let _ = data_dir;
134 return Arc::new(linux::LinuxPresenter::spawn(actions));
135 }
136 #[cfg(not(any(target_os = "windows", target_os = "linux")))]
137 let _ = (actions, data_dir);
138 #[allow(unreachable_code)]
139 Arc::new(NullPresenter)
140}
141
142pub fn start(
144 ws_endpoint: &str,
145 auth_key: &str,
146 data_dir: std::path::PathBuf,
147 dir_hash: String,
148 state: LauncherState,
149) {
150 let ep = Endpoint { ws: ws_endpoint.to_string(), auth_key: auth_key.to_string() };
151 if let Some(h) = HANDLE.get() {
152 let _ = h.endpoint.send(Some(ep));
153 return;
154 }
155 let act_tx = actions().0.clone();
156 let Some(act_rx) = actions().1.lock().unwrap_or_else(|e| e.into_inner()).take() else {
157 return;
158 };
159 let presenter = make_presenter(act_tx, &data_dir);
160 presenter.clear_all();
163 let (ep_tx, ep_rx) = watch::channel(Some(ep));
164 if HANDLE.set(Handle { endpoint: ep_tx, presenter: presenter.clone() }).is_err() {
165 return;
166 }
167 tokio::spawn(run(ep_rx, act_rx, presenter, data_dir, dir_hash, state));
168}
169
170#[cfg(target_os = "windows")]
174pub fn update_endpoint(ws_endpoint: &str, auth_key: &str) {
175 if let Some(h) = HANDLE.get() {
176 h.presenter.clear_all();
177 let _ = h.endpoint.send(Some(Endpoint { ws: ws_endpoint.to_string(), auth_key: auth_key.to_string() }));
178 }
179}
180
181pub fn tray_request(action: crate::tray::notify_menu::NotifyMenuAction) {
185 use crate::tray::notify_menu::{resolve_pause, NotifyMenuAction};
186 let ua = match action {
187 NotifyMenuAction::Open(id) => {
188 #[cfg(target_os = "windows")]
191 unsafe {
192 let _ = ::windows::Win32::UI::WindowsAndMessaging::AllowSetForegroundWindow(
193 ::windows::Win32::UI::WindowsAndMessaging::ASFW_ANY,
194 );
195 }
196 UserAction::Clicked(id)
197 }
198 NotifyMenuAction::Pause(choice) => UserAction::SetPause(resolve_pause(choice, chrono::Local::now())),
199 NotifyMenuAction::Resume => UserAction::SetPause(0),
200 };
201 let _ = actions().0.send(ua);
203}
204
205pub fn request_start_at_login(on: bool) {
209 let _ = actions().0.send(UserAction::SetStartAtLogin(on));
210}
211
212pub fn shutdown() {
214 if let Some(h) = HANDLE.get() {
215 h.presenter.clear_all();
216 }
217}
218
219pub fn ws_url(endpoint: &str) -> String {
221 let base = endpoint.trim_end_matches('/');
222 let base = if base.starts_with("ws://") || base.starts_with("wss://") {
223 base.to_string()
224 } else {
225 format!("ws://{base}")
226 };
227 format!("{base}/ws")
228}
229
230#[derive(Debug, PartialEq, Eq)]
232pub enum Frame {
233 Show(Notification),
234 Retract(String),
235 State(crate::tray::notify_menu::NotifyTrayState),
236 StartAtLogin(Option<bool>),
238 Response { reqid: String, ack: AckResponse },
239 Other,
240}
241
242#[derive(Clone, Debug, Default, PartialEq, Eq, Deserialize)]
244pub struct AckResponse {
245 #[serde(default)]
246 pub has_window: Option<bool>,
247 #[serde(default)]
248 pub window_id: Option<String>,
249 #[serde(default)]
250 pub workspace_id: Option<String>,
251}
252
253#[derive(Debug, PartialEq, Eq)]
255pub enum ClickPlan {
256 Raise(String),
259 OpenWorkspace(String),
261 OpenWindow,
263 Nothing,
264}
265
266pub fn click_plan(ack: &AckResponse) -> ClickPlan {
268 if let Some(w) = ack.window_id.as_ref().filter(|w| !w.is_empty()) {
269 return ClickPlan::Raise(w.clone());
270 }
271 if let Some(ws) = ack.workspace_id.as_ref().filter(|w| !w.is_empty()) {
272 return ClickPlan::OpenWorkspace(ws.clone());
273 }
274 if ack.has_window == Some(false) {
275 return ClickPlan::OpenWindow;
276 }
277 ClickPlan::Nothing
278}
279
280pub fn window_for(state: &crate::state::State, window_id: &str) -> Option<(String, Option<u64>, bool)> {
282 let label = state.backend_window_ids.iter().find(|(_, w)| w.as_str() == window_id).map(|(l, _)| l.clone())?;
283 let mirror = state.windows.get(&label);
284 Some((label, mirror.and_then(|m| m.hwnd), mirror.is_some_and(|m| m.iconic)))
285}
286
287pub fn parse_frame(text: &str) -> Frame {
291 let Ok(v) = serde_json::from_str::<serde_json::Value>(text) else {
292 return Frame::Other;
293 };
294 let Some(data) = v.get("data") else { return Frame::Other };
295 if let Some(resid) = data.get("resid").and_then(|r| r.as_str()) {
296 return Frame::Response {
297 reqid: resid.to_string(),
298 ack: data.get("data").and_then(|d| serde_json::from_value(d.clone()).ok()).unwrap_or_default(),
299 };
300 }
301 if data.get("command").and_then(|c| c.as_str()) != Some("eventrecv") {
302 return Frame::Other;
303 }
304 let Some(ev) = data.get("data") else { return Frame::Other };
305 match ev.get("event").and_then(|e| e.as_str()) {
306 Some("notification") => ev
307 .get("data")
308 .and_then(|d| serde_json::from_value::<Notification>(d.clone()).ok())
309 .map(Frame::Show)
310 .unwrap_or(Frame::Other),
311 Some("notification:state") => ev
312 .get("data")
313 .and_then(|d| serde_json::from_value(d.clone()).ok())
314 .map(Frame::State)
315 .unwrap_or(Frame::Other),
316 Some("config") => ev
317 .get("data")
318 .and_then(|d| d.get("fullconfig"))
319 .and_then(|c| c.get("settings"))
320 .map(|s| Frame::StartAtLogin(s.get(crate::start_at_login::SETTINGS_KEY).and_then(|v| v.as_bool())))
321 .unwrap_or(Frame::Other),
322 Some("notification:retract") => ev
323 .get("data")
324 .and_then(|d| d.get("tag"))
325 .and_then(|t| t.as_str())
326 .map(|t| Frame::Retract(t.to_string()))
327 .unwrap_or(Frame::Other),
328 _ => Frame::Other,
329 }
330}
331
332fn rpc(command: &str, reqid: Option<&str>, data: serde_json::Value) -> String {
333 let mut msg = serde_json::json!({ "command": command, "data": data });
334 if let Some(r) = reqid {
335 msg["reqid"] = serde_json::Value::String(r.to_string());
336 }
337 serde_json::json!({ "wscommand": "rpc", "message": msg }).to_string()
338}
339
340async fn run(
341 mut ep_rx: watch::Receiver<Option<Endpoint>>,
342 mut act_rx: mpsc::UnboundedReceiver<UserAction>,
343 presenter: Arc<dyn Presenter>,
344 data_dir: std::path::PathBuf,
345 dir_hash: String,
346 state: LauncherState,
347) {
348 let mut backoff_ms = 500u64;
349 loop {
350 let ep = ep_rx.borrow_and_update().clone();
351 let Some(ep) = ep else {
352 if ep_rx.changed().await.is_err() {
353 return;
354 }
355 continue;
356 };
357 match session(&ep, &mut ep_rx, &mut act_rx, &presenter, &data_dir, &dir_hash, &state).await {
358 SessionEnd::EndpointChanged => {
359 backoff_ms = 500;
360 continue;
361 }
362 SessionEnd::Closed(reason) => {
363 crate::logging::log(&format!("notify: srv connection ended ({reason}); retrying in {backoff_ms}ms"));
364 }
365 }
366 tokio::select! {
367 _ = tokio::time::sleep(std::time::Duration::from_millis(backoff_ms)) => {}
368 r = ep_rx.changed() => if r.is_err() { return; },
369 }
370 backoff_ms = (backoff_ms * 2).min(30_000);
371 }
372}
373
374enum SessionEnd {
375 EndpointChanged,
376 Closed(String),
377}
378
379async fn session(
380 ep: &Endpoint,
381 ep_rx: &mut watch::Receiver<Option<Endpoint>>,
382 act_rx: &mut mpsc::UnboundedReceiver<UserAction>,
383 presenter: &Arc<dyn Presenter>,
384 data_dir: &std::path::Path,
385 dir_hash: &str,
386 state: &LauncherState,
387) -> SessionEnd {
388 let uri: tokio_tungstenite::tungstenite::http::Uri = match ws_url(&ep.ws).parse() {
389 Ok(u) => u,
390 Err(e) => return SessionEnd::Closed(format!("bad endpoint: {e}")),
391 };
392 let request = ClientRequestBuilder::new(uri).with_header(AUTH_KEY_HEADER, ep.auth_key.clone());
396 let (ws, _) = match tokio_tungstenite::connect_async(request).await {
397 Ok(s) => s,
398 Err(e) => return SessionEnd::Closed(format!("connect: {e}")),
399 };
400 let (mut write, mut read) = ws.split();
401 for event in ["notification", "notification:retract", "notification:state"] {
402 let sub = rpc("eventsub", None, serde_json::json!({ "event": event, "scopes": [], "allscopes": true }));
403 if let Err(e) = write.send(Message::Text(sub.into())).await {
404 return SessionEnd::Closed(format!("subscribe: {e}"));
405 }
406 }
407 crate::logging::log("notify: subscribed to srv notifications");
408
409 let mut pending_clicks: std::collections::HashSet<String> = Default::default();
411
412 loop {
413 tokio::select! {
414 changed = ep_rx.changed() => {
415 if changed.is_err() || ep_rx.borrow().as_ref() != Some(ep) {
416 return SessionEnd::EndpointChanged;
417 }
418 }
419 action = act_rx.recv() => {
420 let Some(action) = action else { return SessionEnd::Closed("presenter gone".into()) };
421 let (id, clicked) = match &action {
422 UserAction::Clicked(id) => (id.clone(), true),
423 UserAction::Dismissed(id) => (id.clone(), false),
424 UserAction::SetPause(until) => {
425 let msg = rpc("setconfig", Some(&uuid::Uuid::new_v4().to_string()), serde_json::json!({ "notify:pause:until": until }));
426 if let Err(e) = write.send(Message::Text(msg.into())).await {
427 return SessionEnd::Closed(format!("send setconfig: {e}"));
428 }
429 continue;
430 }
431 UserAction::SetStartAtLogin(on) => {
432 let msg = rpc("setconfig", Some(&uuid::Uuid::new_v4().to_string()), serde_json::json!({ crate::start_at_login::SETTINGS_KEY: on }));
433 if let Err(e) = write.send(Message::Text(msg.into())).await {
434 return SessionEnd::Closed(format!("send setconfig: {e}"));
435 }
436 continue;
437 }
438 };
439 let reqid = uuid::Uuid::new_v4().to_string();
440 if clicked {
441 pending_clicks.insert(reqid.clone());
442 }
443 let msg = rpc("notify.ack", Some(&reqid), serde_json::json!({ "id": id, "clicked": clicked }));
444 if let Err(e) = write.send(Message::Text(msg.into())).await {
445 return SessionEnd::Closed(format!("send ack: {e}"));
446 }
447 }
448 msg = read.next() => {
449 let text = match msg {
450 Some(Ok(Message::Text(t))) => t.to_string(),
451 Some(Ok(Message::Close(_))) | None => return SessionEnd::Closed("closed by srv".into()),
452 Some(Ok(_)) => continue,
453 Some(Err(e)) => return SessionEnd::Closed(format!("read: {e}")),
454 };
455 match parse_frame(&text) {
456 Frame::Show(n) => presenter.show(&n),
457 Frame::Retract(tag) => presenter.retract(&tag),
458 Frame::State(state) => crate::tray::notify_menu::set(state),
459 Frame::StartAtLogin(v) => crate::start_at_login::observe(v),
460 Frame::Response { reqid, ack } => {
461 if pending_clicks.remove(&reqid) {
462 let plan = click_plan(&ack);
463 crate::logging::log(&format!("notify: click → {plan:?}"));
464 let window = match &plan {
465 ClickPlan::Raise(w) => window_for(&*state.lock().await, w),
466 _ => None,
467 };
468 let (dd, dh) = (data_dir.to_path_buf(), dir_hash.to_string());
469 tokio::task::spawn_blocking(move || carry_out_click(plan, window, &dd, &dh));
470 }
471 }
472 Frame::Other => {}
473 }
474 }
475 }
476 }
477}
478
479fn carry_out_click(
484 plan: ClickPlan,
485 window: Option<(String, Option<u64>, bool)>,
486 data_dir: &std::path::Path,
487 dir_hash: &str,
488) {
489 use crate::second_instance::{forward_host_cmd_with_args, forward_open_new_window, ForwardError};
490 let log_err = |what: &str, r: Result<(), ForwardError>| {
491 if let Err(ForwardError::Transient(e) | ForwardError::Fatal(e)) = r {
492 crate::logging::log(&format!("notify: {what} for click failed: {e}"));
493 }
494 };
495 match plan {
496 ClickPlan::Raise(window_id) => {
497 let Some((label, hwnd, iconic)) = window else {
498 crate::logging::log(&format!("notify: click window {window_id} not in the launcher's mirror"));
502 return;
503 };
504 if hwnd.is_some_and(|h| raise_hwnd(h, iconic)) {
505 return;
506 }
507 crate::logging::log(&format!("notify: raising {label} directly failed; asking the host"));
508 log_err("focus_window", forward_host_cmd_with_args(data_dir, dir_hash, "focus_window", serde_json::json!({ "label": label })));
509 if let Some(h) = hwnd {
510 flash_hwnd(h);
511 }
512 }
513 ClickPlan::OpenWorkspace(workspace_id) => log_err(
514 "open_new_window {workspace_id}",
515 forward_host_cmd_with_args(data_dir, dir_hash, "open_new_window", serde_json::json!({ "workspace_id": workspace_id })),
516 ),
517 ClickPlan::OpenWindow => log_err("open_new_window", forward_open_new_window(data_dir, dir_hash)),
518 ClickPlan::Nothing => {}
519 }
520}
521
522#[cfg(target_os = "windows")]
525fn raise_hwnd(hwnd: u64, iconic: bool) -> bool {
526 use windows_sys::Win32::UI::WindowsAndMessaging::{IsIconic, SetForegroundWindow, ShowWindow, SW_RESTORE};
527 let h = hwnd as usize as windows_sys::Win32::Foundation::HWND;
528 unsafe {
529 if iconic || IsIconic(h) != 0 {
530 ShowWindow(h, SW_RESTORE);
531 }
532 SetForegroundWindow(h) != 0
533 }
534}
535
536#[cfg(not(target_os = "windows"))]
537fn raise_hwnd(_hwnd: u64, _iconic: bool) -> bool {
538 false
539}
540
541#[cfg(target_os = "windows")]
544fn flash_hwnd(hwnd: u64) {
545 use windows_sys::Win32::UI::WindowsAndMessaging::{FlashWindowEx, FLASHWINFO, FLASHW_ALL, FLASHW_TIMERNOFG};
546 let info = FLASHWINFO {
547 cbSize: std::mem::size_of::<FLASHWINFO>() as u32,
548 hwnd: hwnd as usize as windows_sys::Win32::Foundation::HWND,
549 dwFlags: FLASHW_ALL | FLASHW_TIMERNOFG,
550 uCount: 3,
551 dwTimeout: 0,
552 };
553 unsafe {
554 FlashWindowEx(&info);
555 }
556}
557
558#[cfg(not(target_os = "windows"))]
559fn flash_hwnd(_hwnd: u64) {}
560
561#[cfg(test)]
562mod tests {
563 use super::*;
564
565 #[test]
566 fn click_plan_follows_srvs_answer() {
567 let ack = |window: Option<&str>, ws: Option<&str>, has: Option<bool>| AckResponse {
568 has_window: has,
569 window_id: window.map(Into::into),
570 workspace_id: ws.map(Into::into),
571 };
572 assert_eq!(click_plan(&ack(Some("w1"), None, Some(true))), ClickPlan::Raise("w1".into()));
573 assert_eq!(click_plan(&ack(None, Some("ws1"), Some(false))), ClickPlan::OpenWorkspace("ws1".into()));
574 assert_eq!(click_plan(&ack(None, None, Some(false))), ClickPlan::OpenWindow);
575 assert_eq!(click_plan(&ack(None, None, Some(true))), ClickPlan::Nothing);
577 assert_eq!(click_plan(&ack(Some(""), Some(""), None)), ClickPlan::Nothing);
578 }
579
580 #[test]
581 fn window_for_maps_backend_id_to_label_hwnd_and_minimized() {
582 let mut s = crate::state::State::default();
583 s.backend_window_ids.insert("main".into(), "wid-1".into());
584 assert_eq!(window_for(&s, "wid-1"), Some(("main".into(), None, false)), "label known before its HWND");
585 assert_eq!(window_for(&s, "wid-2"), None);
586 }
587
588 #[test]
589 fn ws_url_forms() {
590 assert_eq!(ws_url("127.0.0.1:5000"), "ws://127.0.0.1:5000/ws");
591 assert_eq!(ws_url("ws://127.0.0.1:5000/"), "ws://127.0.0.1:5000/ws");
592 }
593
594 #[test]
595 fn parses_notification_event() {
596 let f = r#"{"eventtype":"rpc","data":{"command":"eventrecv","data":{"event":"notification","data":{
597 "id":"n1-1","kind":"input_waiting","priority":"attention","block_id":"b1","agent_name":"lark",
598 "title":"lark needs your input","body":"Which branch?","tag":"abcdef0123456789","created_at_ms":1}}}}"#;
599 match parse_frame(f) {
600 Frame::Show(n) => {
601 assert_eq!(n.id, "n1-1");
602 assert!(n.is_attention());
603 assert_eq!(n.body.as_deref(), Some("Which branch?"));
604 assert_eq!(n.tag, "abcdef0123456789");
605 }
606 other => panic!("unexpected {other:?}"),
607 }
608 }
609
610 #[test]
611 fn parses_retract_and_response_and_ignores_others() {
612 let r = r#"{"eventtype":"rpc","data":{"command":"eventrecv","data":{"event":"notification:retract","data":{"id":"n1","tag":"t1"}}}}"#;
613 assert_eq!(parse_frame(r), Frame::Retract("t1".into()));
614 let resp = r#"{"eventtype":"rpc","data":{"resid":"q1","data":{"has_window":false}}}"#;
615 assert_eq!(
616 parse_frame(resp),
617 Frame::Response { reqid: "q1".into(), ack: AckResponse { has_window: Some(false), ..Default::default() } }
618 );
619 let resp = r#"{"eventtype":"rpc","data":{"resid":"q2","data":{"has_window":true,"known":true,"window_id":"w1"}}}"#;
620 assert_eq!(
621 parse_frame(resp),
622 Frame::Response {
623 reqid: "q2".into(),
624 ack: AckResponse { has_window: Some(true), window_id: Some("w1".into()), workspace_id: None }
625 }
626 );
627 let st = r#"{"eventtype":"rpc","data":{"command":"eventrecv","data":{"event":"notification:state","data":{"attention":[{"id":"n1","block_id":"b1","title":"lark needs your input"}],"paused_until_ms":5}}}}"#;
628 match parse_frame(st) {
629 Frame::State(s) => {
630 assert_eq!(s.attention.len(), 1);
631 assert_eq!(s.paused_until_ms, 5);
632 }
633 other => panic!("unexpected {other:?}"),
634 }
635 let cfg = r#"{"eventtype":"rpc","data":{"command":"eventrecv","data":{"event":"config","data":{}}}}"#;
636 assert_eq!(parse_frame(cfg), Frame::Other);
637 let with = |settings: &str| {
638 format!(r#"{{"eventtype":"rpc","data":{{"command":"eventrecv","data":{{"event":"config","data":{{"fullconfig":{{"settings":{settings}}}}}}}}}}}"#)
639 };
640 assert_eq!(parse_frame(&with(r#"{"app:startatlogin":true}"#)), Frame::StartAtLogin(Some(true)));
641 assert_eq!(parse_frame(&with(r#"{"app:startatlogin":false}"#)), Frame::StartAtLogin(Some(false)));
642 assert_eq!(parse_frame(&with(r#"{"term:fontsize":12}"#)), Frame::StartAtLogin(None));
643 assert_eq!(parse_frame(r#"{"type":"ping","stime":1}"#), Frame::Other);
644 assert_eq!(parse_frame("garbage"), Frame::Other);
645 }
646
647 #[tokio::test]
651 async fn session_round_trip_against_fake_srv() {
652 use std::sync::Mutex;
653 use tokio::net::TcpListener;
654 use tokio_tungstenite::tungstenite::handshake::server::{Request, Response};
655
656 #[derive(Default)]
657 struct Fake {
658 calls: Mutex<Vec<String>>,
659 }
660 impl Presenter for Fake {
661 fn show(&self, n: &Notification) {
662 self.calls.lock().unwrap().push(format!("show:{}", n.id));
663 }
664 fn retract(&self, tag: &str) {
665 self.calls.lock().unwrap().push(format!("retract:{tag}"));
666 }
667 fn clear_all(&self) {}
668 }
669
670 let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
671 let addr = listener.local_addr().unwrap();
672 let (got_tx, mut got_rx) = mpsc::unbounded_channel::<serde_json::Value>();
673 tokio::spawn(async move {
674 let (sock, _) = listener.accept().await.unwrap();
675 let mut ws = tokio_tungstenite::accept_hdr_async(sock, |req: &Request, resp: Response| {
676 assert_eq!(req.uri().path(), "/ws");
677 assert_eq!(req.headers().get("X-AuthKey").unwrap(), "k1");
678 Ok(resp)
679 })
680 .await
681 .unwrap();
682 for _ in 0..3 {
684 let m = ws.next().await.unwrap().unwrap();
685 got_tx.send(serde_json::from_str(m.to_text().unwrap()).unwrap()).unwrap();
686 }
687 let show = r#"{"eventtype":"rpc","data":{"command":"eventrecv","data":{"event":"notification","data":{"id":"n1","kind":"turn_completed","priority":"normal","title":"lark finished","body":null,"tag":"t1"}}}}"#;
688 ws.send(Message::Text(show.into())).await.unwrap();
689 let retract = r#"{"eventtype":"rpc","data":{"command":"eventrecv","data":{"event":"notification:retract","data":{"id":"n1","tag":"t1"}}}}"#;
690 ws.send(Message::Text(retract.into())).await.unwrap();
691 let m = ws.next().await.unwrap().unwrap();
693 got_tx.send(serde_json::from_str(m.to_text().unwrap()).unwrap()).unwrap();
694 });
695
696 let fake = Arc::new(Fake::default());
697 let presenter: Arc<dyn Presenter> = fake.clone();
698 let (_ep_tx, ep_rx) = watch::channel(Some(Endpoint { ws: addr.to_string(), auth_key: "k1".into() }));
699 let (act_tx, act_rx) = mpsc::unbounded_channel();
700 let dir = std::env::temp_dir();
701 let state: LauncherState = Default::default();
702 tokio::spawn(run(ep_rx, act_rx, presenter, dir, "h".into(), state));
703
704 let sub1 = got_rx.recv().await.unwrap();
705 let sub2 = got_rx.recv().await.unwrap();
706 let sub3 = got_rx.recv().await.unwrap();
707 assert_eq!(sub3["message"]["data"]["event"], "notification:state");
708 assert_eq!(sub1["message"]["command"], "eventsub");
709 assert_eq!(sub1["message"]["data"]["event"], "notification");
710 assert_eq!(sub2["message"]["data"]["event"], "notification:retract");
711
712 for _ in 0..100 {
714 if fake.calls.lock().unwrap().len() >= 2 {
715 break;
716 }
717 tokio::time::sleep(std::time::Duration::from_millis(10)).await;
718 }
719 assert_eq!(*fake.calls.lock().unwrap(), vec!["show:n1".to_string(), "retract:t1".to_string()]);
720 act_tx.send(UserAction::Clicked("n1".into())).unwrap();
721 let ack = got_rx.recv().await.unwrap();
722 assert_eq!(ack["message"]["command"], "notify.ack");
723 assert_eq!(ack["message"]["data"]["id"], "n1");
724 assert_eq!(ack["message"]["data"]["clicked"], true);
725 assert!(ack["message"]["reqid"].is_string());
726 }
727
728 #[test]
729 fn rpc_envelope_shape() {
730 let v: serde_json::Value = serde_json::from_str(&rpc("notify.ack", Some("r1"), serde_json::json!({"id":"n1","clicked":true}))).unwrap();
731 assert_eq!(v["wscommand"], "rpc");
732 assert_eq!(v["message"]["command"], "notify.ack");
733 assert_eq!(v["message"]["reqid"], "r1");
734 assert_eq!(v["message"]["data"]["clicked"], true);
735 }
736}