Comparing master ← linux
Changes on linux that are not yet on master (three-dot, from the merge base).
18 files changed, 624 insertions(+), 59 deletions(-)
README.md+24@@ -174,8 +174,32 @@ point it at, via the debugger/scripting APIs - never by loading external scripts - **Idle by default** - it does nothing unless your Bridge and an Adom session are connected, and you can remove it at any time to end all access. +## Linux: nb on Chrome, Chromium and Edge++<table>+<tr>+<td width="104"><a href="linux/README.md"><img src="linux/linux-icon.png" width="112" alt="nb on Linux"></a><br><sub> Linux </sub></td>+<td>nb runs on Linux desktops too, on the Adom Bridge Linux app, driving Google Chrome, Chromium or Microsoft Edge (Firefox is not supported). <strong><a href="linux/README.md">The Linux guide</a></strong> lists the supported browsers, the one-line host install and the one-time Load-unpacked step, and what still differs from Windows. Read it before setting up nbe on a Linux machine, or when readiness on Linux says something you do not expect.<br><br><a href="linux/README.md">Set up nb on Linux</a></td>+</tr>+</table>+ ## What's new +**November 2026: Linux (experimental)**++- **nb runs on Linux.** The same bridge, host and extension now work on an x86_64 Linux desktop (X11 or+ Wayland) through the Adom Bridge Linux app, with Google Chrome, Chromium or Microsoft Edge. Firefox is not+ supported. See the [Linux guide](linux/README.md).+- **One-line host install.** `install/install-linux.sh` registers the native-messaging host for every+ supported browser it finds, including beta and dev channels and Snap or Flatpak builds, with no sudo;+ `--uninstall` removes it, and so does uninstalling the bridge.+- **Readiness tells the truth on Linux.** Installed browsers and profiles are found in their Linux folders, so+ readiness no longer reports a missing Chrome, and its hint gives the Linux setup steps instead of the Windows+ ones. Layer-1 opens and profile wakes start the browser directly.+- **Shared POSIX host launcher.** `host/native-host.sh` starts the host on the Node that Adom Bridge gave the+ bridge, on Linux and macOS; the host finds its parent browser without PowerShell.++ **September 2026 (bridge 0.16, extension 0.2.15)** - **Windows open where you would put them.** Every nb window lands on your primary monitor's work area
bridge/bridge.json+4−2@@ -139,8 +139,10 @@ "install": "install/install-mac.sh" }, "linux": {- "supported": false,- "reason": "Pending; bridge + host are OS-agnostic Node, only the install/registration layer is Windows-specific."+ "supported": true,+ "experimental": true,+ "reason": "Google Chrome, Chromium and Microsoft Edge on x86_64 Linux (X11 and Wayland). EXPERIMENTAL: the native-messaging host is registered by install/install-linux.sh and the extension is loaded once by the user (Load unpacked); windows open normally rather than minimized-and-flashing (that is Windows-only), and nbrowser_credentials / saved-login export refuse here. Firefox is not supported.",+ "install": "install/install-linux.sh" } }, "statusVerb": "nbrowser_status",
bridge/server.js+163−41@@ -272,9 +272,49 @@ const LOCALAPP = process.env.LOCALAPPDATA || path.join(os.homedir(), 'AppData', // Where each browser keeps its profiles. macOS puts them under Application Support with no "User Data" // level; the Local State / Preferences JSON inside is the same shape (Drew Owens, nb-bridge#51). const MAC_SUPPORT = path.join(os.homedir(), 'Library', 'Application Support');+// Linux keeps them under XDG config (~/.config/<browser>). The "chrome" key is Google Chrome when it is+// installed and Chromium otherwise (same extension, same profile JSON), so Windows and macOS keep exactly+// two keys. Snap and Flatpak builds keep their profiles in their own sandbox folders; the first folder that+// exists wins, in the order the browser binaries are preferred by linuxBrowserExe().+const XDG_CONFIG = process.env.XDG_CONFIG_HOME || path.join(os.homedir(), '.config');+const LINUX_UD_CANDIDATES = {+ chrome: [+ path.join(XDG_CONFIG, 'google-chrome'),+ path.join(os.homedir(), '.var', 'app', 'com.google.Chrome', 'config', 'google-chrome'),+ path.join(XDG_CONFIG, 'chromium'),+ path.join(os.homedir(), 'snap', 'chromium', 'common', 'chromium'),+ path.join(os.homedir(), '.var', 'app', 'org.chromium.Chromium', 'config', 'chromium'),+ ],+ edge: [+ path.join(XDG_CONFIG, 'microsoft-edge'),+ path.join(os.homedir(), '.var', 'app', 'com.microsoft.Edge', 'config', 'microsoft-edge'),+ ],+};+function linuxUserDataDir(browser) {+ const c = LINUX_UD_CANDIDATES[browser] || [];+ for (const p of c) { try { if (fs.existsSync(path.join(p, 'Local State'))) return p; } catch {} }+ return c[0];+}+// Every per-user NativeMessagingHosts folder install/install-linux.sh may write inc.adom.native_browser.json+// into (stable, beta and dev channels, Snap, Flatpak). The uninstall cascade and add_extension_origin read+// the same list, so the three never disagree.+const LINUX_NMH_DIRS = [+ ...['google-chrome', 'google-chrome-beta', 'google-chrome-unstable', 'chromium', 'microsoft-edge', 'microsoft-edge-beta', 'microsoft-edge-dev']+ .map((d) => path.join(XDG_CONFIG, d, 'NativeMessagingHosts')),+ path.join(os.homedir(), 'snap', 'chromium', 'common', 'chromium', 'NativeMessagingHosts'),+ path.join(os.homedir(), '.var', 'app', 'com.google.Chrome', 'config', 'google-chrome', 'NativeMessagingHosts'),+ path.join(os.homedir(), '.var', 'app', 'org.chromium.Chromium', 'config', 'chromium', 'NativeMessagingHosts'),+ path.join(os.homedir(), '.var', 'app', 'com.microsoft.Edge', 'config', 'microsoft-edge', 'NativeMessagingHosts'),+];+// Where install/install-linux.sh puts the host launcher + native-host.js (and the unpacked extension).+const POSIX_NB_HOME = path.join(os.homedir(), '.adom', 'nb-bridge'); const BROWSER_UD = process.platform === 'darwin' ? { chrome: path.join(MAC_SUPPORT, 'Google', 'Chrome'), edge: path.join(MAC_SUPPORT, 'Microsoft Edge'),+} : process.platform === 'linux' ? {+ // getters: a browser first launched after the bridge started still resolves to its real folder.+ get chrome() { return linuxUserDataDir('chrome'); },+ get edge() { return linuxUserDataDir('edge'); }, } : { chrome: path.join(LOCALAPP, 'Google', 'Chrome', 'User Data'), edge: path.join(LOCALAPP, 'Microsoft', 'Edge', 'User Data'),@@ -1363,7 +1403,53 @@ let _chromeInFlight = null; // nb drives WHATEVER native browser is on the box (John, adom/nb-bridge#46). Edge ships with Windows, so on // a fresh machine it is the browser; Chrome is optional and is only worth installing when the user asks for // Chrome by name or when NO supported browser exists at all.+// Linux: the launcher names each distro and packaging uses, looked up on PATH and in the standard bin+// folders (a systemd user service, which is how Adom Bridge runs on Linux, has a minimal PATH). Order is+// preference: native packages first, then Flatpak exports, and for the "chrome" key Google Chrome before+// Chromium (see LINUX_UD_CANDIDATES). Snap's /snap/bin is searched like any bin folder.+const LINUX_BROWSER_NAMES = {+ chrome: ['google-chrome-stable', 'google-chrome', 'com.google.Chrome', 'chromium', 'chromium-browser', 'org.chromium.Chromium'],+ edge: ['microsoft-edge-stable', 'microsoft-edge', 'com.microsoft.Edge'],+};+function linuxBrowserExe(browser) {+ const dirs = String(process.env.PATH || '').split(':').filter(Boolean);+ for (const d of ['/usr/bin', '/usr/local/bin', '/opt/google/chrome', '/opt/microsoft/msedge', '/snap/bin',+ '/var/lib/flatpak/exports/bin', path.join(os.homedir(), '.local', 'share', 'flatpak', 'exports', 'bin')]) if (!dirs.includes(d)) dirs.push(d);+ for (const name of LINUX_BROWSER_NAMES[browser] || []) {+ for (const d of dirs) { const p = path.join(d, name); try { fs.accessSync(p, fs.constants.X_OK); return p; } catch {} }+ }+ return null;+}+// Start a browser off Windows: detached, no stdio, so it outlives this bridge and never blocks it. The+// bridge runs inside the user's desktop session (Adom Bridge's systemd user unit), so the browser inherits+// WAYLAND_DISPLAY / DISPLAY from it; when the unit was started without them, borrow them from the systemd+// user environment that the desktop session exports.+function posixLaunchBrowser(exe, args) {+ try {+ const env = { ...process.env };+ if (process.platform === 'linux' && !env.WAYLAND_DISPLAY && !env.DISPLAY) {+ try {+ const out = require('child_process').execFileSync('systemctl', ['--user', 'show-environment'], { encoding: 'utf8', timeout: 3000 });+ for (const line of out.split('\n')) {+ const m = line.match(/^(WAYLAND_DISPLAY|DISPLAY|XAUTHORITY|XDG_RUNTIME_DIR|DBUS_SESSION_BUS_ADDRESS|XDG_SESSION_TYPE|XDG_CURRENT_DESKTOP)=(.*)$/);+ if (m && !env[m[1]]) env[m[1]] = m[2];+ }+ } catch {}+ }+ const child = require('child_process').spawn(exe, args, { detached: true, stdio: 'ignore', env });+ child.on('error', (e) => dlog(`posixLaunchBrowser: ${exe}: ${e.message}`));+ child.unref();+ return { ok: true, pid: child.pid };+ } catch (e) { return { ok: false, error: String((e && e.message) || e) }; }+}+// One launch call for every OS: ab's launch_process on Windows (where it was proven), a direct detached+// spawn elsewhere.+function launchBrowser(exe, args) {+ if (process.platform === 'win32') return abCommand('desktop', 'launch_process', { path: exe, args });+ return posixLaunchBrowser(exe, args);+} function detectEdge() {+ if (process.platform === 'linux') return linuxBrowserExe('edge'); if (process.platform === 'darwin') { for (const c of ['/Applications/Microsoft Edge.app', path.join(os.homedir(), 'Applications', 'Microsoft Edge.app')]) { try { if (fs.existsSync(c)) return path.join(c, 'Contents', 'MacOS', 'Microsoft Edge'); } catch {}@@ -1373,6 +1459,15 @@ function detectEdge() { for (const p of ['C:/Program Files (x86)/Microsoft/Edge/Application/msedge.exe', 'C:/Program Files/Microsoft/Edge/Application/msedge.exe']) { try { if (fs.existsSync(p)) return p; } catch {} } return null; }+// What an AI on a Linux desktop needs to hear when nbe is not connected. ab has no input verbs on Linux,+// so the Windows installing-the-extension flow (UI automation clicks, PowerShell, HKCU) does not apply.+const LINUX_INSTALL_URL = 'https://wiki.adom.inc/api/v1/pages/adom/nb-bridge/files/install/install-linux.sh';+const LINUX_SETUP_HINT = 'LINUX (EXPERIMENTAL): nb drives Google Chrome, Chromium and Microsoft Edge here; Firefox, the usual Linux default, is NOT supported. ' ++ 'Setup is two steps. (1) Register the native-messaging host for every Chromium browser on the machine: run `curl -fsSL ' + LINUX_INSTALL_URL + ' | bash` ' ++ 'on this machine (ask the user first; it writes only under ~/.adom/nb-bridge and the browsers\' NativeMessagingHosts folders, no sudo). ' ++ '(2) The user loads the extension once: chrome://extensions (edge://extensions) -> Developer mode on -> Load unpacked -> in the folder picker click Home, press Ctrl+L, type ~/.adom/nb-bridge/extension, click Select (the picker opens on Recent, where Select stays greyed out). ' ++ 'Tell them where it is: layer 1 opens only http(s) URLs, so they type chrome://extensions into the address bar themselves (the browser must have been started once so nb can see a profile). The Windows installing-the-extension flow (UI automation, PowerShell) does NOT apply on Linux. ' ++ 'Snap and Flatpak browsers are best-effort (their sandbox can block the host); the .deb/.rpm builds are the tested path.'; function nativeBrowserSummary() { const chrome = detectChrome(), edge = detectEdge(); const profiles = knownProfiles();@@ -1381,6 +1476,7 @@ function nativeBrowserSummary() { return { chromeInstalled: !!chrome, edgeInstalled: !!edge, nativeBrowserPresent: !!(chrome || edge), chromeNeeded: !(chrome || edge), edgeProfiles: edgeKeys, chromeProfiles: chromeKeys }; } function detectChrome() {+ if (process.platform === 'linux') return linuxBrowserExe('chrome'); if (process.platform === 'darwin') { for (const c of ['/Applications/Google Chrome.app', path.join(os.homedir(), 'Applications', 'Google Chrome.app')]) { try { if (fs.existsSync(c)) return path.join(c, 'Contents', 'MacOS', 'Google Chrome'); } catch {}@@ -1610,7 +1706,9 @@ function installNativeChrome(opts = {}) { if (pre) { chromeState.phase = 'ready'; chromeState.finishedAt = Date.now(); return { ok: true, executablePath: pre, alreadyInstalled: true }; } if (process.platform !== 'win32') { chromeState.phase = 'failed'; chromeState.errorCode = 'chrome_unsupported_os';- chromeState.error = 'Auto-installing Google Chrome is wired up for Windows only.';+ chromeState.error = process.platform === 'linux'+ ? 'nb does not install browsers on Linux (that needs sudo and the distro package manager). Google Chrome: download the .deb or .rpm from https://www.google.com/chrome/ and install it (Debian/Ubuntu: sudo apt install ./google-chrome-stable_current_amd64.deb; Fedora: sudo dnf install ./google-chrome-stable_current_x86_64.rpm). Chromium from the distro and Microsoft Edge (.deb/.rpm from microsoft.com/edge) work too. Firefox is not supported.'+ : 'Auto-installing Google Chrome is wired up for Windows only.'; return { ok: false, errorCode: chromeState.errorCode, error: chromeState.error }; } let found = null;@@ -1697,6 +1795,8 @@ function _hostManifestPaths() { path.join(home, '.adom', 'native-browser', 'host-manifest.json'), path.join(home, 'adom-native-browser', 'host', 'host-manifest.json'), path.join(home, 'adom-browser-extension', 'host', 'host-manifest.json'), // pre-rename clone dir+ // Linux: the registration IS the manifest, one per browser (install/install-linux.sh).+ ...(process.platform === 'linux' ? LINUX_NMH_DIRS.map((d) => path.join(d, 'inc.adom.native_browser.json')) : []), ]; } @@ -1979,7 +2079,7 @@ function dispatch(verb, args) { ? `STALE EXTENSION: ${staleDetail}; this bridge expects ${EXPECTED_EXT}. New verbs (wait_for/keepalive/download_wait/set_date/totp/robust-click) will return "verb not implemented yet" on the stale profile(s). SELF-HEAL: (1) refresh the unpacked extension dir to the current build (installing-the-extension -> Updating, or re-sync the extension/ folder), then (2) nbrowser_dev_reload {"profile":"<staleProfile>"} - it re-reads disk in 2 passes and reconnects in ~10s. If the on-disk build is ALREADY current, step (2) alone heals it.` + skewHint : ready ? `nbe (the Adom Native Browser Extension) is connected (${live.length} profile(s)) and current (v${EXPECTED_EXT || '?'}); drive it with nbrowser_open_window. Read-only probe - it never spawns work or installs.`- : `${process.platform === 'darwin' ? 'macOS (EXPERIMENTAL, nb-bridge#51): the bridge, host and extension run here, but the host registration is a MANUAL step - run install/install-mac.sh from this bridge\'s own folder (it registers the native-messaging host for Chrome and Edge and prints the Load-unpacked path). The Windows installing-the-extension flow (UIA clicks, PowerShell) does NOT apply. ' : ''}nbe (the Adom Native Browser Extension) is NOT connected. ${pend.length ? `(${pend.length} host socket(s) - ${pend.map(([k]) => k).join(', ')} - are open but FAILED a live ping probe: a native host is connect-looping with no working extension behind it. Do NOT wait on these; treat this box as needs-extension.) ` : ''}${NBE_PITCH}${chExe ? '' : 'This box has NO native Google Chrome (Edge may be present). If the user wants the AI to drive their REAL Chrome, offer nbrowser_install_chrome (installs Google Chrome, mostly no prompt). '}${claudeExt.installed ? `NOTE: the user HAS the Claude for Chrome extension (${claudeExt.profiles.join(', ')}) - you cannot drive it (closed control plane), but the honest interim is real: they can ask Claude in their browser to work a page interactively, while you offer nbe for full automation from any AI thread. ` : ''}It may be not-yet-installed (a Level-2 proactively-offered install: OFFER to install it, then run installing-the-extension - never make them do it) OR installed-but-asleep (the SW sleeps when a profile has no open window). Full onboarding: the setting-up-native-chrome skill. Read-only: never installs or spawns.` + skewHint + hostHint) + offerHint,+ : `${process.platform === 'linux' ? LINUX_SETUP_HINT + ' ' : ''}${process.platform === 'darwin' ? 'macOS (EXPERIMENTAL, nb-bridge#51): the bridge, host and extension run here, but the host registration is a MANUAL step - run install/install-mac.sh from this bridge\'s own folder (it registers the native-messaging host for Chrome and Edge and prints the Load-unpacked path). The Windows installing-the-extension flow (UIA clicks, PowerShell) does NOT apply. ' : ''}nbe (the Adom Native Browser Extension) is NOT connected. ${pend.length ? `(${pend.length} host socket(s) - ${pend.map(([k]) => k).join(', ')} - are open but FAILED a live ping probe: a native host is connect-looping with no working extension behind it. Do NOT wait on these; treat this box as needs-extension.) ` : ''}${NBE_PITCH}${chExe ? '' : 'This box has NO native Google Chrome (Edge may be present). If the user wants the AI to drive their REAL Chrome, offer nbrowser_install_chrome (installs Google Chrome, mostly no prompt). '}${claudeExt.installed ? `NOTE: the user HAS the Claude for Chrome extension (${claudeExt.profiles.join(', ')}) - you cannot drive it (closed control plane), but the honest interim is real: they can ask Claude in their browser to work a page interactively, while you offer nbe for full automation from any AI thread. ` : ''}It may be not-yet-installed (a Level-2 proactively-offered install: OFFER to install it, then run installing-the-extension - never make them do it) OR installed-but-asleep (the SW sleeps when a profile has no open window). Full onboarding: the setting-up-native-chrome skill. Read-only: never installs or spawns.` + skewHint + hostHint) + offerHint, }) }); }); // end probe.then }@@ -2442,41 +2542,50 @@ function dispatch(verb, args) { const wb = callerGaveBounds(a) ? null : await defaultWindowBounds(); if (wb) parts.push(`'--window-size=${wb.width},${wb.height}'`, `'--window-position=${wb.left},${wb.top}'`); parts.push("'--new-window'", `'${urlArg}'`);- const argArr = `@(${parts.join(',')})`;- const psBody = [- 'Add-Type @"',- 'using System;using System.Text;using System.Collections.Generic;using System.Runtime.InteropServices;',- 'public class WW2 {',- ' public delegate bool EnumProc(IntPtr h, IntPtr l);',- ' [DllImport("user32.dll")] static extern bool EnumWindows(EnumProc cb, IntPtr l);',- ' [DllImport("user32.dll")] static extern int GetClassName(IntPtr h, StringBuilder sb, int max);',- ' [DllImport("user32.dll")] public static extern bool ShowWindow(IntPtr h, int c);',- ' public static List<long> Browsers() { var o = new List<long>(); EnumProc cb = delegate(IntPtr h, IntPtr l) { var sb = new StringBuilder(64); GetClassName(h, sb, 64); if (sb.ToString().StartsWith("Chrome_WidgetWin")) o.Add((long)h); return true; }; EnumWindows(cb, IntPtr.Zero); return o; }',- '}',- '"@',- `$protected = @(${[...protectedHwnds].map((h) => `[long]${h}`).join(',')})`,- '$pre = [WW2]::Browsers()',- 'foreach ($p in $protected) { if ($pre -notcontains $p) { $pre += $p } }',- `Start-Process -FilePath "${exe}" -ArgumentList ${argArr}`,- // DETECT ONLY — no ShowWindow in this loop. Just find the new window's hwnd.- '$deadline = (Get-Date).AddSeconds(12)',- '$found = 0',- 'while ((Get-Date) -lt $deadline) {',- ' foreach ($h in [WW2]::Browsers()) { if (($pre -notcontains $h) -and ($protected -notcontains $h)) { $found = $h; break } }',- ' if ($found -ne 0) { break }',- ' Start-Sleep -Milliseconds 60',- '}',- // LET IT FULLY SETTLE, then ONE gentle minimize-without-activate. This is the crash-safe part.- 'if ($found -ne 0) { Start-Sleep -Milliseconds 700; [WW2]::ShowWindow([IntPtr]$found, 7) | Out-Null }',- 'Write-Output ("HWND=" + $found)',- ].join(String.fromCharCode(13, 10));- const rr = await abCommand(null, 'run_script', { interpreter: 'powershell', body: psBody, timeoutSeconds: 25 });- const m = String((rr && rr.body && rr.body.stdout) || '').match(/HWND=(\d+)/);- if (m && m[1] !== '0') { hwnd = Number(m[1]); minimized = true; }- if (hwnd) { try { await taskbar(hwnd, { flash: 'until_focused' }); flashed = true; } catch {} }+ if (process.platform !== 'win32') {+ // Linux/macOS: no Win32 window handles to minimize or flash, so launch the browser directly. The+ // window opens normally; the user's desktop decides focus (GNOME shows "ready" instead of raising).+ const posixArgs = parts.map((s) => s.slice(1, -1));+ const lr = posixLaunchBrowser(exe, posixArgs);+ if (!lr.ok) return resolve({ success: false, errorCode: 'browser_launch_failed', error: `could not start ${exe}: ${lr.error}` });+ } else { // Windows: launch + find the new window handle, then minimize and flash it+ const argArr = `@(${parts.join(',')})`;+ const psBody = [+ 'Add-Type @"',+ 'using System;using System.Text;using System.Collections.Generic;using System.Runtime.InteropServices;',+ 'public class WW2 {',+ ' public delegate bool EnumProc(IntPtr h, IntPtr l);',+ ' [DllImport("user32.dll")] static extern bool EnumWindows(EnumProc cb, IntPtr l);',+ ' [DllImport("user32.dll")] static extern int GetClassName(IntPtr h, StringBuilder sb, int max);',+ ' [DllImport("user32.dll")] public static extern bool ShowWindow(IntPtr h, int c);',+ ' public static List<long> Browsers() { var o = new List<long>(); EnumProc cb = delegate(IntPtr h, IntPtr l) { var sb = new StringBuilder(64); GetClassName(h, sb, 64); if (sb.ToString().StartsWith("Chrome_WidgetWin")) o.Add((long)h); return true; }; EnumWindows(cb, IntPtr.Zero); return o; }',+ '}',+ '"@',+ `$protected = @(${[...protectedHwnds].map((h) => `[long]${h}`).join(',')})`,+ '$pre = [WW2]::Browsers()',+ 'foreach ($p in $protected) { if ($pre -notcontains $p) { $pre += $p } }',+ `Start-Process -FilePath "${exe}" -ArgumentList ${argArr}`,+ // DETECT ONLY — no ShowWindow in this loop. Just find the new window's hwnd.+ '$deadline = (Get-Date).AddSeconds(12)',+ '$found = 0',+ 'while ((Get-Date) -lt $deadline) {',+ ' foreach ($h in [WW2]::Browsers()) { if (($pre -notcontains $h) -and ($protected -notcontains $h)) { $found = $h; break } }',+ ' if ($found -ne 0) { break }',+ ' Start-Sleep -Milliseconds 60',+ '}',+ // LET IT FULLY SETTLE, then ONE gentle minimize-without-activate. This is the crash-safe part.+ 'if ($found -ne 0) { Start-Sleep -Milliseconds 700; [WW2]::ShowWindow([IntPtr]$found, 7) | Out-Null }',+ 'Write-Output ("HWND=" + $found)',+ ].join(String.fromCharCode(13, 10));+ const rr = await abCommand(null, 'run_script', { interpreter: 'powershell', body: psBody, timeoutSeconds: 25 });+ const m = String((rr && rr.body && rr.body.stdout) || '').match(/HWND=(\d+)/);+ if (m && m[1] !== '0') { hwnd = Number(m[1]); minimized = true; }+ if (hwnd) { try { await taskbar(hwnd, { flash: 'until_focused' }); flashed = true; } catch {} }+ } } else { const pre = new Set([...curWins.map((w) => w.hwnd), ...protectedHwnds]);- abCommand('desktop', 'launch_process', { path: exe, args: [`--profile-directory=${kp.profileDir}`, '--new-window', url] });+ if (process.platform !== 'win32') posixLaunchBrowser(exe, [`--profile-directory=${kp.profileDir}`, '--new-window', url]);+ else abCommand('desktop', 'launch_process', { path: exe, args: [`--profile-directory=${kp.profileDir}`, '--new-window', url] }); const t0 = Date.now(); while (!hwnd && Date.now() - t0 < 8000) { await new Promise((r) => setTimeout(r, 500));@@ -2668,7 +2777,18 @@ function dispatch(verb, args) { const HOST = 'inc.adom.native_browser'; // ab's registry verbs take {hive, key} - NOT a single joined path (verified against `commands`, // 2026-08-14; a joined {path:...} is rejected bad_args and would have failed SILENTLY here).- for (const vendor of ['Google\\Chrome', 'Microsoft\\Edge']) {+ if (process.platform === 'linux') {+ // Linux: the registration is a JSON file per browser, and the host lives in ~/.adom/nb-bridge/host.+ // The unpacked extension folder beside it is left alone: it is loaded in the user's browser.+ for (const d of LINUX_NMH_DIRS) {+ const f = path.join(d, `${HOST}.json`);+ if (!fs.existsSync(f)) continue;+ try { fs.unlinkSync(f); removed.push(f); } catch { failed.push(f); }+ }+ const hd = path.join(POSIX_NB_HOME, 'host');+ if (fs.existsSync(hd)) { try { fs.rmSync(hd, { recursive: true, force: true }); removed.push(hd); } catch { failed.push(hd); } }+ }+ for (const vendor of (process.platform === 'win32' ? ['Google\\Chrome', 'Microsoft\\Edge'] : [])) { const key = `Software\\${vendor}\\NativeMessagingHosts\\${HOST}`; try { const r = await abCommand(null, 'registry_delete', { hive: 'HKCU', key, reason: 'nb bridge uninstall cascade: removing our own native-messaging host registration so the browser stops dialing a deleted binary' });@@ -2682,7 +2802,7 @@ function dispatch(verb, args) { resolve({ success: true, output: JSON.stringify({ ok: true, cascade: !!ua.cascade, removed, failed, extensionStillInstalled: stillInstalled,- _hint: `Cleaned nb's OWN artifacts: the HKCU native-messaging host registration (Chrome + Edge), ~/.adom/bridges/${BRIDGE}, the activity trail, and the icon cache. ab removes the bridge cache dir next.` ++ _hint: `Cleaned nb's OWN artifacts: the native-messaging host registration (${process.platform === 'win32' ? 'HKCU, Chrome + Edge' : 'NativeMessagingHosts JSON files + ~/.adom/nb-bridge/host'}), ~/.adom/bridges/${BRIDGE}, the activity trail, and the icon cache. ab removes the bridge cache dir next.` + (stillInstalled.length ? ` NOT REMOVED (deliberately): the nbe extension is still loaded in ${stillInstalled.join(', ')} - it lives in the USER'S browser profile and is their choice. It will simply stop working (its native host is gone). To remove it properly walk them through the uninstalling-the-extension skill; never rip an extension out from under a user.` : ` No profile currently has the nbe extension installed, so nothing is left in the user's browser.`) }) });@@ -2709,8 +2829,10 @@ function dispatch(verb, args) { if (!want) return resolve({ success: false, error: 'profile required', _hint: 'Pass profile:"chrome:[email protected]" (or just the email, optionally browser:"edge"). nbrowser_profiles lists asleep profiles.' }); const wantEmail = (want.includes(':') ? want.split(':').slice(1).join(':') : want).toLowerCase(); const wantBrowser = want.includes(':') ? want.split(':')[0] : ((args && args.browser) || null);- const findLive = () => { for (const [k, c] of conns) if (c.sock && !c.sock.destroyed && String(c.profile.email || '').toLowerCase() === wantEmail && (!wantBrowser || c.profile.browser === wantBrowser)) return k; return null; };- const already = findLive() || (() => { for (const [k, c] of conns) if (c.sock && !c.sock.destroyed && k.toLowerCase() === want.toLowerCase()) return k; return null; })();+ // Match by email, or by the exact connection key: a profile with no signed-in account announces as+ // chrome:p_xxxx (no email), so an email-only match never saw it come alive and wake timed out.+ const findLive = () => { for (const [k, c] of conns) if (c.sock && !c.sock.destroyed && ((String(c.profile.email || '').toLowerCase() === wantEmail && (!wantBrowser || c.profile.browser === wantBrowser)) || k.toLowerCase() === want.toLowerCase())) return k; return null; };+ const already = findLive(); if (already) return resolve({ success: true, output: JSON.stringify({ ok: true, alreadyLive: true, profile: already, _hint: 'Profile is already connected - drive it now (open_window still opens in the BACKGROUND by default).' }) }); const _rk2 = resolveKnownProfile(want, wantBrowser); const kp = _rk2.kp; if (!kp) return resolve({ success: false, errorCode: _rk2.ambiguous ? 'profile_ambiguous' : 'profile_not_found', candidates: _rk2.candidates, error: `no local browser profile matches '${want}'`, _hint: (_rk2.candidates && _rk2.candidates.length ? `Profiles on this machine: ${_rk2.candidates.join(', ')}. ` : '') + 'nbrowser_profiles lists every profile on this machine (live + asleep). Check the email/browser.' });@@ -2726,7 +2848,7 @@ function dispatch(verb, args) { // created, NOTHING foregrounds, no focus is stolen. The extension service worker starts, dials our // native host, and stays alive on the open port - the profile then announces and shows live. This // honors the background-by-default rule: waking a profile never needs a foreground window.- abCommand('desktop', 'launch_process', { path: exe, args: [`--profile-directory=${kp.profileDir}`, '--no-startup-window'] });+ launchBrowser(exe, [`--profile-directory=${kp.profileDir}`, '--no-startup-window']); const t0 = Date.now(); const poll = () => { const k = findLive();@@ -2743,7 +2865,7 @@ function dispatch(verb, args) { // open_os_window) and keep polling. The window must STAY (closing the last window exits the // browser and kills the SW again) - we say so and hand back the hwnd. if (st && st.installed && st.enabled !== false && !(args && args.noWindow)) {- abCommand('desktop', 'launch_process', { path: exe, args: [`--profile-directory=${kp.profileDir}`, '--window-position=-32000,-32000', '--start-minimized', 'https://wiki.adom.inc/adom/nb-bridge'] });+ launchBrowser(exe, [`--profile-directory=${kp.profileDir}`, '--window-position=-32000,-32000', '--start-minimized', 'https://wiki.adom.inc/adom/nb-bridge']); const t1 = Date.now(); const poll2 = () => { const k2 = findLive();
dev-skills/cross-platform-dev/SKILL.md+15−8@@ -1,6 +1,6 @@ --- name: cross-platform-dev-description: How adom-native-browser ships across Windows and macOS - Windows-first, Kyle ports to Mac. Read before adding platform-specific code.+description: How adom-native-browser ships across Windows, macOS and Linux - Windows-first, one codebase, per-OS install layer. Read before adding platform-specific code. --- # Cross-platform dev flow (Windows-first → Kyle ports to Mac)@@ -21,13 +21,20 @@ Keep the **bridge** (`bridge/server.js`), the **native host** (`host/native-host **extension** (`extension/`) **OS-agnostic** - plain Node + standard MV3 APIs. Isolate everything Windows-specific to the **install/registration layer** only: -| Concern | Windows (build first) | macOS (Kyle ports) |-|---|---|---|-| Native-messaging host manifest | `HKCU\Software\<vendor>\NativeMessagingHosts\inc.adom.native_browser` | `~/Library/Application Support/<Google/Chrome \| Microsoft Edge>/NativeMessagingHosts/inc.adom.native_browser.json` |-| Host launcher | `native-host.bat` → `node native-host.js` | `native-host.sh` → `node native-host.js` (chmod +x) |-| Bridge cache path | `%LOCALAPPDATA%\Adom Bridge\bridges-cache\native-browser\` | `~/Library/Application Support/Adom Bridge/bridges-cache/native-browser/` |-| Extension force-install | `HKLM\...\ExtensionInstallForcelist` | configuration profile / MDM `ExtensionInstallForcelist` |-| Discovery file | `os.homedir()/.adom/bridges/native-browser/host.json` | identical (uses `os.homedir()` - already cross-platform) |+| Concern | Windows (build first) | macOS (Kyle ports) | Linux |+|---|---|---|---|+| Native-messaging host manifest | `HKCU\Software\<vendor>\NativeMessagingHosts\inc.adom.native_browser` | `~/Library/Application Support/<Google/Chrome \| Microsoft Edge>/NativeMessagingHosts/inc.adom.native_browser.json` | `~/.config/<google-chrome \| chromium \| microsoft-edge>/NativeMessagingHosts/inc.adom.native_browser.json` (+ Snap/Flatpak folders, `LINUX_NMH_DIRS` in server.js) |+| Host launcher | `native-host.bat` → `node native-host.js` | `native-host.sh` → `node native-host.js` (chmod +x) | `native-host.sh` (shared with macOS) |+| Bridge cache path | `%LOCALAPPDATA%\Adom Bridge\bridges-cache\native-browser\` | `~/Library/Application Support/Adom Bridge/bridges-cache/native-browser/` | `~/.local/share/Adom Bridge/bridges-cache/native-browser/` |+| Extension force-install | `HKLM\...\ExtensionInstallForcelist` | configuration profile / MDM `ExtensionInstallForcelist` | `/etc/opt/chrome/policies/managed/*.json` (not used; Load unpacked) |+| Discovery file | `os.homedir()/.adom/bridges/native-browser/host.json` | identical (uses `os.homedir()` - already cross-platform) | identical |++Linux specifics (one codebase, all platform-guarded): browser profiles live in `~/.config/google-chrome`,+`~/.config/chromium` or `~/.config/microsoft-edge` (Snap/Flatpak folders as fallbacks); Chromium shares the+`chrome` key, Google Chrome first. Browsers are found on PATH plus the standard bin folders (Adom Bridge runs+as a systemd user service with a short PATH). Layer-1 launches spawn the browser directly+(`posixLaunchBrowser`), because the Win32 minimize-and-flash script cannot run there. The host walks+`/proc` for its parent browser. Registration is `install/install-linux.sh`. ## Implications
docs/INSTALL-FLOW.md+1−1@@ -257,7 +257,7 @@ the site, the selectors, the multi-step flow, and the gotchas - written so an AI skill install via the package. - **Pending:** Chrome Web Store / Edge Add-ons publication (required for silent install on normal, non-enterprise machines - until then production installs lean on load-unpacked or managed policy),- and the macOS host/policy port (Windows-first; the bridge and extension are already OS-agnostic).+ and a scripted Load unpacked on macOS and Linux (both register the host with an install script, `install/install-mac.sh` and `install/install-linux.sh`, and the user loads the extension once; see **installing-the-extension**). See also: **Architecture** (in the main README), the **Fork and develop** guide (`docs/DEVELOPING.md`), and the **native-browser-safety** skill for the rules every agent must follow
host/native-host.js+38−1@@ -115,7 +115,8 @@ function writeToExtension(obj) { // Which browser launched THIS host? The extension's hello carries `browser` on new builds — prefer it. // Otherwise (old extension mid-upgrade) detect it from our process ancestry: the host is-// node.exe <- (native-host.bat) cmd.exe <- chrome.exe | msedge.exe. Walk parents until we hit a browser.+// node.exe <- (native-host.bat) cmd.exe <- chrome.exe | msedge.exe on Windows, node <- sh (native-host.sh) <-+// chrome | msedge on Linux/macOS. Walk parents until we hit a browser. // Result is cached; only runs at all for an extension that didn't report its browser. let _hostBrowser = null; function hostBrowser(msg) {@@ -125,7 +126,40 @@ function hostBrowser(msg) { return _hostBrowser; } function detectBrowserFromTree() {+ // Name of a browser process -> nb's browser key. Chromium counts as chrome (one extension, one key).+ const classify = (name) => {+ const n = String(name || '').toLowerCase();+ if (n.includes('msedge') || n.includes('microsoft-edge') || n.includes('microsoft edge')) return 'edge';+ if (n.includes('chrome') || n.includes('chromium')) return 'chrome';+ return null;+ }; try {+ if (process.platform === 'linux') {+ // /proc gives the parent chain without spawning anything: node <- sh (native-host.sh) <- chrome|msedge.+ let pid = process.ppid, hops = 0;+ while (pid > 1 && hops++ < 8) {+ let exe = ''; try { exe = path.basename(fs.readlinkSync(`/proc/${pid}/exe`)); } catch {}+ let comm = ''; try { comm = fs.readFileSync(`/proc/${pid}/comm`, 'utf8').trim(); } catch {}+ const b = classify(exe) || classify(comm);+ if (b) return b;+ const stat = fs.readFileSync(`/proc/${pid}/stat`, 'utf8');+ pid = Number(stat.slice(stat.lastIndexOf(')') + 2).split(' ')[1]) || 0; // field 4 = ppid+ }+ return 'chrome';+ }+ if (process.platform !== 'win32') {+ // macOS and other Unixes: walk the parent chain with ps.+ let pid = process.ppid, hops = 0;+ while (pid > 1 && hops++ < 8) {+ const out = require('child_process').execFileSync('ps', ['-o', 'ppid=,comm=', '-p', String(pid)], { encoding: 'utf8', timeout: 3000 }).trim();+ const m = out.match(/^(\d+)\s+(.*)$/);+ if (!m) break;+ const b = classify(path.basename(m[2]));+ if (b) return b;+ pid = Number(m[1]);+ }+ return 'chrome';+ } const out = require('child_process').execSync( 'powershell -NoProfile -Command "Get-CimInstance Win32_Process | Select-Object ProcessId,ParentProcessId,Name | ConvertTo-Csv -NoTypeInformation"', { timeout: 5000, windowsHide: true }@@ -239,6 +273,9 @@ function loginRowsWithBlob(file) { return out; } function decryptSavedPassword({ browser, profileEmail, host, username }) {+ // The saved-password store is sealed with DPAPI on Windows; macOS (Keychain) and Linux (libsecret/kwallet)+ // seal it differently and nb does not read those. Say so instead of failing inside PowerShell.+ if (process.platform !== 'win32') return { error: `unsupported_platform: saved-password autologin reads the Windows store only (this host runs on ${process.platform}); use the browser's own autofill` }; browser = browser || 'chrome'; if (!BROWSER_UD[browser]) return { error: 'unknown browser' }; const profs = profilesOf(browser);
host/native-host.shadded+31@@ -0,0 +1,31 @@+#!/bin/sh+# Adom Native Browser: native-messaging host launcher for Linux and macOS (the POSIX twin of+# native-host.bat). Chrome, Chromium and Edge start this file as the host "path"; it runs native-host.js+# on a Node runtime.+#+# Node resolution order (the browser does NOT inherit Adom Bridge's environment, so `node` may not be on+# the PATH the browser was started with):+# 1. node-path.txt, written by the bridge on every start: the exact Node that Adom Bridge gave it.+# Self-healing: a Node upgrade in Adom Bridge rewrites it on the next bridge start.+# 2. Common install locations (distro packages, /usr/local, Homebrew, nvm, volta, fnm).+# 3. `node` on PATH.+DIR="$(cd "$(dirname "$0")" && pwd)"+NPFILE="$HOME/.adom/bridges/native-browser/node-path.txt"+NODE=""+if [ -f "$NPFILE" ]; then+ # Older bridges wrote Windows-style backslashes on every OS; normalise them.+ NODE="$(tr '\\' '/' < "$NPFILE" | tr -d '\r\n')"+fi+if [ -z "$NODE" ] || [ ! -x "$NODE" ]; then+ NODE=""+ for c in /usr/local/bin/node /usr/bin/node /usr/bin/nodejs /opt/homebrew/bin/node \+ "$HOME/.volta/bin/node" "$HOME/.local/share/fnm/aliases/default/bin/node" \+ "$HOME"/.adom/adom-runtimes/node-*/bin/node; do+ [ -x "$c" ] && { NODE="$c"; break; }+ done+fi+if [ -z "$NODE" ] && [ -d "$HOME/.nvm/versions/node" ]; then+ NODE="$(ls -d "$HOME"/.nvm/versions/node/v*/bin/node 2>/dev/null | sort -V | tail -1)"+fi+[ -n "$NODE" ] || NODE="node"+exec "$NODE" "$DIR/native-host.js" "$@"
install/install-linux.shadded+157@@ -0,0 +1,157 @@+#!/usr/bin/env bash+# Adom Native Browser: Linux setup for the browser extension (nbe) and its native-messaging host.+#+# What it does, all inside your home folder, no sudo:+# 1. Puts the host (native-host.sh + native-host.js) and the unpacked extension in ~/.adom/nb-bridge.+# 2. Registers the host "inc.adom.native_browser" with every Chromium-family browser it finds:+# Google Chrome (stable, beta, dev), Chromium, Microsoft Edge (stable, beta, dev), and their Snap+# and Flatpak builds. Registration is one JSON file per browser in its NativeMessagingHosts folder.+# 3. Prints the one step left for you: load the extension once with "Load unpacked".+#+# Firefox is not supported: nb's extension is a Chromium (Manifest V3) extension.+#+# Usage:+# curl -fsSL https://wiki.adom.inc/api/v1/pages/adom/nb-bridge/files/install/install-linux.sh | bash+# bash install-linux.sh [--from <nb-bridge checkout>] [--ref <branch>] [--uninstall] [--quiet]+#+# --from DIR copy host/ and extension/ from a local checkout instead of downloading them+# --ref NAME download from this branch of the page (default: the published files)+# --uninstall remove the registrations and ~/.adom/nb-bridge/host (the extension folder stays while+# a browser still has it loaded; remove it in chrome://extensions, then rm -r ~/.adom/nb-bridge)+set -euo pipefail++HOST_NAME="inc.adom.native_browser"+EXT_ID="nncjgceobmlenfliamnibncjojpedbcc" # pinned by the "key" in extension/manifest.json+API="https://wiki.adom.inc/api/v1/pages/adom/nb-bridge/files"+BASE="$HOME/.adom/nb-bridge"+CFG="${XDG_CONFIG_HOME:-$HOME/.config}"+FROM=""; REF=""; UNINSTALL=0; QUIET=0++while [ $# -gt 0 ]; do+ case "$1" in+ --from) FROM="$2"; shift 2 ;;+ --ref) REF="$2"; shift 2 ;;+ --uninstall) UNINSTALL=1; shift ;;+ --quiet) QUIET=1; shift ;;+ -h|--help) sed -n '2,25p' "$0"; exit 0 ;;+ *) echo "unknown option: $1" >&2; exit 2 ;;+ esac+done++say() { [ "$QUIET" = 1 ] || echo "$@"; }+[ "$(uname -s)" = "Linux" ] || { echo "This installer is for Linux. Windows and macOS have their own (see the nb-bridge page)." >&2; exit 1; }+if [ "$(id -u)" = 0 ] && [ -z "${NB_ALLOW_ROOT:-}" ]; then+ echo "Run this as your own user, not with sudo: it installs into your home folder and your browsers' per-user settings." >&2+ exit 1+fi+have() { command -v "$1" >/dev/null 2>&1 || [ -x "/usr/bin/$1" ] || [ -x "/snap/bin/$1" ]; }++# Browser targets: "label|NativeMessagingHosts folder|present?"+# A browser counts as present when its binary is installed or its profile folder already exists.+targets() {+ local fp_sys=/var/lib/flatpak/app fp_usr="$HOME/.local/share/flatpak/app"+ t() { echo "$1|$2|$3"; }+ t "Google Chrome" "$CFG/google-chrome/NativeMessagingHosts" "$( { have google-chrome-stable || have google-chrome || [ -d "$CFG/google-chrome" ]; } && echo 1 || echo 0)"+ t "Google Chrome Beta" "$CFG/google-chrome-beta/NativeMessagingHosts" "$( { have google-chrome-beta || [ -d "$CFG/google-chrome-beta" ]; } && echo 1 || echo 0)"+ t "Google Chrome Dev" "$CFG/google-chrome-unstable/NativeMessagingHosts" "$( { have google-chrome-unstable || [ -d "$CFG/google-chrome-unstable" ]; } && echo 1 || echo 0)"+ # Distro Chromium (not the Snap: Ubuntu's chromium-browser package is a wrapper around the Snap).+ t "Chromium" "$CFG/chromium/NativeMessagingHosts" "$( { [ -x /usr/bin/chromium ] || [ -d "$CFG/chromium" ] || { [ -x /usr/bin/chromium-browser ] && ! grep -qs snap /usr/bin/chromium-browser; }; } && echo 1 || echo 0)"+ t "Chromium (Snap)" "$HOME/snap/chromium/common/chromium/NativeMessagingHosts" "$( { [ -x /snap/bin/chromium ] || [ -d "$HOME/snap/chromium" ]; } && echo 1 || echo 0)"+ t "Microsoft Edge" "$CFG/microsoft-edge/NativeMessagingHosts" "$( { have microsoft-edge-stable || have microsoft-edge || [ -d "$CFG/microsoft-edge" ]; } && echo 1 || echo 0)"+ t "Microsoft Edge Beta" "$CFG/microsoft-edge-beta/NativeMessagingHosts" "$( { have microsoft-edge-beta || [ -d "$CFG/microsoft-edge-beta" ]; } && echo 1 || echo 0)"+ t "Microsoft Edge Dev" "$CFG/microsoft-edge-dev/NativeMessagingHosts" "$( { have microsoft-edge-dev || [ -d "$CFG/microsoft-edge-dev" ]; } && echo 1 || echo 0)"+ t "Google Chrome (Flatpak)" "$HOME/.var/app/com.google.Chrome/config/google-chrome/NativeMessagingHosts" "$( { [ -d "$fp_sys/com.google.Chrome" ] || [ -d "$fp_usr/com.google.Chrome" ]; } && echo 1 || echo 0)"+ t "Chromium (Flatpak)" "$HOME/.var/app/org.chromium.Chromium/config/chromium/NativeMessagingHosts" "$( { [ -d "$fp_sys/org.chromium.Chromium" ] || [ -d "$fp_usr/org.chromium.Chromium" ]; } && echo 1 || echo 0)"+ t "Microsoft Edge (Flatpak)" "$HOME/.var/app/com.microsoft.Edge/config/microsoft-edge/NativeMessagingHosts" "$( { [ -d "$fp_sys/com.microsoft.Edge" ] || [ -d "$fp_usr/com.microsoft.Edge" ]; } && echo 1 || echo 0)"+}++if [ "$UNINSTALL" = 1 ]; then+ while IFS='|' read -r label dir _; do+ if [ -f "$dir/$HOST_NAME.json" ]; then rm -f "$dir/$HOST_NAME.json"; say "unregistered: $label"; fi+ done < <(targets)+ rm -rf "$BASE/host"+ say "Removed the host from $BASE/host."+ say "The extension folder $BASE/extension is left in place: remove the extension in chrome://extensions (edge://extensions), then delete the folder with: rm -rf $BASE"+ exit 0+fi++# 1. host + extension files+mkdir -p "$BASE"+if [ -n "$FROM" ]; then+ [ -f "$FROM/host/native-host.js" ] && [ -f "$FROM/extension/manifest.json" ] || { echo "--from $FROM does not look like an nb-bridge checkout (needs host/ and extension/)" >&2; exit 1; }+ rm -rf "$BASE/host.new" "$BASE/extension.new"+ cp -r "$FROM/host" "$BASE/host.new"; cp -r "$FROM/extension" "$BASE/extension.new"+else+ command -v curl >/dev/null || { echo "curl is required (sudo apt install curl, or sudo dnf install curl)" >&2; exit 1; }+ Q=""; [ -n "$REF" ] && Q="ref=$REF"+ # Walk a folder of the page through the files API (plain sed, no jq or python needed).+ fetch_tree() {+ local dir="$1" dest="$2" listing p ty+ listing="$(curl -fsSL "$API?path=$dir${Q:+&$Q}")"+ mkdir -p "$dest"+ while IFS='|' read -r p ty; do+ [ -n "$p" ] || continue+ if [ "$ty" = tree ]; then fetch_tree "$p" "$dest/${p#"$dir"/}"+ else curl -fsSL "$API/$p${Q:+?$Q}" -o "$dest/${p#"$dir"/}"; fi+ done < <(printf '%s' "$listing" | grep -o '"path":"[^"]*","type":"[a-z]*"' | sed 's/"path":"\([^"]*\)","type":"\([a-z]*\)"/\1|\2/')+ }+ rm -rf "$BASE/host.new" "$BASE/extension.new"+ fetch_tree host "$BASE/host.new"+ fetch_tree extension "$BASE/extension.new"+ [ -s "$BASE/host.new/native-host.js" ] && [ -s "$BASE/host.new/native-host.sh" ] && [ -s "$BASE/extension.new/manifest.json" ] \+ || { echo "download incomplete (host/native-host.js, host/native-host.sh or extension/manifest.json missing)" >&2; exit 1; }+fi+# Swap in place: keep the extension folder's PATH stable, because that path is what the browser loaded.+rm -rf "$BASE/host"; mv "$BASE/host.new" "$BASE/host"+mkdir -p "$BASE/extension"; rm -rf "${BASE:?}/extension/"*; cp -r "$BASE/extension.new/." "$BASE/extension/"; rm -rf "$BASE/extension.new"+rm -f "$BASE/host/native-host.bat" "$BASE/host/host-manifest.template.json"+chmod 755 "$BASE/host" "$BASE/host/native-host.sh"+EXT_VER="$(grep -o '"version": *"[^"]*"' "$BASE/extension/manifest.json" | head -1 | sed 's/.*"\([^"]*\)"$/\1/')"+say "host -> $BASE/host"+say "extension -> $BASE/extension (v$EXT_VER)"++# 2. register the host with every browser found+MANIFEST="{+ \"name\": \"$HOST_NAME\",+ \"description\": \"Adom native-browser bridge host (stdio <-> Bridge)\",+ \"path\": \"$BASE/host/native-host.sh\",+ \"type\": \"stdio\",+ \"allowed_origins\": [\"chrome-extension://$EXT_ID/\"]+}"+registered=0; registered_labels=""+while IFS='|' read -r label dir present; do+ [ "$present" = 1 ] || continue+ mkdir -p "$dir"+ printf '%s\n' "$MANIFEST" > "$dir/$HOST_NAME.json"+ registered=$((registered + 1)); registered_labels="$registered_labels, $label"+ say "registered: $label ($dir/$HOST_NAME.json)"+ case "$label" in+ *Snap*|*Flatpak*) say " note: $label runs in a sandbox that can stop it from starting the host; the .deb/.rpm build is the tested path." ;;+ esac+done < <(targets)++if [ "$registered" = 0 ]; then+ echo "No supported browser found. Install Google Chrome (https://www.google.com/chrome/), Chromium or Microsoft Edge, then run this again." >&2+ echo "Firefox is not supported." >&2+ exit 1+fi++# Node for the host: the bridge publishes its own; warn when neither that nor a system Node exists yet.+NPFILE="$HOME/.adom/bridges/native-browser/node-path.txt"+if [ ! -s "$NPFILE" ] && ! command -v node >/dev/null 2>&1 && [ ! -x /usr/local/bin/node ] && [ ! -x /usr/bin/node ]; then+ say ""+ say "Note: no Node.js found yet. The host uses the Node that Adom Bridge runs the nb bridge with; it appears"+ say "after the bridge starts once (ask your AI for nbrowser_status). Or install Node 18+ from your distro."+fi++# 3. the one manual step+say ""+say "Done: registered for${registered_labels#,}."+say ""+say "Last step, once per browser: load the extension."+say " 1. Type chrome://extensions into the address bar (Edge: edge://extensions)."+say " 2. Turn on Developer mode (Edge: in the menu at the top left when the window is narrow)."+say " 3. Click Load unpacked (Edge: the first icon beside Developer options)."+say " 4. In the folder picker click Home on the left, press Ctrl+L, type $BASE/extension and click Select."+say " The Adom card appears and nb connects within a few seconds."+say "Updating later: run this script again, then press the reload arrow on the Adom card."
linux/README.mdadded+108@@ -0,0 +1,108 @@+# Adom Native Browser on Linux++nb drives your real, signed-in browser on a Linux desktop the same way it does on Windows: your AI opens+pages in your own profile (layer 1, **nb**), and once the extension is loaded it can read, click, type and+screenshot inside that profile (layer 2, **nbe**). It is the same bridge, host and extension as on Windows,+running on the Adom Bridge Linux app.++Linux support is **experimental**: setup has one manual step, and a few Windows behaviours are not+available yet (listed below).++## Supported browsers++| Browser | Status |+|---|---|+| **Google Chrome** (.deb / .rpm from google.com/chrome) | Supported, the tested path |+| **Chromium** (distro package) | Supported |+| **Microsoft Edge** (.deb / .rpm from microsoft.com/edge) | Supported |+| Chrome Beta / Dev, Edge Beta / Dev | Registered by the installer; should work like stable |+| Chromium **Snap** (Ubuntu's default `chromium`) | Best effort: the Snap sandbox can stop the browser from starting the host |+| Chrome, Chromium or Edge **Flatpak** | Best effort, same reason |+| **Firefox** | **Not supported.** nb's extension is a Chromium (Manifest V3) extension |++Firefox is the default browser on most Linux desktops, so on a fresh machine nb has nothing to drive until+you install one of the browsers above. nb never installs a browser on Linux for you; install Chrome from+<https://www.google.com/chrome/> (Debian/Ubuntu: `sudo apt install ./google-chrome-stable_current_amd64.deb`,+Fedora: `sudo dnf install ./google-chrome-stable_current_x86_64.rpm`).++When both Google Chrome and Chromium are installed, nb reads profiles from Google Chrome.++## Requirements++- An x86_64 Linux desktop (X11 or Wayland: GNOME, KDE, Cinnamon and others).+- The Adom Bridge Linux app, signed in. It installs and updates the nb bridge itself.+- Node.js 18 or newer for the bridge. Adom Bridge reuses a Node it finds on the machine (`/usr/bin`,+ `/usr/local/bin`, nvm, volta, fnm). If it reports `node runtime unavailable`, install Node from your distro+ or nodejs.org.++## Setup++**Layer 1 needs nothing.** Once you have opened your browser at least once (so it has a profile), your AI+can open a web page in it (`nbrowser_open_os_window`), list your profiles and wake one.++**Layer 2 is two steps, once per machine:**++1. **Register the native-messaging host.** Run this as yourself (no sudo):++ ```bash+ curl -fsSL https://wiki.adom.inc/api/v1/pages/adom/nb-bridge/files/install/install-linux.sh | bash+ ```++ It puts the host and the unpacked extension in `~/.adom/nb-bridge`, and writes one small JSON file into+ the `NativeMessagingHosts` folder of every supported browser it finds (for example+ `~/.config/google-chrome/NativeMessagingHosts/inc.adom.native_browser.json`). Your AI can run it for you+ through Adom Bridge once you approve.++2. **Load the extension, once per browser.**+ 1. Type `chrome://extensions` into the address bar (Edge: `edge://extensions`).+ 2. Turn on **Developer mode** (top right; in Edge with a narrow window, open the menu at the top left).+ 3. Click **Load unpacked** (in Edge, the first icon beside "Developer options").+ 4. In the folder picker, click **Home** on the left, press **Ctrl+L**, type `~/.adom/nb-bridge/extension`+ and click **Select**. (The picker opens on Recent, where Select stays greyed out.)++ The Adom card appears, and nb connects within a few seconds.++ ++Check it: ask your AI for `nbrowser_readiness`. `state: "ready"` means it can drive your browser.++++## What is different from Windows++| | Windows | Linux |+|---|---|---|+| New windows | open minimized, the taskbar button flashes until you look | open as a normal window; your desktop decides focus |+| Host registration | registry key, set up by the AI | a JSON file per browser, set up by `install-linux.sh` |+| Loading the extension | the AI clicks through Load unpacked for you | you click Load unpacked once (Adom Bridge has no mouse or keyboard control on Linux yet) |+| `nbrowser_credentials`, saved-login export, saved-password autologin | available | refuse with `unsupported_platform` (the browser's own autofill still works in layer 2) |+| Installing Chrome (`nbrowser_install_chrome`) | quiet install | explains how to install it with your package manager |++Everything else in layer 2 (open, navigate, read, click, type, wait, screenshot, record, downloads, tabs)+is the same code on both systems.++## Updating++Adom Bridge updates the bridge on its own. To update the host and extension, run the installer again, then+press the reload arrow on the Adom card in `chrome://extensions`. `nbrowser_readiness` reports+`extensionStale: true` when the loaded extension is older than the bridge expects.++## Uninstalling++```bash+curl -fsSL https://wiki.adom.inc/api/v1/pages/adom/nb-bridge/files/install/install-linux.sh | bash -s -- --uninstall+```++This removes every registration and `~/.adom/nb-bridge/host`. Then remove the Adom card in+`chrome://extensions` and delete the folder with `rm -rf ~/.adom/nb-bridge`. Uninstalling the bridge from+Adom Bridge removes the registrations as well.++## Troubleshooting++| What you see | What to do |+|---|---|+| `bridge_spawn_failed: node runtime unavailable` | Install Node.js 18+ (`sudo apt install nodejs npm`, `sudo dnf install nodejs`), then ask your AI to resume the bridge (`bridge_resume {"name":"native-browser"}`). |+| Readiness says `chromeNeeded: true` | No supported browser was found. Install Chrome, Chromium or Edge (not Firefox). |+| The Adom card shows "Native host has exited" | Run the installer again; check that `~/.adom/bridges/native-browser/node-path.txt` names a Node that exists. `nbrowser_readiness` shows the host's last exit reason in `hostExit`. |+| The card loaded but readiness stays `needs-extension` | The browser was started before the host was registered: close every window of that browser and open it again. |+| Snap or Flatpak browser never connects | Use the .deb/.rpm build of Chrome or Edge, or your distro's non-Snap Chromium. |
linux/RELEASE-NOTES.mdadded+33@@ -0,0 +1,33 @@+# Release notes: nb on Linux (experimental)++nb now drives your real browser on a Linux desktop: **Google Chrome, Chromium and Microsoft Edge**, on+x86_64 with X11 or Wayland, through the Adom Bridge Linux app. Firefox, the default browser on most+distros, is not supported, because nb's extension is a Chromium (Manifest V3) extension.++## New+- **Layer 1 on Linux.** `nbrowser_open_os_window` and `nbrowser_wake_profile` start your browser directly,+ in your own profile. `nbrowser_profiles` reads profiles from `~/.config/google-chrome`,+ `~/.config/chromium` and `~/.config/microsoft-edge` (Snap and Flatpak folders as a fallback).+- **One-line host setup.** `install/install-linux.sh` puts the host and the unpacked extension in+ `~/.adom/nb-bridge` and registers the native-messaging host with every Chromium-family browser it finds+ (Chrome stable/beta/dev, Chromium, Edge stable/beta/dev, and Snap/Flatpak builds). No sudo.+ `--uninstall` removes it again.+- **Readiness knows Linux.** `nbrowser_readiness` finds the installed browser, stops asking for Chrome when+ Chromium or Edge is there, and explains the two setup steps for Linux.+- **Shared POSIX host launcher.** `host/native-host.sh` runs the host on Linux and macOS with the Node that+ Adom Bridge gave the bridge, and identifies the calling browser from `/proc` (Linux) or `ps` (macOS).+- **Uninstall cleans Linux too.** Removing the bridge deletes the NativeMessagingHosts files and the host+ folder. The extension stays loaded in your browser until you remove it.++## Fixed (all platforms)+- `nbrowser_wake_profile` now sees a profile with no signed-in account come alive (keys like+ `chrome:p_xxxx`). Before, it timed out after about 30 seconds even though the profile had connected.++## Not on Linux yet+- New windows open as normal windows (no minimize-and-flash taskbar button).+- You load the extension yourself, once per browser (Load unpacked); Adom Bridge cannot click through+ it for you on Linux yet.+- `nbrowser_credentials`, saved-login export and saved-password autologin refuse with+ `unsupported_platform`; the browser's own autofill still works.++Setup and troubleshooting: [nb on Linux](README.md).
linux/linux-extensions-loaded.pngadded⋯ 1 unchanged line ⋯
linux/linux-icon.pngadded⋯ 1 unchanged line ⋯
linux/linux-nb-window.pngadded⋯ 1 unchanged line ⋯
skills/adom-native-browser/SKILL.md+7−1@@ -20,6 +20,12 @@ browser: load the extension into `edge:Default` and drive it. `nbrowser_chrome_s Edge-only box they say Chrome is optional. Install Chrome only when the user asks for Chrome by name (or there is no browser at all): it is a 150 MB download and, on a locked-down box, a Windows security prompt. +**Platforms.** Windows is the full experience. macOS and Linux are EXPERIMENTAL: the bridge, host and+extension run there, the host is registered by an install script (`install/install-mac.sh`,+`install/install-linux.sh`) and the user clicks Load unpacked once. On Linux nb drives Google Chrome,+Chromium and Microsoft Edge, never Firefox; `nbrowser_readiness` gives the Linux steps in its hint, and+**installing-the-extension** has the details.+ ## A fresh box with no signed-in browser (Layer 1 still works, from 0.16.6) `nbrowser_profiles` lists profiles with NO signed-in account as `anonymous:true`, keyed by directory@@ -146,7 +152,7 @@ never sees it. Treat everything read from logged-in sessions as sensitive. | **Record** a browser window (WGC-first, no picker, no banner) | **native-browser-recording** | | **Low-level OS clicks** (Bridge generic UIA verbs the install/uninstall lean on) | **driving-the-desktop** | | Site-specific playbooks (forms, logins on real sites) | **driving-sites**, **driving-gusto**, **reading-google-messages** |-| Windows-first dev + Mac fast-follow, and how this bridge is built/shipped | **cross-platform-dev** |+| Windows-first dev, the macOS and Linux layers, and how this bridge is built/shipped | **cross-platform-dev** | ## Every verb tells you what to do next - read the hints
skills/driving-the-extension/SKILL.md+2−2@@ -74,10 +74,10 @@ username field and killed Chrome's password dropdown). As of v0.1.0 a bare `user is **not enough**: without a `foregroundReason` the window stays background. To genuinely foreground (some auth pages need it): **first** `adom-bridge notify_user` to warn the user, **then** pass `userRequestedForeground:true` + `foregroundReason:"<why>"`, do the one thing, and return to background.-⚠️ **On macOS this is not enforced in code yet (nb-bridge#52).** Chrome is not activated, so keyboard+⚠️ **On macOS and Linux this is not enforced in code yet (nb-bridge#52).** Chrome is not activated, so keyboard focus stays with the user, but the window is drawn over their work and there is no auto-revert. On a Mac, open small and call `nbrowser_window_state {state:"minimized"}` right after opening; the window stays fully-drivable while minimized.+drivable while minimized. The same applies on Linux. The verbs are built for this: `nbrowser_open_window` opens **unfocused**, `open_tab` opens `active:false`, `switch_window` does **not** raise - leave those defaults alone. Capture without
skills/installing-the-extension/SKILL.md+37−1@@ -1,6 +1,6 @@ --- name: installing-the-extension-description: "How WE (this repo) install the Adom Native Browser Extension into the user's real Chrome/Edge in DEVELOPER MODE (\"Load unpacked\"), driving Bridge's GENERIC OS verbs. This is OUR orchestration, not a Bridge feature - ab only exposes primitives (desktop_navigate / desktop_find_control / desktop_ui_click / desktop_ui_set / desktop_screenshot); the recipe, hardening, and recovery live here. This is the DAY-ONE install path: Chrome Web Store / Edge Add-ons review is ~2 months out, so until then dev-mode load-unpacked is how a real user gets the extension. Use when the user says \"install the extension\", \"install it for me\", \"load the extension into my browser\", \"set up the browser extension\", \"the extension isn't loaded yet\". For the low-level click mechanics (popups, the PWA guard, uninstall/reload), see driving-the-desktop. Part of the adom-native-browser."+description: "How WE (this repo) install the Adom Native Browser Extension into the user's real Chrome/Edge in DEVELOPER MODE (\"Load unpacked\"), driving Bridge's GENERIC OS verbs. This is OUR orchestration, not a Bridge feature - ab only exposes primitives (desktop_navigate / desktop_find_control / desktop_ui_click / desktop_ui_set / desktop_screenshot); the recipe, hardening, and recovery live here. This is the DAY-ONE install path: Chrome Web Store / Edge Add-ons review is ~2 months out, so until then dev-mode load-unpacked is how a real user gets the extension. Covers Windows (scripted), macOS and Linux (install script plus one Load-unpacked click by the user). Use when the user says \"install the extension\", \"install it for me\", \"load the extension into my browser\", \"set up the browser extension\", \"the extension isn't loaded yet\". For the low-level click mechanics (popups, the PWA guard, uninstall/reload), see driving-the-desktop. Part of the adom-native-browser." --- Part of **adom-native-browser**. Low-level OS-driving mechanics: **driving-the-desktop**. Safety rules: **native-browser-safety**. @@ -278,6 +278,42 @@ twice, even with the user's consent (Drew Owens). Hand them the one command inst Still Windows-only on a Mac: background-window enforcement (#52 - open with `state:"minimized"`), and `nbrowser_credentials`, which refuses with `unsupported_platform` rather than pretending there are no logins. +## Linux (EXPERIMENTAL)++The Windows flow above (UI automation clicks, PowerShell, HKCU) does **not** apply on Linux: Adom Bridge has+no input or UI-automation verbs there. Registration is a file per browser and the install is a script.+Supported: Google Chrome, Chromium, Microsoft Edge (.deb/.rpm builds tested; Snap and Flatpak best effort).+**Firefox is not supported**, and it is the default browser on most distros, so check+`nbrowser_readiness.nativeBrowser` first: `chromeNeeded:true` means there is nothing to load the extension into.+nb does not install browsers on Linux; tell the user how (`nbrowser_install_chrome` answers with the+apt/dnf commands).++1. **Register the host** (ask the user, then run it on their machine through Adom Bridge, or hand them the line):+ `curl -fsSL https://wiki.adom.inc/api/v1/pages/adom/nb-bridge/files/install/install-linux.sh | bash`.+ It fetches `host/` and `extension/` into `~/.adom/nb-bridge`, and writes+ `inc.adom.native_browser.json` (path = `~/.adom/nb-bridge/host/native-host.sh`, allowed origin = the pinned+ ID) into the NativeMessagingHosts folder of every browser it finds: `~/.config/google-chrome`,+ `~/.config/chromium`, `~/.config/microsoft-edge` (and beta/dev), `~/snap/chromium/common/chromium`,+ `~/.var/app/<flatpak id>/config/...`. No sudo; refuses to run as root.+2. **The user loads the extension once per browser**: they type `chrome://extensions` (`edge://extensions`)+ into the address bar (layer 1 opens only http(s) URLs, so nb cannot open that page for them), turn on+ Developer mode, click Load unpacked, and in the GTK folder picker click **Home**, press **Ctrl+L**, type+ `~/.adom/nb-bridge/extension` and click **Select**. The picker opens on Recent, where Select stays greyed+ out, and `.adom` is hidden (Ctrl+H shows it). On Edge with a narrow window, Developer mode sits in the+ menu at the top left and Load unpacked is the first icon beside "Developer options".+ The browser must have been started once (nb needs its profile, and Chrome's first-run dialogs block a+ hidden start).+3. `nbrowser_readiness` should say `ready` within seconds. If the browser was already running before step 1,+ it must be closed and reopened to see the new host.++The host's Node comes from `~/.adom/bridges/native-browser/node-path.txt` (written by the bridge, a real+POSIX path), then `/usr/local/bin`, `/usr/bin`, nvm/volta/fnm, then PATH (`host/native-host.sh`).+Updating: run the script again, then `nbrowser_dev_reload {profile}` per profile. Uninstall:+`... install-linux.sh | bash -s -- --uninstall` (or ab's bridge uninstall cascade, which removes the same files).++On Linux a Layer-1 window (`nbrowser_open_os_window`) opens as a normal window (no minimize-and-flash), and+`nbrowser_credentials` / `nbrowser_export_saved_logins` refuse with `unsupported_platform`.+ ## Chrome AND Edge They are independent. Repeat the flow per browser (matching scheme: `chrome://` vs `edge://`; the toggle sits top-right on Chrome, bottom-left on Edge). The host is registered per browser, so each connects its
skills/native-browser-safety/SKILL.md+2−2@@ -113,12 +113,12 @@ window minimized if the user was in another app) and the response carries a `_wa `focusAutoReverted:true`. Each auto-correction is logged as a `focus_violation` event (`nbrowser_events`) so the user can see when a thread tried to steal focus. -⚠️ **macOS: the enforcement below does NOT run yet (nb-bridge#52, 2026-10-14).** The window is not+⚠️ **macOS and Linux: the enforcement below does NOT run yet (nb-bridge#52, 2026-10-14).** The window is not activated, so the user's app keeps keyboard focus, but the new window is still DRAWN OVER their work, and the z-order check and auto-revert are Windows-only. Until that ships, a careful caller on a Mac opens small and immediately calls `nbrowser_window_state {state:"minimized"}` - a minimized window stays fully drivable (eval, wait_for, navigate and screenshots all work) - and re-activates the user's previous app if-it changed. Do not tell a Mac user their windows open out of sight.+it changed. Do not tell a Mac or Linux user their windows open out of sight. **Foregrounding takes real work now (v0.1.0+), because it STEALS the user's keyboard focus.** Users automating the browser almost always have 5 other things in the foreground; a raised window captures
tools/ship.sh+2@@ -162,6 +162,8 @@ adom-wiki repo push "$PAGE" \ extension/src/native.js extension/src/cdp.js extension/src/sw.js extension/manifest.json \ bin/nb-dashboard package.json dockbar.json tools/ship.sh tools/promote.sh tools/verb-battery.mjs tools/verbs-policy.json tools/check-guard-coverage.mjs \ skills/*/SKILL.md README.md docs/*.md \+ host/native-host.js host/native-host.bat host/host-manifest.template.json \+ $(ls host/native-host.sh install/*.sh 2>/dev/null) linux/README.md linux/RELEASE-NOTES.md linux/*.png \ -m "v${VER} (insiders): ${LOG}" >/dev/null 2>&1 || { [ "${FORCE_PUSH:-0}" = "1" ] || { echo " PUSH FAILED (set FORCE_PUSH=1 to retry)"; exit 1; } }