master
John Lauer rust: Phase 4 parity gate (tools/parity.py) green 37/37 on winvm; Node response envelope ported into dispatch (verb cat… f389b1e 2mo ago
//! The highFps lease (Node: highFpsGrant/Extend/Info/Downgrade): a background window renders full
//! rate only while a lease runs (20 min default, 120 max); a lapsed lease relaunches the window
//! without the anti-throttle flags so a forgotten demo window can never flatten a battery (issue #14).

use crate::Ctx;
use pup_core::{err, ok, Caller};
use serde_json::{json, Value};
use std::collections::HashMap;
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};

struct LoopTick(Instant);
impl Drop for LoopTick { fn drop(&mut self) { pup_core::loops::tick("highfps-lease-janitor", 60_000, self.0.elapsed().as_millis() as u64); } }

pub const DEFAULT_MIN: u64 = 20;
pub const MAX_MIN: u64 = 120;

#[derive(Clone)]
pub struct Lease { pub minutes: u64, pub granted: Instant, pub expires: Instant, pub warned: bool }
pub type Leases = Arc<Mutex<HashMap<String, Lease>>>;

pub fn grant(ctx: &Ctx, sid: &str, minutes: u64) -> Lease {
    let m = minutes.clamp(1, MAX_MIN);
    let l = Lease { minutes: m, granted: Instant::now(), expires: Instant::now() + Duration::from_secs(m * 60), warned: false };
    ctx.highfps.lock().unwrap().insert(sid.into(), l.clone());
    pup_core::log(format!("[highfps] \"{sid}\" lease granted: {m} min"));
    l
}

pub fn info(ctx: &Ctx, sid: &str) -> Value {
    match ctx.highfps.lock().unwrap().get(sid) { Some(l) => json!({ "active": true, "minutes": l.minutes, "msRemaining": l.expires.saturating_duration_since(Instant::now()).as_millis() as u64, "recordingActive": ctx.tab_recs.lock().unwrap().contains_key(sid) }), None => Value::Null }
}

pub fn extend(ctx: &Ctx, caller: &Caller, args: &Value) -> Value {
    let s = match crate::resolve(ctx, caller, args) { Ok(s) => s, Err(e) => return e };
    let minutes = args["minutes"].as_u64().unwrap_or(DEFAULT_MIN).clamp(1, MAX_MIN);
    let mut m = ctx.highfps.lock().unwrap();
    let Some(l) = m.get_mut(&s.id) else { return err("not_high_fps", format!("window \"{}\" is not a high-fps window — nothing to extend", s.id), Some("Open with pup_open_window {highFps:true} (or start a recording) to get a lease.")) };
    l.expires = Instant::now() + Duration::from_secs(minutes * 60); l.minutes = minutes; l.warned = false;
    drop(m);
    pup_core::log(format!("[highfps] \"{}\" lease extended: +{minutes} min", s.id));
    ok(json!({ "success": true, "output": json!({ "ok": true, "sessionId": s.id, "highFps": info(ctx, &s.id) }).to_string(), "highFps": info(ctx, &s.id), "_hint": format!("High-fps lease extended to {minutes} min. While a recording runs the lease cannot lapse.") }))
}

/// Every minute: warn 2 min before expiry, downgrade (relaunch without the flags) on expiry.
pub fn start_loop(ctx: Arc<Ctx>) {
    std::thread::Builder::new().name("highfps".into()).spawn(move || loop {
        std::thread::sleep(Duration::from_secs(60));
        let now = Instant::now();
        let _t0 = Instant::now();
        let _guard = LoopTick(_t0);
        let expired: Vec<String> = { let mut m = ctx.highfps.lock().unwrap(); let recording: Vec<String> = ctx.tab_recs.lock().unwrap().keys().cloned().collect();
            for (sid, l) in m.iter_mut() { if recording.contains(sid) { l.expires = now + Duration::from_secs(5 * 60); } else if !l.warned && l.expires.saturating_duration_since(now) < Duration::from_secs(120) { l.warned = true; pup_core::log(format!("[highfps] \"{sid}\" lease expires in ~2 min — pup_highfps_extend {{sessionId,minutes}} keeps it")); } }
            m.iter().filter(|(_, l)| l.expires <= now).map(|(s, _)| s.clone()).collect() };
        for sid in expired {
            ctx.highfps.lock().unwrap().remove(&sid);
            let Some(s) = ctx.sessions.get(&sid) else { continue };
            let urls: Vec<String> = s.tabs.iter().map(|t| t.url.clone()).filter(|u| u != "about:blank").collect();
            pup_core::log(format!("[highfps] \"{sid}\" DOWNGRADING to normal power (lease lapsed) — relaunching {} tab(s) without the high-fps flags", urls.len()));
            let caller = Caller { thread: s.owner.clone().unwrap_or_default(), reason: "highFps lease lapsed".into(), ..Default::default() };
            let _ = crate::close_window(&ctx, &caller, &json!({ "window": sid, "takeover": true }));
            let first = urls.first().cloned().unwrap_or_else(|| "about:blank".into());
            let _ = crate::open_window(&ctx, &caller, &json!({ "window": sid, "url": first, "profile": s.profile, "takeover": true }));
            for u in urls.iter().skip(1) { let _ = crate::tabs::open_tab(&ctx, &caller, &json!({ "window": sid, "url": u, "takeover": true })); }
        }
    }).ok();
}