← Commit history
Update 5 file(s)
5 files changed
+142−36
bridge/BRIDGE_VERSION+1−1@@ -1 +1 @@-0.1.11+0.1.12
bridge/bridge.json+1−1@@ -2,7 +2,7 @@ "manifest_version": 1, "name": "native-browser", "displayName": "Adom Native Browser (Chrome + Edge)",- "version": "0.1.11",+ "version": "0.1.12", "expectedExtensionVersion": "0.1.2", "description": "Drives the user's REAL signed-in Chrome/Edge via an installed extension (native-messaging transport). Mirrors the puppeteer bridge's verb surface as nbrowser_* but runs inside the actual logged-in profile, so cookies/SSO/saved-logins/captcha human-trust all work. The bridge binds an ephemeral loopback port (PORTS.md compliant) and writes a discovery file; the native-messaging host pumps stdio <-> this bridge; the extension never touches a TCP port.", "homepage": "https://wiki.adom.inc/adom/adom-browser-extension",
bridge/package.json+1−1@@ -1,6 +1,6 @@ { "name": "adom-native-browser-bridge",- "version": "0.1.11",+ "version": "0.1.12", "private": true, "description": "adom-desktop bridge for the Adom native-browser extension (Chrome + Edge)", "main": "server.js",
bridge/server.js+133−27@@ -26,6 +26,7 @@ const net = require('net'); const fs = require('fs'); const os = require('os'); const path = require('path');+const { execFile } = require('child_process'); const BRIDGE = 'native-browser'; let VERSION = '0.0.1';@@ -448,7 +449,7 @@ const badgeIdle = new Map(); // sessionId -> clear timer const badgeOn = new Set(); // sessionIds currently badged function armBadgeIdle(sid) { const prev = badgeIdle.get(sid); if (prev) clearTimeout(prev);- badgeIdle.set(sid, setTimeout(() => { const h = sessionHwnd.get(sid); if (h) taskbar(h, { overlay: { badge: 'none' } }).catch(() => {}); badgeOn.delete(sid); badgeIdle.delete(sid); }, BADGE_IDLE_MS));+ badgeIdle.set(sid, setTimeout(() => { const h = sessionHwnd.get(sid); if (h) nativeOverlay(h, sessionProfile.get(sid)); badgeOn.delete(sid); badgeIdle.delete(sid); }, BADGE_IDLE_MS)); } // Map a driven profile → its Chrome/Edge profile DIRECTORY, so AD (>=1.9.110) can composite the Adom mark // onto that profile's AVATAR instead of evicting it (the taskbar overlay has one slot = the avatar's slot).@@ -480,21 +481,101 @@ function avatarForProfile(profileKey) { _avatarCache.set(profileKey, result); return result; }-// TEMPORARILY OFF (John, 2026-07-15): every current overlay variant looks wrong — the plain badge covers-// the profile avatar, and AD's composite uses the RAW (square) profile PNG + a too-small mark, so it-// replaces Chrome's circular avatar with a square one. Keeping the badge off (icons stay clean/native)-// until the composite looks right: a CIRCLE-cropped avatar + a clearly-visible Adom mark. The startup-// sweep clears any existing overlays, so flipping this off leaves the taskbar clean.-const BADGE_ENABLED = false;+const BADGE_ENABLED = true;+// The BRIDGE is the browser expert, so WE build the FINAL taskbar overlay icon ourselves and hand AD a+// ready PNG via overlay.icon (AD stays generic - no browser/avatar/folder knowledge). The icon is the+// profile's own avatar CIRCLE-cropped (the way Chrome/Edge draw it) with the Adom favicon sitting flush+// in the lower-right corner. Compositing runs natively on Windows via PowerShell + System.Drawing (GDI+):+// zero npm deps (the bridge has none), antialiased circle-crop + HighQualityBicubic resize, reads any PNG.+// Two variants per profile: WITH the favicon (driving) and WITHOUT it (the plain avatar = the native look,+// painted back when driving stops so the taskbar returns to exactly how Chrome had it - no blanked slot).+const FAVICON_PNG = path.join(__dirname, 'adom-favicon.png');+const ICON_DIR = path.join(os.tmpdir(), 'adom-nbrowser-icons');+const PS1_PATH = path.join(ICON_DIR, 'compose-overlay.ps1');+const PS1_BODY = [+ 'param([string]$Avatar,[string]$Favicon,[string]$Out,[int]$Size,[int]$Badge)',+ '$ErrorActionPreference="Stop"',+ 'Add-Type -AssemblyName System.Drawing',+ '$S=$Size; $W=$S*4', // supersample 4x then downscale = clean edges+ '$av=[System.Drawing.Image]::FromFile($Avatar)',+ '$avSq=New-Object System.Drawing.Bitmap($W,$W,[System.Drawing.Imaging.PixelFormat]::Format32bppArgb)',+ '$g=[System.Drawing.Graphics]::FromImage($avSq)',+ '$g.InterpolationMode=[System.Drawing.Drawing2D.InterpolationMode]::HighQualityBicubic',+ '$g.PixelOffsetMode=[System.Drawing.Drawing2D.PixelOffsetMode]::HighQuality',+ '$aw=$av.Width;$ah=$av.Height;$m=[Math]::Min($aw,$ah);$sx=($aw-$m)/2;$sy=($ah-$m)/2', // center-crop to square+ '$g.DrawImage($av,(New-Object System.Drawing.Rectangle(0,0,$W,$W)),$sx,$sy,$m,$m,[System.Drawing.GraphicsUnit]::Pixel)',+ '$g.Dispose()',+ '$big=New-Object System.Drawing.Bitmap($W,$W,[System.Drawing.Imaging.PixelFormat]::Format32bppArgb)',+ '$g2=[System.Drawing.Graphics]::FromImage($big)',+ '$g2.SmoothingMode=[System.Drawing.Drawing2D.SmoothingMode]::AntiAlias',+ '$g2.InterpolationMode=[System.Drawing.Drawing2D.InterpolationMode]::HighQualityBicubic',+ '$brush=New-Object System.Drawing.TextureBrush($avSq)', // circle-crop = antialiased FillEllipse+ '$g2.FillEllipse($brush,0,0,($W-1),($W-1))',+ '$brush.Dispose()',+ 'if($Badge -ne 0){',+ ' $fav=[System.Drawing.Image]::FromFile($Favicon)',+ ' $fs=[int][Math]::Round($W*0.46)', // favicon ~46% of the icon+ ' $g2.DrawImage($fav,($W-$fs),($W-$fs),$fs,$fs)', // flush lower-right corner (no inset)+ ' $fav.Dispose()',+ '}',+ '$g2.Dispose()',+ '$fin=New-Object System.Drawing.Bitmap($S,$S,[System.Drawing.Imaging.PixelFormat]::Format32bppArgb)',+ '$g3=[System.Drawing.Graphics]::FromImage($fin)',+ '$g3.InterpolationMode=[System.Drawing.Drawing2D.InterpolationMode]::HighQualityBicubic',+ '$g3.PixelOffsetMode=[System.Drawing.Drawing2D.PixelOffsetMode]::HighQuality',+ '$g3.SmoothingMode=[System.Drawing.Drawing2D.SmoothingMode]::HighQuality',+ '$g3.DrawImage($big,(New-Object System.Drawing.Rectangle(0,0,$S,$S)),0,0,$W,$W,[System.Drawing.GraphicsUnit]::Pixel)',+ '$g3.Dispose()',+ '$fin.Save($Out,[System.Drawing.Imaging.ImageFormat]::Png)',+ '$fin.Dispose();$big.Dispose();$avSq.Dispose();$av.Dispose()'+].join('\r\n');+try { fs.mkdirSync(ICON_DIR, { recursive: true }); fs.writeFileSync(PS1_PATH, PS1_BODY); } catch {}+const _iconCache = new Map(); // profileKey -> { mtime, badge: path|null, plain: path|null }+const _sanitizeKey = (k) => String(k).replace(/[^a-z0-9]+/gi, '_').slice(0, 60);+// Build (or return cached) the overlay PNG for a profile. withBadge=true → avatar + Adom favicon; false →+// plain circle avatar (the native look). Cache keyed on the avatar file's mtime, so a changed profile pic+// regenerates. Resolves to a file PATH (AD reads it - same machine) or null (no avatar photo for this+// profile → caller falls back to AD's built-in plain badge / clears).+function buildOverlayIcon(profileKey, withBadge) {+ return new Promise((resolve) => {+ try {+ const av = avatarForProfile(profileKey);+ if (!av || !av.avatarPath) return resolve(null);+ let mtime = 0; try { mtime = Math.round(fs.statSync(av.avatarPath).mtimeMs); } catch {}+ const slot = withBadge ? 'badge' : 'plain';+ const cached = _iconCache.get(profileKey);+ if (cached && cached.mtime === mtime && cached[slot] && fs.existsSync(cached[slot])) return resolve(cached[slot]);+ const out = path.join(ICON_DIR, `ov_${_sanitizeKey(profileKey)}_${slot}_${mtime}.png`);+ const record = (p) => { const c = (cached && cached.mtime === mtime) ? cached : { mtime }; c.mtime = mtime; c[slot] = p; _iconCache.set(profileKey, c); };+ if (fs.existsSync(out)) { record(out); return resolve(out); }+ execFile('powershell.exe', ['-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-File', PS1_PATH,+ '-Avatar', av.avatarPath, '-Favicon', FAVICON_PNG, '-Out', out, '-Size', '48', '-Badge', withBadge ? '1' : '0'],+ { timeout: 15000, windowsHide: true }, (err) => {+ if (err || !fs.existsSync(out)) return resolve(null);+ record(out); resolve(out);+ });+ } catch { resolve(null); }+ });+}+// Paint the "Adom is driving" badge on a window: our composite icon if we can build it, else AD's plain badge.+function paintBadge(hwnd, profileKey) {+ if (!hwnd) return;+ buildOverlayIcon(profileKey, true).then((icon) => {+ taskbar(hwnd, icon ? { overlay: { icon, tooltip: 'Adom is driving this browser' } } : { overlay: { badge: 'adom', tooltip: 'Adom is driving this browser' } });+ });+}+// Return a window to its NATIVE look: the plain circle avatar (same image Chrome shows), NOT a blanked+// overlay slot. If we have no avatar photo, clear to none (best we can do).+function nativeOverlay(hwnd, profileKey) {+ if (!hwnd) return;+ buildOverlayIcon(profileKey, false).then((icon) => {+ taskbar(hwnd, icon ? { overlay: { icon } } : { overlay: { badge: 'none' } });+ });+} function applyBadge(sid, hwnd, profileKey) { if (!BADGE_ENABLED || !sid || !hwnd) return;- const overlay = { badge: 'adom', tooltip: 'Adom is driving this browser' };- // The BRIDGE is the browser expert: WE find the profile's avatar file and hand AD the path; AD just- // composites the Adom mark onto it (AD stays generic - no browser/folder knowledge). If we can't find- // an avatar, AD falls back to the plain badge.- const av = avatarForProfile(profileKey || sessionProfile.get(sid));- if (av && av.avatarPath) overlay.avatar = av.avatarPath;- taskbar(hwnd, { overlay }); badgeOn.add(sid); armBadgeIdle(sid);+ badgeOn.add(sid); armBadgeIdle(sid);+ paintBadge(hwnd, profileKey || sessionProfile.get(sid)); } // Called on every DRIVE verb: the badge tracks ACTIVE driving, not mere ownership. On the first activity // (or after a bridge restart) it lazily correlates the driven profile's taskbar window; then it just@@ -557,7 +638,7 @@ async function badgeSpawnsIn(payload, profileKey) { const fresh = spawns.filter((s) => !spawnHwnd.has(s.windowId)); if (!fresh.length && !owned.length) return; let list; try { list = await chromeWindowsDetailed(); } catch { return; }- for (const s of fresh) { const h = matchHwnd(list, s.bounds, s.title); if (h) { spawnHwnd.set(s.windowId, h); taskbar(h, { overlay: { badge: 'adom', tooltip: 'Adom is driving this browser' } }); } }+ for (const s of fresh) { const h = matchHwnd(list, s.bounds, s.title); if (h) { spawnHwnd.set(s.windowId, h); paintBadge(h, profileKey); } } // PROFILE-LEVEL badge: the OS window title for Google pages contains the profile email, and Windows // taskbar buttons group by profile — so badging ANY window of the driven profile lights the right // button. This is STABLE while pages navigate (unlike a per-window title), which is why per-window@@ -1588,16 +1669,41 @@ function startHttp(p) { } startHttp(argPort()); -// On startup, clear any ORPHANED "Adom is driving" overlays a PREVIOUS bridge instance left on Chrome-// windows. The badge idle-expiry timers are in-memory, so a bridge restart (redeploy/crash) LOSES them-// while the OS overlay persists — so without this sweep, a badge applied before a restart would linger-// forever (exactly the "personal profile still badged, nothing driving it" bug). Active driving re-applies-// the badge on its next verb (touchBadge), so the only cost is a brief gap on a currently-driven window.-async function sweepBadgesOnStartup(attempt = 0) {- try {- const list = await chromeWindowsDetailed();- if (list && list.length) { for (const w of list) taskbar(w.hwnd, { overlay: { badge: 'none' } }).catch(() => {}); return; }- } catch {}- if (attempt < 4) setTimeout(() => sweepBadgesOnStartup(attempt + 1), 3000); // AD connection may not be ready at boot+// Enumerate all Chrome + Edge profiles from each browser's Local State (info_cache: dir -> {user_name}),+// so we can map a live window (its OS title carries the signed-in email on Google pages) back to a KEY.+function knownProfiles() {+ const out = [];+ const local = process.env.LOCALAPPDATA;+ if (!local) return out;+ const roots = [['chrome', path.join(local, 'Google', 'Chrome', 'User Data')], ['edge', path.join(local, 'Microsoft', 'Edge', 'User Data')]];+ for (const [browser, root] of roots) {+ try {+ const j = JSON.parse(fs.readFileSync(path.join(root, 'Local State'), 'utf8'));+ const cache = (j.profile && j.profile.info_cache) || {};+ for (const info of Object.values(cache)) {+ const email = String((info && info.user_name) || '');+ if (email.includes('@')) out.push({ email, key: `${browser}:${email}` });+ }+ } catch {}+ }+ return out;+}+// On startup, undo any STALE "Adom is driving" badge a PRIOR bridge left behind (bridges are KILLED, not+// gracefully stopped, so in-memory idle timers can't clear badges across a redeploy). We must NOT blank the+// overlay slot — that evicts Chrome's OWN profile avatar (the destructive regression we had). Instead, for+// each live browser window we can map to a known profile (title carries the signed-in email), we repaint the+// NATIVE look: the plain circle avatar (same image Chrome uses, minus the Adom mark). Undriven profiles look+// unchanged; any stale badge is wiped. Unmappable windows are left untouched; a currently-driven window gets+// its badge back on the thread's next verb.+async function resetStaleBadgesOnStartup(attempt = 0) {+ let list; try { list = await chromeWindowsDetailed(); } catch { list = null; }+ if (!list || !list.length) { if (attempt < 4) setTimeout(() => resetStaleBadgesOnStartup(attempt + 1), 3000); return; }+ const known = knownProfiles();+ const seen = new Set();+ for (const w of list) {+ const t = String(w.title || '');+ const hit = known.find((p) => p.email && t.includes(p.email));+ if (hit && !seen.has(w.hwnd)) { seen.add(w.hwnd); nativeOverlay(w.hwnd, hit.key); }+ } }-setTimeout(() => sweepBadgesOnStartup(), 4000);+setTimeout(() => resetStaleBadgesOnStartup(), 4000);
native-browser-bridge-manifest.json+6−6@@ -1,8 +1,8 @@ { "manifest_version": 1,- "version": "0.1.11",- "url": "https://wiki.adom.inc/download/adom/adom-browser-extension/0.1.11/native-browser-bridge-v0.1.11.bin",- "sha256": "1aefff8bb9fcf61560f1ffa88a1a007078bf5f39747f8924bd5cf9ecebf7e6a1",- "size": 58063,- "released_at": "2026-07-15T00:00:00Z"-}+ "version": "0.1.12",+ "url": "https://wiki.adom.inc/download/adom/adom-browser-extension/0.1.12/native-browser-bridge-v0.1.12.bin",+ "sha256": "83a61cd98a57ac14e225960bed5e680697366d3f2bef5f26997e91614a1084d1",+ "size": 67439,+ "released_at": "2026-07-14T00:00:00Z"+}