← Commit history
CHANGELOG.md+11−1
@@ -1,6 +1,16 @@ # Changelog — Adom Desktop Puppeteer Bridge -## 1.9.75 — 2026-07-24 — Verified-AD-base resolver (HD-hosting fix) + seed-complete zip+## 1.9.85 — 2026-07-24 — MERGE: mac-parity line + John's Windows 1.9.76–1.9.84 line++The two release lines had silently diverged (mac 1.9.74–75 vs Windows 1.9.76–84, neither+containing the other; caught when a 1.9.75 publish rolled the fleet manifest back over+1.9.84). 1.9.85 is the three-way merge (base 1.9.73): all of John's 1.9.76–84 server/chrome+changes + full mac parity + the items below. Also restores the pup-cat-*9.ico category icons+to BOTH the git repo and the zip (they previously lived only in release zips, so mac-line+zips built from the repo silently dropped them).++### carried from the unreleased 1.9.75+  1. VERIFIED AD BASE. KICAD's env-injection finding (confirmed from hd-control source):    an HD-spawned bridge gets ADOM_DIRECT_API_URL pointed at HD's control API (:47084),
adom-bridge-puppeteer-manifest.json+5−5
@@ -1,10 +1,10 @@ {   "manifest_version": 1,   "name": "puppeteer",-  "version": "1.9.84",-  "url": "https://wiki.adom.inc/download/adom/adom-desktop-puppeteer-bridge/1.9.84/adom-bridge-puppeteer-v1.9.84.zip",-  "sha256": "249db0f09fef503fddc550f7829d8e9850a4ed06c223911e847bd656ab041b68",-  "size": 317501,+  "version": "1.9.85",+  "url": "https://wiki.adom.inc/download/adom/adom-desktop-puppeteer-bridge/1.9.85/adom-bridge-puppeteer-v1.9.85.zip",+  "sha256": "1578c060e7c638662caa9f330ac23d5aee34ecccf766da56d915b63793533d4f",+  "size": 358990,   "verbPrefixes": [     "browser_",     "desktop_recorder_",@@ -12,5 +12,5 @@   ],   "healthEndpoint": "/status",   "statusVerb": "browser_readiness",-  "released_at": "2026-07-24T11:54:27Z"+  "released_at": "2026-07-24T11:59:05Z" }\ No newline at end of file
src/BRIDGE_VERSION+1−1
@@ -1 +1 @@-1.9.75\ No newline at end of file+1.9.85\ No newline at end of file
src/bridge.json+1−1
@@ -2,7 +2,7 @@   "manifest_version": 1,   "name": "puppeteer",   "displayName": "Puppeteer (Chrome for Testing + Native)",-  "version": "1.9.75",+  "version": "1.9.85",   "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.",
src/chrome.js+23
@@ -335,6 +335,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({
src/icons/pup-cat-app9.icoadded
⋯ 1 unchanged line ⋯
src/icons/pup-cat-default9.icoadded
⋯ 1 unchanged line ⋯
src/icons/pup-cat-mixed9.icoadded
⋯ 1 unchanged line ⋯
src/icons/pup-cat-web9.icoadded
⋯ 1 unchanged line ⋯
src/icons/pup-cat-wiki-in9.icoadded
⋯ 1 unchanged line ⋯
src/icons/pup-cat-wiki9.icoadded
⋯ 1 unchanged line ⋯
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)@@ -874,7 +918,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;@@ -1158,7 +1203,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;@@ -2807,6 +2853,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@@ -3775,6 +3828,43 @@ setInterval(() => { }, 30000);  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 {@@ -4507,6 +4597,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;     }; @@ -4514,7 +4614,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;@@ -4529,7 +4631,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);@@ -4563,7 +4665,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`);@@ -4801,7 +4903,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) {@@ -6523,6 +6625,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;@@ -6566,7 +6694,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;@@ -6635,6 +6770,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 }));@@ -8361,7 +8503,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',@@ -8407,7 +8561,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.@@ -8872,7 +9026,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);