//! Daemon: reacts to pad presses, hook events and timer ticks. use std::io; use std::path::Path; use std::sync::Arc; use std::sync::mpsc::{self, RecvTimeoutError}; use std::thread; use std::time::{Duration, Instant}; use crate::context::ContextFill; use crate::desktop::Desktop; use crate::drivers::{self, Driver, Health, PressSink}; use crate::event::Event; use crate::profile::{self, PadEvent, Profile}; use crate::registry::Registry; use crate::session::SessionKey; use crate::terminal::Terminal; use crate::{ipc, midi}; const TICK: Duration = Duration::from_secs(2); const PRUNE_EVERY: Duration = Duration::from_secs(5 * 60); enum Msg { Hook(Event), Press(usize), Release(usize), Context(ContextFill), } /// Focuses the session in `slot`. An iTerm2 pane is unregistered first if iTerm2 confirms it is /// gone; a Desktop session opens in Claude Desktop, which resumes it if its process stopped. /// An empty pad opens a new iTerm2 window and reserves the pad for it; pushing a reserved pad /// again focuses that window. pub fn handle_press( reg: &mut Registry, term: &dyn Terminal, desktop: &dyn Desktop, slot: usize, now: Instant, ) { let uuid = match ( reg.session_at(slot).map(|s| &s.key), reg.reserved_at(slot, now), ) { (Some(SessionKey::Desktop(id)), _) => return focus_desktop(desktop, slot, id), (Some(SessionKey::ITerm(uuid)), _) => uuid.clone(), (None, Some(reserved)) => reserved.to_string(), (None, None) => return open_window(reg, term, slot), }; if term.live_sessions().is_ok_and(|live| !live.contains(&uuid)) { let registered = reg.session_at(slot).is_some(); reg.remove_slot(slot); if registered { log!("slot {slot}: pane {uuid} is gone, unregistered"); } else { log!("slot {slot}: window of pane {uuid} is gone, reservation dropped"); } return; } match term.focus(&uuid) { Ok(()) => log!("slot {slot}: focused pane {uuid}"), Err(e) => log!("slot {slot}: focusing pane {uuid} failed: {e}"), } } fn focus_desktop(desktop: &dyn Desktop, slot: usize, id: &str) { match desktop.focus(id) { Ok(()) => log!("slot {slot}: opened Desktop session {id}"), Err(e) => log!("slot {slot}: opening Desktop session {id} failed: {e}"), } } fn open_window(reg: &mut Registry, term: &dyn Terminal, slot: usize) { let window = match term.new_window() { Ok(w) => w, Err(e) => return log!("slot {slot}: opening a new window failed: {e}"), }; match window.session { Some(id) => { // The pulse and the 60 s count start once the window exists, not at the push. reg.reserve(slot, &id, Instant::now()); log!("slot {slot}: opened a new window, pad reserved for pane {id}"); } None => log!("slot {slot}: opened a new window"), } if let Err(e) = window.activation { log!("slot {slot}: bringing iTerm2 to the front failed: {e}"); } } /// Unregisters iTerm2 panes the terminal confirms are gone; other kinds of sessions are not /// iTerm2's to judge. A failed listing changes nothing. pub fn prune(reg: &mut Registry, term: &dyn Terminal) -> bool { match term.live_sessions() { Ok(live) => reg.retain(|key| match key { SessionKey::ITerm(uuid) => live.contains(uuid), SessionKey::Desktop(_) => true, }), Err(e) => { log!("pruning skipped: {e}"); false } } } /// Unregisters Desktop sessions whose Claude Code process is gone; other kinds of sessions are /// not Claude Desktop's to judge. A failed listing changes nothing. pub fn prune_desktop(reg: &mut Registry, desktop: &dyn Desktop) -> bool { match desktop.running_sessions() { Ok(running) => { let gone: Vec = reg .keys() .filter_map(|key| match key { SessionKey::Desktop(id) if !running.contains(id) => Some(id.clone()), _ => None, }) .collect(); for id in &gone { log!("Desktop session {id} has no Claude Code process, unregistered"); } reg.retain(|key| match key { SessionKey::Desktop(id) => running.contains(id), SessionKey::ITerm(_) => true, }) } Err(e) => { log!("Desktop pruning skipped: {e}"); false } } } pub fn run(socket: &Path, term: &dyn Terminal, desktop: &dyn Desktop) -> io::Result<()> { let profiles = profile::load_all(); if profiles.is_empty() { log!("no controller profiles loaded"); } // One driver per profile for the daemon's lifetime; `active` indexes the connected one. let mut controllers: Vec<(Arc, Box)> = profiles .iter() .map(|p| (Arc::new(p.clone()), drivers::make(p))) .collect(); let mut active: Option = None; let listener = ipc::listen(socket)?; log!("listening on {}", socket.display()); let (tx, rx) = mpsc::channel(); let hook_tx = tx.clone(); thread::spawn(move || { ipc::serve(listener, |line| { let msg = Event::from_wire(&line) .map(Msg::Hook) .or_else(|| ContextFill::from_wire(&line).map(Msg::Context)); match msg { Some(m) => { let _ = hook_tx.send(m); } None => log!("dropped malformed event line"), } }) }); // The press sink of a controller: decodes its input with its profile. let sink_for = |profile: &Arc| -> PressSink { let press_tx = tx.clone(); let profile = Arc::clone(profile); Arc::new(move |msg: &[u8]| { let msg = match profile.decode(msg) { Some(PadEvent::Press(slot)) => Msg::Press(slot), Some(PadEvent::Release(slot)) => Msg::Release(slot), None => return, }; let _ = press_tx.send(msg); }) }; let probe = midi::Probe::new().map_err(io::Error::other)?; let mut reg = Registry::new(); let mut wake: Option = None; let mut last_probe: Option = None; let mut last_prune = Instant::now(); loop { midi::pump(); let since_probe = last_probe.map_or(TICK, |t| t.elapsed()); let timeout = TICK .saturating_sub(since_probe) .min(wake.unwrap_or(TICK)) .max(Duration::from_millis(1)); let msg = rx.recv_timeout(timeout); let now = Instant::now(); // one clock reading per iteration reg.expire_reservations(now); let mut full = false; match msg { Ok(Msg::Hook(e)) => { reg.apply_at(&e, now); } Ok(Msg::Press(slot)) => { handle_press(&mut reg, term, desktop, slot, now); full = true; // repaint after every press: it also fixes a switched bank } Ok(Msg::Release(slot)) => { if let Some(i) = active { controllers[i].1.released(slot); } } Ok(Msg::Context(c)) => { reg.set_fill(&c.session, c.fill); } Err(RecvTimeoutError::Timeout) => {} Err(RecvTimeoutError::Disconnected) => return Ok(()), } if last_probe.is_none_or(|t| now.duration_since(t) >= TICK) { last_probe = Some(now); let ports = probe.ports(); if let Some(i) = active { let (profile, driver) = &mut controllers[i]; match driver.check(&ports, now) { Health::Same => {} Health::Repaint => full = true, Health::Gone => { let connection = driver.connection().unwrap_or("none"); log!("controller disconnected ({}, {connection})", profile.id); driver.disconnect(); active = None; } } } if active.is_none() { let cands = drivers::candidates(&profiles, |p| { controllers[index_of(&profiles, p)].1.available(&ports) }); active = drivers::first_connecting(&cands, |p| { let i = index_of(&profiles, p); let (profile, driver) = &mut controllers[i]; driver.connect(&ports, sink_for(profile), now).map(|()| i) }) .map(|(p, i)| { let connection = controllers[i].1.connection().unwrap_or("none"); log!("controller connected ({}, {connection})", p.id); reg.set_capacity(Some(p.pads.len())); full = true; // a new link must get the grid even if it looks unchanged i }); } if active.is_none() { reg.set_capacity(None); } } if last_prune.elapsed() >= PRUNE_EVERY { last_prune = Instant::now(); prune(&mut reg, term); prune_desktop(&mut reg, desktop); } wake = match active { None => None, Some(i) => { let (profile, driver) = &mut controllers[i]; match driver.update(®.views(now), now, reg.epoch(), full) { Ok(wake) => wake, Err(_) => { let connection = driver.connection().unwrap_or("none"); log!("send failed ({}, {connection}), will reconnect", profile.id); driver.disconnect(); active = None; None } } } }; } } /// The position of `profile` (a candidate from `profiles`) in `profiles`; ids are unique. fn index_of(profiles: &[Profile], profile: &Profile) -> usize { profiles .iter() .position(|p| p.id == profile.id) .expect("a candidate comes from the loaded profiles") } #[cfg(test)] mod tests { use super::*; use crate::desktop::ClaudeDesktop; use crate::event::{test_event, test_session_event}; use crate::registry::RESERVATION; use crate::terminal::NewWindow; use serde_json::json; use std::cell::RefCell; use std::collections::HashSet; struct Fake { live: Option>, focused: RefCell>, new_window: Option>, // None: opening fails; Some(session) windows_opened: RefCell, live_calls: RefCell, } impl Fake { fn new(live: Option<&[&str]>) -> Fake { Fake { live: live.map(|l| l.iter().map(|s| s.to_string()).collect()), focused: RefCell::new(Vec::new()), new_window: Some(Some("NEW".to_string())), windows_opened: RefCell::new(0), live_calls: RefCell::new(0), } } } impl Terminal for Fake { fn live_sessions(&self) -> io::Result> { *self.live_calls.borrow_mut() += 1; self.live .clone() .ok_or_else(|| io::Error::other("iTerm2 down")) } fn focus(&self, id: &str) -> io::Result<()> { self.focused.borrow_mut().push(id.to_string()); Ok(()) } fn new_window(&self) -> io::Result { *self.windows_opened.borrow_mut() += 1; let session = self .new_window .clone() .ok_or_else(|| io::Error::other("iTerm2 down"))?; Ok(NewWindow { session, activation: Ok(()), }) } } fn with_desktop(mut r: Registry, ids: &[&str]) -> Registry { for id in ids { r.apply(&test_session_event( SessionKey::Desktop(id.to_string()), "Stop", json!({}), )); } r } #[test] fn iterm_prune_keeps_desktop_sessions() { let mut r = with_desktop(registry_with(&["A"]), &["local_a"]); assert!(prune(&mut r, &Fake::new(Some(&[])))); assert!(r.session_at(0).is_none(), "gone iTerm2 pane removed"); assert!( r.session_at(1).is_some(), "Desktop session is not iTerm2's to judge" ); } struct FakeDesktop { running: Option>, focused: RefCell>, focus_fails: bool, } impl FakeDesktop { fn new(running: Option<&[&str]>) -> FakeDesktop { FakeDesktop { running: running.map(|l| l.iter().map(|s| s.to_string()).collect()), focused: RefCell::new(Vec::new()), focus_fails: false, } } } impl Desktop for FakeDesktop { fn running_sessions(&self) -> io::Result> { self.running .clone() .ok_or_else(|| io::Error::other("registry unreadable")) } fn focus(&self, id: &str) -> io::Result<()> { self.focused.borrow_mut().push(id.to_string()); if self.focus_fails { Err(io::Error::other("open failed")) } else { Ok(()) } } } #[test] fn press_on_desktop_session_opens_it_in_claude_desktop() { let mut r = with_desktop(registry_with(&["A"]), &["local_a"]); let t = Fake::new(Some(&["A"])); let d = FakeDesktop::new(Some(&[])); handle_press(&mut r, &t, &d, 1, Instant::now()); assert_eq!(*d.focused.borrow(), vec!["local_a"]); assert!(t.focused.borrow().is_empty()); assert!( r.session_at(1).is_some(), "no liveness check before the push" ); } #[test] fn press_on_desktop_session_never_asks_iterm() { let mut r = with_desktop(Registry::new(), &["local_a"]); let t = Fake::new(None); // iTerm2 unreachable let d = FakeDesktop::new(None); handle_press(&mut r, &t, &d, 0, Instant::now()); assert_eq!(*d.focused.borrow(), vec!["local_a"]); assert_eq!(*t.live_calls.borrow(), 0); assert!(t.focused.borrow().is_empty()); assert_eq!(*t.windows_opened.borrow(), 0); } #[test] fn a_failed_open_keeps_the_pad() { let mut r = with_desktop(Registry::new(), &["local_a"]); let mut d = FakeDesktop::new(Some(&[])); d.focus_fails = true; handle_press(&mut r, &Fake::new(Some(&[])), &d, 0, Instant::now()); assert!(r.session_at(0).is_some()); } #[test] fn desktop_prune_removes_only_desktop_sessions_without_a_process() { let mut r = with_desktop(registry_with(&["A"]), &["local_a", "local_b"]); assert!( !prune_desktop(&mut r, &FakeDesktop::new(None)), "unreadable registry changes nothing" ); assert!(prune_desktop(&mut r, &FakeDesktop::new(Some(&["local_b"])))); assert!( r.session_at(0).is_some(), "iTerm2 pane is not Desktop's to judge" ); assert!(r.session_at(1).is_none(), "local_a has no process"); assert!(r.session_at(2).is_some()); } #[test] fn desktop_prune_reads_claude_codes_session_registry() { let dir = std::env::temp_dir().join(format!("padmon-prune-{}", std::process::id())); let _ = std::fs::remove_dir_all(&dir); std::fs::create_dir_all(&dir).unwrap(); let me = std::process::id(); std::fs::write( dir.join(format!("{me}.json")), json!({"pid": me, "hostSessionId": "local_live"}).to_string(), ) .unwrap(); let mut r = with_desktop(Registry::new(), &["local_live", "local_gone"]); assert!(!prune_desktop( &mut r, &ClaudeDesktop::at(dir.join("missing")) )); assert!( r.session_at(1).is_some(), "missing registry removes nothing" ); assert!(prune_desktop(&mut r, &ClaudeDesktop::at(dir.clone()))); assert!(r.session_at(0).is_some(), "its process runs"); assert!(r.session_at(1).is_none(), "no registry file"); std::fs::remove_dir_all(&dir).unwrap(); } fn registry_with(panes: &[&str]) -> Registry { let mut r = Registry::new(); for p in panes { r.apply(&test_event(p, "Stop", json!({}))); } r } #[test] fn press_focuses_live_pane_exactly_once() { let mut r = registry_with(&["A", "B"]); let t = Fake::new(Some(&["A", "B"])); let d = FakeDesktop::new(Some(&[])); handle_press(&mut r, &t, &d, 1, Instant::now()); assert_eq!(*t.focused.borrow(), vec!["B"]); } #[test] fn press_on_empty_slot_focuses_nothing_and_opens_one_window() { let mut r = registry_with(&["A"]); let t = Fake::new(Some(&["A"])); let d = FakeDesktop::new(Some(&[])); handle_press(&mut r, &t, &d, 5, Instant::now()); assert!(t.focused.borrow().is_empty()); assert_eq!(*t.windows_opened.borrow(), 1); } #[test] fn press_on_gone_pane_unregisters_without_focus() { let mut r = registry_with(&["A"]); let t = Fake::new(Some(&[])); let d = FakeDesktop::new(Some(&[])); handle_press(&mut r, &t, &d, 0, Instant::now()); assert!(t.focused.borrow().is_empty()); assert!(r.session_at(0).is_none()); } #[test] fn press_when_listing_fails_still_tries_focus_and_keeps_pane() { let mut r = registry_with(&["A"]); let t = Fake::new(None); let d = FakeDesktop::new(Some(&[])); handle_press(&mut r, &t, &d, 0, Instant::now()); assert_eq!(*t.focused.borrow(), vec!["A"]); assert!(r.session_at(0).is_some()); } #[test] fn prune_removes_only_confirmed_gone() { let mut r = registry_with(&["A", "B"]); assert!( !prune(&mut r, &Fake::new(None)), "listing failure changes nothing" ); assert!(r.session_at(0).is_some() && r.session_at(1).is_some()); assert!(prune(&mut r, &Fake::new(Some(&["B"])))); assert!(r.session_at(0).is_none() && r.session_at(1).is_some()); } #[test] fn press_on_empty_slot_opens_a_window_and_reserves_the_pad() { let mut r = registry_with(&["A"]); let t = Fake::new(Some(&["A"])); let now = Instant::now(); let d = FakeDesktop::new(Some(&[])); handle_press(&mut r, &t, &d, 5, now); assert_eq!(*t.windows_opened.borrow(), 1); assert!(t.focused.borrow().is_empty()); assert_eq!(r.reserved_at(5, now), Some("NEW")); } #[test] fn press_on_reserved_pad_focuses_its_window_instead_of_opening_another() { let mut r = registry_with(&[]); let t = Fake::new(Some(&["NEW"])); let now = Instant::now(); let d = FakeDesktop::new(Some(&[])); handle_press(&mut r, &t, &d, 3, now); handle_press(&mut r, &t, &d, 3, now); assert_eq!(*t.windows_opened.borrow(), 1); assert_eq!(*t.focused.borrow(), vec!["NEW"]); } #[test] fn failed_or_unidentified_window_reserves_nothing() { let now = Instant::now(); let mut r = registry_with(&[]); let mut t = Fake::new(Some(&[])); t.new_window = None; let d = FakeDesktop::new(Some(&[])); handle_press(&mut r, &t, &d, 2, now); assert_eq!(r.reserved_at(2, now), None); t.new_window = Some(None); handle_press(&mut r, &t, &d, 2, now); assert_eq!(r.reserved_at(2, now), None); assert_eq!(*t.windows_opened.borrow(), 2); } #[test] fn press_on_reserved_pad_whose_window_is_gone_drops_the_reservation() { let now = Instant::now(); let mut r = registry_with(&[]); r.reserve(4, "NEW", now); let t = Fake::new(Some(&[])); let d = FakeDesktop::new(Some(&[])); handle_press(&mut r, &t, &d, 4, now); assert!(t.focused.borrow().is_empty()); assert_eq!(*t.windows_opened.borrow(), 0); assert_eq!(r.reserved_at(4, now), None); } #[test] fn press_after_the_reservation_expired_opens_a_new_window() { let now = Instant::now(); let mut r = registry_with(&[]); r.reserve(4, "OLD", now); let t = Fake::new(Some(&["OLD"])); let d = FakeDesktop::new(Some(&[])); handle_press(&mut r, &t, &d, 4, now + RESERVATION); assert_eq!(*t.windows_opened.borrow(), 1); assert_eq!(r.reserved_at(4, now + RESERVATION), Some("NEW")); } }