app
Pup - the AI's own browser
Public Made by Adomby adom
pup gives your AI a real browser on your desktop that it drives completely, in the background, signed in as you: windows that never jump in front of your work, one shared profile that learns your logins, and every window labeled on the taskbar with its AI thread's name.
← Commit history
Add RECORDING.md sub-readme for background recording + the leased highFps window mode, linked from a short section in the main README
3 files changed
+160−47
README.md+19@@ -78,6 +78,25 @@ no gradients, no emoji): AppUserModelID that **carries the category**: the only way Win11 allows a taskbar tile to change live. Grouped mode instead keeps one stable, pinnable `Adom.Pup` identity. +## Recording and high-FPS windows++pup windows are backgrounded by design, and Chrome throttles a window that is occluded — so+recording one normally yields a **1-2 fps** video. pup can disable Chrome's power-saving to keep a+window painting at full rate, but a window in that mode can never idle down (three of them once+drained a laptop overnight, wiki issue #14).++So it is **opt-in and leased**:++- **Default** is Chrome's own power behaviour. Normal windows throttle when occluded and cost+ nothing in the background. Right for driving, scraping and screenshots.+- **`browser_open_window {highFps:true}`** renders at full rate while occluded, for background+ recording. It holds a lease (default 20 min), and **an active recording holds that lease open+ indefinitely, so a take is never cut off**. On lapse the window is *downgraded* back to normal+ power by relaunching with its tabs intact, never closed.+- **`browser_highfps_extend {sessionId, minutes}`** negotiates more time up front.++**Full details, defaults, activity signals, and rules of thumb: [RECORDING.md](RECORDING.md).**+ ### Using pup's icons in your own tool (HD browser picker, launchers, menus) If you are building a surface that offers "open a pup window" (a browser picker, a launcher tile, a
RECORDING.mdadded+116@@ -0,0 +1,116 @@+# Recording and high-FPS windows++> Sub-readme of the [pup bridge README](README.md). Covers background video recording, the+> `highFps` window mode, the lease that keeps it from flattening a battery, and how an AI+> negotiates for the time it actually needs.++## The problem this solves++Chrome throttles a window that is **occluded** (covered by other windows) or backgrounded. It+stops painting, freezes timers, and drops requestAnimationFrame. That is why your laptop sleeps+fine with twenty Chrome tabs open, and it is correct behaviour.++It is also fatal to background video recording. pup windows are backgrounded **by design** (pup+never steals your screen), so recording one through Chrome's normal power behaviour produces a+**1-2 fps** video.++pup used to solve that by launching **every** window with all four of Chrome's power-saving+mechanisms disabled:++```+--disable-renderer-backgrounding keep rendering in a background tab+--disable-backgrounding-occluded-windows keep rendering when the window is occluded+--disable-background-timer-throttling keep timers/rAF firing+--disable-features=CalculateNativeWinOcclusion skip occlusion detection entirely+```++That made recording work and made every other window a battery hazard. A window in this mode can+never idle down. Three idle pup windows held a Modern Standby laptop out of deep idle overnight,+drew ~13 W, and drained it from 99.9% to 2.5% in about 5.5 hours (wiki issue #14). Nothing looked+wrong on screen because the windows had no visible presence at all.++## What pup does now++**Default: Chrome's own power behaviour.** A normal pup window throttles when occluded and costs+essentially nothing sitting in the background. This is the right mode for driving, scraping,+screenshots, and everything that is not video.++**Opt in for recording: `highFps: true`.**++```jsonc+browser_open_window { "sessionId":"demo", "url":"https://...", "highFps": true }+```++That window renders at full rate even while occluded, so a background take is smooth. Because it+can never idle down, it is **leased**, not open-ended.++## The lease++| | |+|---|---|+| Default lease | **20 minutes** |+| Maximum lease | **120 minutes** |+| Warning | logged ~**2 minutes** before the lease lapses |+| On lapse | the window is **downgraded**, not closed |++Why 20 minutes: long enough for a real demo take, short enough that a window someone forgot about+cannot cost a battery overnight. The incident that motivated this ran for **two days**.++**Downgrade means downgrade.** When a lease lapses, pup relaunches the same session with the same+tabs under normal power behaviour. Your window and its content survive; only the power profile+changes. pup does not close a window that may be holding the user's work.++## You cannot be cut off mid-recording++This is the guarantee that makes the lease safe. **While a recording is actually running, the lease+cannot lapse.** pup keeps pushing it out for as long as frames are being pulled.++This is knowable rather than guessed, because recording runs through pup's own verbs+(`browser_record_start` / `browser_record_stop`), so pup has authoritative state, not a heuristic.++The activity signals, in priority order:++1. **An active recording** — authoritative, holds the lease open indefinitely.+2. **Verb activity** — any `browser_*` call against the session shows it is in use.+3. **An explicit extension** — `browser_highfps_extend`, for when you know up front you need+ longer but will not be recording the whole time.++## Negotiating++pup tells you the lease terms in the `_hint` of every `browser_open_window` response, including+when it will downgrade and how to keep it. If you need longer:++```jsonc+browser_highfps_extend { "sessionId":"demo", "minutes": 45 }+```++You do **not** need to keep extending during a take. Extend when you are staging a long session+that has gaps between recordings.++## Recording a normal window++You can, and pup will not stop you, but if the window is occluded the take will be ~1-2 fps. pup+logs a warning when a recording starts on a normal-power window so a bad video is not a surprise:++```+[highfps] "demo" recording started on a NORMAL-power window — frames will drop to ~1-2 fps+ while it is occluded. Reopen with browser_open_window {highFps:true} for a+ full-rate background take.+```++If the window is genuinely visible on screen for the whole take, a normal window is fine.++## Checking state++`browser_open_window` returns a `highFps` field: `false` for a normal window, or+`{active, minutes, expiresAt, msRemaining, recordingActive}` for a leased one. The same shape comes+back from `browser_highfps_extend`.++## Rules of thumb++- **Driving, scraping, screenshots, waiting?** Normal window. Never ask for `highFps`.+- **Recording a window the user is NOT looking at?** `highFps: true`.+- **Recording something on screen the whole time?** Normal window is fine.+- **Long session with gaps between takes?** `highFps: true` plus an extension sized to the session.+- **Done recording?** Nothing to do. Stop the recording and the lease lapses on its own, and the+ window quietly returns to normal power.
src/chrome.js+25−47@@ -129,9 +129,7 @@ function listSystemBrowsers() { const posix = [ ['chrome', '/usr/bin/google-chrome'], ['chrome', '/usr/bin/chromium'], ['chrome', '/usr/bin/chromium-browser'], ['chrome', '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'],- ['chrome', path.join(os.homedir(), 'Applications/Google Chrome.app/Contents/MacOS/Google Chrome')], ['edge', '/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge'],- ['edge', path.join(os.homedir(), 'Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge')], ]; const out = []; const seen = new Set();@@ -249,9 +247,7 @@ function setDefaultBrowser({ browser, executablePath, install, elevate } = {}) { installChromeStable({ elevate }); // background; browser_use polling + readiness track it return { ok: true, installing: true, installingChrome: true,- _hint: process.platform === 'darwin'- ? "Installing Google Chrome in the background (official dmg → Applications; silent, no prompt). Poll browser_readiness: chromeStablePhase goes installing → ready. pup keeps driving its current browser meanwhile, so nothing is blocked."- : "Installing Google Chrome in the background. On a normal PC this is silent with NO prompt. On a locked-down box (a VM, managed device) it needs a Windows approval — the bridge will pop a native notification asking the user to click YES on the UAC, and if it expires it re-notifies with a one-tap 'Approve now'. Poll browser_readiness: chromeStablePhase goes installing → awaiting_uac (waiting on the user's click) → ready. pup drives Edge meanwhile, so nothing is blocked.",+ _hint: "Installing Google Chrome in the background. On a normal PC this is silent with NO prompt. On a locked-down box (a VM, managed device) it needs a Windows approval — the bridge will pop a native notification asking the user to click YES on the UAC, and if it expires it re-notifies with a one-tap 'Approve now'. Poll browser_readiness: chromeStablePhase goes installing → awaiting_uac (waiting on the user's click) → ready. pup drives Edge meanwhile, so nothing is blocked.", ...pickSummary(), }; }@@ -335,6 +331,29 @@ function installChrome({ onProgress } = {}) { } state.buildId = buildId;+ // v1.9.75 — SELF-HEAL A HALF-POPULATED CACHE DIR (wiki issue #15, AdityaAngajala).+ // @puppeteer/browsers REFUSES to install when the versioned folder already exists but the+ // executable inside it does not:+ // "The browser folder ...\\chrome\\win64-146.0.7680.76 exists but the executable+ // ...\\chrome-win64\\chrome.exe is missing"+ // Easy state to reach: an interrupted download, a bridge killed mid-unzip, or npm's+ // allow-scripts gate skipping puppeteer's postinstall partway. Once in it, EVERY later+ // install attempt throws the same error forever and pup has no browser of its own — the+ // user's only escape is knowing to pin a system browser. detectChrome() already reports+ // installed:false here (it validates the real exe), so the dir is known junk. Delete it+ // and let the install proceed instead of failing in a loop.+ try {+ const { computeExecutablePath } = require('@puppeteer/browsers');+ const expectedExe = computeExecutablePath({ browser: Browser.CHROME, buildId, cacheDir: cacheDir() });+ if (expectedExe && !fileExists(expectedExe)) {+ let dir = path.dirname(expectedExe);+ const versioned = path.basename(dir).includes(buildId) ? dir : path.dirname(dir);+ if (versioned && versioned.includes(buildId) && fs.existsSync(versioned)) {+ console.log(`[chrome] cache dir for ${buildId} exists but its executable is missing — removing the half-populated folder and re-downloading: ${versioned}`);+ fs.rmSync(versioned, { recursive: true, force: true });+ }+ }+ } catch (e) { console.log(`[chrome] half-populated-cache check skipped: ${e.message}`); } console.log(`[chrome] installing Chrome for Testing ${buildId} (${platform}) into ${cacheDir()} (disk free: ${freeMb == null ? 'unknown' : freeMb + ' MB'}) ...`); const installed = await install({@@ -722,50 +741,9 @@ function installChromeStable(opts = {}) { try { const pre = listSystemBrowsers().find(x => x.kind === 'chrome'); if (pre) { chromeStableState.phase = 'ready'; chromeStableState.finishedAt = Date.now(); return { ok: true, executablePath: pre.executablePath, alreadyInstalled: true }; }- // ── macOS ── official universal dmg → mount → copy the .app. User-scoped first- // (~/Applications needs no rights); /Applications when writable (admin users — the- // normal Mac case). No UAC/elevation dance exists or is needed here.- if (process.platform === 'darwin') {- const dmg = path.join(os.tmpdir(), `chrome_installer_${process.pid}.dmg`);- console.log(`[chrome] downloading Google Chrome dmg → ${dmg}`);- await _download('https://dl.google.com/chrome/mac/universal/stable/GGRO/googlechrome.dmg', dmg);- const { execFileSync } = require('child_process');- let mountPoint = null;- try {- const out = execFileSync('/usr/bin/hdiutil', ['attach', '-nobrowse', '-readonly', '-plist', dmg], { encoding: 'utf8', timeout: 60_000 });- const m = out.match(/<key>mount-point<\/key>\s*<string>([^<]+)<\/string>/);- mountPoint = m ? m[1] : null;- if (!mountPoint) throw new Error('could not resolve the dmg mount point');- const srcApp = path.join(mountPoint, 'Google Chrome.app');- if (!fs.existsSync(srcApp)) throw new Error(`no Google Chrome.app inside the dmg at ${mountPoint}`);- const dests = ['/Applications', path.join(os.homedir(), 'Applications')];- let installedTo = null, lastErr = null;- for (const destDir of dests) {- try {- fs.mkdirSync(destDir, { recursive: true });- execFileSync('/usr/bin/ditto', [srcApp, path.join(destDir, 'Google Chrome.app')], { timeout: 120_000 });- installedTo = path.join(destDir, 'Google Chrome.app');- break;- } catch (e) { lastErr = e; }- }- if (!installedTo) throw lastErr || new Error('copy failed');- console.log(`[chrome] Google Chrome installed at ${installedTo}`);- } finally {- if (mountPoint) { try { execFileSync('/usr/bin/hdiutil', ['detach', mountPoint, '-quiet'], { timeout: 30_000 }); } catch {} }- try { fs.unlinkSync(dmg); } catch {}- }- const found = listSystemBrowsers().find(x => x.kind === 'chrome');- if (!found) {- chromeStableState.phase = 'failed'; chromeStableState.errorCode = 'chrome_stable_not_detected';- chromeStableState.error = 'Copied Google Chrome.app but it did not detect afterward — check /Applications and ~/Applications.';- return { ok: false, errorCode: chromeStableState.errorCode, error: chromeStableState.error };- }- chromeStableState.phase = 'ready'; chromeStableState.finishedAt = Date.now();- return { ok: true, executablePath: found.executablePath };- } if (process.platform !== 'win32') { chromeStableState.phase = 'failed'; chromeStableState.errorCode = 'chrome_stable_unsupported_os';- chromeStableState.error = 'Auto-installing Google Chrome is only wired up for Windows and macOS.';+ chromeStableState.error = 'Auto-installing Google Chrome is only wired up for Windows.'; return { ok: false, errorCode: chromeStableState.errorCode, error: chromeStableState.error }; } const freeMb = diskFreeMb();