master
John Lauer v2.0.720 (insiders): Groundwork for the macOS build of the Rust pup (Kyle's pull request #3, pup-bridge#128): the Mac h… 87f5051 27d ago
//! OS-window etiquette verbs: raise (gated on a reason, toast + caption first), lower, alert
//! (taskbar flash), focus (alias of raise), progress (taskbar progress bar). Everything the
//! desktop does here is an ab verb; the host only re-asserts "bottom" in-process.

use crate::{resolve, touch, Ctx};
use pup_core::{err, ok, Caller};
use serde_json::{json, Value};
use std::time::Duration;

const AB_T: Duration = Duration::from_secs(8);

pub fn hwnd_of_pub(ctx: &Ctx, s: &pup_session::Session) -> Option<i64> { hwnd_of(ctx, s) }
fn hwnd_of(ctx: &Ctx, s: &pup_session::Session) -> Option<i64> {
    s.hwnd.filter(|&h| ctx.host.is_alive(h)).or_else(|| {
        let tag = s.tag_string();
        ctx.host.windows_for_pids(&[], Some("Chrome_WidgetWin_1")).into_iter().find(|w| ctx.host.has_tag(w.hwnd, &tag)).map(|w| { ctx.sessions.with_mut(&s.id, |x| x.hwnd = Some(w.hwnd)); w.hwnd })
    })
}

pub fn raise(ctx: &Ctx, caller: &Caller, args: &Value) -> Value {
    let s = match resolve(ctx, caller, args) { Ok(s) => s, Err(e) => return e };
    let reason = args.get("foregroundReason").and_then(|r| r.as_str()).map(|r| r.trim()).filter(|r| r.len() >= 8);
    let Some(reason) = reason else {
        return pup_core::merge(pup_core::merge(err("foreground_reason_required", "Raise refused: foregroundReason is required.", Some("pup windows stay in the BACKGROUND by default. Pass foregroundReason:\"<why the user asked to see this window>\"; it is captioned on their screen. For a nudge without stealing focus use pup_alert_window.")), json!({ "window": s.id, "sessionId": s.id })), json!({ "ok": false, "output": "" }));
    };
    let Some(h) = hwnd_of(ctx, &s) else { return err("no_window_handle", "pup has no OS window handle for this window yet", Some("pup_rescan {} re-resolves handles; if the window exists it will be tagged again.")) };
    if ctx.host.foreground() == h {
        return ok(json!({ "sessionId": s.id, "window": s.id, "aiThread": s.owner, "focused": true, "placement": "foreground", "windowEtiquette": "foreground", "alreadyForeground": true, "reason": reason, "_hint": "This window is already the user's foreground window; nothing to do." }));
    }
    let thread = if caller.thread.is_empty() { "an AI thread".to_string() } else { caller.thread.clone() };
    if ctx.settings.get("fgCaption", "on") != "off" {
        let ms: u64 = ctx.settings.get("fgCaptionMs", "3500").parse().unwrap_or(3500);
        let _ = ctx.ab.call("desktop_caption", json!({ "text": format!("{thread}: {reason}"), "duration": ms, "expiresInMs": ms, "position": "bottom", "size": "medium", "id": format!("pup-fg-{}", s.id) }), caller, AB_T);
    }
    // 2.0.604 (John, 2026-09-17: "why do you keep shoving windows in front of me without warning me?
    // that's super rude" / "i realize you just put an ab toast up warning me, but that was like a minute
    // after you shoved the window in my face while i was busy typing in another window"). The toast used to
    // carry focus:false, so clicking it did nothing, and it landed in the same instant as the window, which
    // is not a warning at all. Now it CARRIES THE WINDOW (a click raises it) and the raise waits a beat
    // behind it, so the notification reaches the screen first.
    // 2.0.665 (John, 2026-09-20, seeing "pup: user-taskbar-menu needs this window on your screen" after a
    // taskbar click): a HUMAN caller (the jump list's relaunch command routes here as pup_focus_window) asked
    // for the window themselves. Warning them about their own click is noise, so no toast and no wait.
    let human = crate::USER_CALLERS.contains(&caller.thread.as_str());
    if !human {
        let _ = ctx.ab.call("notify_user", json!({ "title": format!("pup: {thread} needs this window on your screen"), "body": reason, "focus": { "hwnd": h }, "level": "warning" }), caller, AB_T);
        std::thread::sleep(Duration::from_millis(1200));
    }
    // macOS raises natively (ab cannot target another app's window by handle there); Windows goes through ab.
    let applied = if ctx.host.raise_window(h) { true } else {
        let r = ctx.ab.call("desktop_bring_to_front", json!({ "hwnd": h }), caller, AB_T);
        if r.is_err() { let _ = ctx.ab.call("desktop_window_show", json!({ "hwnd": h }), caller, AB_T); }
        r.is_ok()
    };
    ctx.sessions.with_mut(&s.id, |x| { x.foreground = true; x.raise_granted = true; x.user_active_at_ms = pup_core::now_ms(); }); touch(ctx, &s.id, Some(reason));
    if let Some(x) = ctx.sessions.get(&s.id) { ctx.sessions.save_file(&x); }
    ok(json!({ "sessionId": s.id, "window": s.id, "aiThread": s.owner, "focused": applied, "applied": applied, "placement": "foreground", "windowEtiquette": "foreground", "reason": reason, "captioned": true, "toasted": !human, "_hint": "Raised onto the user's screen with a caption and a toast naming why. Send it back with pup_lower_os_window when the demo is over.", "_next": ["pup_lower_os_window", "pup_screenshot"] }))
}

/// A HUMAN caller (jump-list task, toolbar button) just clicked something that needs the window on
/// their screen: raise it with a short caption, no toast (they initiated it), and mark the session
/// user-active so the park doctrine leaves it alone. Node v2.0.293 osRaiseSessionWindow for USER_CALLERS.
pub fn raise_for_user(ctx: &Ctx, s: &pup_session::Session, caller: &Caller, caption: &str) -> bool {
    let Some(h) = hwnd_of(ctx, s) else { return false };
    if ctx.settings.get("fgCaption", "on") != "off" {
        let ms: u64 = ctx.settings.get("fgCaptionMs", "3500").parse().unwrap_or(3500);
        let _ = ctx.ab.call("desktop_caption", json!({ "text": caption, "duration": ms, "expiresInMs": ms, "position": "bottom", "size": "medium", "id": format!("pup-fg-{}", s.id) }), caller, AB_T);
    }
    let applied = if ctx.host.foreground() == h { true } else if ctx.host.raise_window(h) { true } else {
        let r = ctx.ab.call("desktop_bring_to_front", json!({ "hwnd": h }), caller, AB_T);
        if r.is_err() { let _ = ctx.ab.call("desktop_window_show", json!({ "hwnd": h }), caller, AB_T); }
        r.is_ok()
    };
    ctx.sessions.with_mut(&s.id, |x| { x.foreground = true; x.raise_granted = true; x.user_active_at_ms = pup_core::now_ms(); });
    if let Some(x) = ctx.sessions.get(&s.id) { ctx.sessions.save_file(&x); }
    applied
}

pub fn lower(ctx: &Ctx, caller: &Caller, args: &Value) -> Value {
    let s = match resolve(ctx, caller, args) { Ok(s) => s, Err(e) => return e };
    let Some(h) = hwnd_of(ctx, &s) else { return err("no_window_handle", "pup has no OS window handle for this window yet", None) };
    let mut applied = ctx.host.send_to_bottom_no_activate(h);
    if ctx.ab.call("desktop_set_window_state", json!({ "hwnd": h, "state": "bottom", "force": true }), caller, AB_T).is_ok() { applied = true; }
    ctx.sessions.with_mut(&s.id, |x| x.foreground = false);
    if let Some(x) = ctx.sessions.get(&s.id) { ctx.sessions.save_file(&x); }
    ok(json!({ "sessionId": s.id, "window": s.id, "aiThread": s.owner, "applied": applied, "placement": "background", "windowEtiquette": "background", "_hint": "Sent behind the user's windows without stealing focus; still fully drivable." }))
}

pub fn alert(ctx: &Ctx, caller: &Caller, args: &Value) -> Value {
    let s = match resolve(ctx, caller, args) { Ok(s) => s, Err(e) => return e };
    let Some(h) = hwnd_of(ctx, &s) else { return err("no_window_handle", "pup has no OS window handle for this window yet", None) };
    let stop = args.get("stop").and_then(|b| b.as_bool()).unwrap_or(false);
    let mode = crate::activity::mode(ctx);
    if stop {
        // Node v1.8.45: FLASHW_STOP only stops the pulse; the Win11 tint needs the activate-and-restore.
        let r = ctx.ab.call("desktop_flash_window", json!({ "hwnd": h, "stop": true }), caller, AB_T);
        let cleared = crate::activity::clear_attention(ctx, h, caller);
        let truly = cleared == "cleared" || cleared == "already-foreground";
        return ok(json!({ "sessionId": s.id, "window": s.id, "aiThread": s.owner, "alerted": false, "cleared": truly, "applied": r.is_ok(), "clearMethod": cleared, "_hint": if truly { "Flash truly cleared: activated the window off-screen to drop the Win11 attention tint, then handed focus back. No pop, no focus steal." } else { "FLASHW_STOP ran but the persistent tint needs activation and that step failed." } }));
    }
    if mode == "progress" {
        // Node v2.0.304 (John: "i thought our new setting was for you to not do the orange flash anymore"):
        // in progress mode the nudge is a short full-bar pulse, never orange.
        let _ = ctx.ab.call("desktop_taskbar", json!({ "hwnd": h, "progress": { "state": "normal", "value": 1.0 } }), caller, AB_T);
        if let Some(a) = ctx.arc() { std::thread::spawn(move || { std::thread::sleep(Duration::from_millis(2500)); let _ = a.ab.call("desktop_taskbar", json!({ "hwnd": h, "progress": { "state": "none" } }), &crate::identity::internal_caller(), AB_T); }); }
        return ok(json!({ "sessionId": s.id, "window": s.id, "aiThread": s.owner, "alerted": true, "cleared": false, "applied": true, "method": "progress-pulse", "_hint": "taskbarActivity is \"progress\", so the nudge was a short full progress pulse on the button (no orange). pup_configure {taskbarActivity:\"flash\"} brings the orange flash back." }));
    }
    if mode == "off" { return ok(json!({ "sessionId": s.id, "window": s.id, "aiThread": s.owner, "alerted": false, "applied": false, "_hint": "taskbarActivity is \"off\": no taskbar signal is painted." })); }
    let r = ctx.ab.call("desktop_flash_window", json!({ "hwnd": h, "mode": "count", "count": 4 }), caller, AB_T);
    let applied = r.is_ok(); ctx.sessions.with_mut(&s.id, |x| x.flash_count += 1);
    ok(json!({ "sessionId": s.id, "window": s.id, "aiThread": s.owner, "alerted": applied, "cleared": false, "applied": applied, "abError": r.as_ref().err().map(|e| e.to_string()), "_hint": "Flashed the taskbar (finite 4-count) via ab, no focus steal. Pass {stop:true} to clear." }))
}

pub fn progress(ctx: &Ctx, caller: &Caller, args: &Value) -> Value {
    let s = match resolve(ctx, caller, args) { Ok(s) => s, Err(e) => return e };
    let Some(h) = hwnd_of(ctx, &s) else { return err("no_window_handle", "pup has no OS window handle for this window yet", None) };
    let mode = args.get("mode").and_then(|m| m.as_str()).unwrap_or("indeterminate");
    let progress = match mode {
        "off" | "none" => json!({ "state": "none" }),
        "value" => json!({ "state": "normal", "value": args.get("value").and_then(|v| v.as_f64()).unwrap_or(0.0).clamp(0.0, 100.0) / 100.0 }),
        "error" => json!({ "state": "error" }),
        "paused" => json!({ "state": "paused" }),
        _ => json!({ "state": "indeterminate" }),
    };
    let mut p = json!({ "hwnd": h, "progress": progress });
    if let Some(note) = args.get("note").and_then(|n| n.as_str()) { p["overlay"] = json!({ "tooltip": format!("Adom Pup — {note}") }); }
    let applied = ctx.ab.call("desktop_taskbar", p, caller, AB_T).is_ok();
    touch(ctx, &s.id, args.get("note").and_then(|n| n.as_str()));
    ok(json!({ "success": true, "output": json!({ "ok": true, "sessionId": s.id, "mode": mode, "painted": applied, "note": args.get("note").and_then(|n| n.as_str()) }).to_string(), "sessionId": s.id, "window": s.id, "aiThread": s.owner, "mode": mode, "applied": applied, "_hint": "Taskbar progress painted on this window's button. Clear it with mode:\"off\" when the long task ends." }))
}