master
John Lauer v2.0.694 (insiders): No more Chrome for Testing icons on the taskbar: a window that appears in pup's browser without pu… 4f934f5 1mo ago
//! pup-session: the window/tab model and its on-disk session files. ONE place that knows what a
//! pup window is (Node had the truth spread over the sessions Map, session files, _hwnd caches
//! and six recovery paths). Phase 1 carries the model + files; reconnect/rescan/recovery and the
//! health loop land in Phase 2.

use serde_json::{json, Value};
use std::fs;
use std::path::{Path, PathBuf};
use std::sync::Mutex;

pub const WIN_PROP: &str = "AdomPupSid";

#[derive(Debug, Clone)]
pub struct Tab {
    pub tab_id: String,
    pub target_id: String,
    pub cdp_session: String,
    pub url: String,
    pub title: String,
    pub error_count: u32,
    pub fav: Option<Value>,
}

#[derive(Debug, Clone)]
pub struct Session {
    pub id: String,
    pub profile: String,
    /// "cdp" (a DevTools port drives it) or "ext" (pup's extension channel drives it, no port). The window is
    /// a first-class session either way: identity, overlay, progress, park, recovery all key on this struct.
    pub transport: String,
    pub owner: Option<String>,
    pub owner_container: Option<String>,
    pub created_ms: u64,
    pub created_iso: String,
    pub hwnd: Option<i64>,
    /// The CDP Browser window id (Browser.getWindowForTarget), the key that ties user-opened tabs and a
    /// replaced last tab back to THIS window (2.0.453).
    pub window_id: Option<i64>,
    /// When the last tab target was destroyed while the OS window may still be alive (2.0.453 grace).
    pub tabless_since_ms: u64,
    pub pid: Option<u32>,
    pub cdp_port: u16,
    pub tabs: Vec<Tab>,
    pub active_tab: usize,
    pub foreground: bool,
    pub app_id: Option<String>,
    pub last_agent_update_ms: u64,
    pub next_tab_seq: u32,
    pub emulated: Option<Value>,
    pub last_note: Option<String>,
    pub pending_cred_hint: Option<String>,
    pub wiki_authed: Option<bool>,
    pub wiki_view: Option<String>,
    pub last_annotation: Option<Value>,
    pub last_verb: Option<String>,
    pub last_tab: Option<String>,
    pub last_foreground: Option<Value>,
    pub overlay_desc: Option<String>,
    pub flash_count: u32,
    pub progress_count: u32,
    pub progress_ms: u64,
    pub icon_applied: bool,
    pub born_at_ms: u64,
    pub user_active_at_ms: u64,
    pub raise_granted: bool,
    pub overlay_why: Option<Value>,
    pub overlay_led: Option<String>,
    pub last_paint_sig: Option<String>,
    pub last_real_fav: Option<(String, String)>,
    pub generic_retried_url: Option<String>,
    pub owner_from_caller: bool,
    pub disruption_notice: Option<String>,
    pub fg_watch_until_ms: u64,
    pub last_outer_width: Option<u64>,
    pub last_url: Option<String>,
    pub last_title: Option<String>,
}

impl Session {
    /// Node: session._lastAgentUpdate = {tabId, verb, note, ts} | null.
    pub fn last_agent_update(&self) -> Value { match &self.last_verb { Some(v) => json!({ "tabId": self.last_tab, "verb": v, "note": self.last_note, "ts": self.last_agent_update_ms }), None => Value::Null } }
    pub fn new(id: &str, profile: &str, owner: Option<&str>, owner_container: Option<&str>, cdp_port: u16, pid: Option<u32>) -> Session {
        Session { id: id.into(), profile: profile.into(), transport: "cdp".into(), owner: owner.map(|s| s.into()), owner_container: owner_container.filter(|s| !s.is_empty()).map(|s| s.into()), created_ms: pup_core::now_ms(), created_iso: pup_core::iso_now(), hwnd: None, window_id: None, tabless_since_ms: 0, pid, cdp_port, tabs: Vec::new(), active_tab: 0, foreground: false, app_id: None, last_agent_update_ms: pup_core::now_ms(), next_tab_seq: 1, emulated: None, last_note: None, pending_cred_hint: None, wiki_authed: None, wiki_view: None, last_annotation: None, last_verb: None, last_tab: None, last_foreground: None, overlay_desc: None, flash_count: 0, progress_count: 0, progress_ms: 0, icon_applied: false, born_at_ms: pup_core::now_ms(), user_active_at_ms: 0, raise_granted: false, overlay_why: None, overlay_led: None, last_paint_sig: None, last_real_fav: None, generic_retried_url: None, owner_from_caller: false, disruption_notice: None, fg_watch_until_ms: 0, last_outer_width: None, last_url: None, last_title: None }
    }
    /// 2.0.694: never hands out an id a tab in this window already holds (a window rebuilt after a restart
    /// keeps its saved ids, and an AI that held "tab-3" must not get a different tab under that name).
    pub fn next_tab_id(&mut self) -> String {
        loop { let id = format!("tab-{}", self.next_tab_seq); self.next_tab_seq += 1; if !self.tabs.iter().any(|t| t.tab_id == id) { return id; } }
    }
    pub fn active(&self) -> Option<&Tab> { self.tabs.get(self.active_tab) }
    pub fn tab_by_id(&self, id: &str) -> Option<&Tab> { self.tabs.iter().find(|t| t.tab_id == id) }
    pub fn age_minutes(&self) -> u64 { pup_core::now_ms().saturating_sub(self.created_ms) / 60000 }
    pub fn tag_string(&self) -> String { format!("{}={}", WIN_PROP, sanitize_sid(&self.id)) }

    /// The on-disk shape (identical to the Node build's saveSessionFile, so either build can
    /// recover the other's windows).
    pub fn file_json(&self) -> Value {
        json!({
            "sessionId": self.id, "profile": self.profile, "transport": self.transport, "url": self.active().map(|t| t.url.clone()),
            "pid": self.pid, "cdpPort": self.cdp_port, "foreground": self.foreground, "owner": self.owner,
            "appId": self.app_id, "_hwnd": self.hwnd, "windowId": self.window_id, "createdAt": self.created_iso, "updatedAt": pup_core::iso_now(),
            "targetId": self.active().map(|t| t.target_id.clone()), "shell": "rust",
            // 2.0.461: EVERY tab, not just the active one. The file used to carry a single url, so when a
            // window came back (a Chrome restart, a wedged browser, a crash) the other tabs were simply gone:
            // John lost 19 of 20 component tabs that way on 2026-09-10 and I had to retype them from a log.
            // 2.0.694: and each tab's pup id plus the browser's own tab id, so a window re-adopted after a restart
            // keeps the ids the AI already holds instead of renumbering from tab-1.
            "tabs": self.tabs.iter().map(|t| json!({ "url": t.url, "title": t.title, "tabId": t.tab_id, "browserTabId": t.target_id })).collect::<Vec<_>>(),
            "activeTab": self.active_tab,
        })
    }
}

pub fn sanitize_sid(s: &str) -> String { s.chars().map(|c| if c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '.' { c } else { '-' }).collect() }

/// A thread's own window is keyed on its --ai-thread name (Node: threadDefaultSessionId).
pub fn thread_default_session_id(thread: &str) -> String { let s = sanitize_sid(thread.trim()); if s.is_empty() { "default".into() } else { s } }

pub struct Sessions { list: Mutex<Vec<Session>>, active: Mutex<Option<String>>, pub dir: PathBuf }

impl Sessions {
    pub fn new(dir: &Path) -> Sessions { let _ = fs::create_dir_all(dir); Sessions { list: Mutex::new(Vec::new()), active: Mutex::new(None), dir: dir.to_path_buf() } }
    pub fn insert(&self, s: Session) { let id = s.id.clone(); self.list.lock().unwrap().retain(|x| x.id != id); self.list.lock().unwrap().push(s); *self.active.lock().unwrap() = Some(id); }
    pub fn get(&self, id: &str) -> Option<Session> { self.list.lock().unwrap().iter().find(|s| s.id == id).cloned() }
    pub fn exists(&self, id: &str) -> bool { self.get(id).is_some() }
    pub fn with_mut<R>(&self, id: &str, f: impl FnOnce(&mut Session) -> R) -> Option<R> { self.list.lock().unwrap().iter_mut().find(|s| s.id == id).map(f) }
    pub fn remove(&self, id: &str) -> Option<Session> {
        let mut l = self.list.lock().unwrap();
        let pos = l.iter().position(|s| s.id == id)?;
        let s = l.remove(pos);
        let mut a = self.active.lock().unwrap();
        if a.as_deref() == Some(id) { *a = l.last().map(|x| x.id.clone()); }
        Some(s)
    }
    pub fn all(&self) -> Vec<Session> { self.list.lock().unwrap().clone() }
    pub fn count(&self) -> usize { self.list.lock().unwrap().len() }
    pub fn active_id(&self) -> Option<String> { self.active.lock().unwrap().clone() }
    pub fn set_active(&self, id: &str) { *self.active.lock().unwrap() = Some(id.into()); }
    pub fn for_profile(&self, profile: &str) -> Vec<Session> { self.all().into_iter().filter(|s| s.profile == profile).collect() }
    pub fn owned_by(&self, thread: &str) -> Vec<Session> { self.all().into_iter().filter(|s| s.owner.as_deref() == Some(thread)).collect() }

    pub fn file_path(&self, id: &str) -> PathBuf { self.dir.join(format!("{}.json", sanitize_sid(id))) }
    pub fn save_file(&self, s: &Session) { let _ = fs::write(self.file_path(&s.id), serde_json::to_string_pretty(&s.file_json()).unwrap_or_default()); }
    pub fn delete_file(&self, id: &str) { let _ = fs::remove_file(self.file_path(id)); }
    /// Every session file on disk (Node and Rust builds share the folder and the shape).
    pub fn read_files(&self) -> Vec<Value> {
        let mut out = Vec::new();
        if let Ok(rd) = fs::read_dir(&self.dir) { for e in rd.flatten() { if e.path().extension().map(|x| x == "json").unwrap_or(false) { if let Ok(s) = fs::read_to_string(e.path()) { if let Ok(v) = serde_json::from_str::<Value>(&s) { out.push(v); } } } } }
        out
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    #[test] fn tab_ids_never_repeat() {
        let mut s = Session::new("t", "adom-you", None, None, 0, None);
        for id in ["tab-1", "tab-3"] { s.tabs.push(Tab { tab_id: id.into(), target_id: id.into(), cdp_session: String::new(), url: String::new(), fav: None, title: String::new(), error_count: 0 }); }
        assert_eq!(s.next_tab_id(), "tab-2"); assert_eq!(s.next_tab_id(), "tab-4");
    }
    #[test] fn ids() { assert_eq!(thread_default_session_id("my thread/x"), "my-thread-x"); assert_eq!(thread_default_session_id("  "), "default"); }
    #[test] fn files_roundtrip() {
        let dir = std::env::temp_dir().join(format!("pup-sess-{}", std::process::id())); let ss = Sessions::new(&dir);
        let mut s = Session::new("t1", "adom-you", Some("thread"), None, 9222, Some(42));
        let tid = s.next_tab_id(); s.tabs.push(Tab { tab_id: tid, target_id: "T".into(), cdp_session: "S".into(), url: "https://x".into(), fav: None, title: "X".into(), error_count: 0 });
        ss.insert(s.clone()); ss.save_file(&s);
        let files = ss.read_files(); assert_eq!(files.len(), 1); assert_eq!(files[0]["sessionId"], json!("t1")); assert_eq!(files[0]["cdpPort"], json!(9222)); assert_eq!(files[0]["url"], json!("https://x"));
        assert_eq!(ss.active_id().as_deref(), Some("t1")); ss.remove("t1"); assert_eq!(ss.count(), 0); assert!(ss.active_id().is_none());
        ss.delete_file("t1"); assert!(ss.read_files().is_empty()); let _ = fs::remove_dir_all(&dir);
    }
}