diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index e3e5098..61a573a 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -36,7 +36,7 @@ body: attributes: label: raw version description: The `raw status` output or `package.json` version. - placeholder: "0.5.1" + placeholder: "0.7.0" validations: required: true - type: input diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 535a7f9..9efb9d8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,6 +1,6 @@ name: ci -on: [push, pull_request] +on: [push, pull_request, workflow_dispatch] permissions: contents: read @@ -70,6 +70,66 @@ jobs: - run: dotnet build edge-bridge/host/RawEdgeBridge.csproj -c Release --nologo - run: node scripts/smoke-bridge.mjs + bridge-host-posix: + timeout-minutes: 15 + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest] + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 24.x + - uses: actions/setup-dotnet@v4 + with: + dotnet-version: 9.0.x + - name: publish the single-file host + shell: bash + run: | + RID=$(node -p "process.platform === 'darwin' ? (process.arch === 'arm64' ? 'osx-arm64' : 'osx-x64') : 'linux-x64'") + dotnet publish edge-bridge/host/RawEdgeBridge.csproj -c Release -r "$RID" \ + --self-contained true -p:PublishSingleFile=true \ + -p:IncludeNativeLibrariesForSelfExtract=true \ + -p:EnableCompressionInSingleFile=true -o host-out --nologo + chmod +x host-out/raw-edge-bridge + test -x host-out/raw-edge-bridge + - name: smoke the native host + shell: bash + env: + RAW_BRIDGE_HOST: ${{ github.workspace }}/host-out/raw-edge-bridge + run: node scripts/smoke-bridge.mjs + - name: install into a disposable home + shell: bash + run: | + node scripts/install-edge-bridge.mjs --browser both \ + --host-path "$PWD/host-out/raw-edge-bridge" --home "$RUNNER_TEMP/raw-home" + node - <<'NODE' + const fs = require("node:fs"); + const path = require("node:path"); + const home = path.join(process.env.RUNNER_TEMP, "raw-home"); + const raw = path.join(home, ".raw"); + for (const file of ["edge-bridge.json", "chrome-bridge.json"]) { + if (!fs.existsSync(path.join(raw, file))) throw new Error(`missing ${file}`); + } + const manifestRoot = path.join(raw, "edge-bridge"); + for (const file of ["com.raw.edge_bridge.edge.json", "com.raw.edge_bridge.chrome.json"]) { + const manifest = JSON.parse(fs.readFileSync(path.join(manifestRoot, file), "utf8")); + if (manifest.type !== "stdio" || !manifest.allowed_origins[0].startsWith("chrome-extension://")) { + throw new Error(`bad manifest ${file}`); + } + } + const browserDir = process.platform === "darwin" + ? path.join(home, "Library", "Application Support", "Google", "Chrome", "NativeMessagingHosts") + : path.join(home, ".config", "google-chrome", "NativeMessagingHosts"); + if (!fs.existsSync(path.join(browserDir, "com.raw.edge_bridge.json"))) { + throw new Error("chrome native-messaging manifest was not registered"); + } + const host = path.join(raw, "edge-bridge", "current", "raw-edge-bridge"); + if (!(fs.statSync(host).mode & 0o111)) throw new Error("installed host is not executable"); + NODE + dependency-audit: timeout-minutes: 5 runs-on: ubuntu-latest diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 05e3e94..26d7cc8 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -12,8 +12,8 @@ concurrency: cancel-in-progress: false jobs: - release: - runs-on: windows-latest + verify: + runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 @@ -22,54 +22,133 @@ jobs: node-version: 24.x cache: npm - - uses: actions/setup-dotnet@v4 - with: - dotnet-version: 9.0.x - - run: npm ci - run: node scripts/check-versions.mjs --tag ${{ github.ref_name }} - run: npm run typecheck - run: npm test - run: npm run build + - run: node scripts/smoke-browser.mjs + + host-windows: + runs-on: windows-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-dotnet@v4 + with: + dotnet-version: 9.0.x - - name: publish the self-contained bridge host + - name: publish the self-contained Windows host shell: pwsh run: | dotnet publish edge-bridge/host/RawEdgeBridge.csproj -c Release -r win-x64 ` --self-contained true -p:PublishSingleFile=true ` -p:IncludeNativeLibrariesForSelfExtract=true ` -p:EnableCompressionInSingleFile=true -o host-out --nologo - - - name: pack the host and checksums - shell: pwsh - run: | New-Item -ItemType Directory -Path host-pack -Force | Out-Null Copy-Item host-out/raw-edge-bridge.exe host-pack/raw-edge-bridge.exe -Force Compress-Archive -Path host-pack/raw-edge-bridge.exe -DestinationPath raw-bridge-host-win-x64.zip -Force - $hash = (Get-FileHash raw-bridge-host-win-x64.zip -Algorithm SHA256).Hash.ToLowerInvariant() - "$hash raw-bridge-host-win-x64.zip" | Set-Content SHA256SUMS.txt -Encoding ascii - - name: pack the npm tarball - run: npm pack + - uses: actions/upload-artifact@v4 + with: + name: host-win-x64 + path: raw-bridge-host-win-x64.zip - - name: add a stable tarball name - shell: pwsh + host-linux: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-dotnet@v4 + with: + dotnet-version: 9.0.x + + - name: publish the self-contained Linux hosts + shell: bash + run: | + for rid in linux-x64 linux-arm64; do + dotnet publish edge-bridge/host/RawEdgeBridge.csproj -c Release -r "$rid" \ + --self-contained true -p:PublishSingleFile=true \ + -p:IncludeNativeLibrariesForSelfExtract=true \ + -p:EnableCompressionInSingleFile=true -o "host-out/$rid" --nologo + mkdir -p "host-pack/$rid" + cp "host-out/$rid/raw-edge-bridge" "host-pack/$rid/" + chmod +x "host-pack/$rid/raw-edge-bridge" + tar -czf "raw-bridge-host-$rid.tar.gz" -C "host-pack/$rid" raw-edge-bridge + test -s "raw-bridge-host-$rid.tar.gz" + done + + - uses: actions/upload-artifact@v4 + with: + name: host-linux + path: raw-bridge-host-linux-*.tar.gz + + host-macos: + runs-on: macos-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-dotnet@v4 + with: + dotnet-version: 9.0.x + + - name: publish the self-contained macOS hosts + shell: bash run: | - $tarball = (Get-ChildItem *.tgz | Select-Object -First 1).Name - Copy-Item $tarball raw-browser.tgz -Force + for rid in osx-x64 osx-arm64; do + dotnet publish edge-bridge/host/RawEdgeBridge.csproj -c Release -r "$rid" \ + --self-contained true -p:PublishSingleFile=true \ + -p:IncludeNativeLibrariesForSelfExtract=true \ + -p:EnableCompressionInSingleFile=true -o "host-out/$rid" --nologo + mkdir -p "host-pack/$rid" + cp "host-out/$rid/raw-edge-bridge" "host-pack/$rid/" + chmod +x "host-pack/$rid/raw-edge-bridge" + tar -czf "raw-bridge-host-$rid.tar.gz" -C "host-pack/$rid" raw-edge-bridge + test -s "raw-bridge-host-$rid.tar.gz" + done + + - uses: actions/upload-artifact@v4 + with: + name: host-macos + path: raw-bridge-host-osx-*.tar.gz + + release: + needs: [verify, host-windows, host-linux, host-macos] + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 24.x + cache: npm + + - run: npm ci + + - uses: actions/download-artifact@v4 + with: + path: release-assets + merge-multiple: true + + - name: checksums for every host archive + run: | + cd release-assets + sha256sum raw-bridge-host-* > SHA256SUMS.txt + cat SHA256SUMS.txt + + - name: pack the npm tarball + run: | + npm pack + tarball=$(ls raw-browser-*.tgz | head -1) + cp "$tarball" raw-browser.tgz - name: create the release and upload assets - shell: pwsh env: GH_TOKEN: ${{ github.token }} run: | - $tag = "${{ github.ref_name }}" - gh release view $tag 2>$null - if ($LASTEXITCODE -ne 0) { - gh release create $tag --title "raw-browser $tag" --generate-notes - } - $tarball = (Get-ChildItem *.tgz | Select-Object -First 1).Name - gh release upload $tag raw-bridge-host-win-x64.zip SHA256SUMS.txt $tarball raw-browser.tgz --clobber + tag="${{ github.ref_name }}" + gh release view "$tag" >/dev/null 2>&1 || gh release create "$tag" --title "raw-browser $tag" --generate-notes + gh release upload "$tag" release-assets/*.zip release-assets/*.tar.gz release-assets/SHA256SUMS.txt raw-browser.tgz --clobber npm: needs: release diff --git a/.gitignore b/.gitignore index 49be3e0..0df62d4 100644 --- a/.gitignore +++ b/.gitignore @@ -28,4 +28,6 @@ demo/.frames/ host-out/ host-pack/ raw-bridge-host-win-x64.zip +raw-bridge-host-*.zip +raw-bridge-host-*.tar.gz SHA256SUMS.txt diff --git a/CHANGELOG.md b/CHANGELOG.md index de8259b..c0a74b4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,35 @@ All notable changes to `raw` are documented here. The format follows Keep a Changelog and versions follow Semantic Versioning. +## [0.7.0] - 2026-10-23 + +### Added + +- The extension bridge now installs on macOS and Linux. A cross-platform + `scripts/install-edge-bridge.mjs` handles the native-messaging host, + `~/.raw/edge-bridge.json` / `~/.raw/chrome-bridge.json`, and the browser + manifests under `~/Library/Application Support` (macOS) or `~/.config` + (Linux). On Windows it delegates to the existing PowerShell installer, so + one command works everywhere. +- Release builds now publish self-contained hosts for `linux-x64`, + `linux-arm64`, `osx-x64` and `osx-arm64` alongside `win-x64`, plus a single + `SHA256SUMS.txt` covering every archive. +- CI runs the native-host smoke test on Ubuntu, macOS and Windows, plus a real + installer pass on Ubuntu and macOS. + +### Changed + +- Browser discovery also finds Chrome Beta/Canary, Chromium and Edge + Beta/Dev/Canary, plus Linux packages under `/opt` and snap Chromium, so + macOS/Linux users are less likely to need `RAW_BROWSER_PATH`. +- README, README.zh-CN and `edge-bridge/README.md` document the cross-platform + install, the per-OS native-messaging directories, and the WSL/snap caveats. + +### Fixed + +- The native host treats process enumeration as best-effort, so a restricted + `Process.GetProcessesByName` call on macOS or Linux cannot prevent startup. + ## [0.6.2] - 2026-10-23 ### Added @@ -203,7 +232,9 @@ follow Semantic Versioning. - Strict TypeScript checking, a locked development toolchain, and full CLI protocol, setup, and permission smoke tests now gate publication. - Browser smoke testing uses a disposable profile and covers forms, iframes, downloads, screenshots, isolated spaces, cleanup, and CPU timeouts. -[Unreleased]: https://github.com/liutingqiu/raw-browser/compare/v0.6.1...HEAD +[Unreleased]: https://github.com/liutingqiu/raw-browser/compare/v0.7.0...HEAD +[0.7.0]: https://github.com/liutingqiu/raw-browser/releases/tag/v0.7.0 +[0.6.2]: https://github.com/liutingqiu/raw-browser/releases/tag/v0.6.2 [0.6.1]: https://github.com/liutingqiu/raw-browser/releases/tag/v0.6.1 [0.6.0]: https://github.com/liutingqiu/raw-browser/releases/tag/v0.6.0 [0.5.1]: https://github.com/liutingqiu/raw-browser/releases/tag/v0.5.1 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index aea8209..a6f8036 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -20,9 +20,9 @@ Run `npm run smoke:browser` when changing browser automation. It opens the dedicated local browser profile and verifies same-origin iframe interaction, downloads, screenshots and CPU-timeout termination. -Run `npm run smoke:bridge` on Windows (with the .NET 9 SDK) when changing the -native host or the extension. It builds the host and exercises the bridge -protocol. +Run `npm run smoke:bridge` on Windows, macOS or Linux (with the .NET 9 SDK) +when changing the native host or the extension. It builds the host and +exercises the bridge protocol. Add focused `node:test` coverage for protocol serialization, context boundaries, permissions, and filesystem behavior. Keep generated files out of commits; diff --git a/README.md b/README.md index 03df82f..d2b8bd3 100644 --- a/README.md +++ b/README.md @@ -45,7 +45,8 @@ raw status Pinned or offline installs can use the `raw-browser.tgz` tarball attached to each [release](https://github.com/liutingqiu/raw-browser/releases/latest). The extension bridge installer ships in both the npm package and a clone; a global -install runs it with `$(npm root -g)\raw-browser\scripts\install-edge-bridge.ps1`. +install runs it with +`node "$(npm root -g)/raw-browser/scripts/install-edge-bridge.mjs"`. ## Why raw-browser @@ -145,23 +146,43 @@ returned only when a script explicitly calls a cookie API. ## Drive your signed-in browser -The bridge installer is currently Windows PowerShell. Direct CDP mode works on -Windows, macOS and Linux. +The bridge installer supports Windows, macOS and Linux. Direct CDP mode also +works on all three. + +```bash +# global npm install (macOS / Linux) +node "$(npm root -g)/raw-browser/scripts/install-edge-bridge.mjs" --browser both + +# source checkout (any platform) +node scripts/install-edge-bridge.mjs --browser both +``` + +On Windows the same Node command delegates to the PowerShell installer. You can +also run that script directly: ```powershell -# global npm install powershell -ExecutionPolicy Bypass -File "$(npm root -g)\raw-browser\scripts\install-edge-bridge.ps1" -Browser both - -# source checkout -powershell -ExecutionPolicy Bypass -File scripts/install-edge-bridge.ps1 -Browser both ``` The installer publishes a self-contained native host, writes independent tokens to `~/.raw/edge-bridge.json` and `~/.raw/chrome-bridge.json`, and registers the -selected native-messaging hosts. If the .NET 9 SDK is missing, it downloads the -matching prebuilt host from [Releases](https://github.com/liutingqiu/raw-browser/releases) -and verifies `SHA256SUMS.txt` when available. Use `-HostPath ` to install a -host you built yourself, or `-BuildFromSource` to force a local build. +selected native-messaging hosts: HKCU on Windows, +`NativeMessagingHosts` under `~/Library/Application Support` on macOS, and under +`~/.config` on Linux. If the .NET 9 SDK is missing, it downloads the matching +prebuilt host from [Releases](https://github.com/liutingqiu/raw-browser/releases) +and verifies `SHA256SUMS.txt` when available. Use `--host-path` to install a +host you built yourself, or `--build-from-source` to force a local build. + +The host always lives at `~/.raw/edge-bridge/current/` so the native-messaging +manifest does not move between upgrades; the installer replaces the executable +in place and keeps the previous pair for rollback. + +The browser and the host must be in the same operating system. A Windows Chrome +cannot use a host installed in WSL: use the Windows installer for the Windows +browser, or install a Linux Chrome/Edge inside WSL (with WSLg) and run this +installer there. Snap- and Flatpak-confined browsers do not read the +user-level native-messaging directory the installer writes; use a deb/rpm or +app-bundle install for the bridge. Then load the unpacked extension once per browser: @@ -180,8 +201,8 @@ or `{ "mode": "chrome-extension" }`. `raw status` prints the selected bridge, port and extension version. If the bridge does not connect after an upgrade, restart the browser once: it caches the native-messaging host path for the life of the process. Upgrading from an older installer? Restart the browser, then run -the installer once with `-CleanLegacyHosts` to remove the old `host-*` -directories. +the installer once with `--clean-legacy-hosts` (`-CleanLegacyHosts` in +PowerShell) to remove the old `host-*` directories. ## CLI @@ -265,19 +286,20 @@ See [SECURITY.md](SECURITY.md). ```bash npm run typecheck # strict TypeScript -npm test # 51 unit tests +npm test # 55 unit tests npm run build npm run smoke:browser npm run check # all of the above plus the browser smoke test ``` -CI runs the suite on Ubuntu, Windows and macOS across Node 22, 24 and 26, plus a -Windows bridge-host smoke test and a dependency audit. +CI runs the suite on Ubuntu, Windows and macOS across Node 22, 24 and 26, plus +native-host smoke tests on Windows, macOS and Linux and a dependency audit. ## Known limitations -1. **The bridge installer is Windows-only today.** Direct CDP mode is - cross-platform. +1. **Native messaging needs a desktop browser in the same OS.** The installer + covers Chrome and Edge stable on Windows, macOS and Linux; snap/Flatpak + confined browsers and WSL-to-Windows browser setups are not registered. 2. **One debugger per tab.** Chromium allows a single CDP debugger client per tab; concurrent clients detach each other. 3. **No `--space` in bridge mode.** Extensions cannot create an equivalent of diff --git a/README.zh-CN.md b/README.zh-CN.md index bf4b049..c668ca4 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -45,7 +45,7 @@ raw status 需要固定版本或离线安装时,可以用每个 [Release](https://github.com/liutingqiu/raw-browser/releases/latest) 附带的 `raw-browser.tgz`。扩展桥安装脚本在 npm 包和克隆仓库里都有;全局安装时用 -`$(npm root -g)\raw-browser\scripts\install-edge-bridge.ps1` 运行。 +`node "$(npm root -g)/raw-browser/scripts/install-edge-bridge.mjs"` 运行。 ## 为什么是 raw-browser @@ -137,22 +137,39 @@ Cookie API 时才会返回 Cookie 值。 ## 驱动已登录的浏览器 -桥接安装脚本目前是 Windows PowerShell;直连 CDP 模式支持 Windows / macOS / Linux。 +桥接安装器支持 Windows / macOS / Linux;直连 CDP 模式同样三平台可用。 + +```bash +# 全局 npm 安装(macOS / Linux) +node "$(npm root -g)/raw-browser/scripts/install-edge-bridge.mjs" --browser both + +# 源码克隆(任意平台) +node scripts/install-edge-bridge.mjs --browser both +``` + +Windows 上同一条 Node 命令会转交给 PowerShell 安装器;也可以直接运行: ```powershell -# 全局 npm 安装 powershell -ExecutionPolicy Bypass -File "$(npm root -g)\raw-browser\scripts\install-edge-bridge.ps1" -Browser both - -# 源码克隆 -powershell -ExecutionPolicy Bypass -File scripts/install-edge-bridge.ps1 -Browser both ``` -安装脚本会发布自包含的原生宿主,生成独立的 +安装器会发布自包含的原生宿主,生成独立的 `~/.raw/edge-bridge.json` 与 `~/.raw/chrome-bridge.json`,并注册所选浏览器的 -Native Messaging 宿主。没有 .NET 9 SDK 时,它会从 +Native Messaging 宿主:Windows 写 HKCU,macOS 写到 +`~/Library/Application Support` 下的 `NativeMessagingHosts`,Linux 写到 +`~/.config` 下。没有 .NET 9 SDK 时,它会从 [Releases](https://github.com/liutingqiu/raw-browser/releases) 下载匹配版本的 -预编译宿主,并在有 `SHA256SUMS.txt` 时校验。也可以用 `-HostPath ` -安装自己编译的宿主,或用 `-BuildFromSource` 强制本地编译。 +预编译宿主,并在有 `SHA256SUMS.txt` 时校验。也可以用 `--host-path` +安装自己编译的宿主,或用 `--build-from-source` 强制本地编译。 + +宿主始终安装在 `~/.raw/edge-bridge/current/`,升级时原地替换,上一版留在 +`previous/` 便于回滚,Native Messaging 清单不会因升级改路径。 + +浏览器和宿主必须在同一个操作系统里。Windows 的 Chrome 不能使用装在 WSL +里的宿主:要驱动 Windows 浏览器就用 Windows 安装器;要在 WSL 里用桥接,就 +在 WSL 内安装 Linux 版 Chrome / Edge(需要 WSLg),再运行这个安装器。Snap 和 +Flatpak 沙箱里的浏览器不会读取安装器写入的用户级 Native Messaging 目录, +桥接请使用 deb/rpm 或 app bundle 安装的浏览器。 然后在每个要驱动的浏览器里加载一次解压扩展: @@ -170,7 +187,8 @@ Native Messaging 宿主。没有 .NET 9 SDK 时,它会从 或 `{ "mode": "chrome-extension" }`。`raw status` 会显示当前桥、端口和扩展版本。 如果升级后桥接没有连上,重启一次浏览器:它会在进程生命周期内缓存 Native Messaging 宿主路径。从旧版安装脚本升级的用户,重启浏览器后再运行一次 -安装脚本并加上 `-CleanLegacyHosts`,即可删除旧的 `host-*` 目录。 +安装脚本并加上 `--clean-legacy-hosts`(PowerShell 里是 `-CleanLegacyHosts`), +即可删除旧的 `host-*` 目录。 ## CLI @@ -251,18 +269,20 @@ Native Messaging 宿主路径。从旧版安装脚本升级的用户,重启浏 ```bash npm run typecheck # 严格 TypeScript 检查 -npm test # 51 个单元测试 +npm test # 55 个单元测试 npm run build npm run smoke:browser npm run check # 以上全部 + 浏览器冒烟测试 ``` -CI 在 Ubuntu / Windows / macOS × Node 22/24/26 上跑完整套件,另有 Windows -桥接宿主冒烟测试和依赖审计。 +CI 在 Ubuntu / Windows / macOS × Node 22/24/26 上跑完整套件,另有 +Windows / macOS / Linux 的桥接宿主冒烟测试和依赖审计。 ## 已知限制 -1. **桥接安装脚本目前仅限 Windows。** 直连 CDP 模式跨平台。 +1. **Native Messaging 需要同一操作系统里的桌面浏览器。** 安装器覆盖 + Windows / macOS / Linux 上的 Chrome 与 Edge 稳定版;Snap/Flatpak 沙箱浏览器 + 和“WSL 宿主 + Windows 浏览器”的组合不会自动注册。 2. **单标签页单调试器。** Chromium 同一标签页只允许一个 CDP 调试客户端; 并发挂载会互相 detach。 3. **桥接模式不支持 `--space`。** 扩展无法创建 diff --git a/SECURITY.md b/SECURITY.md index 0c4e497..e0ccc1f 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -23,7 +23,8 @@ profile. The calling agent's prompt and permission mode are the authority for wh The bridge is defended at the transport boundary: listeners bind only to `127.0.0.1`, every HTTP and WebSocket request requires a per-browser random token, native messaging is restricted to the pinned extension id, and a host only attaches to a debug port it owns. Release host binaries are published -with `SHA256SUMS.txt`; `scripts/install-edge-bridge.ps1` verifies the checksum when it downloads one. +with `SHA256SUMS.txt`; `scripts/install-edge-bridge.mjs` and `scripts/install-edge-bridge.ps1` +verify the checksum when they download one. Cookies, tokens and page contents are secrets; do not log or forward them to external services. Configuration, downloads, bridge tokens and the dedicated browser profile are stored locally under diff --git a/assets/social-card.html b/assets/social-card.html index 405e9bc..e71451f 100644 --- a/assets/social-card.html +++ b/assets/social-card.html @@ -200,7 +200,7 @@

Your browser.

MIT Node 22+ - v0.6.1 + v0.7.0 MCP 16 tools
diff --git a/assets/social-card.png b/assets/social-card.png index a4a6852..dafe1da 100644 Binary files a/assets/social-card.png and b/assets/social-card.png differ diff --git a/docs/RELEASING.md b/docs/RELEASING.md index 5f7ea0c..e09ded2 100644 --- a/docs/RELEASING.md +++ b/docs/RELEASING.md @@ -1,8 +1,19 @@ # Releasing The release workflow (`.github/workflows/release.yml`) runs on a `v*` tag. It -builds the self-contained Windows host, publishes `raw-bridge-host-win-x64.zip` -and `SHA256SUMS.txt`, packs the npm tarball, and optionally publishes to npm. +builds self-contained hosts for Windows, macOS and Linux, publishes: + +```text +raw-bridge-host-win-x64.zip +raw-bridge-host-linux-x64.tar.gz +raw-bridge-host-linux-arm64.tar.gz +raw-bridge-host-osx-x64.tar.gz +raw-bridge-host-osx-arm64.tar.gz +SHA256SUMS.txt +raw-browser.tgz +``` + +It packs the npm tarball and optionally publishes to npm. ## Checklist @@ -28,12 +39,22 @@ and `SHA256SUMS.txt`, packs the npm tarball, and optionally publishes to npm. npm run check ``` - `npm run check` includes the browser smoke test. On Windows, also run: + `npm run check` includes the browser smoke test. On any platform with the + .NET 9 SDK, also build the host and run the native bridge smoke test: - ```powershell + ```bash + dotnet build edge-bridge/host/RawEdgeBridge.csproj -c Release --nologo node scripts/smoke-bridge.mjs ``` + On macOS/Linux, also exercise the installer against a disposable home: + + ```bash + node scripts/install-edge-bridge.mjs --browser both \ + --host-path "$PWD/edge-bridge/host/bin/Release/net9.0/raw-edge-bridge" \ + --home "$(mktemp -d)/raw-home" + ``` + 4. Commit, tag, and push: ```bash @@ -42,8 +63,8 @@ and `SHA256SUMS.txt`, packs the npm tarball, and optionally publishes to npm. git push origin main --tags ``` -5. The workflow creates the GitHub release and uploads the host archive, - checksums and npm tarball. To publish to npm automatically, set the +5. The workflow creates the GitHub release and uploads every host archive, + checksums and the npm tarball. To publish to npm automatically, set the repository variable `NPM_AUTOPUBLISH` to `true` and add a valid `NPM_TOKEN` repository secret; otherwise publish manually with `npm publish`. @@ -52,7 +73,13 @@ and `SHA256SUMS.txt`, packs the npm tarball, and optionally publishes to npm. ## Bridge upgrades -The host installs to `~/.raw/edge-bridge/current/` so the native-messaging path -stays stable. After upgrading from a pre-`current` install, restart the browser -once, then run the installer with `-CleanLegacyHosts` to remove the old +The host installs to `~/.raw/edge-bridge/current/` on every platform so the +native-messaging path stays stable. After upgrading from a pre-`current` +install, restart the browser once, then run the installer with +`--clean-legacy-hosts` (`-CleanLegacyHosts` on Windows) to remove the old `host-` directories. + +The macOS and Linux single-file hosts are unsigned. Node's installer downloads +them with `fetch`, which does not add the macOS quarantine attribute; if a host +was downloaded through a browser instead, macOS may ask the user to allow it in +Privacy & Security. diff --git a/edge-bridge/README.md b/edge-bridge/README.md index 4b897d3..e8f76de 100644 --- a/edge-bridge/README.md +++ b/edge-bridge/README.md @@ -13,9 +13,12 @@ values are returned only when a script explicitly calls a cookie API. - `host/`: a .NET native-messaging host. It exposes a token-protected CDP facade on loopback for the existing Raw browser runtime: Edge on `127.0.0.1:9333`, Chrome on `127.0.0.1:9334`. -- `scripts/install-edge-bridge.ps1`: builds a versioned host, copies it to - `raw-edge-bridge.exe` and `raw-chrome-bridge.exe`, generates independent local - tokens, and registers both native hosts for the current Windows user. +- `scripts/install-edge-bridge.mjs`: the cross-platform installer for macOS, + Linux and Windows (on Windows it delegates to the PowerShell script). +- `scripts/install-edge-bridge.ps1`: the Windows installer. It builds or + downloads a versioned host, copies it to `raw-edge-bridge.exe` and + `raw-chrome-bridge.exe`, generates independent local tokens, and registers + both native hosts under HKCU. The extension exposes tabs across browser windows. Page-scoped CDP commands are forwarded without a method allowlist, including Network and Storage commands. @@ -30,6 +33,19 @@ paused, the extension keeps no native port and cannot be driven remotely; ## Install +The cross-platform installer is the normal entry point: + +```bash +# from a source checkout, macOS / Linux / Windows +node scripts/install-edge-bridge.mjs --browser both + +# global npm install, macOS / Linux +node "$(npm root -g)/raw-browser/scripts/install-edge-bridge.mjs" --browser both +``` + +On Windows it delegates to the PowerShell installer, which registers the HKCU +native-messaging keys. The PowerShell script also remains directly usable: + ```powershell # both browsers (default) & .\scripts\install-edge-bridge.ps1 -Browser both @@ -40,22 +56,41 @@ paused, the extension keeps no native port and cannot be driven remotely; ``` The installer builds a self-contained host when the .NET 9 SDK is on `PATH`. -Without the SDK it downloads `raw-bridge-host-win-x64.zip` from the matching -GitHub release and verifies `SHA256SUMS.txt` when present. To use a host you -built yourself, pass `-HostPath C:\path\to\raw-edge-bridge.exe`; to force a -local build, pass `-BuildFromSource`. `-PreferPrebuilt` downloads the release -asset even when an SDK is installed. - -The host lives at `~/.raw/edge-bridge/current/`. Upgrades replace the two -executables in place, because a running Edge or Chrome caches the resolved -native-messaging path for the life of the browser process. If you upgraded from -an installer that used `host-` directories, the browser may keep -using one of those until it restarts; restart it, then run the installer once -with `-CleanLegacyHosts` to remove the old directories. +Without the SDK it downloads the matching release asset +(`raw-bridge-host-.tar.gz` on macOS/Linux, `raw-bridge-host-win-x64.zip` on +Windows) and verifies `SHA256SUMS.txt` when present. To use a host you built +yourself, pass `--host-path` (`-HostPath` in PowerShell); to force a local +build, pass `--build-from-source` (`-BuildFromSource`). `--prefer-prebuilt` +(`-PreferPrebuilt`) downloads the release asset even when an SDK is installed. + +On macOS and Linux the installer writes the native-messaging manifests to the +browser's user directory: + +```text +macOS Chrome: ~/Library/Application Support/Google/Chrome/NativeMessagingHosts +macOS Edge: ~/Library/Application Support/Microsoft Edge/NativeMessagingHosts +Linux Chrome: ~/.config/google-chrome/NativeMessagingHosts +Linux Edge: ~/.config/microsoft-edge/NativeMessagingHosts +``` + +The host lives at `~/.raw/edge-bridge/current/` on every platform. Upgrades +replace the executable in place, because a running Edge or Chrome caches the +resolved native-messaging path for the life of the browser process. If you +upgraded from an installer that used `host-` directories, the +browser may keep using one of those until it restarts; restart it, then run the +installer once with `--clean-legacy-hosts` (`-CleanLegacyHosts`) to remove the +old directories. + +The browser and the host must run in the same operating system. A Windows +browser cannot use a host installed inside WSL; install a Linux Chrome/Edge in +WSL with WSLg and run the macOS/Linux installer there instead. Snap- and +Flatpak-confined browsers do not read the user-level native-messaging directory; +use a deb/rpm or app-bundle install for the bridge. Load the unpacked extension once in each browser you want to drive: open `edge://extensions` and/or `chrome://extensions`, enable Developer mode, choose -**Load unpacked**, and select `edge-bridge\extension`. The extension ID must be +**Load unpacked**, and select `edge-bridge/extension` (`edge-bridge\extension` +on Windows). The extension ID must be `gfpadofoapicldopjkghnfbnbkljihmk`; the installer verifies the manifest key before registering anything. @@ -117,9 +152,21 @@ Host logs are written to `~/.raw/edge-bridge.log` or Remove `mode` from `current.json` to return to the selected dedicated profile. Disable or remove **Raw Browser Bridge** from the browser's extensions page and -remove the per-user registry keys: +remove the per-user registration: ```text -HKCU\Software\Microsoft\Edge\NativeMessagingHosts\com.raw.edge_bridge -HKCU\Software\Google\Chrome\NativeMessagingHosts\com.raw.edge_bridge +Windows (HKCU) + HKCU\Software\Microsoft\Edge\NativeMessagingHosts\com.raw.edge_bridge + HKCU\Software\Google\Chrome\NativeMessagingHosts\com.raw.edge_bridge + +macOS + ~/Library/Application Support/Google/Chrome/NativeMessagingHosts/com.raw.edge_bridge.json + ~/Library/Application Support/Microsoft Edge/NativeMessagingHosts/com.raw.edge_bridge.json + +Linux + ~/.config/google-chrome/NativeMessagingHosts/com.raw.edge_bridge.json + ~/.config/microsoft-edge/NativeMessagingHosts/com.raw.edge_bridge.json ``` + +The previous host pair remains at `~/.raw/edge-bridge/previous/` after an +upgrade; move it back to `current/` if a new host misbehaves. diff --git a/edge-bridge/extension/manifest.json b/edge-bridge/extension/manifest.json index e7bac9d..e87aaa3 100644 --- a/edge-bridge/extension/manifest.json +++ b/edge-bridge/extension/manifest.json @@ -1,7 +1,7 @@ { "manifest_version": 3, "name": "Raw Browser Bridge", - "version": "0.6.2", + "version": "0.7.0", "description": "Lets the local Raw browser tool drive tabs in this Edge or Chrome profile.", "key": "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAub0YG034DGCj1v0EQW+z2H4E825wmRQoXKgjnq0fYYDjALcVykvta/3/VUKbfV2vO6yoBzyk6HRlvBnbpxqx9whkVzMAFKXjfgT9k4IhsmMtjRwbj33kPVvts02Zx5amkBPSkFVC2G2J08eqH+IOOXo3ZsgQz4wb1O+I+FIa0ljOJHJbryFcBI+oYJWjix14NFFze0gnP6xl4bWdfRfHmrqGG/VHburhz8jrcFQ7ZU65iyrPRxbPDiEHrtTss7JcnZG7c3QgNKC0OIeBa15b/eeuh8JDHU3KZmWATwD2zxrR3ucpQW1bbXcUzTl8EdLjOvV3jWblGfpFzX+udwUTCwIDAQAB", "permissions": [ diff --git a/edge-bridge/host/Program.cs b/edge-bridge/host/Program.cs index 23ae570..3a683e5 100644 --- a/edge-bridge/host/Program.cs +++ b/edge-bridge/host/Program.cs @@ -7,7 +7,7 @@ using System.Text.Json.Nodes; using System.Threading.Channels; -const string Version = "0.6.2"; +const string Version = "0.7.0"; var processName = Process.GetCurrentProcess().ProcessName; string? forcedBrowser = Environment.GetEnvironmentVariable("RAW_BRIDGE_BROWSER"); @@ -80,10 +80,17 @@ void Log(string message) // and Chrome builds have different process names so they never kill each other. if (Console.IsInputRedirected) { - foreach (var stale in Process.GetProcessesByName(processName)) + try + { + foreach (var stale in Process.GetProcessesByName(processName)) + { + if (stale.Id == Environment.ProcessId) continue; + try { stale.Kill(); stale.WaitForExit(2000); } catch { /* already gone */ } + } + } + catch { - if (stale.Id == Environment.ProcessId) continue; - try { stale.Kill(); stale.WaitForExit(2000); } catch { /* already gone */ } + // Process enumeration is best-effort; macOS and Linux may restrict it. } } diff --git a/mcp/raw-browser-mcp.mjs b/mcp/raw-browser-mcp.mjs index 18afb19..ff0713a 100644 --- a/mcp/raw-browser-mcp.mjs +++ b/mcp/raw-browser-mcp.mjs @@ -427,7 +427,7 @@ async function handle(message) { reply(id, { protocolVersion: params?.protocolVersion || "2024-11-05", capabilities: { tools: {} }, - serverInfo: { name: "raw-browser", version: "0.6.2" }, + serverInfo: { name: "raw-browser", version: "0.7.0" }, }); return; } diff --git a/package-lock.json b/package-lock.json index 5c8c4aa..2f944f2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "raw-browser", - "version": "0.6.1", + "version": "0.7.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "raw-browser", - "version": "0.6.1", + "version": "0.7.0", "license": "MIT", "bin": { "raw": "dist/browser-cli.js", diff --git a/package.json b/package.json index fea091b..057a42f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "raw-browser", - "version": "0.6.2", + "version": "0.7.0", "description": "Drive the Chrome or Edge you're already signed into from one script — a zero-dependency browser automation runtime, CLI and MCP server for AI agents.", "keywords": [ "ai", @@ -64,7 +64,7 @@ "dev": "node src/browser-cli.ts", "test": "node --no-warnings --test", "smoke:browser": "npm run build && node scripts/smoke-browser.mjs", - "smoke:bridge": "npm run build && node scripts/smoke-bridge.mjs", + "smoke:bridge": "dotnet build edge-bridge/host/RawEdgeBridge.csproj -c Release --nologo && npm run build && node scripts/smoke-bridge.mjs", "browser": "node src/browser-cli.ts", "demo": "npm run build && node scripts/demo.mjs", "social-card": "npm run build && node scripts/render-social-card.mjs" diff --git a/scripts/check-versions.mjs b/scripts/check-versions.mjs index 814dc04..bd19e99 100644 --- a/scripts/check-versions.mjs +++ b/scripts/check-versions.mjs @@ -1,7 +1,7 @@ #!/usr/bin/env node /** * Fail when the version reported by the package, extension, native host and - * MCP server drift apart. `--tag v0.5.1` also checks a release tag. + * MCP server drift apart. `--tag v0.7.0` also checks a release tag. */ import fs from "node:fs"; import path from "node:path"; @@ -36,7 +36,7 @@ if (versions.size !== 1) { const tagIndex = process.argv.indexOf("--tag"); if (tagIndex !== -1) { const tag = (process.argv[tagIndex + 1] ?? "").replace(/^v/, ""); - if (!tag) throw new Error("--tag needs a value such as v0.5.1"); + if (!tag) throw new Error("--tag needs a value such as v0.7.0"); if (tag !== pkg) { console.error(`release tag v${tag} does not match the package version ${pkg}`); process.exit(1); diff --git a/scripts/install-edge-bridge.mjs b/scripts/install-edge-bridge.mjs new file mode 100644 index 0000000..082c3a5 --- /dev/null +++ b/scripts/install-edge-bridge.mjs @@ -0,0 +1,564 @@ +#!/usr/bin/env node +/** + * Install the Raw Browser Bridge native-messaging host. + * + * macOS and Linux use this script directly. Windows delegates to the existing + * PowerShell installer, which owns the HKCU registry entries; the flags are the + * same so one command works everywhere. + * + * node scripts/install-edge-bridge.mjs --browser both + * node scripts/install-edge-bridge.mjs --browser chrome --build-from-source + * node scripts/install-edge-bridge.mjs --dry-run + * + * The host is published to a stable path under ~/.raw/edge-bridge/current/ + * because Chrome and Edge cache a native-messaging host path for the life of + * the browser process. Upgrades replace the files in place. + */ +import crypto from "node:crypto"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { spawnSync } from "node:child_process"; +import { fileURLToPath } from "node:url"; + +export const HOST_NAME = "com.raw.edge_bridge"; +export const EXPECTED_EXTENSION_ID = "gfpadofoapicldopjkghnfbnbkljihmk"; + +const SCRIPT_DIR = path.dirname(fileURLToPath(import.meta.url)); +const REPO_ROOT = path.resolve(SCRIPT_DIR, ".."); +const HOST_PROJECT = path.join(REPO_ROOT, "edge-bridge", "host", "RawEdgeBridge.csproj"); +const DEFAULT_RELEASE_BASE = "https://github.com/liutingqiu/raw-browser/releases/download"; +const PACKAGE = JSON.parse(fs.readFileSync(path.join(REPO_ROOT, "package.json"), "utf8")); + +function pathFor(platform) { + return platform === "win32" ? path.win32 : path.posix; +} + +export function hostRid(platform = process.platform, arch = process.arch) { + const osName = platform === "win32" ? "win" : platform === "darwin" ? "osx" : platform; + if (!["win32", "linux", "darwin"].includes(platform)) { + throw new Error(`unsupported platform: ${platform}`); + } + if (!["x64", "arm64"].includes(arch)) { + throw new Error(`unsupported architecture: ${arch}`); + } + return `${osName}-${arch}`; +} + +export function hostFilename(platform = process.platform) { + return platform === "win32" ? "raw-edge-bridge.exe" : "raw-edge-bridge"; +} + +/** Chrome's extension id is the first 16 bytes of SHA-256(DER key), a-p. */ +export function extensionIdFromManifestKey(key) { + if (typeof key !== "string" || !key) throw new Error("extension manifest has no key"); + const hash = crypto.createHash("sha256").update(Buffer.from(key, "base64")).digest(); + let id = ""; + for (let index = 0; index < 16; index++) { + id += String.fromCharCode(97 + (hash[index] >> 4)); + id += String.fromCharCode(97 + (hash[index] & 0x0f)); + } + return id; +} + +export function nativeMessagingDir(platform, home, browser) { + if (platform === "win32") return null; + const p = pathFor(platform); + if (platform === "darwin") { + return browser === "chrome" + ? p.join(home, "Library", "Application Support", "Google", "Chrome", "NativeMessagingHosts") + : p.join(home, "Library", "Application Support", "Microsoft Edge", "NativeMessagingHosts"); + } + if (platform === "linux") { + return browser === "chrome" + ? p.join(home, ".config", "google-chrome", "NativeMessagingHosts") + : p.join(home, ".config", "microsoft-edge", "NativeMessagingHosts"); + } + throw new Error(`unsupported platform: ${platform}`); +} + +export function planInstall({ + platform = process.platform, + arch = process.arch, + home = os.homedir(), + browser = "both", + version = PACKAGE.version, + repoRoot = REPO_ROOT, +} = {}) { + if (!["edge", "chrome", "both"].includes(browser)) { + throw new Error(`--browser must be edge, chrome or both (got ${browser})`); + } + version = String(version).replace(/^v/, ""); + const p = pathFor(platform); + const rid = hostRid(platform, arch); + const extensionPath = p.join(repoRoot, "edge-bridge", "extension"); + const rawHome = p.join(home, ".raw"); + const installRoot = p.join(rawHome, "edge-bridge"); + const current = p.join(installRoot, "current"); + const previous = p.join(installRoot, "previous"); + const hostName = hostFilename(platform); + const edgeHost = p.join(current, hostName); + const chromeHost = p.join(current, `raw-chrome-bridge${platform === "win32" ? ".exe" : ""}`); + const assetName = platform === "win32" + ? `raw-bridge-host-${rid}.zip` + : `raw-bridge-host-${rid}.tar.gz`; + return { + platform, + arch, + rid, + browser, + version, + repoRoot, + extensionPath, + rawHome, + installRoot, + current, + previous, + hostName, + edgeHost, + chromeHost, + assetName, + edgeConfig: p.join(rawHome, "edge-bridge.json"), + chromeConfig: p.join(rawHome, "chrome-bridge.json"), + edgeManifest: p.join(installRoot, `${HOST_NAME}.edge.json`), + chromeManifest: p.join(installRoot, `${HOST_NAME}.chrome.json`), + browserDirs: { + edge: nativeMessagingDir(platform, home, "edge"), + chrome: nativeMessagingDir(platform, home, "chrome"), + }, + }; +} + +function writeJson(file, value, mode = 0o644) { + fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 }); + fs.writeFileSync(file, `${JSON.stringify(value, null, 2)}\n`, { mode }); + try { fs.chmodSync(file, mode); } catch { /* Windows ignores POSIX modes */ } +} + +function ensureConfig(file, port) { + try { + const existing = JSON.parse(fs.readFileSync(file, "utf8").replace(/^/, "")); + if (typeof existing.token === "string" && existing.token && Number.isInteger(Number(existing.port))) { + return { ...existing, port: Number(existing.port) }; + } + } catch { + /* write a fresh config below */ + } + const config = { version: 1, token: crypto.randomBytes(32).toString("base64url"), port }; + writeJson(file, config, 0o600); + return config; +} + +function extensionId(plan) { + const manifestPath = path.join(plan.extensionPath, "manifest.json"); + const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8")); + const id = extensionIdFromManifestKey(manifest.key); + if (id !== EXPECTED_EXTENSION_ID) { + throw new Error(`extension id changed: expected ${EXPECTED_EXTENSION_ID}, manifest yields ${id}`); + } + return id; +} + +function walkFor(dir, name) { + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + const full = path.join(dir, entry.name); + if (entry.isDirectory()) { + const found = walkFor(full, name); + if (found) return found; + } else if (entry.name === name) { + return full; + } + } + return null; +} + +function run(command, args, options = {}) { + const result = spawnSync(command, args, { + stdio: "inherit", + windowsHide: true, + ...options, + }); + if (result.error) throw result.error; + if (result.status !== 0) { + throw new Error(`${command} exited with ${result.status ?? "no status"}`); + } + return result; +} + +function dotnetVersion() { + const result = spawnSync("dotnet", ["--version"], { encoding: "utf8", windowsHide: true }); + return result.status === 0 ? result.stdout?.trim() || null : null; +} + +function dotnetSupportsNet9() { + const version = dotnetVersion(); + if (!version) return false; + return Number.parseInt(version.split(".")[0], 10) >= 9; +} + +function buildFromSource(plan, destination) { + const version = dotnetVersion(); + if (!version) throw new Error("building from source needs the .NET 9 SDK on PATH"); + if (Number.parseInt(version.split(".")[0], 10) < 9) { + throw new Error(`building from source needs the .NET 9 SDK (found ${version})`); + } + console.error(`building the ${plan.rid} bridge host from source`); + run("dotnet", [ + "publish", HOST_PROJECT, + "-c", "Release", + "-r", plan.rid, + "--self-contained", "true", + "-p:PublishSingleFile=true", + "-p:IncludeNativeLibrariesForSelfExtract=true", + "-p:EnableCompressionInSingleFile=true", + "-o", path.dirname(destination), + "--nologo", + ], { cwd: REPO_ROOT, stdio: ["inherit", 2, 2] }); + if (!fs.existsSync(destination)) { + throw new Error(`the bridge host was not produced: ${destination}`); + } + const frameworkDependent = path.join(path.dirname(destination), "raw-edge-bridge.dll"); + if (fs.existsSync(frameworkDependent)) { + throw new Error("the source build did not produce a single-file bridge host"); + } + try { fs.chmodSync(destination, 0o755); } catch { /* Windows */ } +} + +function checksumFor(text, asset) { + const line = text + .split(/\r?\n/) + .find((entry) => entry.trim().split(/\s+/).at(-1) === asset); + return line ? line.trim().split(/\s+/)[0].toLowerCase() : null; +} + +async function downloadPrebuilt(plan, destination, releaseBase) { + const base = releaseBase.replace(/\/+$/, ""); + const url = `${base}/v${plan.version}/${plan.assetName}`; + const temp = fs.mkdtempSync(path.join(os.tmpdir(), "raw-bridge-")); + try { + console.error(`downloading prebuilt bridge host: ${url}`); + const response = await fetch(url, { redirect: "follow", signal: AbortSignal.timeout(120_000) }); + if (!response.ok) { + throw new Error(`could not download ${url} (HTTP ${response.status}); install the .NET 9 SDK and rerun with --build-from-source, pass --host-path, or publish the release asset`); + } + const archive = path.join(temp, plan.assetName); + fs.writeFileSync(archive, Buffer.from(await response.arrayBuffer())); + + try { + const sumsResponse = await fetch(`${base}/v${plan.version}/SHA256SUMS.txt`, { + redirect: "follow", + signal: AbortSignal.timeout(30_000), + }); + if (sumsResponse.ok) { + const expected = checksumFor(await sumsResponse.text(), plan.assetName); + const actual = crypto.createHash("sha256").update(fs.readFileSync(archive)).digest("hex"); + if (expected && expected !== actual) { + throw new Error(`checksum mismatch for ${plan.assetName}`); + } + if (!expected) console.error(`warning: SHA256SUMS.txt has no entry for ${plan.assetName}`); + } else { + console.error(`warning: could not fetch SHA256SUMS.txt (HTTP ${sumsResponse.status})`); + } + } catch (error) { + if (String(error.message).includes("checksum mismatch")) throw error; + console.error(`warning: could not verify ${plan.assetName}: ${error.message}`); + } + + const extract = path.join(temp, "extract"); + fs.mkdirSync(extract, { recursive: true }); + run("tar", ["-xzf", archive, "-C", extract], { stdio: "ignore" }); + const found = walkFor(extract, "raw-edge-bridge"); + if (!found) throw new Error(`the downloaded archive does not contain raw-edge-bridge`); + fs.copyFileSync(found, destination); + try { fs.chmodSync(destination, 0o755); } catch { /* Windows */ } + } finally { + fs.rmSync(temp, { recursive: true, force: true }); + } +} + +async function resolveHost(plan, options, staging) { + const destination = path.join(staging, plan.hostName); + if (options.hostPath) { + const source = path.resolve(options.hostPath); + if (!fs.existsSync(source) || !fs.statSync(source).isFile()) { + throw new Error(`--host-path is not a file: ${source}`); + } + fs.copyFileSync(source, destination); + try { fs.chmodSync(destination, 0o755); } catch { /* Windows */ } + return destination; + } + + if (options.buildFromSource || (!options.preferPrebuilt && dotnetSupportsNet9())) { + try { + buildFromSource(plan, destination); + return destination; + } catch (error) { + if (options.buildFromSource) throw error; + console.error(`warning: source build failed, falling back to the prebuilt host: ${error.message}`); + } + } + + await downloadPrebuilt(plan, destination, options.releaseBase); + return destination; +} + +function installHost(plan, stagedHost) { + const p = pathFor(plan.platform); + fs.mkdirSync(plan.current, { recursive: true, mode: 0o700 }); + fs.mkdirSync(plan.previous, { recursive: true, mode: 0o700 }); + try { fs.chmodSync(plan.current, 0o700); } catch { /* Windows */ } + try { fs.chmodSync(plan.previous, 0o700); } catch { /* Windows */ } + + const previousEdge = p.join(plan.previous, p.basename(plan.edgeHost)); + const previousChrome = p.join(plan.previous, p.basename(plan.chromeHost)); + const edgeTemp = `${plan.edgeHost}.new-${process.pid}`; + const chromeTemp = `${plan.chromeHost}.new-${process.pid}`; + try { + if (fs.existsSync(plan.edgeHost)) { + fs.rmSync(previousEdge, { force: true }); + fs.renameSync(plan.edgeHost, previousEdge); + } + if (fs.existsSync(plan.chromeHost)) { + fs.rmSync(previousChrome, { force: true }); + fs.renameSync(plan.chromeHost, previousChrome); + } + fs.copyFileSync(stagedHost, edgeTemp); + fs.copyFileSync(stagedHost, chromeTemp); + try { fs.chmodSync(edgeTemp, 0o755); } catch { /* Windows */ } + try { fs.chmodSync(chromeTemp, 0o755); } catch { /* Windows */ } + fs.renameSync(edgeTemp, plan.edgeHost); + fs.renameSync(chromeTemp, plan.chromeHost); + } catch (error) { + fs.rmSync(edgeTemp, { force: true }); + fs.rmSync(chromeTemp, { force: true }); + fs.rmSync(plan.edgeHost, { force: true }); + fs.rmSync(plan.chromeHost, { force: true }); + if (fs.existsSync(previousEdge)) fs.renameSync(previousEdge, plan.edgeHost); + if (fs.existsSync(previousChrome)) fs.renameSync(previousChrome, plan.chromeHost); + throw error; + } +} + +function installManifests(plan, id) { + const allowedOrigin = `chrome-extension://${id}/`; + writeJson(plan.edgeManifest, { + name: HOST_NAME, + description: "Local bridge between Raw and the user's Edge profile", + path: plan.edgeHost, + type: "stdio", + allowed_origins: [allowedOrigin], + }); + writeJson(plan.chromeManifest, { + name: HOST_NAME, + description: "Local bridge between Raw and the user's Chrome profile", + path: plan.chromeHost, + type: "stdio", + allowed_origins: [allowedOrigin], + }); +} + +function registerManifests(plan) { + const p = pathFor(plan.platform); + const registered = {}; + const selected = plan.browser === "both" ? ["edge", "chrome"] : [plan.browser]; + for (const browser of selected) { + const dir = plan.browserDirs[browser]; + if (!dir) continue; + fs.mkdirSync(dir, { recursive: true }); + const target = p.join(dir, `${HOST_NAME}.json`); + fs.copyFileSync(browser === "edge" ? plan.edgeManifest : plan.chromeManifest, target); + try { fs.chmodSync(target, 0o644); } catch { /* Windows */ } + registered[browser] = target; + } + return registered; +} + +function legacyHostDirs(plan) { + if (!fs.existsSync(plan.installRoot)) return []; + return fs.readdirSync(plan.installRoot, { withFileTypes: true }) + .filter((entry) => entry.isDirectory() && entry.name.startsWith("host-")) + .map((entry) => path.join(plan.installRoot, entry.name)); +} + +function delegateWindows(options) { + if (options.home) { + throw new Error("--home is only supported on macOS/Linux; the Windows installer uses %USERPROFILE% and HKCU"); + } + if (options.version !== PACKAGE.version) { + console.error("warning: --version is ignored on Windows; the PowerShell installer uses the package version"); + } + if (options.releaseBase !== DEFAULT_RELEASE_BASE) { + console.error("warning: --release-base is ignored on Windows; the PowerShell installer downloads from GitHub"); + } + const ps1 = path.join(SCRIPT_DIR, "install-edge-bridge.ps1"); + const args = ["-NoProfile", "-ExecutionPolicy", "Bypass", "-File", ps1, "-Browser", options.browser]; + if (options.hostPath) args.push("-HostPath", path.resolve(options.hostPath)); + if (options.preferPrebuilt) args.push("-PreferPrebuilt"); + if (options.buildFromSource) args.push("-BuildFromSource"); + if (options.cleanLegacyHosts) args.push("-CleanLegacyHosts"); + const result = spawnSync("powershell.exe", args, { stdio: "inherit", windowsHide: true }); + if (result.error) throw result.error; + if (result.status !== 0) process.exitCode = result.status ?? 1; +} + +function parseArgs(argv) { + const options = { + browser: "both", + hostPath: undefined, + buildFromSource: false, + preferPrebuilt: false, + cleanLegacyHosts: false, + dryRun: false, + help: false, + home: undefined, + version: PACKAGE.version, + releaseBase: process.env.RAW_BRIDGE_RELEASE_BASE || DEFAULT_RELEASE_BASE, + }; + for (let index = 0; index < argv.length; index++) { + const arg = argv[index]; + const take = (name) => { + const value = argv[++index]; + if (!value || value.startsWith("--")) throw new Error(`${name} requires a value`); + return value; + }; + if (arg === "--help" || arg === "-h") options.help = true; + else if (arg === "--browser") options.browser = take("--browser"); + else if (arg.startsWith("--browser=")) options.browser = arg.slice("--browser=".length); + else if (arg === "--host-path") options.hostPath = take("--host-path"); + else if (arg.startsWith("--host-path=")) options.hostPath = arg.slice("--host-path=".length); + else if (arg === "--build-from-source") options.buildFromSource = true; + else if (arg === "--prefer-prebuilt") options.preferPrebuilt = true; + else if (arg === "--clean-legacy-hosts") options.cleanLegacyHosts = true; + else if (arg === "--dry-run") options.dryRun = true; + else if (arg === "--home") options.home = take("--home"); + else if (arg.startsWith("--home=")) options.home = arg.slice("--home=".length); + else if (arg === "--version") options.version = take("--version").replace(/^v/, ""); + else if (arg.startsWith("--version=")) options.version = arg.slice("--version=".length).replace(/^v/, ""); + else if (arg === "--release-base") options.releaseBase = take("--release-base"); + else if (arg.startsWith("--release-base=")) options.releaseBase = arg.slice("--release-base=".length); + else throw new Error(`unknown option: ${arg}`); + } + if (options.browser !== "edge" && options.browser !== "chrome" && options.browser !== "both") { + throw new Error("--browser must be edge, chrome or both"); + } + if (options.preferPrebuilt && options.buildFromSource) { + throw new Error("use either --prefer-prebuilt or --build-from-source, not both"); + } + if (options.hostPath && (options.preferPrebuilt || options.buildFromSource)) { + throw new Error("--host-path cannot be combined with a build/download switch"); + } + if (!options.version) throw new Error("--version cannot be empty"); + if (!options.releaseBase) throw new Error("--release-base cannot be empty"); + return options; +} + +function help() { + return `Install the Raw Browser Bridge native-messaging host. + +Usage: + node scripts/install-edge-bridge.mjs [options] + +Options: + --browser Browser manifests to register (default: both) + --host-path Install a host you built yourself + --build-from-source Require a local .NET 9 SDK build + --prefer-prebuilt Download the release host even with an SDK + --clean-legacy-hosts Remove old host-* directories after a restart + --home Test-only home override (macOS/Linux) + --version Release version to download (default: package) + --release-base Override the GitHub release download base + --dry-run Print the resolved plan without writing + -h, --help Show this help + +Windows delegates to scripts/install-edge-bridge.ps1; macOS and Linux install +the native-messaging manifests under the browser's user config directory. +`; +} + +async function main() { + const options = parseArgs(process.argv.slice(2)); + if (options.help) { + process.stdout.write(help()); + return; + } + if (process.platform === "win32" && !options.dryRun) { + delegateWindows(options); + return; + } + + const plan = planInstall({ + platform: process.platform, + arch: process.arch, + home: options.home ? path.resolve(options.home) : os.homedir(), + browser: options.browser, + version: options.version, + repoRoot: REPO_ROOT, + }); + const id = extensionId(plan); + + if (options.dryRun) { + process.stdout.write(`${JSON.stringify({ + dryRun: true, + ...plan, + extensionId: id, + releaseUrl: `${options.releaseBase.replace(/\/+$/, "")}/v${plan.version}/${plan.assetName}`, + }, null, 2)}\n`); + return; + } + + fs.mkdirSync(plan.installRoot, { recursive: true, mode: 0o700 }); + const staging = path.join(plan.installRoot, `.install-${process.pid}-${Date.now()}`); + fs.mkdirSync(staging, { recursive: true, mode: 0o700 }); + try { + const stagedHost = await resolveHost(plan, options, staging); + installHost(plan, stagedHost); + const edgeConfig = ensureConfig(plan.edgeConfig, 9333); + const chromeConfig = ensureConfig(plan.chromeConfig, 9334); + installManifests(plan, id); + const registered = registerManifests(plan); + + const legacy = legacyHostDirs(plan); + if (legacy.length) { + if (options.cleanLegacyHosts) { + for (const dir of legacy) fs.rmSync(dir, { recursive: true, force: true }); + console.error("removed legacy host directories"); + } else { + console.error( + `warning: legacy host directories remain: ${legacy.map((dir) => path.basename(dir)).join(", ")}. ` + + "A running browser may still use one until it restarts; rerun with --clean-legacy-hosts after the restart.", + ); + } + } + + process.stdout.write(`${JSON.stringify({ + ExtensionId: id, + ExtensionPath: plan.extensionPath, + EdgeHost: plan.edgeHost, + ChromeHost: plan.chromeHost, + EdgeConfig: plan.edgeConfig, + ChromeConfig: plan.chromeConfig, + EdgeManifest: plan.edgeManifest, + ChromeManifest: plan.chromeManifest, + EdgePort: edgeConfig.port, + ChromePort: chromeConfig.port, + Registered: registered, + }, null, 2)}\n`); + } finally { + fs.rmSync(staging, { recursive: true, force: true }); + } +} + +const invokedDirectly = process.argv[1] && (() => { + try { + return fs.realpathSync(process.argv[1]) === fs.realpathSync(fileURLToPath(import.meta.url)); + } catch { + return path.resolve(process.argv[1]) === path.resolve(fileURLToPath(import.meta.url)); + } +})(); + +if (invokedDirectly) { + main().catch((error) => { + console.error(`raw-bridge-install: ${error instanceof Error ? error.message : String(error)}`); + process.exitCode = 1; + }); +} diff --git a/scripts/raw-bridge.mjs b/scripts/raw-bridge.mjs index 3ba78d3..954a059 100644 --- a/scripts/raw-bridge.mjs +++ b/scripts/raw-bridge.mjs @@ -41,7 +41,7 @@ function readConfig() { parsed = JSON.parse(fs.readFileSync(CONFIG, "utf8").replace(/^/, "")); } catch { throw new Error( - `no bridge config at ${CONFIG}. Install the extension bridge first (see scripts/install-edge-bridge.ps1).`, + `no bridge config at ${CONFIG}. Install the extension bridge first (see scripts/install-edge-bridge.mjs).`, ); } if (typeof parsed.token !== "string" || parsed.token.length === 0) { diff --git a/scripts/smoke-bridge.mjs b/scripts/smoke-bridge.mjs index 47e5b36..ec7c9c6 100644 --- a/scripts/smoke-bridge.mjs +++ b/scripts/smoke-bridge.mjs @@ -17,7 +17,10 @@ import { fileURLToPath } from "node:url"; const repo = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); const buildDir = path.join(repo, "edge-bridge", "host", "bin", "Release", "net9.0"); -const builtHost = path.join(buildDir, "raw-edge-bridge.exe"); +const hostName = process.platform === "win32" ? "raw-edge-bridge.exe" : "raw-edge-bridge"; +const builtHost = process.env.RAW_BRIDGE_HOST + ? path.resolve(process.env.RAW_BRIDGE_HOST) + : path.join(buildDir, hostName); if (!fs.existsSync(builtHost)) { console.error(`bridge host not built: ${builtHost}\nrun: dotnet publish edge-bridge/host/RawEdgeBridge.csproj -c Release`); @@ -75,13 +78,23 @@ function frame(message) { async function runMode({ kind, port, token, versionField }) { const root = fs.mkdtempSync(path.join(os.tmpdir(), `raw-bridge-smoke-${kind}-`)); - const exe = path.join(root, `raw-${kind}-bridge-smoke.exe`); - for (const file of ["raw-edge-bridge.exe", "raw-edge-bridge.dll", "raw-edge-bridge.deps.json", "raw-edge-bridge.runtimeconfig.json"]) { - const source = path.join(buildDir, file); - if (!fs.existsSync(source)) throw new Error(`missing build output: ${source}`); - const target = path.join(root, file === "raw-edge-bridge.exe" ? path.basename(exe) : file); + const smokeName = `raw-${kind}-bridge-smoke${process.platform === "win32" ? ".exe" : ""}`; + const exe = path.join(root, smokeName); + const sourceDir = path.dirname(builtHost); + const sourceName = path.basename(builtHost); + const sourceStem = sourceName.replace(/\.exe$/i, ""); + const files = fs.readdirSync(sourceDir).filter((file) => + file.startsWith(sourceStem) && fs.statSync(path.join(sourceDir, file)).isFile() + ); + for (const file of files) { + const source = path.join(sourceDir, file); + const target = path.join(root, file === sourceName ? smokeName : file); fs.copyFileSync(source, target); + if (file === sourceName) { + try { fs.chmodSync(target, 0o755); } catch { /* Windows */ } + } } + if (!fs.existsSync(exe)) throw new Error(`could not stage the bridge host from ${builtHost}`); const configName = kind === "chrome" ? "chrome-bridge.json" : "edge-bridge.json"; fs.writeFileSync(path.join(root, configName), JSON.stringify({ version: 1, token, port })); diff --git a/src/browser-cli.ts b/src/browser-cli.ts index 12a55ec..353b36f 100644 --- a/src/browser-cli.ts +++ b/src/browser-cli.ts @@ -3,6 +3,10 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const PACKAGE_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const BRIDGE_INSTALLER = path.join(PACKAGE_ROOT, "scripts", "install-edge-bridge.mjs"); const HELP = `raw - browser automation runtime @@ -72,7 +76,7 @@ if (bridgeBrowser) { process.env.RAW_BRIDGE_CONFIG ??= bridgeConfigPath; } catch (error: any) { process.stderr.write( - `raw: cannot use the ${bridgeBrowser} extension bridge (${bridgeConfigPath}): ${error.message}. Run scripts/install-edge-bridge.ps1 first.\n`, + `raw: cannot use the ${bridgeBrowser} extension bridge (${bridgeConfigPath}): ${error.message}. Run the bridge installer first: node "${BRIDGE_INSTALLER}" --browser ${bridgeBrowser}.\n`, ); process.exit(2); } diff --git a/src/browser.ts b/src/browser.ts index 8933321..5465250 100644 --- a/src/browser.ts +++ b/src/browser.ts @@ -210,21 +210,42 @@ const CHROME_CANDIDATES = [ "C:/Program Files/Google/Chrome/Application/chrome.exe", "C:/Program Files (x86)/Google/Chrome/Application/chrome.exe", path.join(os.homedir(), "AppData", "Local", "Google", "Chrome", "Application", "chrome.exe"), + path.join(os.homedir(), "AppData", "Local", "Google", "Chrome Beta", "Application", "chrome.exe"), + path.join(os.homedir(), "AppData", "Local", "Google", "Chrome SxS", "Application", "chrome.exe"), "/usr/bin/google-chrome", "/usr/bin/google-chrome-stable", + "/usr/bin/google-chrome-beta", + "/usr/bin/google-chrome-unstable", + "/opt/google/chrome/chrome", "/usr/bin/chromium", "/usr/bin/chromium-browser", + "/snap/bin/chromium", + "/snap/bin/chromium-browser", "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome", + "/Applications/Google Chrome Beta.app/Contents/MacOS/Google Chrome Beta", + "/Applications/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary", + "/Applications/Chromium.app/Contents/MacOS/Chromium", + path.join(os.homedir(), "Applications", "Google Chrome.app", "Contents", "MacOS", "Google Chrome"), ].filter(Boolean) as string[]; const EDGE_CANDIDATES = [ process.env.RAW_BROWSER_PATH, path.join(os.homedir(), "AppData", "Local", "Microsoft", "Edge", "Application", "msedge.exe"), + path.join(os.homedir(), "AppData", "Local", "Microsoft", "Edge Beta", "Application", "msedge.exe"), + path.join(os.homedir(), "AppData", "Local", "Microsoft", "Edge Dev", "Application", "msedge.exe"), + path.join(os.homedir(), "AppData", "Local", "Microsoft", "Edge SxS", "Application", "msedge.exe"), "C:/Program Files/Microsoft/Edge/Application/msedge.exe", "C:/Program Files (x86)/Microsoft/Edge/Application/msedge.exe", "/usr/bin/microsoft-edge", "/usr/bin/microsoft-edge-stable", + "/usr/bin/microsoft-edge-beta", + "/usr/bin/microsoft-edge-dev", + "/opt/microsoft/msedge/msedge", "/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge", + "/Applications/Microsoft Edge Beta.app/Contents/MacOS/Microsoft Edge Beta", + "/Applications/Microsoft Edge Dev.app/Contents/MacOS/Microsoft Edge Dev", + "/Applications/Microsoft Edge Canary.app/Contents/MacOS/Microsoft Edge Canary", + path.join(os.homedir(), "Applications", "Microsoft Edge.app", "Contents", "MacOS", "Microsoft Edge"), ].filter(Boolean) as string[]; export type BrowserKind = "edge" | "chrome" | "auto"; diff --git a/test/installer.test.mjs b/test/installer.test.mjs new file mode 100644 index 0000000..203801a --- /dev/null +++ b/test/installer.test.mjs @@ -0,0 +1,98 @@ +import "./_setup.mjs"; + +import { test } from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import path from "node:path"; + +import { + EXPECTED_EXTENSION_ID, + extensionIdFromManifestKey, + hostFilename, + hostRid, + nativeMessagingDir, + planInstall, +} from "../scripts/install-edge-bridge.mjs"; + +const repo = path.resolve(import.meta.dirname, ".."); + +test("host RIDs and file names match the release matrix", () => { + assert.equal(hostRid("win32", "x64"), "win-x64"); + assert.equal(hostRid("win32", "arm64"), "win-arm64"); + assert.equal(hostRid("darwin", "x64"), "osx-x64"); + assert.equal(hostRid("darwin", "arm64"), "osx-arm64"); + assert.equal(hostRid("linux", "x64"), "linux-x64"); + assert.equal(hostRid("linux", "arm64"), "linux-arm64"); + assert.equal(hostFilename("win32"), "raw-edge-bridge.exe"); + assert.equal(hostFilename("darwin"), "raw-edge-bridge"); + assert.equal(hostFilename("linux"), "raw-edge-bridge"); + assert.throws(() => hostRid("freebsd", "x64"), /unsupported platform/); + assert.throws(() => hostRid("linux", "ia32"), /unsupported architecture/); +}); + +test("native messaging directories follow Chrome and Edge on macOS and Linux", () => { + assert.equal( + nativeMessagingDir("darwin", "/Users/me", "chrome"), + "/Users/me/Library/Application Support/Google/Chrome/NativeMessagingHosts", + ); + assert.equal( + nativeMessagingDir("darwin", "/Users/me", "edge"), + "/Users/me/Library/Application Support/Microsoft Edge/NativeMessagingHosts", + ); + assert.equal( + nativeMessagingDir("linux", "/home/me", "chrome"), + "/home/me/.config/google-chrome/NativeMessagingHosts", + ); + assert.equal( + nativeMessagingDir("linux", "/home/me", "edge"), + "/home/me/.config/microsoft-edge/NativeMessagingHosts", + ); + assert.equal(nativeMessagingDir("win32", "C:\\Users\\me", "chrome"), null); +}); + +test("the extension id is derived from the checked-in manifest key", () => { + const manifest = JSON.parse(fs.readFileSync(path.join(repo, "edge-bridge", "extension", "manifest.json"), "utf8")); + assert.equal(extensionIdFromManifestKey(manifest.key), EXPECTED_EXTENSION_ID); +}); + +test("the installer keeps one stable host path per browser", () => { + const linux = planInstall({ + platform: "linux", + arch: "arm64", + home: "/home/me", + browser: "both", + version: "0.7.0", + repoRoot: "/repo", + }); + assert.equal(linux.rid, "linux-arm64"); + assert.equal(linux.assetName, "raw-bridge-host-linux-arm64.tar.gz"); + assert.equal(linux.edgeHost, "/home/me/.raw/edge-bridge/current/raw-edge-bridge"); + assert.equal(linux.chromeHost, "/home/me/.raw/edge-bridge/current/raw-chrome-bridge"); + assert.equal(linux.edgeManifest, "/home/me/.raw/edge-bridge/com.raw.edge_bridge.edge.json"); + assert.equal(linux.extensionPath, "/repo/edge-bridge/extension"); + + const mac = planInstall({ + platform: "darwin", + arch: "arm64", + home: "/Users/me", + browser: "chrome", + version: "0.7.0", + repoRoot: "/repo", + }); + assert.equal(mac.rid, "osx-arm64"); + assert.equal(mac.assetName, "raw-bridge-host-osx-arm64.tar.gz"); + assert.equal(mac.edgeHost, "/Users/me/.raw/edge-bridge/current/raw-edge-bridge"); + + const windows = planInstall({ + platform: "win32", + arch: "x64", + home: "C:\\Users\\me", + browser: "both", + version: "0.7.0", + repoRoot: "C:\\repo", + }); + assert.equal(windows.rid, "win-x64"); + assert.equal(windows.assetName, "raw-bridge-host-win-x64.zip"); + assert.equal(windows.edgeHost, "C:\\Users\\me\\.raw\\edge-bridge\\current\\raw-edge-bridge.exe"); + assert.equal(windows.chromeHost, "C:\\Users\\me\\.raw\\edge-bridge\\current\\raw-chrome-bridge.exe"); +});