← Commit history

v1.9.74: macOS parity — hidden-app background-by-default, mac lower/raise/alert, dmg Chrome install, mac ffmpeg + process-scan fixes

Kyle Bergstedt ·accc00c46a · ·parent ef0e1f4
7 files changed +279−25
CHANGELOG.md+38
@@ -1,5 +1,43 @@ # Changelog — Adom Desktop Puppeteer Bridge +## 1.9.74 — 2026-07-23 — macOS parity: the whole OS-presentation layer, mac-native++Until now pup on macOS ran "headless-in-spirit": every CDP verb worked, but the OS layer+(background-by-default, lower/raise, alert) silently no-op'd (`applied:false`) because it was all+Win32/PowerShell. Verified live on a Mac (AD 1.9.167, feat/macos-parity):++1. BACKGROUND-BY-DEFAULT, THE MAC WAY. AppKit clamps windows onto a visible screen, so the+   Windows off-screen-launch trick can't work. Instead the detached launch now runs on darwin+   too (`--no-startup-window` + spawn detached), and the bridge HIDES the browser process+   (the Cmd+H state, via System Events) BEFORE its first window is created — windows born+   into a hidden app are born invisible. The park then sizes the window full-screen via CDP+   (while hidden, so a later raise looks right) and re-asserts the hide. Cross-origin+   navigation cannot unhide it. Screenshots/eval/recording all work on the hidden window+   (--disable-backgrounding-occluded-windows). One session = one process, so hide is+   per-session; shared-profile window groups hide together (documented DEGRADED vs per-hwnd).+2. LOWER / RAISE. browser_lower_os_window = hide (applied:true, verified via System Events);+   browser_raise_os_window / focus (foregroundReason gate unchanged) = unhide + activate by+   pid. The wiki-toggle raise path (osRaiseSessionWindow) gained the same branch.+3. ALERT. macOS has no taskbar flash and no API to bounce another app's Dock tile. The mac+   nudge: un-hide the window at its z-position (visible, NOT focused) + an AD native toast+   whose click focuses the window (notify_user focus:{titleContains}); {stop:true} dismisses+   the toast (notify_dismiss).+4. CHROME INSTALL. browser_use {browser:'chrome',install:true} now works on mac: official+   universal dmg → hdiutil attach → ditto to /Applications (falls to ~/Applications when not+   writable). No UAC machinery needed. ~/Applications is also probed by detection now.+5. PROCESS SCANS. The POSIX ps-scan filters were case-sensitive on 'chrome' — mac system+   Chrome ("…/MacOS/Google Chrome") never matched, so rescan/recovery missed it. Now /chrome|+   msedge|Microsoft Edge/i.+6. RECORDING. ffmpeg detection probes Homebrew/MacPorts paths (/opt/homebrew/bin,+   /usr/local/bin, /opt/local/bin — AD spawns bridges outside a login shell, so PATH lacks+   them), and the not-found hint says `brew install ffmpeg` on mac instead of winget. Verified:+   tab-record of a HIDDEN window produces a playable webm.+7. CfT PREWARM on branded-browser fallback now kicks on darwin too.++Windows behavior is unchanged: every new branch is `process.platform === 'darwin'`-gated, and+the AUMID/jump-list/overlay/tooltip identity stack stays Windows-only by design (no Dock+equivalent; the window title's "(session: <id>)" suffix is the mac identity).+ ## 1.9.43 — 2026-07-20 — Why some icons had overlays and others did not, plus stacked session titles  John: "why do you sometimes have overlays on your icons and sometimes not?" It was never a rule, it
SKILL.md+18−1
@@ -29,7 +29,8 @@ does NOT attach to the user's running browser and does NOT run headless. It:    below.) 3. **Renders a REAL window, but keeps it in the BACKGROUND.** It is *not* headless — a real GPU-composited    window, so `browser_screenshot` / recording are true-to-life and canvas/WebGL/media render correctly.-   But pup immediately z-orders it to the **bottom** (Win32 `SetWindowPos` with `SWP_NOACTIVATE`), so it+   But pup immediately z-orders it to the **bottom** (Windows: Win32 `SetWindowPos` with+   `SWP_NOACTIVATE`; macOS: the browser process is **hidden**, the Cmd+H state — same contract), so it    renders **without stealing focus and without covering the user's active window**. You drive and    screenshot it fully while it stays hidden. `foreground:true` is the ONLY thing that shows it. 4. **Spawn-verifies + falls through.** The pick order is **Chrome → Edge → cached Chrome for Testing**; pup@@ -81,6 +82,22 @@ adom-desktop browser_screenshot  '{"sessionId":"mytask-web"}' - **On a fresh PC it's usually a non-event** — pup drives the installed Chrome/Edge with no download. The   rare "no browser at all" / Node-provisioning / disk cases are all in `pup-browsers-and-chrome`. +## macOS differences (v1.9.74+) — same verbs, mac-native mechanics++Every `browser_*` 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.+  `browser_lower_os_window` re-hides; `browser_raise_os_window` (with `foregroundReason`)+  unhides + activates. One session = one browser process, so a shared-profile window group+  hides/shows together.+- **`browser_alert_window` = un-hide (not focused) + a native toast** whose click focuses the+  window — macOS has no taskbar to flash. `{stop:true}` dismisses the toast.+- **No taskbar identity on mac** — the per-window icons/badges/jump-lists below are+  Windows-only; on mac the window title's `(session: <id>)` suffix is the identity.+- **Recording** needs ffmpeg (`brew install ffmpeg` — the error hint says so; re-detects+  without a restart).+ ## Taskbar preference — the USER can ask for "combined mode"  pup windows each get their own taskbar button by default ("split": one button per window, badged
adom-bridge-puppeteer-manifest.json+5−5
@@ -1,10 +1,10 @@ {   "manifest_version": 1,   "name": "puppeteer",-  "version": "1.9.73",-  "url": "https://wiki.adom.inc/download/adom/adom-desktop-puppeteer-bridge/1.9.73/adom-bridge-puppeteer-v1.9.73.zip",-  "sha256": "599c867cc6738caf228089e64f3b32359d6325629674feef39c95db85e80cd20",-  "size": 308819,+  "version": "1.9.74",+  "url": "https://wiki.adom.inc/download/adom/adom-desktop-puppeteer-bridge/1.9.74/adom-bridge-puppeteer-v1.9.74.zip",+  "sha256": "0f434106907515788dbf180ee81e8151e2f316234241ad107205d3bdd9ede1c1",+  "size": 267309,   "verbPrefixes": [     "browser_",     "desktop_recorder_",@@ -12,5 +12,5 @@   ],   "healthEndpoint": "/status",   "statusVerb": "browser_readiness",-  "released_at": "2026-07-20T15:30:00Z"+  "released_at": "2026-07-23T16:55:27Z" }\ No newline at end of file
src/BRIDGE_VERSION+1−1
@@ -1 +1 @@-1.9.73+1.9.74\ No newline at end of file
src/bridge.json+6−6
@@ -2,8 +2,8 @@   "manifest_version": 1,   "name": "puppeteer",   "displayName": "Puppeteer (Chrome for Testing + Native)",-  "version": "1.9.73",-  "description": "Drives a browser ALREADY on the machine \u2014 installed Chrome, else Microsoft Edge (every Windows PC ships with Edge) \u2014 launched with a FRESH isolated profile, so the common case has NO ~150 MB Chrome-for-Testing download and it never touches the user's real tabs/logins. Binds its local server to LOOPBACK (127.0.0.1) only, so no Windows Firewall 'allow Node through the firewall' prompt ever pops. Chrome for Testing is fetched only as a LAST RESORT if the machine has no Chromium browser at all. SPAWN-VERIFIED fallback: pup tries each detected browser (Chrome \u2192 Edge \u2192 CfT) and actually launches it, falling through to the next if one won't start (and handling browsers like Edge that relaunch onto another PID), so a corrupt/partial install never hard-fails an open. The browser that launches is cached as the default (persisted across restarts) so it isn't re-probed each time. On spawn the bridge SELF-WARMS: detects browsers + disk, and only if the box has no Chrome/Edge and no cached CfT does it background-fetch CfT so the first open is instant. browser_use pins a specific browser (chrome | edge | cft | auto); {browser:'chrome',install:true} downloads & installs REAL Google Chrome (user-scoped, no admin) on an Edge-only box. browser_readiness (READ-ONLY) reports which browser will be used + the full candidate list + the default + free disk. Every failure returns a specific errorCode + _hint (chrome_install_no_disk, chrome_download_network, chrome_install_permission, chrome_stable_not_detected, \u2026). Launches with FULL browser permissions by default for full debug (strictPermissions:true opts out). Headful automation: open/close windows+tabs, navigate, screenshot, eval JS, record. pup is a GENERIC, non-profile browser only \u2014 every session is a fresh isolated Chrome/Edge process, never the user's real signed-in browser; profile defaults to sessionId. To drive the user's REAL Chrome/Edge (their profile, cookies, SSO), use the Adom browser EXTENSION's nbrowser_* verbs \u2014 a separate bridge, not pup's job.",+  "version": "1.9.74",+  "description": "Drives a browser ALREADY on the machine — installed Chrome, else Microsoft Edge (every Windows PC ships with Edge) — launched with a FRESH isolated profile, so the common case has NO ~150 MB Chrome-for-Testing download and it never touches the user's real tabs/logins. Binds its local server to LOOPBACK (127.0.0.1) only, so no Windows Firewall 'allow Node through the firewall' prompt ever pops. Chrome for Testing is fetched only as a LAST RESORT if the machine has no Chromium browser at all. SPAWN-VERIFIED fallback: pup tries each detected browser (Chrome → Edge → CfT) and actually launches it, falling through to the next if one won't start (and handling browsers like Edge that relaunch onto another PID), so a corrupt/partial install never hard-fails an open. The browser that launches is cached as the default (persisted across restarts) so it isn't re-probed each time. On spawn the bridge SELF-WARMS: detects browsers + disk, and only if the box has no Chrome/Edge and no cached CfT does it background-fetch CfT so the first open is instant. browser_use pins a specific browser (chrome | edge | cft | auto); {browser:'chrome',install:true} downloads & installs REAL Google Chrome (user-scoped, no admin) on an Edge-only box. browser_readiness (READ-ONLY) reports which browser will be used + the full candidate list + the default + free disk. Every failure returns a specific errorCode + _hint (chrome_install_no_disk, chrome_download_network, chrome_install_permission, chrome_stable_not_detected, …). Launches with FULL browser permissions by default for full debug (strictPermissions:true opts out). Headful automation: open/close windows+tabs, navigate, screenshot, eval JS, record. pup is a GENERIC, non-profile browser only — every session is a fresh isolated Chrome/Edge process, never the user's real signed-in browser; profile defaults to sessionId. To drive the user's REAL Chrome/Edge (their profile, cookies, SSO), use the Adom browser EXTENSION's nbrowser_* verbs — a separate bridge, not pup's job. macOS (v1.9.74+): full parity with mac-native mechanics — background-by-default is the app-level hide (windows stay drivable/screenshottable while hidden), raise/lower map to unhide+activate/hide, browser_alert_window posts a native toast whose click focuses the window, browser_use {browser:'chrome',install:true} installs Chrome from the official dmg, and taskbar identity/badging remains Windows-only.",   "homepage": "https://wiki.adom.inc/adom/adom-desktop",   "author": "Adom Inc.",   "license": "MIT",@@ -74,13 +74,13 @@   "updateManifestUrl": "https://wiki.adom.inc/api/v1/pages/adom-desktop-puppeteer-bridge/files/adom-bridge-puppeteer-manifest.json",   "dependencies": {     "node": ">=20",-    "browser": "Chromium-family \u2014 installed Chrome or Edge (every Windows box ships Edge); if neither exists, Chrome for Testing is auto-fetched as a last resort. No hard Chrome requirement."+    "browser": "Chromium-family — installed Chrome or Edge (every Windows box ships Edge); if neither exists, Chrome for Testing is auto-fetched as a last resort. No hard Chrome requirement."   },   "runtime": {     "kind": "node",     "version": ">=20",     "preferred": "22",-    "_note": "AD-managed shared-runtime declaration (see PROPOSAL-shared-runtimes.md). Bridges DECLARE their runtime; AD provisions a portable interpreter from its shared adom-runtimes cache and spawns by absolute path \u2014 no UAC, no PATH reliance. Additive: older AD ignores this and uses `dependencies` / bare `node` on PATH."+    "_note": "AD-managed shared-runtime declaration (see PROPOSAL-shared-runtimes.md). Bridges DECLARE their runtime; AD provisions a portable interpreter from its shared adom-runtimes cache and spawns by absolute path — no UAC, no PATH reliance. Additive: older AD ignores this and uses `dependencies` / bare `node` on PATH."   },   "category": "Browser Automation",   "tags": [@@ -96,11 +96,11 @@     },     "macos": {       "supported": false,-      "reason": "macOS port pending \u2014 puppeteer-core is cross-platform but the bridge's spawn config uses Windows-only killImageName 'node.exe' and Chrome for Testing path resolution hasn't been tested."+      "reason": "macOS port pending — puppeteer-core is cross-platform but the bridge's spawn config uses Windows-only killImageName 'node.exe' and Chrome for Testing path resolution hasn't been tested."     },     "linux": {       "supported": false,-      "reason": "Linux port pending \u2014 same killImageName + Chrome resolution issues as macOS."+      "reason": "Linux port pending — same killImageName + Chrome resolution issues as macOS."     }   },   "prewarm": {
src/chrome.js+47−2
@@ -129,7 +129,9 @@ 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();@@ -247,7 +249,9 @@ function setDefaultBrowser({ browser, executablePath, install, elevate } = {}) {       installChromeStable({ elevate }); // background; browser_use polling + readiness track it       return {         ok: true, installing: true, installingChrome: true,-        _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.",+        _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.",         ...pickSummary(),       };     }@@ -718,9 +722,50 @@ 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.';+        chromeStableState.error = 'Auto-installing Google Chrome is only wired up for Windows and macOS.';         return { ok: false, errorCode: chromeStableState.errorCode, error: chromeStableState.error };       }       const freeMb = diskFreeMb();
src/server.js+164−10
@@ -51,6 +51,50 @@ function runPsHiddenPromise(scriptPath, { timeout = 8000 } = {}) {     });   }); }+// ── macOS window-management primitives (the darwin analogs of the Win32 helpers) ──+// Strategy on macOS: no cross-process per-window z-bottom API exists, and AppKit CLAMPS+// windows onto a visible screen (the -32000 off-screen launch trick doesn't hold). The+// reliable "background by default" primitive is the APP-LEVEL HIDE (the Cmd+H state):+// every pup session runs its own browser process (one per profile), so hiding that+// process hides exactly that session's windows — while CDP keeps driving + rendering+// them (--disable-backgrounding-occluded-windows), so screenshot/eval still work.+// Raise = unhide + activate. Driven via System Events (rides the Automation grant the+// embedding Adom Desktop already holds; a denied grant degrades to applied:false, never+// a throw). Hide is PER-PROCESS, so multi-session shared-profile windows hide/show as a+// group on mac — a documented DEGRADED vs the per-hwnd Windows path.+function runOsascript(script, { timeout = 6000 } = {}) {+  try { return execFileSync('/usr/bin/osascript', ['-e', script], { encoding: 'utf8', timeout }).trim(); }+  catch (e) { return null; }+}+function runOsascriptPromise(script, { timeout = 8000 } = {}) {+  return new Promise(resolve => {+    execFile('/usr/bin/osascript', ['-e', script], { encoding: 'utf8', timeout }, (err, stdout) => resolve(err ? null : (stdout || '').trim()));+  });+}+// Hide (true) / unhide (false) every window of a process. Resolves true if the script ran.+async function macSetAppHiddenByPid(pid, hidden) {+  if (!pid) return false;+  const r = await runOsascriptPromise(`tell application "System Events" to set visible of (first process whose unix id is ${Number(pid)}) to ${hidden ? 'false' : 'true'}`);+  return r !== null;+}+// Unhide + bring the process's windows to the foreground (the user-asked-to-watch path).+async function macActivateByPid(pid) {+  if (!pid) return false;+  await runOsascriptPromise(`tell application "System Events" to set visible of (first process whose unix id is ${Number(pid)}) to true`);+  const r = await runOsascriptPromise(`tell application "System Events" to set frontmost of (first process whose unix id is ${Number(pid)}) to true`);+  return r !== null;+}+// The pid that owns a session's browser (detached launches record it; puppeteer.launch+// exposes process()). Null when unresolvable — callers report applied:false.+function macSessionPid(session) {+  try {+    const be = browsers.get(session.profileName);+    const b = be && be.browser;+    if (!b) return null;+    return b._detachedPid || (b.process() && b.process().pid) || null;+  } catch (e) { return null; }+}+ const puppeteer = require('puppeteer'); const credentialVault = require('./credential_vault'); const chrome = require('./chrome'); // Chrome-for-Testing self-heal: detect / auto-install / readiness (cold-start fix)@@ -833,7 +877,8 @@ async function findChromesByProfile(profileName) {       const result = spawnSync('ps', ['-eo', 'pid,command'], { encoding: 'utf8', timeout: 5_000 });       if (result.stdout) {         for (const line of result.stdout.split('\n')) {-          if (!line.includes('chrome')) continue;+          // Case-insensitive: mac system Chrome is ".../MacOS/Google Chrome" (no lowercase hit).+          if (!/chrome|msedge|Microsoft Edge/i.test(line)) continue;           if (/--type=/.test(line)) continue;           const portMatch = line.match(/--remote-debugging-port=(\d+)/);           if (!portMatch) continue;@@ -1117,7 +1162,8 @@ async function rescanAllProfiles({ adoptOrphans = false } = {}) {       if (result.stdout) {         const seen = new Set();         for (const line of result.stdout.split('\n')) {-          if (!line.includes('chrome')) continue;+          // Case-insensitive: mac system Chrome is ".../MacOS/Google Chrome" (no lowercase hit).+          if (!/chrome|msedge|Microsoft Edge/i.test(line)) continue;           if (line.includes('--type=')) continue;           if (!line.includes('remote-debugging-port')) continue;           if (!line.includes(PROFILES_DIR)) continue;@@ -2738,6 +2784,13 @@ async function osRaiseSessionWindow(session, sessionId) {     try { await session.page.evaluate(() => { const a = document.activeElement; if (a && a.blur) a.blur(); }); } catch (e) {}     try { await session.page.bringToFront(); } catch (e) {}     session._foreground = true;+    if (process.platform === 'darwin') {+      // macOS: unhide + activate the session's browser process (the CDP part above sized it).+      const pid = macSessionPid(session);+      const ok = await macActivateByPid(pid);+      console.log(`[foreground] "${sessionId}" mac activate pid=${pid} → ${ok}`);+      return;+    }     if (process.platform !== 'win32') return;     // v1.9.66 — RAISE BY HWND. The CDP-only path above (setWindowBounds + bringToFront) happens to     // surface a CHROME window, but it does NOT raise an EDGE one: bringToFront activates the TAB@@ -3610,6 +3663,43 @@ function osBottomUnlessForeground(hwnd, force) { //   5. Verify POSITION via CDP getWindowBounds (catch-2's park "verified" by focus alone and //      lied) → 'backgrounded' | 'park-failed'. async function parkSessionWindowDirect(page, sessionId) {+  // ── macOS park ── the whole background contract is one primitive: (re-)hide the+  // session's browser process. The window was born hidden (pre-window hide in+  // tryDetachedLaunch); here we size it full-screen via CDP while STILL hidden — so a+  // later raise shows a properly-sized window — then re-assert the hide and verify.+  // No z-order machinery: a hidden app has no on-screen windows to cover the user.+  if (process.platform === 'darwin') {+    try {+      // The session may not be registered in `sessions` yet at launch-park time — resolve+      // the pid from the page's OWN browser (detached pid, else puppeteer's child process).+      let pid = null;+      try { const b = page.browser(); pid = b._detachedPid || (b.process() && b.process().pid) || null; } catch (e) {}+      if (!pid) { const session = sessions.get(sessionId); pid = session ? macSessionPid(session) : null; }+      try {+        const cdp2 = await page.target().createCDPSession();+        const { windowId } = await cdp2.send('Browser.getWindowForTarget');+        let s = null;+        for (let i = 0; i < 4 && !s; i++) {+          s = await page.evaluate(() => ({ w: screen.availWidth, h: screen.availHeight })).catch(() => null);+          if (s && !(s.w > 400 && s.h > 300)) s = null;+          if (!s) await new Promise(r2 => setTimeout(r2, 300));+        }+        await cdp2.send('Browser.setWindowBounds', { windowId, bounds: { windowState: 'normal' } });+        if (s) await cdp2.send('Browser.setWindowBounds', { windowId, bounds: { left: 0, top: 0, width: Math.round(s.w), height: Math.round(s.h) } });+        try { await cdp2.detach(); } catch (e) {}+      } catch (e) {}+      if (!pid) { console.log(`[park] "${sessionId}": no pid to hide (mac) — park-failed`); return 'park-failed'; }+      const hidden = await macSetAppHiddenByPid(pid, true);+      if (!hidden) {+        console.log(`[park] "${sessionId}": mac hide failed (System Events denied? grant Automation to Adom Desktop) — park-failed`);+        return 'park-failed';+      }+      return 'backgrounded';+    } catch (e) {+      console.error(`[park] ${sessionId} (mac): ${e.message}`);+      return 'error';+    }+  }   if (process.platform !== 'win32') return null;   let cdp = null;   try {@@ -4332,6 +4422,16 @@ async function getOrLaunchBrowser(profileName, { freshProfile = false, strictPer         b._bgResult = 'parking';         b._needsPark = true;       }+      if (!foreground && process.platform === 'darwin') {+        // macOS background-by-default: hide the app NOW, while --no-startup-window means it+        // has no window yet — windows created by a hidden app are born hidden, so the launch+        // is truly invisible (AppKit clamps the -32000 off-screen coords, so only the hide+        // covers the gap here). The park then sizes the window + re-asserts the hide.+        b._bgResult = 'parking';+        b._needsPark = true;+        b._macHidden = await macSetAppHiddenByPid(child.pid, true);+        if (!b._macHidden) console.log(`[launch] mac pre-window hide failed for pid ${child.pid} (Automation grant missing?) — park will retry`);+      }       return b;     }; @@ -4339,7 +4439,9 @@ async function getOrLaunchBrowser(profileName, { freshProfile = false, strictPer     // attempts each (with orphan/lock cleanup between attempts). If a browser can't     // actually spawn (corrupt install, wrong arch, locked), fall THROUGH to the next     // candidate rather than failing the open. First one that launches wins + is cached.-    const isWinDetached = process.platform === 'win32' && userDataDir;+    // v1.9.74: the detached launch runs on macOS too (same --no-startup-window contract;+    // the mac park is the app-level hide instead of the off-screen/z-bottom dance).+    const isDetachedLaunch = (process.platform === 'win32' || process.platform === 'darwin') && userDataDir;     const MAX_ATTEMPTS = 3;     let lastErr = null;     let chosen = null;@@ -4354,7 +4456,7 @@ async function getOrLaunchBrowser(profileName, { freshProfile = false, strictPer       lastErr = null;       for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {         try {-          if (isWinDetached) {+          if (isDetachedLaunch) {             browser = await tryDetachedLaunch(attempt, cand.executablePath);           } else {             browser = await puppeteer.launch(launchOpts);@@ -4388,7 +4490,7 @@ async function getOrLaunchBrowser(profileName, { freshProfile = false, strictPer       // v1.8.71 (#202): a BRANDED-browser launch means CfT wasn't cached — kick a background       // CfT install (once per bridge process) so this box converges to the deterministic       // CfT-first order by its next session. Never blocks or stalls the current open.-      if (chosen.kind !== 'chrome-for-testing' && !global._cftPrewarmKicked && process.platform === 'win32') {+      if (chosen.kind !== 'chrome-for-testing' && !global._cftPrewarmKicked && (process.platform === 'win32' || process.platform === 'darwin')) {         global._cftPrewarmKicked = true;         try {           console.log(`[prewarm] branded fallback (${chosen.kind}) — starting background CfT install so future opens use Chrome for Testing`);@@ -4624,7 +4726,7 @@ async function launchSession(sessionId, url, { freshProfile = false, profile, st   // full-size, z-bottomed, and z-confirmed. Only for a freshly launched browser (_needsPark) —   // a reused window is already placed, and re-parking it could hide a window the user has up.   const kickoffPark = () => {-    if (foreground || process.platform !== 'win32') return;+    if (foreground || (process.platform !== 'win32' && process.platform !== 'darwin')) return;     try {       const be = browsers.get(profileName);       if (be && be.browser && be.browser._needsPark) {@@ -6274,6 +6376,32 @@ const server = http.createServer(async (req, res) => {           const session = sessions.get(sessionId);           try {             const stop = !!(args.stop || args.clear);+            // ── macOS ── there is no taskbar to flash and macOS has no API to bounce ANOTHER+            // app's Dock tile. The mac analog: unhide the window at its z-position (visible but+            // NOT focused — the non-intrusive nudge) + a native AD toast whose click focuses it.+            // {stop} dismisses the toast.+            if (process.platform === 'darwin') {+              const sid = session._sessionId || sessionId;+              const toastId = `pup-alert-${sid}`;+              let applied = false;+              if (stop) {+                try { const r2 = await chrome.adCommand('notify_dismiss', { id: toastId }, { timeoutMs: 5000 }); applied = !!r2; } catch (e) {}+                sendJSON(res, { success: true, output: JSON.stringify({ ok: true, sessionId, alerted: false, cleared: applied, applied, clearMethod: applied ? 'toast-dismissed' : null, _hint: applied ? 'Alert toast dismissed.' : 'No toast to dismiss (or AD unreachable).' }) });+                return;+              }+              try { await macSetAppHiddenByPid(macSessionPid(session), false); } catch (e) {}+              try {+                const r2 = await chrome.adCommand('notify_user', {+                  id: toastId, level: 'info', scenario: 'reminder',+                  title: 'A pup window wants your attention',+                  body: `Session "${sid}" has something to show — click to bring it up.`,+                  focus: { titleContains: `(session: ${sid}` },+                }, { timeoutMs: 6000 });+                applied = !!(r2 && r2.success !== false);+              } catch (e) {}+              sendJSON(res, { success: true, output: JSON.stringify({ ok: true, sessionId, alerted: true, cleared: false, applied, clearMethod: null, _hint: applied ? 'macOS nudge: window un-hidden (not focused) + a native toast posted — its click focuses the window. Pass {stop:true} to dismiss the toast.' : 'Window un-hidden, but the AD toast could not be posted (AD unreachable or notify unsupported).' }) });+              return;+            }             // v1.8.43: route the FLASH through AD's desktop_flash_window (reliable, native).             const r = await flashViaAD(session._sessionId || sessionId, { stop });             let applied = false;@@ -6317,7 +6445,14 @@ const server = http.createServer(async (req, res) => {           // a deliberate re-park, so clear the user-foreground latch too.           const be = browsers.get(session.profileName);           if (be && be.browser) be.browser._userForeground = false;-          const ok = (be && be.browser) ? (osBackgroundWindowByPid(be.browser._detachedPid, be.browser._cdpPort, be.browser._priorForeground) === 'backgrounded') : osSendWindowToBack(session.profileName);+          let ok;+          if (process.platform === 'darwin') {+            // macOS lower = hide the session's browser process (still fully drivable — CDP+            // keeps rendering hidden windows). Per-process, so shared-profile siblings hide too.+            ok = await macSetAppHiddenByPid(macSessionPid(session), true);+          } else {+            ok = (be && be.browser) ? (osBackgroundWindowByPid(be.browser._detachedPid, be.browser._cdpPort, be.browser._priorForeground) === 'backgrounded') : osSendWindowToBack(session.profileName);+          }           session._foreground = false;           sendJSON(res, { success: true, output: JSON.stringify({ ok: true, sessionId, placement: 'background', applied: ok, _hint: 'Window sent behind the user\'s windows (still drivable/screenshottable). Keep pup in the background unless the user asked to watch.' }) });           return;@@ -6386,6 +6521,13 @@ const server = http.createServer(async (req, res) => {             // handing the hwnd to AD, because backgrounded pup windows carry WS_EX_NOACTIVATE — structurally             // unable to take focus until that style is CLEARED, which is exactly what Raise() does.             let _raisedByHwnd = false;+            if (process.platform === 'darwin') {+              // macOS raise = unhide the session's process + make it frontmost. The CDP+              // bounds/maximize above already sized it; System Events does the activation.+              const pid = macSessionPid(session);+              _raisedByHwnd = await macActivateByPid(pid);+              console.log(`[foreground] "${sessionId}" mac activate pid=${pid} → ${_raisedByHwnd}`);+            }             if (process.platform === 'win32') {               try {                 const fw = adPayload(await chrome.adCommand('desktop_find_window', { titleContains: `(session: ${sessionId}` }, { timeoutMs: 5000 }));@@ -8072,7 +8214,19 @@ function detectFFmpeg() {     const r = spawnSync('ffmpeg', ['-version'], { stdio: ['ignore', 'pipe', 'ignore'] });     if (r.status === 0) { FFMPEG_PATH = 'ffmpeg'; return; }   } catch {}-  // 2. Common Windows install paths+  // 2. Common macOS install paths (Homebrew arm64/x64, MacPorts) — AD spawns the bridge+  // outside a login shell, so /opt/homebrew/bin is usually NOT on PATH here.+  if (process.platform === 'darwin') {+    for (const p of ['/opt/homebrew/bin/ffmpeg', '/usr/local/bin/ffmpeg', '/opt/local/bin/ffmpeg']) {+      try {+        if (fs.existsSync(p)) {+          const r = spawnSync(p, ['-version'], { stdio: ['ignore', 'pipe', 'ignore'] });+          if (r.status === 0) { FFMPEG_PATH = p; return; }+        }+      } catch {}+    }+  }+  // 3. Common Windows install paths   if (process.platform === 'win32') {     const candidates = [       'C:\\Program Files\\ffmpeg\\bin\\ffmpeg.exe',@@ -8118,7 +8272,7 @@ function detectFFmpeg() { } detectFFmpeg(); if (FFMPEG_PATH) console.log(`[recorder] ffmpeg available at: ${FFMPEG_PATH}`);-else console.warn(`[recorder] ffmpeg NOT FOUND — desktop WebMs will play fine but ffprobe format=duration will be N/A. Install via 'winget install Gyan.FFmpeg' to enable Duration metadata.`);+else console.warn(`[recorder] ffmpeg NOT FOUND — desktop WebMs will play fine but ffprobe format=duration will be N/A. Install via '${process.platform === 'darwin' ? 'brew install ffmpeg' : 'winget install Gyan.FFmpeg'}' to enable Duration metadata.`);  /**  * Remux a WebM file in place to add a SegmentDuration tag.@@ -8583,7 +8737,7 @@ async function tabRecordStartImpl({ }) {   if (!FFMPEG_PATH) detectFFmpeg();   // lazy re-scan: ffmpeg may have been installed since the bridge started   if (!FFMPEG_PATH) {-    throw new Error('ffmpeg not found on Windows. Install it (winget install Gyan.FFmpeg), then just retry — the bridge re-detects it, no restart needed.');+    throw new Error(`ffmpeg not found. Install it (${process.platform === 'darwin' ? 'brew install ffmpeg' : 'winget install Gyan.FFmpeg'}), then just retry — the bridge re-detects it, no restart needed.`);   }    const session = sessions.get(sessionId);