--- a/src/server.js +++ b/src/server.js @@ -8479,7 +8479,7 @@ // v1.9.297 semantics preserved natively (v2.0.27): the owner pid ONLY for a REAL top-level visible // Chrome browser frame. desktop_enum_windows enumerates top-level windows filtered by class, so a // child/utility HWND simply is not in the result — same rejection, zero PowerShell. - if (process.platform !== 'win32' || !hwnd) return null; + if ((process.platform !== 'win32' && process.platform !== 'darwin') || !hwnd) return null; // v2.0.308: this ran a RAW ab enum on every validation (every verb that touches an hwnd). Use the // shared cached snapshot; a handle missing from it (a window younger than the cache) gets ONE // fresh look, which is also coalesced across concurrent callers. @@ -8628,7 +8628,7 @@ let _enumCalls = 0; // lifetime count of REAL ab calls (exposed on /status for auditing) let _enumWindow = { at: 0, n: 0 }; // rolling 60s rate, so "is it spamming?" is answerable with data async function enumChromeWidgetWindows(opts) { - if (process.platform !== 'win32') return []; + if (process.platform !== 'win32' && process.platform !== 'darwin') return []; const fresh = !!(opts && opts.fresh); if (fresh) { if (_enumCache.rows && Date.now() - _enumCache.at < ENUM_FRESH_MS) return _enumCache.rows; @@ -8642,6 +8642,22 @@ const _now = Date.now(); if (_now - _enumWindow.at > 60000) _enumWindow = { at: _now, n: 0 }; _enumWindow.n++; + if (process.platform === 'darwin') { + // macOS: AD's desktop_list_windows gives each window's CGWindowID (as hwnd), owner pid, title and + // bundle id. The Chromium-family rows are the candidates; callers fence them by pup's own pids, as + // on Windows. Without this every resolver below returned nothing on a Mac, so pup_list_windows + // reported hwnd:null for every session and AD's window recording / screenshots had no target. + const dm = abPayload(await chrome.abCommand('desktop_list_windows', { includeOffscreen: true }, { timeoutMs: 8000 })); + const list = (dm && (dm.windows || (dm.data && dm.data.windows))) || []; + const rows = []; + for (const w of list) { + if (!/chrom|edge/i.test(String(w.appBundleId || ''))) continue; + const h = Number(w.hwnd), r = w.rect || {}; + if (h) rows.push({ h, pid: Number(w.ownerPid), L: Number(r.left) || 0, T: Number(r.top) || 0, W: Number(r.width) || 0, H: Number(r.height) || 0, title: String(w.title || '') }); + } + _enumCache = { at: Date.now(), rows }; + return rows; + } const d = abPayload(await chrome.abCommand('desktop_enum_windows', { className: 'Chrome_WidgetWin_1', includeOffscreen: true }, { timeoutMs: 8000 })); const rows = []; for (const w of ((d && d.windows) || [])) { @@ -8699,7 +8715,9 @@ return { titleContains: `(${TITLE_LABEL}: ${sessionId}` }; } async function resolveSessionHwndByBounds(session, sessionId) { - if (process.platform !== 'win32' || !session) return null; + if ((process.platform !== 'win32' && process.platform !== 'darwin') || !session) return null; + // macOS: no birth capture or window-property tag exists there, so the natural-title match and the + // sole-window-of-profile pick below (both read-only, pid-fenced) are what resolve the handle. // v2.0.17 (#557 follow-through): NEGATIVE-RESULT BACKOFF. A shared-profile window that resolution // CANNOT disambiguate (N windows, many wiggle-matches) was re-probed on every overlay/park/flash // touch — a hot loop of PowerShell EnumWindows passes, forever, per unresolvable session. That @@ -15656,6 +15674,14 @@ // v1.9.76 — HIGH-FPS / POWER hint on EVERY open. An AI cannot discover this // capability otherwise, and the wrong choice stays invisible until a video comes // out at 1-2 fps or a user's battery is flat overnight (issue #14). + // macOS: a background window lives in a HIDDEN app (Cmd+H), which AD's window recording and desktop + // screenshots cannot see. capturable:true un-hides it where it sits (visible, NOT focused, behind the + // user's active app: the same primitive as pup_alert_window's mac nudge), so it can be filmed or + // photographed without taking the user's screen. Windows and Linux are unchanged. + let _capturable; + if (process.platform === 'darwin' && !foreground && args.capturable) { + try { const _cs = sessions.get(sessionId); _capturable = !!(_cs && await macSetAppHiddenByPid(macSessionPid(_cs), false)); } catch (e) { _capturable = false; } + } const hf = highFpsInfo(sessionId); const fpsHint = hf ? ` HIGH-FPS WINDOW, LEASED ${hf.minutes} MIN (expires ${hf.expiresAt}). Chrome's four power-saving mechanisms are DISABLED here, so it renders at FULL RATE even while occluded — that is what makes a background video recording usable. It also means it never idles down, so it is LEASED: when the lease lapses pup DOWNGRADES it to normal power by relaunching the same session with the same tabs (it is NOT closed — your content survives). YOU CANNOT BE CUT OFF MID-RECORDING: while a recording is actually running the lease cannot lapse. If you know you need longer WITHOUT recording the whole time, negotiate now: pup_highfps_extend {sessionId:"${sessionId}", minutes:N} (max ${HIGHFPS_MAX_MINUTES}). pup warns ~2 min before it downgrades.` @@ -15692,6 +15718,8 @@ placement: foreground ? 'foreground' : 'background', // v2.0.383 (#63): the canonical SDK key a caller branches on; `placement` stays as the legacy alias. windowEtiquette: foreground ? 'foreground' : 'background', + // macOS capturable:true: whether the window was un-hidden for capture (undefined when not asked) + capturable: _capturable, // v2.0.383 (#59): the terminal progress frame (actuals; startKind cold|warm; estimate from this machine's history). progress: (() => { _inflightOpens.delete(_openId); try { return openProgressFrame(_openT0); } catch (e) { return undefined; } })(), // v1.8.2: window size (if width/height were given). null = default maximized. @@ -15754,6 +15782,12 @@ // i.e. "pup looks frozen". Now the list ALWAYS returns within ~2.5s with every window present; // a window too busy to answer live is marked degraded with its last-known state, never dropped. const entries = [...sessions]; + // macOS: nothing else resolves a session's window handle there (no park/overlay/flash machinery), + // so resolve the missing ones here, bounded, before the rows report hwnd. + if (process.platform === 'darwin') { + await Promise.all(entries.filter(([, s]) => s && !s._hwnd).map(([id, s]) => + withTimeout(resolveSessionHwndByBounds(s, id), 2500, 'resolveSessionHwnd').catch(() => null))); + } const list = await Promise.all(entries.map(([id, s]) => withTimeout(getSessionInfo(id, s), 2500, 'getSessionInfo').catch(() => getSessionInfoFast(id, s)) )); --- a/SKILL.md +++ b/SKILL.md @@ -569,7 +569,12 @@ Every `pup_*` verb works on macOS; only the OS-presentation layer differs: - **Background = hidden app.** A background open launches the session's browser process hidden - (the Cmd+H state), truly invisible, still fully drivable/screenshottable/recordable. + (the Cmd+H state), truly invisible, still fully drivable, and `pup_screenshot` / pup's own + recording still work (they read the page through CDP). A **desktop** capture of the window does + not: AD's window recording (`desktop_record_window_start`) and `desktop_screenshot_window` cannot + see a hidden app's window. Open with **`capturable: true`** to have the window un-hidden where it + sits (visible, not focused, behind the user's active app) so it can be filmed or photographed + without taking the screen; `pup_list_windows` reports its `hwnd` (the macOS window id) to target. `pup_lower_os_window` re-hides; `pup_raise_os_window` (with `foregroundReason`) unhides + activates. One session = one browser process, so a shared-profile window group hides/shows together.