diff --git a/.github/workflows/anti-fingerprint.yml b/.github/workflows/anti-fingerprint.yml new file mode 100644 index 0000000..159cf06 --- /dev/null +++ b/.github/workflows/anti-fingerprint.yml @@ -0,0 +1,182 @@ +name: Anti-fingerprint + +# Three tiers of verification for the anti-fingerprint track: +# 1. harness-and-measure — config harness + empirical fingerprint baseline +# (real Gecko via Playwright). Fast; the regression gate. +# 2. patch-apply — proves the gecko-patches/ apply to the pinned Firefox +# source (the `check-patchfail` equivalent). No compile. +# 3. full-build — manual: real `make build` + geckodriver measurement of +# the actual binary. Heavy; needs a large/self-hosted +# runner (a full Firefox build OOMs/overflows the stock +# 14 GB GitHub runner — the project's Forgejo/Woodpecker +# pipeline is the natural home; this is the GH scaffold). + +on: + pull_request: + push: + branches: [main, anti-fingerprint-verify] + schedule: + - cron: '23 7 * * 1' + workflow_dispatch: + +env: + # Keep in sync with the build repo's `version` file. + FIREFOX_VERSION: '150.0.1' + +jobs: + # ── Tier 1: config posture + measured fingerprint baseline ───────────────── + harness-and-measure: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: '20' + - name: Install deps + Gecko engine + run: | + npm ci || npm install + npx playwright install --with-deps firefox + - name: Config harness (static + runtime RFP) — HARD GATE + run: node scripts/verify-gecko-rfp.mjs + - name: Fingerprint measurement (both profiles) + run: | + node scripts/measure-fingerprint.mjs --profile human-secure --json > fp-human-secure.json + node scripts/measure-fingerprint.mjs --profile agent-runtime --json > fp-agent-runtime.json + echo "human-secure: $(jq -r '.neutralized' fp-human-secure.json)/$(jq -r '.total' fp-human-secure.json) neutralized" + echo "agent-runtime: $(jq -r '.neutralized' fp-agent-runtime.json)/$(jq -r '.total' fp-agent-runtime.json) neutralized" + - name: Upload fingerprint scorecards + uses: actions/upload-artifact@v4 + with: + name: fingerprint-scorecards + path: fp-*.json + + # ── Tier 2: do the gecko-patches still apply to pinned Firefox source? ────── + patch-apply: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Fetch pinned Firefox source + run: | + url="https://archive.mozilla.org/pub/firefox/releases/${FIREFOX_VERSION}/source/firefox-${FIREFOX_VERSION}.source.tar.xz" + echo "fetching $url" + wget -q "$url" -O firefox-src.tar.xz + tar xf firefox-src.tar.xz + - name: Dry-run apply every anti-fp patch (fail on reject) + run: | + patches="$(pwd)/gecko-patches" + cd "firefox-${FIREFOX_VERSION}" + fail=0 + while IFS= read -r p; do + echo "==> $p" + if ! patch -p1 --dry-run -i "$p"; then + echo "::error file=$p::patch does not apply to firefox-${FIREFOX_VERSION}" + fail=1 + fi + done < <(find "$patches" -name '*.patch' | sort) + exit $fail + + # ── Tier 2.5: does the FULL stack apply to current ESR source? (Tor mode) ── + # Tor mode rides the Firefox 140 cohort but pulls the current ESR point release + # (140.12.0esr) for security — see docs/tor-mode.md §version. The open risk is + # that LibreWolf's 140.0.4-era patch stack (32 patches) + our anti-fp patches + # may not apply cleanly to the ESR tree (backports shift hunks). This proves it + # with GNU patch on Linux — the authoritative answer (local macOS BSD patch is + # only indicative). No compile, so it runs on a free runner. + tor-esr-patch-apply: + runs-on: ubuntu-latest + env: + TOR_COHORT_TAG: '140.0.4-1' # LibreWolf build-scripts tag (140 line) + TOR_ESR_VERSION: '140.12.0esr' # current ESR point release to retarget to + MIRROR: 'https://github.com/SourceOS-Linux/librewolf-source-mirror.git' + steps: + - uses: actions/checkout@v4 + with: + path: overlay + - name: Clone LibreWolf build scripts at the 140 cohort tag + run: git clone --depth 1 --branch "$TOR_COHORT_TAG" "$MIRROR" lw + - name: Retarget source to current ESR + register anti-fp patches + run: | + cd lw + echo "$TOR_ESR_VERSION" > version + cp ../overlay/gecko-patches/anti-fingerprint/*.patch patches/ + for p in ../overlay/gecko-patches/anti-fingerprint/*.patch; do + echo "patches/$(basename "$p")" >> assets/patches.txt + done + echo "stack size: $(wc -l < assets/patches.txt) patches against firefox-$(cat version)" + - name: Fetch ESR source + run: | + cd lw + url="https://archive.mozilla.org/pub/firefox/releases/${TOR_ESR_VERSION}/source/firefox-${TOR_ESR_VERSION}.source.tar.xz" + echo "fetching $url" + wget -q "$url" -O "firefox-${TOR_ESR_VERSION}.source.tar.xz" + - name: check-patchfail against ESR (GNU patch — authoritative, robust dir) + run: | + cd lw + rm -rf t && mkdir t && cd t + tar xf ../firefox-${TOR_ESR_VERSION}.source.tar.xz + srcdir="$(ls -d firefox-* | head -1)" + echo "::notice::ESR tarball extracts to '$srcdir' (version file says $TOR_ESR_VERSION)" + cd "$srcdir" + fail=0 + while IFS= read -r p; do + [ -z "$p" ] && continue + echo "==> $p" + if ! patch -p1 --dry-run -i "../../$p" > /tmp/po 2>&1; then + echo "::error::REJECT $p"; cat /tmp/po; fail=1 + fi + done < ../../assets/patches.txt + [ "$fail" = 0 ] && echo "✓ all $(grep -c . ../../assets/patches.txt) patches apply to $srcdir" + exit $fail + + # ── Tier 3: real build + measure the actual binary (manual, large runner) ── + full-build: + if: github.event_name == 'workflow_dispatch' + # A full Firefox build needs ~40 GB disk + lots of RAM/time. Override with a + # large or self-hosted runner: set repo variable BUILD_RUNNER (e.g. a + # self-hosted label) — falls back to ubuntu-latest, which will likely run out + # of space (kept here so the wiring is visible; the Forgejo pipeline is the + # production path). + runs-on: ${{ vars.BUILD_RUNNER || 'ubuntu-latest' }} + steps: + - uses: actions/checkout@v4 + with: + path: overlay + - name: Checkout build repo + uses: actions/checkout@v4 + with: + # Set repo variable BUILD_REPO to the LibreWolf-style build repo + # (the one with Makefile + scripts/bearbrowser-patches.py + assets/patches.txt). + repository: ${{ vars.BUILD_REPO }} + path: build-repo + - name: Register anti-fp patches into the build + run: | + cp overlay/gecko-patches/anti-fingerprint/*.patch build-repo/patches/ + for p in overlay/gecko-patches/anti-fingerprint/*.patch; do + echo "patches/$(basename "$p")" >> build-repo/assets/patches.txt + done + - name: Validate patches apply (fast gate before the slow build) + run: cd build-repo && make check-patchfail + - name: Build BearBrowser + run: cd build-repo && make bootstrap && make build + - name: Set up measurement deps + run: cd overlay && npm ci || (cd overlay && npm install) + - name: Measure the REAL binary (geckodriver — authoritative) + run: | + # Locate the built binary (Linux: dist/bin/{firefox,bearbrowser}; + # macOS: dist/*.app/Contents/MacOS/*). measure-fingerprint.mjs --bin + # drives it via geckodriver — Playwright (Juggler) can't drive a stock + # build. This is the authoritative measurement: it sees what Playwright + # masks (e.g. RFP timezone, real letterboxing). + bin="$(find build-repo -type f \( -name firefox -o -name bearbrowser \) -path '*dist*' 2>/dev/null | grep -vE '\.dSYM' | head -1)" + if [ -z "$bin" ]; then echo "::error::built binary not found under build-repo dist"; exit 1; fi + echo "measuring: $bin" + export PATH="$PWD/overlay/node_modules/.bin:$PATH" + node overlay/scripts/measure-fingerprint.mjs --profile human-secure --bin "$bin" --json > fp-real-human-secure.json + node overlay/scripts/measure-fingerprint.mjs --profile agent-runtime --bin "$bin" --json > fp-real-agent-runtime.json + echo "human-secure (real binary): $(jq -r '.neutralized' fp-real-human-secure.json)/$(jq -r '.total' fp-real-human-secure.json)" + jq -r '.rows[] | select(.status=="LEAKING") | " LEAK: \(.vector)"' fp-real-human-secure.json + - name: Upload real-binary scorecards + uses: actions/upload-artifact@v4 + with: + name: real-binary-scorecards + path: fp-real-*.json diff --git a/.github/workflows/feature-plane.yml b/.github/workflows/feature-plane.yml index a313986..3e459e5 100644 --- a/.github/workflows/feature-plane.yml +++ b/.github/workflows/feature-plane.yml @@ -26,6 +26,8 @@ on: - "scripts/verify-interactive-sidecar.sh" - "scripts/verify-comparison-plane.sh" - "scripts/verify-agent-sidecar-contract.py" + - "scripts/bearbrowser-create-receipt.py" + - "scripts/bearbrowser-update-receipt.py" - ".github/workflows/feature-plane.yml" push: branches: [main] @@ -53,6 +55,8 @@ on: - "scripts/verify-interactive-sidecar.sh" - "scripts/verify-comparison-plane.sh" - "scripts/verify-agent-sidecar-contract.py" + - "scripts/bearbrowser-create-receipt.py" + - "scripts/bearbrowser-update-receipt.py" - ".github/workflows/feature-plane.yml" workflow_dispatch: @@ -84,7 +88,9 @@ jobs: scripts/bearbrowser-governance-queue.py \ scripts/bearbrowser-sidecar-server.py \ scripts/bearbrowser-sidecar-status.py \ - scripts/verify-agent-sidecar-contract.py + scripts/verify-agent-sidecar-contract.py \ + scripts/bearbrowser-create-receipt.py \ + scripts/bearbrowser-update-receipt.py - name: Shell syntax run: | diff --git a/.github/workflows/nightly-dmg.yml b/.github/workflows/nightly-dmg.yml new file mode 100644 index 0000000..f2124c6 --- /dev/null +++ b/.github/workflows/nightly-dmg.yml @@ -0,0 +1,124 @@ +name: BearBrowser Nightly DMG + +on: + schedule: + - cron: '0 4 * * *' + workflow_dispatch: + push: + branches: [main] + paths: + - 'settings/**' + - 'scripts/bearbrowser-patches.py' + - 'branding/**' + +jobs: + nightly-dmg: + name: Build and package (macOS) + runs-on: macos-15 + timeout-minutes: 360 + + env: + MOZBUILD_STATE_PATH: ${{ github.workspace }}/.mozbuild + VERSION: 150.0.1 + RELEASE: 1 + PROFILE: human-secure + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Cache Mozilla toolchain + uses: actions/cache@v4 + with: + path: .mozbuild + key: mozbuild-${{ env.VERSION }}-macos15-v1 + restore-keys: mozbuild-${{ env.VERSION }}-macos15- + + - name: Cache Firefox source tarball + uses: actions/cache@v4 + with: + path: build/workspaces/${{ env.PROFILE }}-${{ env.VERSION }}-${{ env.RELEASE }}/source/firefox-${{ env.VERSION }}.source.tar.xz + key: ff-tarball-${{ env.VERSION }} + + - name: Cache Cargo registry + uses: actions/cache@v4 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + key: cargo-${{ env.VERSION }}-macos15-v1 + + - name: Set up workspace from LibreWolf mirror + run: | + bash scripts/apply-sourceos-overlays.sh \ + --profile ${{ env.PROFILE }} \ + --ref latest \ + --workspace-root build/workspaces + + - name: Fetch Firefox source tarball + working-directory: build/workspaces/${{ env.PROFILE }}-${{ env.VERSION }}-${{ env.RELEASE }}/source + run: make fetch + + - name: Apply BearBrowser patches + working-directory: build/workspaces/${{ env.PROFILE }}-${{ env.VERSION }}-${{ env.RELEASE }}/source + run: make dir + + - name: Bootstrap Gecko build environment + working-directory: build/workspaces/${{ env.PROFILE }}-${{ env.VERSION }}-${{ env.RELEASE }}/source/bearbrowser-${{ env.VERSION }}-${{ env.RELEASE }} + run: | + MOZBUILD_STATE_PATH=${{ github.workspace }}/.mozbuild \ + ./mach --no-interactive bootstrap --application-choice=browser + + - name: Build BearBrowser + working-directory: build/workspaces/${{ env.PROFILE }}-${{ env.VERSION }}-${{ env.RELEASE }}/source/bearbrowser-${{ env.VERSION }}-${{ env.RELEASE }} + run: MOZBUILD_STATE_PATH=${{ github.workspace }}/.mozbuild ./mach build + + - name: Package BearBrowser.app + run: | + bash scripts/bearbrowser-package-source-build.sh \ + --workspace build/workspaces/${{ env.PROFILE }}-${{ env.VERSION }}-${{ env.RELEASE }}/source/bearbrowser-${{ env.VERSION }}-${{ env.RELEASE }} \ + --profile ${{ env.PROFILE }} \ + --out-dir build/nightly \ + --version ${{ env.VERSION }} \ + --skip-verify + + - name: Create DMG + id: dmg + run: | + DATE=$(date +%Y%m%d) + DMG="BearBrowser-${{ env.VERSION }}-${DATE}-dev.dmg" + hdiutil create \ + -volname "BearBrowser" \ + -srcfolder "build/nightly/BearBrowser.app" \ + -ov -format UDZO \ + "build/nightly/${DMG}" + echo "name=${DMG}" >> $GITHUB_OUTPUT + echo "path=build/nightly/${DMG}" >> $GITHUB_OUTPUT + + - name: Upload DMG artifact + uses: actions/upload-artifact@v4 + with: + name: ${{ steps.dmg.outputs.name }} + path: ${{ steps.dmg.outputs.path }} + retention-days: 30 + + - name: Publish nightly release + if: github.ref == 'refs/heads/main' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + DATE=$(date +%Y-%m-%d) + TAG="nightly-${DATE}" + # Replace any same-day release rather than fail on duplicate tag + gh release delete "$TAG" --yes 2>/dev/null || true + gh release create "$TAG" \ + "${{ steps.dmg.outputs.path }}" \ + --title "BearBrowser Nightly ${DATE}" \ + --notes "Automated nightly build from \`main\`. + +**Not notarized — developer use only.** +To open: right-click BearBrowser.app → Open (bypasses Gatekeeper). + +Commit: \`${{ github.sha }}\` +Firefox base: ${{ env.VERSION }}" \ + --prerelease diff --git a/.github/workflows/nightly-linux.yml b/.github/workflows/nightly-linux.yml new file mode 100644 index 0000000..a96fbdf --- /dev/null +++ b/.github/workflows/nightly-linux.yml @@ -0,0 +1,202 @@ +name: BearBrowser Nightly Linux + +on: + schedule: + - cron: '0 5 * * *' + workflow_dispatch: + push: + # anti-fingerprint-verify added so the compile can be triggered on this branch + # (workflow_dispatch only works from the default branch, which is behind). + # Remove the branch + the extra paths once merged to main. + branches: [main, anti-fingerprint-verify] + paths: + - 'settings/**' + - 'scripts/bearbrowser-patches.py' + - 'branding/**' + - 'gecko-patches/**' + - 'packaging/bundled-fonts/**' + - '.github/workflows/nightly-linux.yml' + +jobs: + nightly-linux: + name: Build (${{ matrix.distro }}) + runs-on: ubuntu-24.04 + timeout-minutes: 360 + + strategy: + fail-fast: false + matrix: + include: + - distro: ubuntu + container: "" + pkg_update: sudo apt-get update -qq + pkg_install: >- + sudo apt-get install -y --no-install-recommends + build-essential curl git python3 python3-pip + libgtk-3-dev libdbus-glib-1-dev libxt-dev + libx11-xcb-dev libxcb-shm0-dev libxcb-composite0-dev + nasm yasm libpulse-dev libasound2-dev zip unzip + artifact_suffix: linux-x86_64-ubuntu + appimage_suffix: x86_64 + + - distro: fedora + container: fedora:41 + pkg_update: dnf makecache -q + pkg_install: >- + dnf install -y + gcc gcc-c++ make python3 python3-pip git curl which + gtk3-devel dbus-glib-devel libXt-devel libXcomposite-devel + libxcb-devel pulseaudio-libs-devel alsa-lib-devel + nasm yasm zip unzip findutils diffutils patch + artifact_suffix: linux-x86_64-fedora + appimage_suffix: x86_64-fedora + + container: ${{ matrix.container != '' && matrix.container || null }} + + env: + MOZBUILD_STATE_PATH: ${{ github.workspace }}/.mozbuild + VERSION: 150.0.1 + RELEASE: 1 + PROFILE: human-secure + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Free disk space (Firefox build needs ~40GB) + if: matrix.container == '' + run: | + sudo rm -rf /usr/share/dotnet /usr/local/lib/android /opt/ghc \ + /opt/hostedtoolcache/CodeQL /usr/local/share/boost "$AGENT_TOOLSDIRECTORY" || true + sudo docker image prune --all --force || true + df -h / + + - name: Install system dependencies + run: | + ${{ matrix.pkg_update }} + ${{ matrix.pkg_install }} + + - name: Cache Mozilla toolchain + uses: actions/cache@v4 + with: + path: .mozbuild + key: mozbuild-${{ env.VERSION }}-${{ matrix.distro }}-v1 + restore-keys: mozbuild-${{ env.VERSION }}-${{ matrix.distro }}- + + - name: Cache Firefox source tarball + uses: actions/cache@v4 + with: + path: build/workspaces/${{ env.PROFILE }}-${{ env.VERSION }}-${{ env.RELEASE }}/source/firefox-${{ env.VERSION }}.source.tar.xz + key: ff-tarball-${{ env.VERSION }} + + - name: Cache Cargo registry + uses: actions/cache@v4 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + key: cargo-${{ env.VERSION }}-${{ matrix.distro }}-v1 + + - name: Set up workspace from LibreWolf mirror + run: | + bash scripts/apply-sourceos-overlays.sh \ + --profile ${{ env.PROFILE }} \ + --ref latest \ + --workspace-root build/workspaces + + - name: Fetch Firefox source tarball + working-directory: build/workspaces/${{ env.PROFILE }}-${{ env.VERSION }}-${{ env.RELEASE }}/source + run: make fetch + + - name: Apply BearBrowser patches + working-directory: build/workspaces/${{ env.PROFILE }}-${{ env.VERSION }}-${{ env.RELEASE }}/source + run: make dir + + - name: Bootstrap Gecko build environment + working-directory: build/workspaces/${{ env.PROFILE }}-${{ env.VERSION }}-${{ env.RELEASE }}/source/bearbrowser-${{ env.VERSION }}-${{ env.RELEASE }} + run: | + MOZBUILD_STATE_PATH=${{ github.workspace }}/.mozbuild \ + ./mach --no-interactive bootstrap --application-choice=browser + + - name: Build BearBrowser + working-directory: build/workspaces/${{ env.PROFILE }}-${{ env.VERSION }}-${{ env.RELEASE }}/source/bearbrowser-${{ env.VERSION }}-${{ env.RELEASE }} + run: MOZBUILD_STATE_PATH=${{ github.workspace }}/.mozbuild ./mach build + + - name: Measure fingerprint on the REAL compiled binary (authoritative) + if: matrix.distro == 'ubuntu' + continue-on-error: true + run: | + OBJ="build/workspaces/${{ env.PROFILE }}-${{ env.VERSION }}-${{ env.RELEASE }}/source/bearbrowser-${{ env.VERSION }}-${{ env.RELEASE }}/obj-x86_64-pc-linux-gnu" + bin="$(find "$OBJ/dist" -type f \( -name bearbrowser -o -name firefox \) 2>/dev/null | grep -vE '\.so|crashreporter|minidump|pingsender|updater' | head -1)" + echo "real binary: $bin" + npm ci || npm install + export PATH="$PWD/node_modules/.bin:$PATH" + # The compiled binary has our patches + fonts. This is the authoritative + # scorecard: it shows what Playwright masks (timezone, real letterboxing) + # and whether canvas-text-metric/audio/fonts flipped green. + node scripts/measure-fingerprint.mjs --profile ${{ env.PROFILE }} --bin "$bin" --json > fp-real.json || true + node scripts/measure-fingerprint.mjs --profile ${{ env.PROFILE }} --bin "$bin" || true + + - name: Upload real-binary scorecard + if: matrix.distro == 'ubuntu' + continue-on-error: true + uses: actions/upload-artifact@v4 + with: + name: fp-real-binary-scorecard + path: fp-real.json + + - name: Package — tarball + id: tarball + run: | + DATE=$(date +%Y%m%d) + OBJ="build/workspaces/${{ env.PROFILE }}-${{ env.VERSION }}-${{ env.RELEASE }}/source/bearbrowser-${{ env.VERSION }}-${{ env.RELEASE }}/obj-x86_64-pc-linux-gnu" + TAR="BearBrowser-${{ env.VERSION }}-${DATE}-${{ matrix.artifact_suffix }}.tar.bz2" + mkdir -p build/nightly + tar -C "${OBJ}/dist" -cjf "build/nightly/${TAR}" bearbrowser/ + echo "name=${TAR}" >> $GITHUB_OUTPUT + echo "path=build/nightly/${TAR}" >> $GITHUB_OUTPUT + + - name: Package — AppImage + id: appimage + run: | + DATE=$(date +%Y%m%d) + bash scripts/package-linux-appimage.sh \ + --workspace "build/workspaces/${{ env.PROFILE }}-${{ env.VERSION }}-${{ env.RELEASE }}/source/bearbrowser-${{ env.VERSION }}-${{ env.RELEASE }}" \ + --out-dir build/nightly \ + --version ${{ env.VERSION }} || echo "AppImage packaging non-fatal" >&2 + APPIMG="BearBrowser-${{ env.VERSION }}-${DATE}-${{ matrix.appimage_suffix }}.AppImage" + [ -f "build/nightly/${APPIMG}" ] && echo "name=${APPIMG}" >> $GITHUB_OUTPUT && echo "path=build/nightly/${APPIMG}" >> $GITHUB_OUTPUT || true + + - name: Upload tarball artifact + uses: actions/upload-artifact@v4 + with: + name: ${{ steps.tarball.outputs.name }} + path: ${{ steps.tarball.outputs.path }} + retention-days: 30 + + - name: Upload AppImage artifact + if: steps.appimage.outputs.path != '' + uses: actions/upload-artifact@v4 + with: + name: ${{ steps.appimage.outputs.name }} + path: ${{ steps.appimage.outputs.path }} + retention-days: 30 + + - name: Publish nightly release + if: github.ref == 'refs/heads/main' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + DATE=$(date +%Y-%m-%d) + TAG="nightly-${DATE}" + ASSETS=() + [ -f "${{ steps.tarball.outputs.path }}" ] && ASSETS+=("${{ steps.tarball.outputs.path }}") + [ -n "${{ steps.appimage.outputs.path }}" ] && [ -f "${{ steps.appimage.outputs.path }}" ] && ASSETS+=("${{ steps.appimage.outputs.path }}") + NOTES="$(printf 'Automated nightly build — Linux (%s).\n\nNot signed. Developer use only.\n\nCommit: `%s`\nFirefox base: %s\n' '${{ matrix.distro }}' '${{ github.sha }}' '${{ env.VERSION }}')" + if [ ${#ASSETS[@]} -gt 0 ]; then + gh release upload "$TAG" "${ASSETS[@]}" --clobber 2>/dev/null || \ + gh release create "$TAG" "${ASSETS[@]}" \ + --title "BearBrowser Nightly ${DATE}" \ + --notes "$NOTES" \ + --prerelease + fi diff --git a/.github/workflows/policy-engine.yml b/.github/workflows/policy-engine.yml new file mode 100644 index 0000000..ccb5434 --- /dev/null +++ b/.github/workflows/policy-engine.yml @@ -0,0 +1,150 @@ +name: Policy Engine Validation + +on: + pull_request: + paths: + - "policy/local-default-actions.yaml" + - "scripts/bearbrowser-policy-engine.py" + - "scripts/bearbrowser-verify-policy-engine.py" + - "scripts/bearbrowser-create-receipt.py" + - "scripts/bearbrowser-update-receipt.py" + - "scripts/bearbrowser-verify-automation-receipt.py" + - "schemas/policy-action.schema.json" + - "schemas/browser-automation-receipt.schema.json" + - ".github/workflows/policy-engine.yml" + push: + branches: [main] + paths: + - "policy/local-default-actions.yaml" + - "scripts/bearbrowser-policy-engine.py" + - "scripts/bearbrowser-verify-policy-engine.py" + - "scripts/bearbrowser-create-receipt.py" + - "scripts/bearbrowser-update-receipt.py" + - "scripts/bearbrowser-verify-automation-receipt.py" + - "schemas/policy-action.schema.json" + - "schemas/browser-automation-receipt.schema.json" + - ".github/workflows/policy-engine.yml" + workflow_dispatch: + +permissions: + contents: read + +jobs: + validate-policy-engine: + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Python syntax + run: | + python3 -m py_compile \ + scripts/bearbrowser-policy-engine.py \ + scripts/bearbrowser-verify-policy-engine.py \ + scripts/bearbrowser-create-receipt.py \ + scripts/bearbrowser-update-receipt.py \ + scripts/bearbrowser-verify-automation-receipt.py + + - name: Policy engine — all action types pass (21/21) + run: | + python3 scripts/bearbrowser-verify-policy-engine.py + + - name: Policy engine — navigate/human-secure allows (exit 0) + run: | + python3 scripts/bearbrowser-policy-engine.py \ + --action navigate --profile human-secure --dry-run + + - name: Policy engine — request_credential/agent-runtime denies (exit 3) + run: | + output=$(python3 scripts/bearbrowser-policy-engine.py \ + --action request_credential --profile agent-runtime --dry-run 2>&1) || code=$? + if [ "${code:-0}" -ne 3 ]; then + echo "ERROR: expected exit code 3, got ${code:-0}" + echo "$output" + exit 1 + fi + echo "$output" | grep -q '"decision": "deny"' || { + echo "ERROR: decision field not 'deny'" + echo "$output" + exit 1 + } + echo "PASS: credential deny for agent-runtime" + + - name: Policy engine — run_automation/agent-runtime holds (exit 2) + run: | + output=$(python3 scripts/bearbrowser-policy-engine.py \ + --action run_automation --profile agent-runtime --dry-run 2>&1) || code=$? + if [ "${code:-0}" -ne 2 ]; then + echo "ERROR: expected exit code 2, got ${code:-0}" + echo "$output" + exit 1 + fi + echo "$output" | grep -q '"decision": "hold"' || { + echo "ERROR: decision field not 'hold'" + echo "$output" + exit 1 + } + echo "PASS: automation hold for agent-runtime" + + - name: Policy engine — unknown action exits 1 + run: | + python3 scripts/bearbrowser-policy-engine.py \ + --action nonexistent_action --profile human-secure --dry-run && { + echo "ERROR: expected exit code 1 for unknown action" + exit 1 + } || true + echo "PASS: unknown action rejected" + + - name: Policy engine — decision record has required fields + run: | + output=$(python3 scripts/bearbrowser-policy-engine.py \ + --action summarize_page --profile agent-runtime --dry-run) + for field in decisionId action profile decision risk schemaVersion product timestamp engine; do + echo "$output" | grep -q "\"$field\"" || { + echo "ERROR: missing required field '$field' in decision output" + echo "$output" + exit 1 + } + done + echo "PASS: all required fields present" + + - name: Receipt creation — syntax and dry-run + run: | + python3 -m py_compile scripts/bearbrowser-create-receipt.py + python3 -m py_compile scripts/bearbrowser-update-receipt.py + echo "PASS: receipt scripts compile cleanly" + + - name: Policy contract — local-default-actions.yaml is parseable + run: | + python3 - <<'PY' + from pathlib import Path + text = Path("policy/local-default-actions.yaml").read_text() + required_actions = [ + "navigate", "summarize_page", "compare_tabs", "share_page_with_agent", + "request_credential", "request_autofill", "download_file", "upload_file", + "read_clipboard", "write_clipboard", "run_automation", + "write_memory_candidate", "commit_memory", + ] + for action in required_actions: + assert action in text, f"Missing action: {action}" + assert "agent-runtime" in text, "Missing agent-runtime profile overrides" + print(f"PASS: all {len(required_actions)} required actions present") + PY + + - name: Automation receipt schema — required fields present + run: | + python3 - <<'PY' + import json + from pathlib import Path + schema = json.loads(Path("schemas/browser-automation-receipt.schema.json").read_text()) + required = schema.get("required", []) + expected = { + "schemaVersion", "receiptId", "sessionRef", "ownerRef", + "transport", "permissionScope", "origin", "userVisible", + "revocable", "policyDecisionRef", "evidenceRefs", "capturedAt", "status", + } + missing = expected - set(required) + assert not missing, f"Schema missing required fields: {missing}" + print(f"PASS: schema has {len(required)} required fields") + PY diff --git a/docs/anti-fingerprint-T3-fonts-text-metrics.md b/docs/anti-fingerprint-T3-fonts-text-metrics.md new file mode 100644 index 0000000..90721d8 --- /dev/null +++ b/docs/anti-fingerprint-T3-fonts-text-metrics.md @@ -0,0 +1,143 @@ +# T3 — Font & Text-Metric Uniformity (implementation spec) + +Status: spec / not yet implemented. Owner: anti-fingerprint track. +Tranche: T3 (architecture — needs LibreWolf source patches + packaging, not just prefs). + +## 1. Problem — two distinct vectors, both wide open + +Bundling glyph *files* is necessary but **not sufficient**. There are two separate +fingerprint surfaces in text: + +1. **Font enumeration** — "which fonts exist." Measured today (Gecko 141 + our RFP + profile): **13 of 14 macOS system fonts still detectable** (Zapfino, Papyrus, + Hoefler Text, Optima, …). `layout.css.font-visibility` = 1, 2, and 3 are + *identical* — the pref does not hide OS-bundled fonts. No pref closes this. + +2. **Text-metric / kerning readback** — "how text measures." A page renders a + string and reads the exact transform back. Measured today: + + ``` + measureText("AVA To Wa Yo PAW fjffifl 9.9.9.9").width + control (bare FF) = 453.54998779296875 + human-secure RFP = 453.54998779296875 ← RFP does NOTHING to this + actualBoundingBoxRight = 453.015625, ascent = 23.28125, descent = 6.75 … + getBoundingClientRect().width = 453.54998779296875 + ``` + + That single float encodes **font selection + shaper (HarfBuzz GPOS kerning) + + platform rasterizer (CoreText / DirectWrite / FreeType)** at ~15 digits of + sub-pixel precision. RFP leaves it fully exposed. This is the "huge fingerprint" + — bundling fonts alone will *not* fix it, because the same font file still + rasterizes to different advance widths on different platforms. + +Harness vectors tracking these (in `scripts/measure-fingerprint.mjs`): +`non-base fonts` (target `0/14`) and `text-metric readback` (target `int`). +Both currently `LEAKING`. + +## 2. Design — three layers (each strictly necessary) + +### Layer A — Bundle fonts and make them exclusive +Goal: every user resolves `serif`/`sans-serif`/`monospace` to the **same** font, +and no system font is visible to enumeration *or* fallback. + +- **Font set** (metric-compatible, broad coverage — the Tor/Mullvad lineage): + - Arimo (Arial-metric), Tinos (Times-metric), Cousine (Courier-metric) + — Croscore/Liberation, metric-compatible with the MS core fonts so existing + site layouts don't reflow. + - Noto Sans / Noto Serif / Noto Sans Mono for Unicode coverage (CJK, Cyrillic, + Arabic, Hebrew, etc.) — prevents "tofu" (missing-glyph) differences, which are + themselves a fingerprint. + - One emoji font (Noto Color Emoji or Twemoji) so emoji raster is uniform. +- **Packaging:** ship the set in the app bundle (`Contents/Resources/fonts/`) and + activate via `gfx.bundled-fonts` (verify exact pref/level on the target build — + treat pref names as unverified until checked, per the bootstrapAddress/http3 + lessons). +- **Exclusivity (the load-bearing part — a source patch, not a pref):** restrict + `gfxPlatformFontList` to the bundled allowlist so system fonts are invisible to + both `local()` fallback and enumeration. This is Tor's approach; `font-visibility` + alone is proven insufficient (§1). Without this patch, Layer A is cosmetic. +- **Generic remap (prefs):** point the CSS generics at the bundled fonts — + `font.name.serif.x-western=Tinos`, `font.name.sans-serif.x-western=Arimo`, + `font.name.monospace.x-western=Cousine`, and the matching `font.name-list.*` + fallbacks for each script. Set `layout.css.font-visibility.* = 1`. + +Outcome: font enumeration → `0/14`, and font *selection* entropy removed +(everyone's `sans-serif` is Arimo). Per-platform uniform. + +### Layer B — Deterministic shaping & kerning +Goal: advance widths come from the **font's own tables** (hmtx + GPOS), computed +identically on every OS — not from the platform rasterizer's optical adjustments. + +- Force HarfBuzz shaping (Firefox default; confirm no platform shaper path is hit). +- Disable platform hinting / optical sizing / tracking that perturbs advances: + - macOS: disable CoreText optical/tracking adjustments so metrics derive from + the font, not from CoreText's display tuning. + - Disable subpixel-positioned advance variation (`gfx.text.*` / + `gfx.font_rendering.*` — exact prefs to be confirmed on-build). +- This shrinks cross-rasterizer variance but, realistically, will **not** fully + equalize sub-pixel output across OSes. That residue is what Layer C guarantees. + +### Layer C — Metric readback normalization ("do our own kerning") +Goal: whatever the rasterizer produces, the **API the page can read** returns a +deterministic, platform-independent value. This is the belt that makes the +guarantee, and it is a `nsRFPService` patch gated on `privacy.resistFingerprinting` +— the Gecko-native, unbypassable equivalent of what the legacy WKWebView JS shield +did with `measureText` noise and `_bbOff`. + +Normalize **every** surface that exposes the transform: +- `CanvasRenderingContext2D.measureText` → quantize `width` **and all `TextMetrics` + fields** (`actualBoundingBox*`, `fontBoundingBox*`, `emHeight*`). +- `Element.getBoundingClientRect` / `getClientRects` and + `Range.getBoundingClientRect` / `getClientRects` for text. +- `SVGTextContentElement.getComputedTextLength` / `getSubStringLength` / + `getExtentOfChar` / `getStartPositionOfChar`. + +**Quantization policy (this is the "our own kerning" decision):** +- *Minimum (T3b):* round to integer CSS px. Simple, Tor-ish, kills sub-pixel + entropy. Some layout shift; sites tolerate it. +- *Best (T3c):* compute advances **directly from font units** — + `advance = Σ hmtx[glyph] (± GPOS kerning) × fontSize / unitsPerEm`, rounded to a + fixed grid. Because the bundled font's tables are identical everywhere, this + yields the **same number on every OS** — true cross-platform uniformity, derived + from the font, not the rasterizer. This is literally "doing our own kerning." + +**Critical distinction — uniform, NOT randomized.** Canvas and audio use +*per-session randomization* (unlinkable noise). Text metrics must be the opposite: +**uniform** — every user and session returns the identical value. Randomizing text +metrics would split the cohort and *add* entropy. The quantizer must be +deterministic and session-stable. + +## 3. Verification +- `scripts/measure-fingerprint.mjs` already tracks `non-base fonts` (→ `0/14`) and + `text-metric readback` (→ `int`). After implementation both flip to cohort/normalized. +- Add kerning-pair cross-checks (AV/To/Wa/Yo + ligatures fj/ffi/fl) to ensure GPOS + output is uniform, and assert identical metrics across two OSes in CI if a Linux + runner is available (the cross-platform proof). +- Lock the targets into `verify-gecko-rfp.mjs` once met so they can't regress. + +## 4. Where each piece lands +| Piece | Mechanism | Location | +|-------|-----------|----------| +| Font files | packaging | app bundle `Resources/fonts/` + build step | +| Bundled-font activation, generic remap, visibility=1 | prefs | both `settings/profiles/*/user.js` | +| `gfxPlatformFontList` allowlist (exclusivity) | source patch | LibreWolf patch set | +| Shaping/hinting determinism | prefs + maybe patch | user.js + patch set | +| Text-metric quantizer | source patch | `nsRFPService` (gated on RFP) | +| Verification vectors | already present | `measure-fingerprint.mjs` | + +## 5. Risks +- **Layout reflow** from quantized/remapped metrics — minor; metric-compatible + fonts (Arimo/Tinos/Cousine) minimize it; Tor accepts the residual. +- **`local()` bypass** reaching system fonts — closed only by the allowlist patch, + not by prefs. Do not ship Layer A without it. +- **Coverage gaps** (CJK/emoji) producing tofu — bundle Noto + an emoji font. +- **Pref-name drift** — verify every pref on the actual build (we have a harness + habit for this now); a misspelled pref is a silent no-op. + +## 6. Rollout order +1. **T3a** (prefs + packaging + allowlist patch): font enumeration → `0/14`, + selection entropy gone. Per-platform uniform. +2. **T3b** (`nsRFPService` integer-round quantizer): `text-metric readback` → `int`. + Kills the sub-pixel transform readback. +3. **T3c** (font-unit-derived advances): identical metrics across OSes — the + strongest form, "our own kerning." diff --git a/docs/anti-fingerprint-T3-implementation-plan.md b/docs/anti-fingerprint-T3-implementation-plan.md new file mode 100644 index 0000000..7fc84d1 --- /dev/null +++ b/docs/anti-fingerprint-T3-implementation-plan.md @@ -0,0 +1,245 @@ +# T3 — Implementation Plan / Runbook (font + text-metric uniformity) + +Companion to `anti-fingerprint-T3-fonts-text-metrics.md` (the *why*). This is the +*how* — the no-holes execution plan. Authored ahead of build; the compile/ +integrate/ship steps happen on the build pipeline. + +Guiding rule: **fix it once at the lowest shared layer, not N times at each JS +API.** Every text-metric API is a *consumer* of one thing — the per-glyph advances +the shaper produces. Patch that, and canvas / layout / SVG / Range all inherit the +uniform value. Patching each API is whack-a-mole and *will* leave holes. + +## 0. Build-environment on-ramp (turnkey) + +You don't need to invent anything here — the project **already ships RFP/FPP +patches through this exact mechanism**: see `patches/fpp-canvas-fix.patch` and +`patches/ui-patches/website-appearance-ui-rfp.patch`, both registered in +`assets/patches.txt`. Our three T3 patches follow that precedent. + +### 0.1 The build repo +The LibreWolf-style BearBrowser build repo (the one containing `Makefile`, +`scripts/bearbrowser-patches.py`, `patches/`, `assets/patches.txt`, +`firefox-.source.tar.xz`) is checked out per build under +`build/workspaces/-/source/`. That `source/` **is** the repo to +work in for Gecko patches. (The product overlay — settings/policy/packaging — +stays in this repo and is layered by `apply-sourceos-overlays.sh`.) + +### 0.2 One-time toolchain setup +``` +cd +make bootstrap # extracts source + ./mach bootstrap --application-choice=browser + # (installs rust/clang/etc; uses assets/mozconfig.new) +``` + +### 0.3 Get a stable source tree to edit +``` +make patches # tar xf firefox-.source.tar.xz -> firefox-/ + # python3 scripts/bearbrowser-patches.py -> bearbrowser--/ +``` +Edit the **extracted, patched** tree `bearbrowser--/` (this is +the tree that vanished mid-session — it only exists after `make patches`). + +### 0.4 Where each T3 artifact lands +| Artifact | Location | Registration | +|----------|----------|--------------| +| W2 `gfxPlatformFontList` allowlist patch | `patches/anti-fp-font-allowlist.patch` | add to `assets/patches.txt` | +| W4/W5 `gfxShapedText` advance quantizer patch | `patches/anti-fp-text-metrics.patch` | add to `assets/patches.txt` | +| W6 `nsRFPService` audio-noise patch | `patches/anti-fp-audio.patch` | add to `assets/patches.txt` | +| W1 font files (Arimo/Tinos/Cousine/Noto/emoji) | `assets/fonts/` + packaging copy step | packaging script | +| W3 generic-remap prefs | this overlay `settings/profiles/*/user.js` | ships **atomically** with W1 | + +### 0.5 Inner dev loop for a patch (fast → full) +``` +# 1. edit files under bearbrowser--/ +# 2. produce the diff (the tree is a git checkout, or use diff -u vs pristine): +git -C bearbrowser-- diff -- gfx/thebes/gfxFont.cpp \ + > ../patches/anti-fp-text-metrics.patch # paths must be -p1 (a/ b/) +# 3. register it +echo "patches/anti-fp-text-metrics.patch" >> assets/patches.txt +# 4. FAST verify it applies cleanly (no compile): +make check-patchfail # scripts/check-patchfail.sh — catches apply/fuzz errors +# 5. FULL build + run when the patch applies: +make build # ./mach build (the slow step; needs 0.2) +make run # ./mach run (or `make package`) +``` +`make check-patchfail` is the tight loop — it tells you the diff applies before +paying for a compile. Gate every patch through it. + +### 0.6 Measure the *real* binary (not Playwright) +`measure-fingerprint.mjs` drives Playwright's Firefox, which only works on +Playwright's Juggler-patched build — it **cannot** drive a stock LibreWolf binary. +For the real build use Firefox's own remote protocol: +- `geckodriver` (WebDriver) pointed at the built binary, or Marionette (port 2828). +- Add a geckodriver adapter to the harness (or a thin Marionette probe runner) and + pass the binary via `BEARBROWSER_BIN`. The same `PROBE` payload is reused. +This is what clears the `screen WxH` (letterboxing) residual that headless +Playwright can't show, and confirms the 75% baseline holds on the real build. + +### 0.7 Definition of done (per work item) +Each work item is done when its harness vector flips and stays green: +W1/W2 → `non-base fonts = 0/14`; W4/W5 → `canvas text metric` & `layout text +metric = int` **and** identical across a Mac and a Linux runner (the cross-OS +proof); W6 → `audio (oac)` RANDOMIZED across sessions; W7 → `screen WxH` +normalized on the real binary. Then lock each target into `verify-gecko-rfp.mjs`. + +## 1. Complete readback surface (the holes register) + +Measured (Gecko 141, our RFP profile). Every row must end up uniform/quantized or +it is a hole. + +| Surface | Path | Measured now | Covered by | +|---------|------|--------------|------------| +| `CanvasRenderingContext2D.measureText` (+ all `TextMetrics` fields) | canvas → gfxTextRun | 453.549987… subpixel, RFP-noop | W4 helper | +| `Element.getBoundingClientRect` / `getClientRects` | layout → gfxTextRun | 453.549987… subpixel, RFP-noop | W4 | +| `Range.getBoundingClientRect` / `getClientRects` | layout → gfxTextRun | 453.549987… subpixel, RFP-noop | W4 | +| `Element.offsetWidth/Height`, `scrollWidth/Height`, `clientWidth/Height` | layout | 454 integer, but carries metric | W4 (uniform advances → uniform box) | +| `getComputedStyle().width` etc. (resolved px) | layout | depends on box | W4 | +| `SVGTextContentElement.getComputedTextLength` / `getSubStringLength` / `getExtentOfChar` / `getStart/EndPositionOfChar` / `getRotationOfChar` | SVG text → gfxTextRun | 439.600006… subpixel, RFP-noop | W4 | +| Font enumeration via `measureText` width deltas | font list | 13/14 macOS fonts, RFP-noop | W1+W2 | +| `document.fonts.check()` / `FontFaceSet`, `@font-face local()` | font list | system fonts reachable | W2 | +| Emoji / CJK glyph raster (tofu differences) | font list | OS-dependent | W1 (bundle Noto+emoji) | +| Audio `OfflineAudioContext` (related residual) | WebAudio | stable, RFP-noop | W6 | + +Note the two metric paths produce *different* numbers (canvas 453.55 vs SVG +439.60) — proof a single-API fix is insufficient; the quantizer must be a shared +helper called from each path (§2). + +## 2. Architecture — `nsRFPService` helper + DOM call sites + +**Correction (grounded in the existing `patches/fpp-canvas-fix.patch`).** The first +draft proposed quantizing in `gfxShapedText` (gfx layer) as a single chokepoint. +Reading how this tree actually plumbs RFP shows why that's wrong: RFP gating needs +the **principal + cookie-jar settings** to call `ShouldResistFingerprinting`, and +those are only available at the **DOM layer**, not deep in `gfx/thebes`. The canvas +randomization proves the real pattern — it lives in `dom/canvas/*` and calls +`nsRFPService::RandomizePixels(GetCookieJarSettings(), PrincipalOrNull(), …)`: + +``` +patches/fpp-canvas-fix.patch: + dom/canvas/CanvasRenderingContext2D.cpp → nsRFPService::RandomizePixels(...) + dom/canvas/ClientWebGLContext.cpp → gfxUtils::GetImageBufferWithRandomNoise(...) + (gated by ImageExtraction::Randomize / EfficientRandomize, principal in hand) +``` + +So the real architecture mirrors that: **one `nsRFPService` helper, called from each +DOM entry point that exposes a text metric.** Not a single gfx chokepoint, but a +single *helper* with a few faithful call sites — exactly how canvas already works. + +``` +nsRFPService::SpoofTextMetrics(cookieJarSettings, principal, &metrics) // new helper + ▲ ▲ ▲ ▲ + CanvasRenderingContext2D::MeasureText Element/Range BCR SVGTextContentElement::* + (dom/canvas) (dom/base + layout) (dom/svg) +``` + +Trade-off vs the chokepoint dream: a few more call sites, but each one *has* the +RFP context and matches the established pattern — far more likely to compile and +be accepted than reaching into `gfxShapedText` without a principal. The quantizer +math (round to integer CSS px, or derive from font units — §W4) is unchanged; only +*where* it runs moves up to the DOM, beside the existing canvas RFP code. + +Per-glyph note: because all these DOM APIs ultimately read advances that gfx +already rounds to app-units (gfxFont.h: "each glyph advance is always rounded to +the nearest appunit"), the helper quantizes the **returned metric** (total advance ++ bounding-box fields) deterministically — uniform, never randomized (§W4). + +## 3. Work items + +### W1 — Font bundle + packaging +- Vendor a metric-compatible set: **Arimo** (Arial-metric), **Tinos** (Times), + **Cousine** (Courier) + **Noto Sans/Serif/Mono** (Unicode coverage) + **Noto + Color Emoji**. License: Apache-2.0/OFL — record in `branding/`/licensing. +- Place in app bundle (`Contents/Resources/fonts/`); add a build step in the + packaging path (`scripts/bearbrowser-overlay-binary.sh` / + `apply-sourceos-overlays.sh`) that copies them and activates bundled fonts. +- Pref: `gfx.bundled-fonts.*` (verify exact name/level on build — treat as + unverified, per the bootstrapAddress/http3 lessons). + +### W2 — Font exclusivity (allowlist) — **source patch, load-bearing** +- `layout.css.font-visibility` is proven insufficient (1/2/3 all leak 13/14 macOS + fonts). The real lever is the platform font list allowlist. +- Patch `gfxPlatformFontList` so that, when RFP/bundling is on, the registered + family list is **only** the bundled families — system families are never added, + so they are invisible to enumeration **and** `local()` fallback. (Gecko already + has allowlist plumbing à la Tor; populate it with the bundled set and force the + system list empty.) +- Verify: `non-base fonts` vector → `0/14`. + +### W3 — Generic remap + prefs (lands WITH W1/W2, never before) +- `font.name.serif.x-western=Tinos`, `…sans-serif…=Arimo`, `…monospace…=Cousine`, + plus `font.name-list.*` per script (Noto for CJK/Arabic/Hebrew/Cyrillic). +- `layout.css.font-visibility.* = 1`. +- **Gate:** these prefs are inert/harmful without the font files + allowlist — + shipping them first makes `sans-serif` resolve to a missing Arimo and fall back + unpredictably. Ship W1+W2+W3 as one atomic change. + +### W4 — Text-metric quantizer (`nsRFPService` helper + DOM call sites) +Following the `fpp-canvas-fix.patch` pattern (§2), not a gfx chokepoint. +- **New helper** in `nsRFPService` (decl in `.h`, body in `.cpp`), styled like + `RandomizePixels` — takes cookie-jar settings + principal, early-returns unless + `ShouldResistFingerprinting(...)`, then quantizes in place: + - *T3b (minimum):* round each exposed length to whole CSS px. + - *T3c (best, "our own kerning"):* derive from font units — + `len = round(Σ hmtxAdvance ± GPOS × size / unitsPerEm)` — font-intrinsic, + identical on every OS, independent of the platform rasterizer. +- **Call sites** (each has the principal in hand, like the canvas patch): + - `dom/canvas/CanvasRenderingContext2D::MeasureText` — quantize `width` + every + `TextMetrics` field (`actual/font/emHeight*`). + - `Element::GetBoundingClientRect` / `GetClientRects`, `Range` equivalents + (`dom/base`, via `nsLayoutUtils`) — quantize the returned rect for text. + - `SVGTextContentElement::{GetComputedTextLength,GetSubStringLength, + GetExtentOfChar,…}` (`dom/svg`). +- **Uniform, not randomized** — every session/user returns the same value. + (Contrast canvas/audio, which randomize. Getting this backwards adds entropy.) +- Verify: `canvas text metric` and `layout text metric` vectors → `int`. + +### W5 — Deterministic advance source (Layer B, esp. macOS) +- Ensure advances come from HarfBuzz/hmtx, not CoreText optical/tracking + adjustments (which perturb advances per-platform). Disable CoreText advance + tuning in `gfxMacFont` advance retrieval, or compute advances from the font + tables directly. Without this, W4's input still varies pre-quantization on + borderline-rounding glyphs. + +### W6 — Audio randomization (fold in while patching nsRFPService) +- RFP randomizes canvas but not `OfflineAudioContext` (measured: stable). Add a + per-session noise to the WebAudio output in `nsRFPService` (the Gecko-native + version of what the WKWebView shield did). **Randomized** (unlike text metrics). +- Verify: `audio (oac)` vector → RANDOMIZED across sessions. + +### W7 — Verification +- `measure-fingerprint.mjs` already tracks: `non-base fonts`→`0/14`, + `canvas text metric`→`int`, `layout text metric`→`int`, `audio (oac)`→randomized. +- Add `BEARBROWSER_BIN` runs against the real build to also clear the `screen WxH` + (letterboxing) residual that headless can't show. +- **Cross-OS proof:** run the same probe on a Linux CI runner; assert identical + text metrics Mac vs Linux (the real test of W4/W5 uniformity). +- Lock all targets into `verify-gecko-rfp.mjs` once met. + +## 4. Sequencing & gates +1. **W1+W2+W3 atomic** (fonts + allowlist + remap) → kills font enumeration and + font-selection entropy. Per-platform uniform. `non-base fonts`→`0/14`. +2. **W4 (+W5)** → kills the metric readback transform. `*text metric`→`int`, + cross-OS uniform. +3. **W6** → closes the audio residual. +4. **W7 on real binary** → clears `screen WxH`; full scorecard. + +Gate: never ship W3 prefs without W1/W2 in the same change (broken generics). + +## 5. Risk / holes register +| Risk | Hole it would leave | Mitigation | +|------|--------------------|-----------| +| Fix only `measureText` | layout + SVG still leak (different number) | W4 sits below all consumers | +| `local()` reaches system fonts | enumeration bypass | W2 allowlist (not just visibility pref) | +| CoreText optical advances | W4 input varies pre-round | W5 | +| Quantize → layout reflow | minor visual shift | metric-compatible fonts; integer round is uniform | +| Randomize text metrics by reflex | splits cohort, adds entropy | W4 is explicitly UNIFORM | +| CJK/emoji tofu | OS-dependent missing-glyph fingerprint | bundle Noto + emoji (W1) | +| Pref-name drift | silent no-op | verify every pref on-build (harness habit) | +| offsetWidth still differs | coarse metric leak | follows from uniform advances (W4) | + +## 6. Rollback / safety +- All behaviour gated on `privacy.resistFingerprinting` (+ a `bearbrowser.fonts. + bundled` master) so it can be disabled without a rebuild for debugging. +- Patches isolated in the LibreWolf patch set; revert = drop the patch + prefs. +- Keep the bundled fonts behind the same flag so a packaging issue degrades to + stock behaviour rather than no-fonts. diff --git a/docs/anti-fingerprint-forgejo-build.md b/docs/anti-fingerprint-forgejo-build.md new file mode 100644 index 0000000..be6c1b5 --- /dev/null +++ b/docs/anti-fingerprint-forgejo-build.md @@ -0,0 +1,62 @@ +# Anti-fingerprint: building + measuring on the Forgejo pipeline + +How the anti-fingerprint Gecko patches get compiled and measured by the project's +existing Firefox build CI (Forgejo/Woodpecker), respecting the read-only mirror. + +## Constraint +The build repo is `SourceOS-Linux/librewolf-source-mirror`, which AGENTS.md marks +**read-only** (no direct commits except via the approved sync). So our patches are +**not** committed there. They live in this overlay at +`gecko-patches/anti-fingerprint/` and are injected into a transient workspace clone +at build time. + +## How injection works (already wired) +`scripts/apply-sourceos-overlays.sh` clones the mirror into +`build/workspaces/-/source`, then: +- copies `gecko-patches/anti-fingerprint/*.patch` → `/source/patches/` +- appends them to `/source/assets/patches.txt` (canvas, then audio) + +`bearbrowser-patches.py` (run by `make`) then applies them with `patch -p1` to the +extracted Firefox source. **Verified** with `check-patchfail.sh`: the full upstream +sequence + both patches apply to a fresh Firefox 150.0.1 with zero rejects. + +## Build + measure (the Forgejo job) +The pipeline step is the standard overlay build, then the geckodriver measurement: + +```sh +# 1. Materialize the patched workspace (injects our patches) +scripts/apply-sourceos-overlays.sh --profile human-secure --ref latest + +# 2. Build (the heavy step — needs the bootstrapped toolchain on the runner) +ws="$(find build/workspaces -maxdepth 2 -type d -name source | tail -1)" +( cd "$ws" && make bootstrap && make build ) + +# 3. Measure the REAL binary (authoritative — sees what Playwright masks) +bin="$(find "$ws" -type f \( -name firefox -o -name bearbrowser \) -path '*dist*' \ + | grep -vE '\.dSYM' | head -1)" +npm ci # geckodriver + selenium-webdriver are devDependencies +export PATH="$PWD/node_modules/.bin:$PATH" +node scripts/measure-fingerprint.mjs --profile human-secure --bin "$bin" +``` + +Step 3 is the same engine-agnostic measurement used in the GitHub Tier-3 job +(`.github/workflows/anti-fingerprint.yml`). It reports the real-binary scorecard +and — critically — authoritatively answers the open questions Playwright can't: +the **timezone** spoof (Playwright sets `TZ` and masks it) and real **letterboxing**. + +## What "green" means here +After the build, the measurement should show, on the real binary: +- `canvas text metric` → `int` (W4 canvas patch compiled in) +- `audio (oac)` → randomized across two sessions (W6 patch compiled in) +- `non-base fonts` → `0/14` (bundled fonts + `font.system.whitelist` active) +- `rfp_timezone` → offset 0 / neutral — **if not, that's the one real gap to fix** + (add a `TZ=UTC` launcher env + confirm RFP timezone). + +Lock any newly-confirmed behaviours into `verify-gecko-rfp.mjs` once measured. + +## Trigger +The mirror's own Forgejo workflow builds the mirror's patch set; to build *with* +our overlay patches, the job must run `apply-sourceos-overlays.sh` first (above). +Wire that as a Forgejo workflow in the overlay/CI of your choice, or run the GitHub +Tier-3 job (`workflow_dispatch`) against a large/self-hosted runner — both paths +produce the same patched binary + scorecard. diff --git a/docs/tor-mode.md b/docs/tor-mode.md new file mode 100644 index 0000000..b6563ee --- /dev/null +++ b/docs/tor-mode.md @@ -0,0 +1,156 @@ +# BearBrowser Tor mode + +Closing the last gap our anti-fingerprint work can't reach with prefs: the +**network layer** (TLS ClientHello / JA3-JA4, HTTP/2 frame fingerprint, and the +real client IP). RFP normalizes the JS surface; it does nothing about how our TCP +connections look on the wire or where they originate. Only routing through Tor +fixes that — by moving the network fingerprint to Tor's *uniform exit*. + +## The strategy: a tier, not a default +BearBrowser runs in one of two modes; the user chooses per session: + +- **BearBrowser mode** (default) — our best-in-class *direct-connection* hardening: + text-metric quantization, audio farble, bundled fonts, full RFP. Beats Tor and + Brave on the JS surface. Fast. +- **Tor mode** — route through Tor + **blend into the Tor Browser cohort**. Gets + the network-layer anonymity Tor has. Slower; some sites challenge Tor exits. + +Offering both is how we genuinely lead: **everything Tor offers (as a mode) PLUS +our direct-mode innovations Tor doesn't have.** + +## The cohort paradox (the thing to get right) +Tor's power is that ~millions of Tor Browser users look **byte-for-byte +identical**. Anonymity = hiding in that crowd. **Uniqueness is the enemy of +anonymity.** So if "BearBrowser over Tor" carried our *extra* protections +(text-metric quantizer, audio farble, our exact build), we'd be a *tiny, distinct +cohort riding Tor* — easier to single out than a default Tor Browser user. + +## Spoof normality — don't just turn our tech off +The naive fix is "disable our extra protections in Tor mode." That is only correct +when **off already equals the cohort value.** Turning a protection off can leave us +in a state that *differs* from the Tor crowd — which is exposure, not blending. So +the rule is: **spoof the cohort's normal value; only disable when disabling IS that +value.** Three cases: + +| Surface | What we do in Tor mode | Why it's the normal value | +|---|---|---| +| Text-metric quantizer, audio farble | **Disable** | Tor Browser does *neither* — its un-touched values ARE the cohort normal. Off = stock RFP = exactly what Tor emits. | +| Fonts | **Keep** Croscore (Arimo/Tinos/Cousine) | Tor ships the same set — we already match. | +| OS identity (UA / platform / oscpu) | **Actively spoof → Windows** | Tor forces *every* platform to Windows so Mac/Linux hide in the majority. Off would expose our real OS. Needs a patch (see §OS spoof). | +| Locale, WebGL renderer | **Force** en-US, mask GPU | Tor spoofs these; stock RFP alone would leak real locale/GPU. | + +> You cannot be *uniquely-best-BearBrowser* and *network-anonymous* at the same +> time. You pick per session. That's the honest physics of it. + +## §OS spoof — the biggest lever (needs a patch, not a pref) +Tor Browser presents `Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:140.0) +Gecko/20100101 Firefox/140.0` for **all** desktop platforms. RFP computes its own +UA and **ignores** `general.useragent.override`, so this cannot be a pref — it is an +`nsRFPService` patch across ~8 use-sites (UA, platform, oscpu, appVersion, worker +equivalents, maxTouchPoints). Fully specified in +`gecko-patches/anti-fingerprint/anti-fp-tor-os-spoof.SPEC.md`; the `tor-mode` +profile already sets the trigger pref `bearbrowser.tor-mode.spoof-os=windows` +(a no-op until the patch lands). It is a *coordinated* change — one missed site = +an inconsistent identity that is worse than no spoof — so it is authored with a +compiler in the loop, not blind. + +## §version — ride the 140 line, not 150 +Default BearBrowser is **Firefox 150**; the live Tor cohort is **Firefox 140 ESR**. +Building Tor mode on 150 leaves `Firefox/150.0` ≠ `Firefox/140.0`, and version-probes +expose the real engine — a 150-over-Tor is a distinct cohort from 140-over-Tor. + +**The fix is operational, and now wired in.** RFP freezes the spoofed UA to the +major only (`140.0`), independent of point release — so *any* 140-line build is +fingerprint-equivalent to Tor's 140.x ESR at the UA layer. The upstream mirror +already carries the 140 line (`140.0.4-1`), so `apply-sourceos-overlays.sh +--profile tor-mode` now **auto-pins `latest` to the newest 140-line tag** (override +with `BEARBROWSER_TOR_COHORT_MAJOR` or an explicit `--ref`). No new base, no version +spoofing (which would invite detectable inconsistencies and is not done). + +### Current security, not stale 140.0.4 — without touching the mirror +The mirror is a *verbatim* `--mirror` of LibreWolf upstream (the sync `--prune`s +anything not upstream, so we cannot park a custom `esr140` branch there). LibreWolf +tracks Firefox *release*, so its newest 140 tag is `140.0.4` (mid-2025) — ~a year of +ESR security backports behind the `140.x` ESR Tor actually ships. + +We close that gap **without modifying the mirror**, because LibreWolf's Makefile +fetches `archive.mozilla.org/.../releases/$(version)/source/firefox-$(version) +.source.tar.xz` purely by version string, and Mozilla hosts the ESR source at the +*same* path. So `apply-sourceos-overlays.sh --profile tor-mode` now: +1. pins the 140-line mirror tag (`140.0.4-1`) for LibreWolf's **build scripts**, then +2. **overrides the workspace `version` file to the current ESR point** + (`140.12.0esr` default; verified present on archive.mozilla.org), so `make fetch` + pulls current-security ESR source. RFP still freezes the UA to `140.0`, so the + cohort match holds. Override via `BEARBROWSER_TOR_FIREFOX_VERSION` (set empty to + fall back to the mirror's `140.0.4` release pin). + +The result: **cohort-matching UA *and* current ESR security**, no mirror change. + +### Open verification (the remaining risk) +- **Patch-apply on the ESR tree.** LibreWolf's `140.0.4`-era patch stack and our + anti-fp patches were verified against *release* source; ESR backports can shift + hunks. Run `check-patchfail.sh` against the ESR source before trusting a Tor-mode + build (same major → likely fuzz-absorbable, not guaranteed). +- **OS-spoof line numbers.** The SPEC offsets were resolved on the 150 tree; re-resolve + against `firefox-140.12.0esr` when authoring the patch. + +Net: Tor mode gives **full network-layer anonymity** plus a JS identity that is +version-aligned to the cohort (`140.0`) on current-security ESR source — with the +OS-spoof patch (§OS) closing the last identity gap. + +## Why NOT the others +- **JonDonym / JAP ("johndo"):** defunct — the mixes shut down ~2021, client + unmaintained. Skipped. +- **I2P / Nym / Lokinet (mixnets):** each *additional* network fragments the + cohort and they're high-latency / early-stage. Tor has the giant crowd, which + is the entire point. Deferred indefinitely. +- **obfs4 / Snowflake / meek (pluggable transports):** YES — but they ride *with* + Tor (censorship circumvention), so they land in Phase 2, not as separate nets. + +## Phase 1 — what's shipped (this profile) +`settings/profiles/tor-mode/` — applied on top of the human-secure RFP baseline: +- **SOCKS routing, fail-closed:** all traffic → `127.0.0.1:9050`, `socks_remote_dns` + (DNS through Tor, no leak), `failover_direct=false` (never silently go direct). +- **Every proxy-bypass vector killed:** WebRTC off, DoH/TRR off (Tor does DNS), + IPv6 off, HTTP/3/QUIC off (UDP doesn't traverse SOCKS5 cleanly), no + prefetch/predictor/speculative connects. +- **Proxy LOCKED via enterprise policy** (`Proxy` + `Locked: true`) so no site or + script can change it and deanonymize the user. +- **Cohort alignment (spoof normality):** RFP on; bundled Croscore fonts kept + (matches Tor); our unique targets disabled via + `privacy.fingerprintingProtection.overrides=-CanvasTextMetrics,-WebAudioFarble` + (off = stock RFP = Tor's value); plus `privacy.spoof_english=2` and + `webgl.enable-debug-renderer-info=false` to force the locale/GPU values Tor + spoofs but stock RFP would leak. OS-spoof trigger pref set (patch pending, §OS). + +### Running Phase 1 (external tor) +Phase 1 assumes a standalone Tor daemon is already listening on `127.0.0.1:9050`: +```sh +tor # or: brew services start tor (macOS), systemctl start tor (Linux) +# then build/launch BearBrowser with the tor-mode profile: +scripts/apply-sourceos-overlays.sh --profile tor-mode --ref latest +``` +Verify routing at `https://check.torproject.org`. + +## Phase 2 — bundle + launch Tor +- Vendor the `tor` binary + a control/launcher (à la Tor Browser's tor-launcher): + start tor on a private port, wait for bootstrap, wire the proxy. +- Bundle **obfs4 / Snowflake / meek** pluggable transports for censored networks. +- A "Tor mode" toggle in the shell (no separate profile build). + +## Phase 3 — polish +- Security slider (Standard / Safer / Safest), `.onion` handling + onion-location, + "New Identity" / "New Circuit", first-party stream isolation by SOCKS auth. + +## Honest limitations (don't oversell) +- **Not byte-identical to Tor Browser.** The two open items are the OS spoof (§OS, + patch pending) and the engine version (§version — we are FF150, the cohort is FF140 + ESR; the real fix is building Tor mode on ESR 140). We are *aligned to* the Tor + cohort, not yet indistinguishable. The **network-layer anonymity (Tor exit) is + full** regardless; the JS-fingerprint blend-in is best-effort and improves as we + ride the same ESR and land the OS-spoof patch. +- **The `overrides` disable of our custom targets must be verified on the real + build** — the override-syntax handling of patch-added RFPTargets is unconfirmed. +- Tor mode is **only as safe as the leak list is complete** — every new web API + that can open a non-proxied socket is a potential deanonymization and must be + audited (this is why Tor Browser is years of work, not a pref file). diff --git a/gecko-patches/anti-fingerprint/README.md b/gecko-patches/anti-fingerprint/README.md new file mode 100644 index 0000000..d1aa8af --- /dev/null +++ b/gecko-patches/anti-fingerprint/README.md @@ -0,0 +1,77 @@ +# Anti-fingerprint Gecko patches (SourceOS overlay) + +Gecko source patches for the BearBrowser anti-fingerprint track (tranche T3). +Authored against **Firefox 150.0.1** source (`firefox-150.0.1.source.tar.xz`), +following the existing in-tree pattern (`patches/fpp-canvas-fix.patch`). + +## Wiring into the build +Each patch is a `patch -p1` unified diff. Integration is **automatic and +mirror-safe**: `scripts/apply-sourceos-overlays.sh` copies these into the +transient build-workspace clone and appends them to `assets/patches.txt` +(canvas before audio), so `bearbrowser-patches.py` applies them during the build. +The patches live canonically HERE in the overlay — never committed to the +read-only `librewolf-source-mirror`. + +**Verified (2026-06-18):** `check-patchfail.sh` applied the **full upstream patch +sequence (~40 LibreWolf patches) + both of ours** to a fresh Firefox 150.0.1 +extraction with **zero rejects** — "All patches were applied successfully." So +these are no longer just "applies to pristine"; they apply in the real build +order (the `fpp-canvas-fix.patch` co-location on CanvasRenderingContext2D.cpp / +nsRFPService.cpp is handled by patch offset detection). Remaining gate: `./mach +build` (compile) on a capable runner / the Forgejo pipeline. + +## Patches + +### `anti-fp-canvas-text-metrics.patch` — status: APPLIES CLEANLY, compile-pending +Covers the **canvas** text-metric readback (the first W4 call site). + +- Adds `RFPTarget::CanvasTextMetrics` (id 81) to `RFPTargets.inc`. +- In `CanvasRenderingContext2D::DrawOrMeasureText` (the `MEASURE` branch), + gates on `nsContentUtils::ShouldResistFingerprinting(doc, RFPTarget:: + CanvasTextMetrics)` and quantizes **every** `TextMetrics` field (`width`, + `actualBoundingBox*`, `fontBoundingBox*`, `emHeight*`, baselines) to whole CSS + px via `std::round`. **Uniform, never randomized** (randomizing text metrics + would split the RFP cohort — see the T3 spec). + +Verified: applies and reverses cleanly against pristine Firefox 150.0.1 +(`patch -p1 --dry-run` + reverse). Grounded APIs confirmed present in-tree: +the `(const Document*, RFPTarget)` `ShouldResistFingerprinting` overload, +`std::round` (already used in the file), `nsContentUtils.h`/`nsRFPService.h` +includes. **Not yet compiled** — `./mach build` is the remaining gate. + +Flips harness vector `canvas text metric` → `int`. Does NOT cover the layout +(`getBoundingClientRect`/`Range`) or SVG (`getComputedTextLength`) paths — those +are separate call sites (see the T3 implementation plan, §W4) and remain TODO. + +### `anti-fp-audio.patch` — status: APPLIES CLEANLY, compile-pending (W6) +Closes the audio fingerprint residual — the one vector where Brave currently beats +us. RFP randomizes canvas but **not** WebAudio, so the OfflineAudioContext hash is +stable across sessions (measured). This adds a per-session, inaudible (±1e-7 +multiplicative) farble to the audio sample data so the hash is unlinkable across +sessions, gated on the new `RFPTarget::WebAudioFarble`. + +Touches 5 files: adds the RFPTarget, an `nsRFPService::FarbleAudioData()` helper +(modeled on `RandomizePixels`, per-session static factor from `RandomUint64`), and +calls it from the read paths — `AudioBuffer::RestoreJSChannelData` (the single +materialization point feeding `getChannelData`/`copyFromChannel`) and +`AnalyserNode::GetFloat{Frequency,TimeDomain}Data`. Byte variants are skipped +(quantized to 0–255, a 1e-7 farble is a no-op there — not a hole). **Uniform-noise +caveat:** per-session (per-process), not yet per-origin like Brave — defeats +cross-session linkage (the measured residual); per-origin is a follow-up. + +Verified: applies + reverses cleanly on pristine Firefox 150.0.1, **and sequences +cleanly after `anti-fp-canvas-text-metrics.patch`** (both edit `RFPTargets.inc` at +non-overlapping locations). Forward-declares `nsIGlobalObject` in nsRFPService.h. +**Not yet compiled.** Flips harness vector `audio (oac)` → randomized on the real +build. + +### Patch order (assets/patches.txt) +Register in this order; they're independent but verified to apply in sequence: +1. `anti-fp-canvas-text-metrics.patch` +2. `anti-fp-audio.patch` + +### Remaining TODO +- `anti-fp-layout-text-metrics.patch` (Element/Range BCR via nsLayoutUtils) +- `anti-fp-svg-text-metrics.patch` (SVGTextContentElement::*) +- W2 font allowlist is shipped as **prefs** (`font.system.whitelist` + bundle), not + a patch — see `packaging/bundled-fonts/`. diff --git a/gecko-patches/anti-fingerprint/anti-fp-audio.patch b/gecko-patches/anti-fingerprint/anti-fp-audio.patch new file mode 100644 index 0000000..ea81561 --- /dev/null +++ b/gecko-patches/anti-fingerprint/anti-fp-audio.patch @@ -0,0 +1,141 @@ +--- a/toolkit/components/resistfingerprinting/RFPTargets.inc ++++ b/toolkit/components/resistfingerprinting/RFPTargets.inc +@@ -62,6 +62,11 @@ + ITEM_VALUE(MediaDevices, 37) + ITEM_VALUE(MediaCapabilities, 38) + ITEM_VALUE(AudioSampleRate, 39) ++// BearBrowser: per-session farble of WebAudio sample data (AudioBuffer / ++// AnalyserNode) so the audio fingerprint is unlinkable across sessions. RFP ++// randomizes canvas but not audio — this closes that residual (id placed here, ++// not by the WebGL block, so it can't collide with the CanvasTextMetrics patch). ++ITEM_VALUE(WebAudioFarble, 82) + ITEM_VALUE(NetworkConnection, 40) + ITEM_VALUE(WindowDevicePixelRatio, 41) + ITEM_VALUE(MouseEventScreenPoint, 42) +--- a/toolkit/components/resistfingerprinting/nsRFPService.h ++++ b/toolkit/components/resistfingerprinting/nsRFPService.h +@@ -69,6 +69,7 @@ + struct JSContext; + + class nsIChannel; ++class nsIGlobalObject; // BearBrowser: FarbleAudioData + + class nsICanvasRenderingContextInternal; + +@@ -536,6 +537,13 @@ + uint32_t aWidth, uint32_t aHeight, + uint32_t aSize, + mozilla::gfx::SurfaceFormat aSurfaceFormat); ++ // BearBrowser anti-fingerprint: apply an inaudible, per-session multiplicative ++ // farble to WebAudio sample data (AudioBuffer / AnalyserNode read paths), gated ++ // on RFPTarget::WebAudioFarble. RFP randomizes canvas but not audio; this makes ++ // the audio fingerprint unlinkable across sessions. No-op when the global is ++ // null or RFP is not active for it. Deterministic within a session. ++ static void FarbleAudioData(nsIGlobalObject* aGlobal, float* aData, ++ size_t aLength); + // This function is used to randomize the elements in the given data + // according to the given parameters. For example, for an RGBA pixel, by group + // in this context, we refer to a single pixel and by element, we refer to +--- a/toolkit/components/resistfingerprinting/nsRFPService.cpp ++++ b/toolkit/components/resistfingerprinting/nsRFPService.cpp +@@ -82,6 +82,7 @@ + #include "nsRFPTargetSetIDL.h" + + #include "nsICookieJarSettings.h" ++#include "mozilla/RandomNum.h" + #include "nsICryptoHash.h" + #include "nsIEffectiveTLDService.h" + #include "nsIGlobalObject.h" +@@ -1862,6 +1863,29 @@ + } + return RandomizeElements(aCookieJarSettings, aPrincipal, aData, aSize, + bytesPerPixel, bytesPerChannel, offset, true); ++} ++ ++// static ++void nsRFPService::FarbleAudioData(nsIGlobalObject* aGlobal, float* aData, ++ size_t aLength) { ++ if (!aData || !aGlobal || ++ !nsContentUtils::ShouldResistFingerprinting(aGlobal, ++ RFPTarget::WebAudioFarble)) { ++ return; ++ } ++ // Per-session (per-process) inaudible multiplicative farble in ++ // [1-1e-7, 1+1e-7]. Deterministic within the session (repeated reads are ++ // stable, so audio still works), random across sessions (so the audio ++ // fingerprint cannot be linked between sessions — RFP randomizes canvas but ++ // not audio, and this closes that residual). ++ static const double sFarbleFactor = []() { ++ uint64_t r = mozilla::RandomUint64OrZero(); ++ double frac = (double)(r % 2000001) / 1000000.0 - 1.0; // [-1, 1] ++ return 1.0 + frac * 1e-7; ++ }(); ++ for (size_t i = 0; i < aLength; ++i) { ++ aData[i] = (float)((double)aData[i] * sFarbleFactor); ++ } + } + + // static +--- a/dom/media/webaudio/AudioBuffer.cpp ++++ b/dom/media/webaudio/AudioBuffer.cpp +@@ -13,6 +13,7 @@ + #include "js/experimental/TypedData.h" // JS_NewFloat32Array, JS_GetFloat32ArrayData, JS_GetTypedArrayLength, JS_GetArrayBufferViewBuffer + #include "jsfriendapi.h" + #include "mozilla/ErrorResult.h" ++#include "nsRFPService.h" // BearBrowser: WebAudio farble + #include "mozilla/HoldDropJSObjects.h" + #include "mozilla/MemoryReporting.h" + #include "mozilla/PodOperations.h" +@@ -300,6 +301,11 @@ + float* jsData = JS_GetFloat32ArrayData(array, &isShared, nogc); + MOZ_ASSERT(!isShared); // Was created as unshared above + CopyChannelDataToFloat(mSharedChannels, i, 0, jsData, Length()); ++ // BearBrowser anti-fingerprint: farble the materialized channel data once ++ // (this is the single point where getChannelData / copyFromChannel obtain ++ // their samples), gated on RFP. Inaudible, per-session — closes the audio ++ // fingerprint residual RFP leaves open. ++ nsRFPService::FarbleAudioData(global->AsGlobal(), jsData, Length()); + } + mJSChannels[i] = array; + } +--- a/dom/media/webaudio/AnalyserNode.cpp ++++ b/dom/media/webaudio/AnalyserNode.cpp +@@ -8,6 +8,7 @@ + #include "AudioNodeTrack.h" + #include "Tracing.h" + #include "mozilla/Mutex.h" ++#include "nsRFPService.h" // BearBrowser: WebAudio farble + #include "mozilla/PodOperations.h" + #include "mozilla/dom/AnalyserNodeBinding.h" + #include "nsMathUtils.h" +@@ -214,6 +215,7 @@ + return; + } + ++ nsIGlobalObject* global = GetOwnerGlobal(); + aArray.ProcessData([&](const Span& aData, JS::AutoCheckCannotGC&&) { + size_t length = std::min(size_t(aData.Length()), mOutputBuffer.Length()); + +@@ -221,6 +223,8 @@ + aData[i] = WebAudioUtils::ConvertLinearToDecibels( + mOutputBuffer[i], -std::numeric_limits::infinity()); + } ++ // BearBrowser anti-fingerprint: farble the analyser frequency output. ++ nsRFPService::FarbleAudioData(global, aData.Elements(), length); + }); + } + +@@ -249,10 +253,13 @@ + } + + void AnalyserNode::GetFloatTimeDomainData(const Float32Array& aArray) { ++ nsIGlobalObject* global = GetOwnerGlobal(); + aArray.ProcessData([&](const Span& aData, JS::AutoCheckCannotGC&&) { + size_t length = std::min(aData.Length(), size_t(FftSize())); + + GetTimeDomainData(aData.Elements(), length); ++ // BearBrowser anti-fingerprint: farble the analyser time-domain output. ++ nsRFPService::FarbleAudioData(global, aData.Elements(), length); + }); + } + diff --git a/gecko-patches/anti-fingerprint/anti-fp-canvas-text-metrics.patch b/gecko-patches/anti-fingerprint/anti-fp-canvas-text-metrics.patch new file mode 100644 index 0000000..9bf2a43 --- /dev/null +++ b/gecko-patches/anti-fingerprint/anti-fp-canvas-text-metrics.patch @@ -0,0 +1,60 @@ +--- a/toolkit/components/resistfingerprinting/RFPTargets.inc ++++ b/toolkit/components/resistfingerprinting/RFPTargets.inc +@@ -113,6 +113,9 @@ + ITEM_VALUE(WebGLVendorConstant, 78) + ITEM_VALUE(WebGLVendorRandomize, 79) + ITEM_VALUE(WebGLRendererConstant, 80) ++// BearBrowser: quantize text-metric readback (measureText / layout / SVG) so the ++// per-glyph kerning+rasterizer transform can't be read back as a fingerprint. ++ITEM_VALUE(CanvasTextMetrics, 81) + + // !!! Adding a new target? Rename PointerId and repurpose it. + +--- a/dom/canvas/CanvasRenderingContext2D.cpp ++++ b/dom/canvas/CanvasRenderingContext2D.cpp +@@ -5283,18 +5283,34 @@ + -processor.mBoundingBox.Y() - baselineAnchor; + double actualBoundingBoxDescent = + processor.mBoundingBox.YMost() + baselineAnchor; ++ // BearBrowser anti-fingerprint: the text-metric readback (advance width and ++ // bounding box) encodes font + GPOS kerning + platform rasterizer at sub-pixel ++ // precision — a high-entropy fingerprint. When RFP is active, quantize every ++ // exposed metric to whole CSS pixels so it is uniform across users/platforms. ++ // Uniform, never randomized (randomizing would split the RFP cohort). ++ // Cover BOTH the document-backed canvas and the OffscreenCanvas (worker / ++ // transferControlToOffscreen) path, which has no mCanvasElement. ++ bool rfpText = false; ++ if (mCanvasElement) { ++ rfpText = nsContentUtils::ShouldResistFingerprinting( ++ mCanvasElement->OwnerDoc(), RFPTarget::CanvasTextMetrics); ++ } else if (mOffscreenCanvas) { ++ rfpText = ++ mOffscreenCanvas->ShouldResistFingerprinting(RFPTarget::CanvasTextMetrics); ++ } ++ auto q = [rfpText](double aV) { return rfpText ? std::round(aV) : aV; }; + return MakeUnique( +- totalWidth, actualBoundingBoxLeft, actualBoundingBoxRight, +- fontMetrics.maxAscent - baselineAnchor, // fontBBAscent +- fontMetrics.maxDescent + baselineAnchor, // fontBBDescent +- actualBoundingBoxAscent, actualBoundingBoxDescent, +- fontMetrics.emAscent - baselineAnchor, // emHeightAscent +- fontMetrics.emDescent + baselineAnchor, // emHeightDescent +- font->GetBaseline(gfxFont::kHanging, fontOrientation) - baselineAnchor, +- font->GetBaseline(gfxFont::kAlphabetic, fontOrientation) - +- baselineAnchor, +- font->GetBaseline(gfxFont::kIdeographicUnder, fontOrientation) - +- baselineAnchor); ++ q(totalWidth), q(actualBoundingBoxLeft), q(actualBoundingBoxRight), ++ q(fontMetrics.maxAscent - baselineAnchor), // fontBBAscent ++ q(fontMetrics.maxDescent + baselineAnchor), // fontBBDescent ++ q(actualBoundingBoxAscent), q(actualBoundingBoxDescent), ++ q(fontMetrics.emAscent - baselineAnchor), // emHeightAscent ++ q(fontMetrics.emDescent + baselineAnchor), // emHeightDescent ++ q(font->GetBaseline(gfxFont::kHanging, fontOrientation) - baselineAnchor), ++ q(font->GetBaseline(gfxFont::kAlphabetic, fontOrientation) - ++ baselineAnchor), ++ q(font->GetBaseline(gfxFont::kIdeographicUnder, fontOrientation) - ++ baselineAnchor)); + } + + // If we did not actually calculate bounds, set up a simple bounding box diff --git a/gecko-patches/anti-fingerprint/anti-fp-tor-os-spoof.SPEC.md b/gecko-patches/anti-fingerprint/anti-fp-tor-os-spoof.SPEC.md new file mode 100644 index 0000000..a80f7ca --- /dev/null +++ b/gecko-patches/anti-fingerprint/anti-fp-tor-os-spoof.SPEC.md @@ -0,0 +1,77 @@ +# SPEC — Tor-mode OS spoof (force Windows identity for all platforms) + +Status: **SPEC, not yet a patch.** This is the single biggest "spoof normality" +lever for Tor mode and it is a *coordinated multi-site* change — a single missed +use-site leaves an INCONSISTENT identity (UA says Windows, `navigator.oscpu` says +Mac), which is MORE fingerprintable than not spoofing at all. For that reason it +is authored against the build with a compiler in the loop, not blind. + +## Why a patch and not a pref +Tor Browser makes **every desktop platform report Windows** (`Windows NT 10.0; +Win64; x64`) so Mac/Linux users hide in the Windows majority. Stock Firefox RFP +does NOT do this — it spoofs to the user's *real* OS family. And critically: + +> When `privacy.resistFingerprinting=true`, RFP computes its own UA/platform/ +> oscpu/appVersion from the compile-time `SPOOFED_*` macros and **ignores** +> `general.useragent.override` / `general.platform.override` / `general.oscpu.override`. + +So the override prefs are a no-op here. The OS must be forced at the `nsRFPService` +/ `SPOOFED_*` layer, exactly as Tor does (upstream bugs 1918009 + 42467/42647). + +## Target cohort values (Tor Browser 15.0.x / Firefox 140 ESR, Windows branch) +| Surface | Value | +|---|---| +| `navigator.userAgent` + HTTP `User-Agent:` header | `Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:140.0) Gecko/20100101 Firefox/140.0` | +| `navigator.platform` | `Win32` | +| `navigator.oscpu` | `Windows NT 10.0; Win64; x64` | +| `navigator.appVersion` | `5.0 (Windows)` | +| `navigator.buildID` | `20181001000000` (RFP `LEGACY_BUILD_ID`, automatic) | +| `navigator.hardwareConcurrency` | `2` (RFP, automatic) | +| `navigator.maxTouchPoints` | `0` (Tor desktop; our Linux RFP default is 5 → must force 0) | +| `navigator.userAgentData` / `deviceMemory` | `undefined` | +| timezone (`Intl…timeZone`) | `Atlantic/Reykjavik` (RFP, automatic — NOT the literal "UTC") | + +## Use-sites to patch (Firefox 150.0.1 tree — verify line numbers at apply time) +The `SPOOFED_*` macros are `#ifdef`-selected compile-time constants in +`toolkit/components/resistfingerprinting/nsRFPService.h:38-60`. Every consumer +must read the SAME runtime decision, or the identity desyncs: + +1. `nsRFPService.h:38-60` — keep the per-platform `SPOOFED_*` defaults BUT also + define always-available Windows constants: `SPOOFED_UA_OS_WIN`, + `SPOOFED_OSCPU_WIN` = `"Windows NT 10.0; Win64; x64"`, `SPOOFED_APPVERSION_WIN` + = `"5.0 (Windows)"`, `SPOOFED_PLATFORM_WIN` = `"Win32"`. +2. Add a cached static bool `sSpoofOsToWindows` read from pref + `bearbrowser.tor-mode.spoof-os == "windows"` (StaticPrefs or a Preferences + observer; must be readable from the DOM thread + workers). +3. `nsRFPService.cpp:1027,1036` `GetSpoofedUserAgent` — select + `sSpoofOsToWindows ? SPOOFED_UA_OS_WIN : SPOOFED_UA_OS` for BOTH the + preallocated length (1027) and the `AppendLiteral` (1036). +4. `dom/base/Navigator.cpp:458` `GetOscpu` — `SPOOFED_OSCPU_WIN` when set. +5. `dom/base/Navigator.cpp:2053` `GetAppVersion` — `SPOOFED_APPVERSION_WIN`. +6. `dom/base/Navigator.cpp` `GetPlatform` — force `Win32` when set (find the + RFP branch that currently returns the per-OS platform). +7. `dom/workers/WorkerNavigator.cpp:131` `GetAppVersion` (worker) — same. +8. Worker oscpu/platform/UA equivalents — audit `WorkerNavigator.cpp` for every + `SPOOFED_*` use and gate identically. +9. `maxTouchPoints` — force 0 for the desktop cohort when spoofing (find the + `SPOOFED_MAX_TOUCH_POINTS` consumer). +10. HTTP header path — confirm `nsHttpHandler` builds its `User-Agent` from + `GetSpoofedUserAgent` (it does), so #3 covers the header too. VERIFY. + +## ⚠️ The residual a Windows-OS spoof does NOT fix: the Firefox VERSION +Our build is **Firefox 150**; the Tor cohort is **140 ESR**. Even with a perfect +OS spoof, our UA reads `Firefox/150.0` while the cohort reads `Firefox/140.0` — +and JS version-probes (feature detection, `navigator.userAgent` parsing) expose +the real engine version. A 150-over-Tor is a *distinct* cohort from 140-over-Tor. + +Two ways to close it, both bigger than this patch: +- **(preferred) Build Tor mode on Firefox 140 ESR** — the same train Tor Browser + rides. Then version, RFP constants, and engine quirks all line up for free. + This is a build-version decision, tracked in docs/tor-mode.md §version. +- **(risky) Also spoof the version string to 140** — invites detectable + inconsistencies between the claimed UA and real engine behavior. Not advised. + +Until one of those lands, Tor mode delivers FULL network-layer anonymity (the Tor +exit) with a JS identity that is Windows-OS-aligned but **version-distinct**. +Honest framing for the user: the wire is anonymous; the JS blend-in is partial +until we ride the same ESR. diff --git a/native/macos/BearBrowser-start.html b/native/macos/BearBrowser-start.html index 552ebd0..2ab22a5 100644 --- a/native/macos/BearBrowser-start.html +++ b/native/macos/BearBrowser-start.html @@ -5,25 +5,230 @@ BearBrowser -
-
🐻
-

BearBrowser

-

Native BearBrowser bootstrap shell is running. The Dock process and application identity are BearBrowser.

-

The governed feature plane is now active: local provenance events, policy-visible action records, and a sidecar status surface.

+ +
+
🐻
+ BearBrowser +
+ +
+ Provenance active + Policy governed + No Google telemetry + Fingerprint shield +
+ +
+ 🔍 + + ⌘L +
+
-
ProvenanceLaunch and navigation events are recorded locally with redaction.
-
PolicyAgentic actions are proposed before authority is granted.
+ +
🦆
+ DuckDuckGo +
+ +
🔍
+ Kagi +
+ +
🐙
+ BearBrowser +
+ +
🟠
+ Hacker News +
+
+ + -
Use the Sidecar Status button to inspect local governance state.
-
+ diff --git a/native/macos/BearBrowserPolicyQueue.swift b/native/macos/BearBrowserPolicyQueue.swift new file mode 100644 index 0000000..7186fee --- /dev/null +++ b/native/macos/BearBrowserPolicyQueue.swift @@ -0,0 +1,306 @@ +// BearBrowserPolicyQueue — macOS status bar agent for PolicyFabric hold decisions. +// +// Watches ~/Library/Application Support/BearBrowser/policy/actions.jsonl for +// entries with decision.state == "hold" that have not been resolved. Shows a +// menubar badge with the count and a click-through menu to approve or deny each +// held action. Resolution calls bearbrowser-resolve-action.py and appends a +// signed resolution record to the same actions.jsonl file. +// +// Build: +// swiftc -framework Cocoa native/macos/BearBrowserPolicyQueue.swift \ +// -o build/BearBrowserPolicyQueue +// +// Launch at login (optional): +// cp build/BearBrowserPolicyQueue /usr/local/bin/ +// # Add a LaunchAgent plist pointing to it + +import Cocoa +import Foundation + +// MARK: — Data model + +struct HoldAction { + let actionId: String + let actionType: String + let profile: String + let context: [String: String] + let reason: String + let timestamp: String + + var displayTitle: String { + let type = actionType.replacingOccurrences(of: "_", with: " ") + if let url = context["url"] ?? context["destination"] { + let short = url.count > 60 ? String(url.prefix(57)) + "…" : url + return "\(type): \(short)" + } + return type + } + + var displayDetail: String { + var parts: [String] = [] + if !profile.isEmpty { parts.append("profile: \(profile)") } + if !reason.isEmpty { parts.append(reason) } + return parts.joined(separator: " · ") + } +} + +// MARK: — JSONL reader + +func readPendingHolds(from path: URL) -> [HoldAction] { + guard let content = try? String(contentsOf: path, encoding: .utf8) else { return [] } + + var allActions: [(String, [String: Any])] = [] + var resolvedIds: Set = [] + + for line in content.split(separator: "\n", omittingEmptySubsequences: true) { + guard let data = line.data(using: .utf8), + let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any] + else { continue } + + if let id = obj["actionId"] as? String { + allActions.append((id, obj)) + } + if let target = obj["target"] as? [String: Any], + let fromId = target["resolvedFromActionId"] as? String { + resolvedIds.insert(fromId) + } + } + + return allActions.compactMap { (id, obj) -> HoldAction? in + guard resolvedIds.contains(id) == false else { return nil } + guard let decision = obj["decision"] as? [String: Any], + decision["state"] as? String == "hold" + else { return nil } + + let ctx = obj["context"] as? [String: String] ?? [:] + return HoldAction( + actionId: id, + actionType: obj["actionType"] as? String ?? "unknown", + profile: obj["profile"] as? String ?? "", + context: ctx, + reason: (obj["decision"] as? [String: Any])?["reason"] as? String ?? "", + timestamp: obj["timestamp"] as? String ?? "" + ) + } +} + +// MARK: — PolicyFabric resolver + +func resolveAction(actionId: String, decision: String, repoRoot: String, completion: @escaping (Bool) -> Void) { + let script = "\(repoRoot)/scripts/bearbrowser-resolve-action.py" + guard FileManager.default.fileExists(atPath: script) else { + completion(false) + return + } + let task = Process() + task.executableURL = URL(fileURLWithPath: "/usr/bin/python3") + task.arguments = [ + script, + "--action-id", actionId, + "--decision", decision, + "--actor-type", "human", + "--note", "Resolved via BearBrowser policy queue status bar" + ] + task.terminationHandler = { p in + completion(p.terminationStatus == 0) + } + try? task.run() +} + +// MARK: — App delegate + +class AppDelegate: NSObject, NSApplicationDelegate { + var statusItem: NSStatusItem! + var pollTimer: Timer! + var pendingHolds: [HoldAction] = [] + let actionsURL: URL + let repoRoot: String + + override init() { + let support = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first! + actionsURL = support + .appendingPathComponent("BearBrowser") + .appendingPathComponent("policy") + .appendingPathComponent("actions.jsonl") + + // Resolve repo root from this binary's path (works for both build/ and /usr/local/bin) + let binDir = Bundle.main.executableURL?.deletingLastPathComponent().path ?? "" + if binDir.hasSuffix("/build") { + repoRoot = URL(fileURLWithPath: binDir).deletingLastPathComponent().path + } else if let env = ProcessInfo.processInfo.environment["BEARBROWSER_HOME"] { + repoRoot = env + } else { + repoRoot = FileManager.default.homeDirectoryForCurrentUser + .appendingPathComponent("dev/SourceOS-Linux__BearBrowser").path + } + super.init() + } + + func applicationDidFinishLaunching(_ n: Notification) { + NSApp.setActivationPolicy(.accessory) + + statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength) + if let button = statusItem.button { + button.title = "⚖" + button.font = NSFont.systemFont(ofSize: 14) + } + + pollTimer = Timer.scheduledTimer(withTimeInterval: 3.0, repeats: true) { [weak self] _ in + self?.refresh() + } + refresh() + } + + func refresh() { + DispatchQueue.global(qos: .background).async { [weak self] in + guard let self else { return } + let holds = readPendingHolds(from: self.actionsURL) + DispatchQueue.main.async { + self.pendingHolds = holds + self.rebuildMenu() + } + } + } + + func rebuildMenu() { + let menu = NSMenu() + + if pendingHolds.isEmpty { + statusItem.button?.title = "⚖" + statusItem.button?.appearsDisabled = true + let idle = NSMenuItem(title: "No pending holds", action: nil, keyEquivalent: "") + idle.isEnabled = false + menu.addItem(idle) + } else { + statusItem.button?.title = "⚖ \(pendingHolds.count)" + statusItem.button?.appearsDisabled = false + + let header = NSMenuItem(title: "\(pendingHolds.count) pending hold\(pendingHolds.count == 1 ? "" : "s")", action: nil, keyEquivalent: "") + header.isEnabled = false + menu.addItem(header) + menu.addItem(.separator()) + + for hold in pendingHolds { + let titleItem = NSMenuItem(title: hold.displayTitle, action: nil, keyEquivalent: "") + titleItem.isEnabled = false + menu.addItem(titleItem) + + if !hold.displayDetail.isEmpty { + let detailItem = NSMenuItem(title: " ↳ \(hold.displayDetail)", action: nil, keyEquivalent: "") + detailItem.isEnabled = false + detailItem.attributedTitle = NSAttributedString( + string: " ↳ \(hold.displayDetail)", + attributes: [.foregroundColor: NSColor.secondaryLabelColor, + .font: NSFont.systemFont(ofSize: 11)] + ) + menu.addItem(detailItem) + } + + let approve = NSMenuItem( + title: " ✓ Allow", + action: #selector(approveAction(_:)), + keyEquivalent: "" + ) + approve.representedObject = hold.actionId + approve.target = self + approve.attributedTitle = NSAttributedString( + string: " ✓ Allow", + attributes: [.foregroundColor: NSColor.systemGreen, + .font: NSFont.systemFont(ofSize: 13)] + ) + menu.addItem(approve) + + let deny = NSMenuItem( + title: " ✗ Deny", + action: #selector(denyAction(_:)), + keyEquivalent: "" + ) + deny.representedObject = hold.actionId + deny.target = self + deny.attributedTitle = NSAttributedString( + string: " ✗ Deny", + attributes: [.foregroundColor: NSColor.systemRed, + .font: NSFont.systemFont(ofSize: 13)] + ) + menu.addItem(deny) + menu.addItem(.separator()) + } + + // Bulk actions + let approveAll = NSMenuItem(title: "Allow all", action: #selector(approveAll), keyEquivalent: "") + approveAll.target = self + menu.addItem(approveAll) + + let denyAll = NSMenuItem(title: "Deny all", action: #selector(denyAll), keyEquivalent: "") + denyAll.target = self + menu.addItem(denyAll) + } + + menu.addItem(.separator()) + let showQueue = NSMenuItem(title: "Show queue…", action: #selector(showQueue), keyEquivalent: "q") + showQueue.target = self + menu.addItem(showQueue) + + let quit = NSMenuItem(title: "Quit policy queue", action: #selector(NSApplication.terminate(_:)), keyEquivalent: "") + menu.addItem(quit) + + statusItem.menu = menu + } + + @objc func approveAction(_ sender: NSMenuItem) { + guard let id = sender.representedObject as? String else { return } + resolveAction(actionId: id, decision: "allow", repoRoot: repoRoot) { [weak self] _ in + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { self?.refresh() } + } + } + + @objc func denyAction(_ sender: NSMenuItem) { + guard let id = sender.representedObject as? String else { return } + resolveAction(actionId: id, decision: "deny", repoRoot: repoRoot) { [weak self] _ in + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { self?.refresh() } + } + } + + @objc func approveAll() { + let ids = pendingHolds.map(\.actionId) + for id in ids { + resolveAction(actionId: id, decision: "allow", repoRoot: repoRoot) { _ in } + } + DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) { [weak self] in self?.refresh() } + } + + @objc func denyAll() { + let ids = pendingHolds.map(\.actionId) + for id in ids { + resolveAction(actionId: id, decision: "deny", repoRoot: repoRoot) { _ in } + } + DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) { [weak self] in self?.refresh() } + } + + @objc func showQueue() { + let script = "\(repoRoot)/scripts/bearbrowser-governance-queue.py" + let task = Process() + task.executableURL = URL(fileURLWithPath: "/usr/bin/python3") + task.arguments = [script] + let pipe = Pipe() + task.standardOutput = pipe + task.standardError = pipe + try? task.run() + task.waitUntilExit() + let output = String(data: pipe.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8) ?? "" + + let alert = NSAlert() + alert.messageText = "BearBrowser Policy Queue" + alert.informativeText = output.isEmpty ? "(empty)" : output + alert.alertStyle = .informational + alert.addButton(withTitle: "OK") + alert.runModal() + } +} + +// MARK: — Entry point + +let delegate = AppDelegate() +let app = NSApplication.shared +app.delegate = delegate +app.run() diff --git a/native/macos/BearBrowserWebKitLauncher.m b/native/macos/BearBrowserWebKitLauncher.m index 5874415..2a3fd40 100644 --- a/native/macos/BearBrowserWebKitLauncher.m +++ b/native/macos/BearBrowserWebKitLauncher.m @@ -1,396 +1,4869 @@ #import #import +#import +#import +#import +#import +#import +#include +#include +#include -static NSString *BBSupportDir(void) { - return [NSHomeDirectory() stringByAppendingPathComponent:@"Library/Application Support/BearBrowser"]; +// Forward declarations needed by classes defined before the support helpers +static NSString *BBSupportDir(void); +static NSString *BBLogDir(void); + +// ── BBFontSchemeHandler ─────────────────────────────────────────────────────── +// Serves bundled woff2 fonts via bbfont:// scheme so pages never hit Google Fonts CDN. +@interface BBFontSchemeHandler : NSObject +@property(strong) NSString *fontsDir; +@end +@implementation BBFontSchemeHandler +- (instancetype)init { + self=[super init]; + NSString *bundle=[[NSBundle mainBundle] resourcePath]; + _fontsDir=[bundle stringByAppendingPathComponent:@"fonts/woff2"]; + return self; +} +- (void)webView:(WKWebView *)wv startURLSchemeTask:(id)task { + NSURL *url=task.request.URL; + // bbfont://fonts/ + NSString *filename=url.path.lastPathComponent; + NSString *path=[self.fontsDir stringByAppendingPathComponent:filename]; + NSData *data=[NSData dataWithContentsOfFile:path]; + if (!data) { + [task didFailWithError:[NSError errorWithDomain:NSURLErrorDomain code:NSURLErrorFileDoesNotExist userInfo:nil]]; + return; + } + NSURLResponse *resp=[[NSURLResponse alloc]initWithURL:url MIMEType:@"font/woff2" + expectedContentLength:data.length textEncodingName:nil]; + [task didReceiveResponse:resp]; + [task didReceiveData:data]; + [task didFinish]; } +- (void)webView:(WKWebView *)wv stopURLSchemeTask:(id)task {} +@end + +// ── BBContentBlocker ────────────────────────────────────────────────────────── +// Compiles and caches WKContentRuleList for tracker/ad blocking. +@interface BBContentBlocker : NSObject ++ (void)loadRulesInto:(WKWebViewConfiguration *)config completion:(void(^)(void))done; +@end +@implementation BBContentBlocker -static NSString *BBLogDir(void) { - return [NSHomeDirectory() stringByAppendingPathComponent:@"Library/Logs/BearBrowser"]; +// Baseline tracker/ad rules — covers the highest-traffic domains. +// Full list updated via scripts/update-content-rules.sh → compiled JSON cached to disk. ++ (NSString *)baselineRulesJSON { + // Format: WKContentRuleList declarative JSON. + // Block trackers at the network layer before any request fires. + static NSArray *trackerDomains = nil; + if (!trackerDomains) trackerDomains = @[ + // Analytics & tracking + @"google-analytics\\.com", @"googletagmanager\\.com", @"googletagservices\\.com", + @"doubleclick\\.net", @"googlesyndication\\.com", @"adservice\\.google\\.com", + @"facebook\\.com/tr", @"connect\\.facebook\\.net", @"analytics\\.twitter\\.com", + @"t\\.co/[0-9]", @"static\\.ads-twitter\\.com", + @"hotjar\\.com", @"fullstory\\.com", @"logrocket\\.com", @"smartlook\\.com", + @"mixpanel\\.com", @"amplitude\\.com/api", @"segment\\.io", @"segment\\.com/analytics", + @"heap\\.io", @"heapanalytics\\.com", + @"newrelic\\.com/browser", @"nr-data\\.net", + @"intercom\\.io/api", @"intercomcdn\\.com", + @"crisp\\.chat/client", @"widget\\.intercom\\.io", + // Ad networks + @"ads\\.linkedin\\.com", @"platform\\.linkedin\\.com/in\\.js", + @"snap\\.licdn\\.com", @"px\\.ads\\.linkedin\\.com", + @"bing\\.com/bat", @"bat\\.bing\\.com", + @"amazon-adsystem\\.com", @"aax-us-east\\.amazon-adsystem\\.com", + @"rubiconproject\\.com", @"openx\\.net", @"pubmatic\\.com", + @"casalemedia\\.com", @"criteo\\.com", @"criteo\\.net", + @"outbrain\\.com", @"taboola\\.com", @"revcontent\\.com", + @"moatads\\.com", @"adnxs\\.com", @"appnexus\\.com", + @"bidswitch\\.net", @"ssp\\.yahoo\\.com", @"gemini\\.yahoo\\.com", + // Font CDNs (served locally instead) + @"fonts\\.googleapis\\.com", @"fonts\\.gstatic\\.com", + @"use\\.typekit\\.net", @"p\\.typekit\\.net", + // Fingerprinting & session replay + @"fingerprintjs\\.com", @"fp\\.clarity\\.ms", @"clarity\\.ms/tag", + @"mouseflow\\.com", @"inspectlet\\.com", @"sessioncam\\.com", + // Social widgets (privacy leak even without interaction) + @"platform\\.twitter\\.com/widgets", @"platform\\.instagram\\.com", + @"staticxx\\.facebook\\.com", @"www\\.facebook\\.com/plugins", + // Data brokers / identity resolution + @"quantserve\\.com", @"scorecardresearch\\.com", @"comscore\\.com", + @"bluekai\\.com", @"turn\\.com", @"mediamath\\.com", + @"adsymptotic\\.com", @"adsafeprotected\\.com", + ]; + + NSMutableArray *rules=[NSMutableArray array]; + // Block all tracker domains + for (NSString *pattern in trackerDomains) { + [rules addObject:@{ + @"trigger": @{@"url-filter": pattern, @"load-type": @[@"third-party"]}, + @"action": @{@"type": @"block"} + }]; + } + // Block font CDNs entirely (we serve locally) + [rules addObject:@{ + @"trigger": @{@"url-filter": @"fonts\\.googleapis\\.com|fonts\\.gstatic\\.com|use\\.typekit\\.net"}, + @"action": @{@"type": @"block"} + }]; + // Block known tracking pixels (1x1 images) + [rules addObject:@{ + @"trigger": @{@"url-filter": @".*", @"resource-type": @[@"image"], + @"url-filter-is-case-sensitive": @NO, + @"load-type": @[@"third-party"]}, + @"action": @{@"type": @"css-display-none", @"selector": @"img[width='1'][height='1'],img[src*='pixel'],img[src*='beacon'],img[src*='tracking']"} + }]; + NSData *json=[NSJSONSerialization dataWithJSONObject:rules options:0 error:nil]; + return [[NSString alloc]initWithData:json encoding:NSUTF8StringEncoding]; } -static NSString *BBProvenancePath(void) { - return [[BBSupportDir() stringByAppendingPathComponent:@"provenance"] stringByAppendingPathComponent:@"events.jsonl"]; ++ (void)loadRulesInto:(WKWebViewConfiguration *)config completion:(void(^)(void))done { + // Check for compiled rules on disk (put there by update-content-rules.sh) + NSString *compiledPath=[[BBSupportDir() stringByAppendingPathComponent:@"content-rules"] stringByAppendingPathComponent:@"rules.json"]; + NSString *rulesJSON=([NSFileManager.defaultManager fileExistsAtPath:compiledPath]) + ? [NSString stringWithContentsOfFile:compiledPath encoding:NSUTF8StringEncoding error:nil] + : nil; + if (!rulesJSON.length) rulesJSON=[self baselineRulesJSON]; + + WKContentRuleListStore *store=[WKContentRuleListStore defaultStore]; + [store compileContentRuleListForIdentifier:@"bb-baseline" + encodedContentRuleList:rulesJSON + completionHandler:^(WKContentRuleList *list, NSError *err) { + if (list) [config.userContentController addContentRuleList:list]; + if (err) NSLog(@"[BBContentBlocker] compile error: %@", err.localizedDescription); + if (done) done(); + }]; } +@end + +// ── BBVoice ─────────────────────────────────────────────────────────────────── +// Read-aloud with voice tuned between Gemini Ursa / ChatGPT Sol (female) +// and Australian-inflected natural male. Falls back gracefully on older macOS. +@interface BBVoice : NSObject +@property(strong) AVSpeechSynthesizer *synth; +@property(assign) BOOL speaking; ++ (instancetype)shared; +- (void)readPage:(WKWebView *)wv; +- (void)stop; +@end +@implementation BBVoice ++ (instancetype)shared { static BBVoice *s; static dispatch_once_t t; dispatch_once(&t,^{s=[[self alloc]init];}); return s; } +- (instancetype)init { self=[super init]; _synth=[[AVSpeechSynthesizer alloc]init]; _synth.delegate=self; return self; } -static NSString *BBPolicyPath(void) { - return [[BBSupportDir() stringByAppendingPathComponent:@"policy"] stringByAppendingPathComponent:@"actions.jsonl"]; +// Preferred voice identifiers in priority order. +// Female: Karen Enhanced (AU) → Zoe Enhanced → Samantha Enhanced → Samantha +// Male: Lee Enhanced (AU) → Daniel Enhanced (UK) → Alex ++ (AVSpeechSynthesisVoice *)preferredVoiceForGender:(AVSpeechSynthesisVoiceGender)gender { + NSArray *femaleIds=@[ + @"com.apple.voice.enhanced.en-AU.Karen", + @"com.apple.voice.premium.en-AU.Karen", + @"com.apple.ttsbundle.Karen-premium", + @"com.apple.voice.enhanced.en-US.Zoe", + @"com.apple.voice.enhanced.en-US.Samantha", + @"com.apple.ttsbundle.Samantha-premium", + ]; + NSArray *maleIds=@[ + @"com.apple.voice.enhanced.en-AU.Lee", + @"com.apple.voice.premium.en-AU.Lee", + @"com.apple.ttsbundle.Lee-premium", + @"com.apple.voice.enhanced.en-GB.Daniel", + @"com.apple.ttsbundle.Alex-compact", + ]; + NSArray *candidates=(gender==AVSpeechSynthesisVoiceGenderFemale)?femaleIds:maleIds; + for (NSString *vid in candidates) { + AVSpeechSynthesisVoice *v=[AVSpeechSynthesisVoice voiceWithIdentifier:vid]; + if (v) return v; + } + // Final fallback: pick first system voice matching language + for (AVSpeechSynthesisVoice *v in [AVSpeechSynthesisVoice speechVoices]) { + if ([v.language hasPrefix:@"en"] && v.gender==gender) return v; + } + return nil; } -static NSString *BBMemoryPath(void) { - return [[BBSupportDir() stringByAppendingPathComponent:@"memory"] stringByAppendingPathComponent:@"candidates.jsonl"]; +- (void)readPage:(WKWebView *)wv { + if (self.speaking) { [self stop]; return; } + [wv evaluateJavaScript: + @"(function(){" + @"var sel=window.getSelection&&window.getSelection().toString().trim();" + @"if(sel&&sel.length>0)return sel;" + @"var a=document.querySelector('article')||document.querySelector('main')||document.body;" + @"return (a?a.innerText:'').replace(/\\s+/g,' ').trim().slice(0,8000);" + @"})()" + completionHandler:^(id r,NSError *e){ + if(e||![r isKindOfClass:[NSString class]]||![(NSString*)r length]) return; + AVSpeechUtterance *u=[AVSpeechUtterance speechUtteranceWithString:(NSString*)r]; + u.voice=[BBVoice preferredVoiceForGender:AVSpeechSynthesisVoiceGenderFemale]; + u.rate=0.52f; // slightly slower than default (0.5) for clarity — between Ursa and Sol pacing + u.pitchMultiplier=1.05f; + u.volume=0.95f; + self.speaking=YES; + [self.synth speakUtterance:u]; + }]; } +- (void)stop { [self.synth stopSpeakingAtBoundary:AVSpeechBoundaryImmediate]; self.speaking=NO; } +- (void)speechSynthesizer:(AVSpeechSynthesizer *)s didFinishSpeechUtterance:(AVSpeechUtterance *)u { self.speaking=NO; } +@end +// ── Support helpers ─────────────────────────────────────────────────────────── +static NSString *BBSupportDir(void) { return [NSHomeDirectory() stringByAppendingPathComponent:@"Library/Application Support/BearBrowser"]; } +static NSString *BBLogDir(void) { return [NSHomeDirectory() stringByAppendingPathComponent:@"Library/Logs/BearBrowser"]; } +static NSString *BBProvenancePath(void) { return [[BBSupportDir() stringByAppendingPathComponent:@"provenance"] stringByAppendingPathComponent:@"events.jsonl"]; } +static NSString *BBPolicyPath(void) { return [[BBSupportDir() stringByAppendingPathComponent:@"policy"] stringByAppendingPathComponent:@"actions.jsonl"]; } +static NSString *BBMemoryPath(void) { return [[BBSupportDir() stringByAppendingPathComponent:@"memory"] stringByAppendingPathComponent:@"candidates.jsonl"]; } static NSString *BBTimestamp(void) { - NSDateFormatter *fmt = [[NSDateFormatter alloc] init]; - fmt.locale = [NSLocale localeWithLocaleIdentifier:@"en_US_POSIX"]; - fmt.timeZone = [NSTimeZone timeZoneForSecondsFromGMT:0]; - fmt.dateFormat = @"yyyy-MM-dd'T'HH:mm:ss'Z'"; - return [fmt stringFromDate:[NSDate date]]; + NSDateFormatter *f=[[NSDateFormatter alloc]init]; + f.locale=[NSLocale localeWithLocaleIdentifier:@"en_US_POSIX"]; + f.timeZone=[NSTimeZone timeZoneForSecondsFromGMT:0]; + f.dateFormat=@"yyyy-MM-dd'T'HH:mm:ss'Z'"; + return [f stringFromDate:[NSDate date]]; +} +static NSString *BBRandomHex(NSUInteger n) { + NSMutableString *s=[NSMutableString stringWithCapacity:n*2]; + for (NSUInteger i=0;i":text?:@"", + @"policy":@{@"decision":@"hold",@"decisionId":[@"local-" stringByAppendingString:BBRandomHex(8)],@"mode":@"local-default",@"reason":@"Candidates require explicit commit or reject."} + })); + BBEmitEvent(@"memory.candidate_created",@"hold",@"Held memory candidate.",@{@"memoryId":memId,@"url":srcURL?:@""}); } -static NSString *BBRandomHex(NSUInteger bytes) { - NSMutableString *out = [NSMutableString stringWithCapacity:bytes * 2]; - for (NSUInteger i = 0; i < bytes; i++) { - uint8_t value = (uint8_t)arc4random_uniform(256); - [out appendFormat:@"%02x", value]; +// ── Layout constants ────────────────────────────────────────────────────────── +static const CGFloat kToolbarH = 52.0; +static const CGFloat kTabBarH = 36.0; +static const CGFloat kFindBarH = 44.0; +static const CGFloat kBMBarH = 30.0; +static const CGFloat kDLPanelW = 280.0; +static const CGFloat kTabMaxW = 220.0; +static const CGFloat kTabMinW = 80.0; + +// ── BBTab ───────────────────────────────────────────────────────────────────── +@interface BBTab : NSObject +@property(strong) WKWebView *webView; +@property(copy) NSString *title; +@property(strong) NSImage *favicon; +@property(assign) BOOL isLoading; +@property(assign) BOOL isPrivate; +@end +@implementation BBTab +- (instancetype)init { self=[super init]; _title=@"New Tab"; return self; } +@end + +// ── BBTabItemView ───────────────────────────────────────────────────────────── +@protocol BBTabItemDelegate +- (void)tabItemDidSelect:(NSInteger)index; +- (void)tabItemDidClose:(NSInteger)index; +@end + +@interface BBTabItemView : NSView +@property(assign) NSInteger index; +@property(nonatomic,assign) BOOL isActive; +@property(nonatomic,assign) BOOL isHovered; +@property(nonatomic,assign) BOOL isPrivate; +@property(strong) NSImageView *faviconView; +@property(strong) NSTextField *titleLabel; +@property(strong) NSButton *closeButton; +@property(weak) id delegate; +- (void)setTabTitle:(NSString *)title favicon:(NSImage *)favicon loading:(BOOL)loading; +@end + +@implementation BBTabItemView +- (instancetype)initWithFrame:(NSRect)f index:(NSInteger)idx delegate:(id)d { + self=[super initWithFrame:f]; _index=idx; _delegate=d; + [self addTrackingArea:[[NSTrackingArea alloc]initWithRect:self.bounds + options:NSTrackingMouseEnteredAndExited|NSTrackingActiveInKeyWindow|NSTrackingInVisibleRect + owner:self userInfo:nil]]; + // Favicon (16×16) + _faviconView=[[NSImageView alloc]initWithFrame:NSMakeRect(8,10,16,16)]; + _faviconView.imageScaling=NSImageScaleProportionallyUpOrDown; + [self addSubview:_faviconView]; + // Title + _titleLabel=[[NSTextField alloc]initWithFrame:NSMakeRect(28,8,f.size.width-54,20)]; + _titleLabel.autoresizingMask=NSViewWidthSizable; + _titleLabel.bordered=NO; _titleLabel.editable=NO; _titleLabel.selectable=NO; + _titleLabel.backgroundColor=[NSColor clearColor]; + _titleLabel.font=[NSFont systemFontOfSize:12 weight:NSFontWeightRegular]; + _titleLabel.lineBreakMode=NSLineBreakByTruncatingTail; + [self addSubview:_titleLabel]; + // Close button + _closeButton=[[NSButton alloc]initWithFrame:NSMakeRect(f.size.width-26,9,18,18)]; + _closeButton.autoresizingMask=NSViewMinXMargin; + _closeButton.bezelStyle=NSBezelStyleCircular; _closeButton.bordered=NO; + NSImage *xi=[NSImage imageWithSystemSymbolName:@"xmark" accessibilityDescription:@"Close Tab"]; + xi=[xi imageWithSymbolConfiguration:[NSImageSymbolConfiguration configurationWithPointSize:8 weight:NSFontWeightMedium]]; + [xi setTemplate:YES]; _closeButton.image=xi; _closeButton.imagePosition=NSImageOnly; + _closeButton.target=self; _closeButton.action=@selector(closeTab:); _closeButton.toolTip=@"Close Tab"; + [self addSubview:_closeButton]; + return self; +} +- (void)setTabTitle:(NSString *)title favicon:(NSImage *)favicon loading:(BOOL)loading { + self.titleLabel.stringValue=title.length?title:@"New Tab"; + self.titleLabel.textColor=self.isActive?[NSColor labelColor]:[NSColor secondaryLabelColor]; + if (loading) { + NSImage *spinner=[NSImage imageWithSystemSymbolName:@"arrow.2.circlepath" accessibilityDescription:@"Loading"]; + self.faviconView.image=spinner; + } else if (favicon) { + self.faviconView.image=favicon; + } else { + NSImage *globe=[NSImage imageWithSystemSymbolName:@"globe" accessibilityDescription:@"Page"]; + [globe setTemplate:YES]; self.faviconView.image=globe; + } + if (self.isPrivate) { + NSImage *priv=[NSImage imageWithSystemSymbolName:@"eyeglasses" accessibilityDescription:@"Private"]; + [priv setTemplate:YES]; self.faviconView.image=priv; + } +} +- (void)setIsActive:(BOOL)active { + _isActive=active; [self setNeedsDisplay:YES]; + self.titleLabel.textColor=active?[NSColor labelColor]:[NSColor secondaryLabelColor]; + self.titleLabel.font=[NSFont systemFontOfSize:12 weight:active?NSFontWeightMedium:NSFontWeightRegular]; +} +- (void)drawRect:(NSRect)r { + if (self.isActive) { + [[NSColor windowBackgroundColor] setFill]; + [[NSBezierPath bezierPathWithRoundedRect:NSInsetRect(self.bounds,1,1) xRadius:7 yRadius:7] fill]; + [[NSColor separatorColor] setStroke]; + NSBezierPath *p=[NSBezierPath bezierPathWithRoundedRect:NSInsetRect(self.bounds,1,1) xRadius:7 yRadius:7]; + p.lineWidth=0.5; [p stroke]; + if (self.isPrivate) { + [[NSColor colorWithRed:0.2 green:0.1 blue:0.3 alpha:0.12] setFill]; + [[NSBezierPath bezierPathWithRoundedRect:NSInsetRect(self.bounds,1,1) xRadius:7 yRadius:7] fill]; + } + } else if (self.isHovered) { + [[NSColor colorWithWhite:0.5 alpha:0.12] setFill]; + [[NSBezierPath bezierPathWithRoundedRect:NSInsetRect(self.bounds,1,1) xRadius:7 yRadius:7] fill]; } - return out; } +- (void)mouseEntered:(NSEvent *)e { self.isHovered=YES; [self setNeedsDisplay:YES]; } +- (void)mouseExited:(NSEvent *)e { self.isHovered=NO; [self setNeedsDisplay:YES]; } +- (void)mouseDown:(NSEvent *)e { [self.delegate tabItemDidSelect:self.index]; } +- (void)closeTab:(id)s { [self.delegate tabItemDidClose:self.index]; } +@end + +// ── BBChromeBGView ───────────────────────────────────────────────────────────── +// Background fill that resolves at draw time — never set CGColor at init time +// since NSColor.windowBackgroundColor.CGColor is nil before the view has a window. +@interface BBChromeBGView : NSView @end +@implementation BBChromeBGView +- (void)drawRect:(NSRect)r { [[NSColor windowBackgroundColor] setFill]; NSRectFill(r); } +@end -static NSString *BBShellQuote(NSString *value) { - NSString *safe = [value stringByReplacingOccurrencesOfString:@"'" withString:@"'\\''"]; - return [NSString stringWithFormat:@"'%@'", safe]; +// ── BBTabBarView ────────────────────────────────────────────────────────────── +// NSVisualEffectView with Sidebar material (NOT Titlebar — no click interception). +@interface BBTabBarView : NSVisualEffectView +@property(strong) NSMutableArray *items; +@property(assign) NSInteger activeIndex; +@property(strong) NSButton *addTabButton; +@property(weak) id outerDelegate; +- (void)reloadWithTabs:(NSArray *)tabs activeIndex:(NSInteger)active; +@end +@implementation BBTabBarView +- (instancetype)initWithFrame:(NSRect)f delegate:(id)d { + self=[super initWithFrame:f]; + self.material=NSVisualEffectMaterialSidebar; + self.blendingMode=NSVisualEffectBlendingModeWithinWindow; + self.state=NSVisualEffectStateActive; + NSBox *sep=[[NSBox alloc]initWithFrame:NSMakeRect(0,0,f.size.width,1)]; + sep.autoresizingMask=NSViewWidthSizable; sep.boxType=NSBoxSeparator; [self addSubview:sep]; + _items=[NSMutableArray array]; _outerDelegate=d; + _addTabButton=[[NSButton alloc]initWithFrame:NSMakeRect(f.size.width-34,4,28,28)]; + _addTabButton.autoresizingMask=NSViewMinXMargin; + NSImage *pi=[NSImage imageWithSystemSymbolName:@"plus" accessibilityDescription:@"New Tab"]; + pi=[pi imageWithSymbolConfiguration:[NSImageSymbolConfiguration configurationWithPointSize:12 weight:NSFontWeightMedium]]; + [pi setTemplate:YES]; _addTabButton.image=pi; _addTabButton.imagePosition=NSImageOnly; + _addTabButton.bezelStyle=NSBezelStyleToolbar; _addTabButton.bordered=NO; + _addTabButton.toolTip=@"New Tab (⌘T)"; [self addSubview:_addTabButton]; + return self; } +- (void)reloadWithTabs:(NSArray *)tabs activeIndex:(NSInteger)active { + for (BBTabItemView *v in self.items) [v removeFromSuperview]; + [self.items removeAllObjects]; + self.activeIndex=active; + NSInteger count=tabs.count; if (!count) return; + CGFloat avail=self.bounds.size.width-40; + CGFloat tabW=MIN(kTabMaxW,MAX(kTabMinW,floor(avail/count))); + for (NSInteger i=0;i *items; ++ (instancetype)shared; +- (void)addTitle:(NSString *)t url:(NSString *)u; +- (void)removeAtIndex:(NSInteger)i; +- (BOOL)isBookmarked:(NSString *)u; +@end +@implementation BBBookmarksStore ++ (instancetype)shared { static BBBookmarksStore *s; static dispatch_once_t o; dispatch_once(&o,^{s=[[self alloc]init];}); return s; } +- (instancetype)init { + self=[super init]; _items=[NSMutableArray array]; + NSString *path=[BBSupportDir() stringByAppendingPathComponent:@"bookmarks.json"]; + NSData *d=[NSData dataWithContentsOfFile:path]; + if (d) for (NSDictionary *r in [NSJSONSerialization JSONObjectWithData:d options:0 error:nil]) { + BBBookmark *b=[BBBookmark new]; b.title=r[@"title"]?:@""; b.urlString=r[@"url"]?:@""; + b.addedAt=[NSDate dateWithTimeIntervalSince1970:[r[@"t"] doubleValue]]; + [_items addObject:b]; } - [handle seekToEndOfFile]; - [handle writeData:[withNewline dataUsingEncoding:NSUTF8StringEncoding]]; - [handle closeFile]; + return self; +} +- (void)addTitle:(NSString *)t url:(NSString *)u { + BBBookmark *b=[BBBookmark new]; b.title=t?:@""; b.urlString=u?:@""; b.addedAt=[NSDate date]; + [self.items addObject:b]; [self save]; } +- (void)removeAtIndex:(NSInteger)i { if(i>=0&&i<(NSInteger)self.items.count){[self.items removeObjectAtIndex:i];[self save];} } +- (BOOL)isBookmarked:(NSString *)u { for(BBBookmark *b in self.items) if([b.urlString isEqualToString:u]) return YES; return NO; } +- (void)save { + NSMutableArray *arr=[NSMutableArray array]; + for (BBBookmark *b in self.items) [arr addObject:@{@"title":b.title,@"url":b.urlString,@"t":@(b.addedAt.timeIntervalSince1970)}]; + NSData *d=[NSJSONSerialization dataWithJSONObject:arr options:0 error:nil]; + [[NSFileManager defaultManager] createDirectoryAtPath:BBSupportDir() withIntermediateDirectories:YES attributes:nil error:nil]; + [d writeToFile:[BBSupportDir() stringByAppendingPathComponent:@"bookmarks.json"] atomically:YES]; +} +@end -static NSString *BBJSON(NSDictionary *dict) { - NSData *data = [NSJSONSerialization dataWithJSONObject:dict options:0 error:nil]; - if (!data) { return @"{}"; } - return [[NSString alloc] initWithData:data encoding:NSUTF8StringEncoding]; +// ── BBHistoryStore ──────────────────────────────────────────────────────────── +@interface BBHistoryEntry : NSObject +@property(copy) NSString *title, *urlString; +@property(strong) NSDate *visitedAt; +@end +@implementation BBHistoryEntry +@end + +@interface BBHistoryStore : NSObject +@property(strong) NSMutableArray *entries; // newest-last, capped 20k ++ (instancetype)shared; +- (void)recordTitle:(NSString *)t url:(NSString *)u; +- (NSArray *)search:(NSString *)q limit:(NSInteger)n; +@end +@implementation BBHistoryStore ++ (instancetype)shared { static BBHistoryStore *s; static dispatch_once_t o; dispatch_once(&o,^{s=[[self alloc]init];}); return s; } +- (instancetype)init { + self=[super init]; _entries=[NSMutableArray array]; + NSString *path=[[BBSupportDir() stringByAppendingPathComponent:@"history"] stringByAppendingPathComponent:@"history.jsonl"]; + NSString *raw=[NSString stringWithContentsOfFile:path encoding:NSUTF8StringEncoding error:nil]; + NSArray *lines=[raw componentsSeparatedByString:@"\n"]; + NSInteger start=MAX(0,(NSInteger)lines.count-20000); + for (NSInteger i=start;i<(NSInteger)lines.count;i++) { + NSData *d=[lines[i] dataUsingEncoding:NSUTF8StringEncoding]; if(!d.length) continue; + NSDictionary *obj=[NSJSONSerialization JSONObjectWithData:d options:0 error:nil]; if(!obj) continue; + BBHistoryEntry *e=[BBHistoryEntry new]; e.urlString=obj[@"url"]?:@""; e.title=obj[@"title"]?:@""; + e.visitedAt=[NSDate dateWithTimeIntervalSince1970:[obj[@"t"] doubleValue]]; + [_entries addObject:e]; + } + return self; +} +- (void)recordTitle:(NSString *)t url:(NSString *)u { + if (!u.length||[u hasPrefix:@"bearbrowser://"]) return; + BBHistoryEntry *e=[BBHistoryEntry new]; e.title=t?:@""; e.urlString=u; e.visitedAt=[NSDate date]; + [self.entries addObject:e]; if(self.entries.count>20000) [self.entries removeObjectAtIndex:0]; + NSString *dir=[BBSupportDir() stringByAppendingPathComponent:@"history"]; + BBAppendLine([dir stringByAppendingPathComponent:@"history.jsonl"], + BBJSON(@{@"url":u,@"title":t?:@"",@"t":@(e.visitedAt.timeIntervalSince1970)})); +} +- (NSArray *)search:(NSString *)q limit:(NSInteger)n { + if(!q.length) return @[]; + NSString *ql=[q lowercaseString]; NSMutableArray *r=[NSMutableArray array]; NSMutableSet *seen=[NSMutableSet set]; + for (NSInteger i=self.entries.count-1;i>=0&&(NSInteger)r.count *items; +@property(strong) NSScrollView *scroll; +@property(strong) NSStackView *stack; +@property(strong) NSTimer *pollTimer; +- (void)addItem:(BBDownloadItem *)item; +- (void)refresh; +- (void)show; +- (void)hide; +@end + +@implementation BBDownloadPanel +- (instancetype)initWithFrame:(NSRect)f { + self=[super initWithFrame:f]; + self.wantsLayer=YES; self.layer.backgroundColor=[NSColor windowBackgroundColor].CGColor; + [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(themeChanged:) + name:NSSystemColorsDidChangeNotification object:nil]; + _items=[NSMutableArray array]; + // Header + NSTextField *hdr=[[NSTextField alloc]initWithFrame:NSMakeRect(12,f.size.height-36,f.size.width-24,24)]; + hdr.autoresizingMask=NSViewWidthSizable|NSViewMinYMargin; + hdr.stringValue=@"Downloads"; hdr.font=[NSFont systemFontOfSize:13 weight:NSFontWeightSemibold]; + hdr.bordered=NO; hdr.editable=NO; hdr.selectable=NO; hdr.backgroundColor=[NSColor clearColor]; + [self addSubview:hdr]; + // Separator + NSBox *sep=[[NSBox alloc]initWithFrame:NSMakeRect(0,f.size.height-38,f.size.width,1)]; + sep.autoresizingMask=NSViewWidthSizable|NSViewMinYMargin; sep.boxType=NSBoxSeparator; [self addSubview:sep]; + // Scrollable stack + _stack=[NSStackView new]; _stack.orientation=NSUserInterfaceLayoutOrientationVertical; + _stack.alignment=NSLayoutAttributeLeading; _stack.spacing=1; + _stack.translatesAutoresizingMaskIntoConstraints=NO; + _scroll=[[NSScrollView alloc]initWithFrame:NSMakeRect(0,0,f.size.width,f.size.height-40)]; + _scroll.autoresizingMask=NSViewWidthSizable|NSViewHeightSizable; + _scroll.hasVerticalScroller=YES; _scroll.drawsBackground=NO; + _scroll.documentView=_stack; [self addSubview:_scroll]; + [NSLayoutConstraint activateConstraints:@[ + [_stack.leadingAnchor constraintEqualToAnchor:_scroll.contentView.leadingAnchor], + [_stack.trailingAnchor constraintEqualToAnchor:_scroll.contentView.trailingAnchor], + [_stack.topAnchor constraintEqualToAnchor:_scroll.contentView.topAnchor], + ]]; + return self; +} +- (void)themeChanged:(NSNotification *)n { self.layer.backgroundColor=[NSColor windowBackgroundColor].CGColor; } +- (void)addItem:(BBDownloadItem *)item { + [self.items addObject:item]; + if (!self.pollTimer) self.pollTimer=[NSTimer scheduledTimerWithTimeInterval:0.5 target:self selector:@selector(pollFileSizes) userInfo:nil repeats:YES]; + [self refresh]; [self show]; +} +- (void)pollFileSizes { + BOOL anyActive=NO; + for (BBDownloadItem *item in self.items) { + if (item.state==BBDownloadStateActive && item.destURL) { + NSDictionary *attr=[[NSFileManager defaultManager] attributesOfItemAtPath:item.destURL.path error:nil]; + if (attr) item.writtenBytes=[attr[NSFileSize] longLongValue]; + anyActive=YES; + } + } + if (!anyActive) { [self.pollTimer invalidate]; self.pollTimer=nil; } + dispatch_async(dispatch_get_main_queue(),^{ [self refresh]; }); +} +- (void)refresh { + for (NSView *v in self.stack.arrangedSubviews) [self.stack removeArrangedSubview:v]; + for (NSView *v in self.stack.arrangedSubviews.copy) [v removeFromSuperview]; + for (BBDownloadItem *item in self.items.reverseObjectEnumerator.allObjects) { + NSView *row=[self rowForItem:item]; [self.stack addArrangedSubview:row]; + [NSLayoutConstraint activateConstraints:@[ + [row.leadingAnchor constraintEqualToAnchor:self.stack.leadingAnchor], + [row.trailingAnchor constraintEqualToAnchor:self.stack.trailingAnchor], + [row.heightAnchor constraintEqualToConstant:68], + ]]; + } +} +- (NSView *)rowForItem:(BBDownloadItem *)item { + NSView *row=[[NSView alloc]initWithFrame:NSZeroRect]; row.wantsLayer=YES; + row.layer.backgroundColor=[NSColor controlBackgroundColor].CGColor; + // Filename + NSTextField *name=[[NSTextField alloc]initWithFrame:NSZeroRect]; + name.translatesAutoresizingMaskIntoConstraints=NO; + name.stringValue=item.filename?:@"file"; name.font=[NSFont systemFontOfSize:12 weight:NSFontWeightMedium]; + name.bordered=NO; name.editable=NO; name.selectable=NO; name.backgroundColor=[NSColor clearColor]; + name.lineBreakMode=NSLineBreakByTruncatingMiddle; [row addSubview:name]; + // Status label + NSTextField *status=[[NSTextField alloc]initWithFrame:NSZeroRect]; + status.translatesAutoresizingMaskIntoConstraints=NO; + status.stringValue=[self statusStringForItem:item]; + status.font=[NSFont systemFontOfSize:11]; status.textColor=[NSColor secondaryLabelColor]; + status.bordered=NO; status.editable=NO; status.selectable=NO; status.backgroundColor=[NSColor clearColor]; + [row addSubview:status]; + // Progress bar + NSProgressIndicator *bar=[[NSProgressIndicator alloc]initWithFrame:NSZeroRect]; + bar.translatesAutoresizingMaskIntoConstraints=NO; + bar.style=NSProgressIndicatorStyleBar; bar.minValue=0; bar.maxValue=1; + bar.controlSize=NSControlSizeSmall; + double pct=(item.totalBytes>0)?(double)item.writtenBytes/item.totalBytes:(item.state==BBDownloadStateDone?1.0:0.0); + bar.indeterminate=(item.state==BBDownloadStateActive&&item.totalBytes<=0); + bar.doubleValue=pct; if(bar.indeterminate)[bar startAnimation:nil]; + bar.hidden=(item.state==BBDownloadStateFailed); + [row addSubview:bar]; + // Action button + NSButton *btn=[[NSButton alloc]initWithFrame:NSZeroRect]; + btn.translatesAutoresizingMaskIntoConstraints=NO; + btn.bezelStyle=NSBezelStyleToolbar; btn.bordered=NO; + NSString *sym=(item.state==BBDownloadStateDone)?@"arrow.down.circle.fill": + (item.state==BBDownloadStateFailed)?@"arrow.clockwise":@"xmark.circle"; + NSImage *img=[NSImage imageWithSystemSymbolName:sym accessibilityDescription:@"Action"]; + img=[img imageWithSymbolConfiguration:[NSImageSymbolConfiguration configurationWithPointSize:14 weight:NSFontWeightMedium]]; + [img setTemplate:YES]; btn.image=img; + btn.target=self; btn.action=@selector(downloadAction:); + // tag = display row index (0 = newest shown at top) + NSInteger displayIdx=[self.items.reverseObjectEnumerator.allObjects indexOfObject:item]; + btn.tag=(displayIdx==NSNotFound)?0:(NSInteger)displayIdx; + [row addSubview:btn]; + // Layout + [NSLayoutConstraint activateConstraints:@[ + [name.leadingAnchor constraintEqualToAnchor:row.leadingAnchor constant:12], + [name.trailingAnchor constraintEqualToAnchor:btn.leadingAnchor constant:-4], + [name.topAnchor constraintEqualToAnchor:row.topAnchor constant:10], + [status.leadingAnchor constraintEqualToAnchor:name.leadingAnchor], + [status.trailingAnchor constraintEqualToAnchor:name.trailingAnchor], + [status.topAnchor constraintEqualToAnchor:name.bottomAnchor constant:2], + [bar.leadingAnchor constraintEqualToAnchor:name.leadingAnchor], + [bar.trailingAnchor constraintEqualToAnchor:name.trailingAnchor], + [bar.topAnchor constraintEqualToAnchor:status.bottomAnchor constant:5], + [btn.trailingAnchor constraintEqualToAnchor:row.trailingAnchor constant:-10], + [btn.centerYAnchor constraintEqualToAnchor:row.centerYAnchor], + [btn.widthAnchor constraintEqualToConstant:28], + [btn.heightAnchor constraintEqualToConstant:28], + ]]; + return row; +} +- (NSString *)statusStringForItem:(BBDownloadItem *)item { + if (item.state==BBDownloadStateFailed) return item.errorMessage?:@"Failed"; + if (item.state==BBDownloadStateDone) return [NSString stringWithFormat:@"Done — %@",[self sizeStr:item.writtenBytes]]; + if (item.totalBytes>0) return [NSString stringWithFormat:@"%@ of %@",[self sizeStr:item.writtenBytes],[self sizeStr:item.totalBytes]]; + return item.writtenBytes>0?[self sizeStr:item.writtenBytes]:@"Waiting…"; +} +- (NSString *)sizeStr:(long long)b { + if(b<1024) return [NSString stringWithFormat:@"%lld B",b]; + if(b<1024*1024) return [NSString stringWithFormat:@"%.1f KB",(double)b/1024]; + if(b<1024*1024*1024) return [NSString stringWithFormat:@"%.1f MB",(double)b/(1024*1024)]; + return [NSString stringWithFormat:@"%.2f GB",(double)b/(1024*1024*1024)]; +} +- (void)downloadAction:(NSButton *)btn { + NSInteger idx=self.items.count-1-btn.tag; // rows displayed newest-first + if(idx<0||idx>=(NSInteger)self.items.count) return; + BBDownloadItem *item=self.items[idx]; + if (item.state==BBDownloadStateDone && item.destURL) + [[NSWorkspace sharedWorkspace] activateFileViewerSelectingURLs:@[item.destURL]]; + else if (item.state==BBDownloadStateFailed) + { item.state=BBDownloadStateActive; [self refresh]; } + else if (item.download) + [item.download cancel:^(NSData *rd){}]; +} +- (void)show { self.hidden=NO; } +- (void)hide { self.hidden=YES; } +@end + +// ── BBAddressDropdown ───────────────────────────────────────────────────────── +@interface BBAddressSuggestion : NSObject +@property(copy) NSString *title, *urlString, *badge; // badge: "Bookmark", "History", "Search" +@end +@implementation BBAddressSuggestion +@end + +@protocol BBAddressDropdownDelegate +- (void)dropdownSelectedURL:(NSString *)urlString; +@end + +// NSView-based overlay — no child window, no focus theft. +@interface BBAddressDropdown : NSObject +@property(strong) NSView *overlay; // lives in main window's contentView +@property(strong) NSTableView *table; +@property(strong) NSMutableArray *suggestions; +@property(weak) id delegate; +@property(strong) NSTimer *ddgTimer; +- (void)updateForQuery:(NSString *)q belowField:(NSTextField *)field inWindow:(NSWindow *)win; +- (void)hide; +- (BOOL)selectNext; +- (BOOL)selectPrev; +- (BOOL)confirmSelection; +@end + +@implementation BBAddressDropdown +- (instancetype)init { + self=[super init]; _suggestions=[NSMutableArray array]; + // Overlay container — added to contentView on first show + _overlay=[[NSView alloc]initWithFrame:NSZeroRect]; + _overlay.wantsLayer=YES; + _overlay.hidden=YES; + [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(themeChanged:) + name:NSSystemColorsDidChangeNotification object:nil]; + NSScrollView *scroll=[[NSScrollView alloc]initWithFrame:_overlay.bounds]; + scroll.autoresizingMask=NSViewWidthSizable|NSViewHeightSizable; + scroll.hasVerticalScroller=NO; scroll.drawsBackground=NO; + _table=[[NSTableView alloc]init]; _table.headerView=nil; + _table.rowHeight=40; _table.intercellSpacing=NSMakeSize(0,0); + _table.backgroundColor=[NSColor clearColor]; + NSTableColumn *col=[[NSTableColumn alloc]initWithIdentifier:@"row"]; col.width=600; + [_table addTableColumn:col]; _table.dataSource=self; _table.delegate=self; + scroll.documentView=_table; [_overlay addSubview:scroll]; + return self; +} +- (void)themeChanged:(NSNotification *)n { [_overlay setNeedsDisplay:YES]; } +- (void)updateForQuery:(NSString *)q belowField:(NSTextField *)field inWindow:(NSWindow *)win { + [_suggestions removeAllObjects]; + if (!q.length) { [self hide]; return; } + // Bookmarks first + for (BBBookmark *b in [BBBookmarksStore shared].items) { + if ([[b.urlString lowercaseString] containsString:q.lowercaseString]|| + [[b.title lowercaseString] containsString:q.lowercaseString]) { + BBAddressSuggestion *s=[BBAddressSuggestion new]; s.title=b.title; s.urlString=b.urlString; s.badge=@"★"; + [_suggestions addObject:s]; if(_suggestions.count>=3) break; + } + } + // History + for (BBHistoryEntry *e in [[BBHistoryStore shared] search:q limit:6]) { + BBAddressSuggestion *s=[BBAddressSuggestion new]; s.title=e.title.length?e.title:e.urlString; + s.urlString=e.urlString; s.badge=@"↺"; [_suggestions addObject:s]; + if(_suggestions.count>=9) break; + } + // Search row always last + BBAddressSuggestion *search=[BBAddressSuggestion new]; + search.title=[NSString stringWithFormat:@"Search DuckDuckGo: %@",q]; + NSString *eq=[q stringByAddingPercentEncodingWithAllowedCharacters:[NSCharacterSet URLQueryAllowedCharacterSet]]; + search.urlString=[NSString stringWithFormat:@"https://duckduckgo.com/?q=%@",eq]; + search.badge=@"⌕"; [_suggestions addObject:search]; + [_table reloadData]; [_table deselectAll:nil]; + // Position overlay in contentView coordinates below the address field + NSView *cv=win.contentView; + if (_overlay.superview!=cv) [cv addSubview:_overlay positioned:NSWindowAbove relativeTo:nil]; + NSRect fieldInContent=[field.superview convertRect:field.frame toView:cv]; + CGFloat rowH=40; CGFloat h=MIN((CGFloat)_suggestions.count*rowH,280); + _overlay.frame=NSMakeRect(fieldInContent.origin.x, + fieldInContent.origin.y-h, + fieldInContent.size.width, h); + _overlay.hidden=NO; + // DDG autocomplete after 250ms debounce + [_ddgTimer invalidate]; NSString *qc=q; + _ddgTimer=[NSTimer scheduledTimerWithTimeInterval:0.25 repeats:NO block:^(NSTimer *t){ + [self fetchDDGSuggestions:qc]; + }]; +} +- (void)fetchDDGSuggestions:(NSString *)q { + NSString *eq=[q stringByAddingPercentEncodingWithAllowedCharacters:[NSCharacterSet URLQueryAllowedCharacterSet]]; + NSURL *url=[NSURL URLWithString:[NSString stringWithFormat:@"https://duckduckgo.com/ac/?q=%@&type=list",eq]]; + [[[NSURLSession sharedSession] dataTaskWithURL:url completionHandler:^(NSData *d,NSURLResponse *r,NSError *e){ + if(e||!d) return; + NSArray *resp=[NSJSONSerialization JSONObjectWithData:d options:0 error:nil]; + NSArray *terms=(resp.count>1&&[resp[1] isKindOfClass:[NSArray class]])?resp[1]:@[]; + dispatch_async(dispatch_get_main_queue(),^{ + // Replace old DDG completions (not the search row) with fresh ones + [self.suggestions removeObjectsAtIndexes:[self.suggestions indexesOfObjectsPassingTest: + ^BOOL(BBAddressSuggestion *s,NSUInteger i,BOOL *stop){ + return [s.badge isEqualToString:@"⌕"] && ![s.title hasPrefix:@"Search DuckDuckGo"];}]]; + NSInteger ins=MAX(0,(NSInteger)self.suggestions.count-1); + for (NSString *term in terms) { + if(![term isKindOfClass:[NSString class]]||!term.length) continue; + BBAddressSuggestion *s=[BBAddressSuggestion new]; s.title=term; s.badge=@"⌕"; + NSString *teq=[term stringByAddingPercentEncodingWithAllowedCharacters:[NSCharacterSet URLQueryAllowedCharacterSet]]; + s.urlString=[NSString stringWithFormat:@"https://duckduckgo.com/?q=%@",teq]; + if(ins<(NSInteger)self.suggestions.count) [self.suggestions insertObject:s atIndex:ins++]; + if(self.suggestions.count>=12) break; + } + if(terms.count) [self.table reloadData]; + }); + }] resume]; +} +- (void)hide { + [_ddgTimer invalidate]; _ddgTimer=nil; + _overlay.hidden=YES; [_table deselectAll:nil]; +} +- (BOOL)selectNext { + if(!_suggestions.count||_overlay.hidden) return NO; + NSInteger row=MIN(_table.selectedRow+1,(NSInteger)_suggestions.count-1); + [_table selectRowIndexes:[NSIndexSet indexSetWithIndex:row] byExtendingSelection:NO]; + [_table scrollRowToVisible:row]; return YES; +} +- (BOOL)selectPrev { + if(!_suggestions.count||_overlay.hidden) return NO; + NSInteger row=_table.selectedRow; + if(row<=0){[_table deselectAll:nil];return YES;} + row=MAX(row-1,0); + [_table selectRowIndexes:[NSIndexSet indexSetWithIndex:row] byExtendingSelection:NO]; + [_table scrollRowToVisible:row]; return YES; +} +- (BOOL)confirmSelection { + NSInteger row=_table.selectedRow; + if(row<0||row>=(NSInteger)_suggestions.count||_overlay.hidden) return NO; + NSString *url=_suggestions[row].urlString; + [self hide]; [self.delegate dropdownSelectedURL:url]; return YES; +} +- (NSInteger)numberOfRowsInTableView:(NSTableView *)tv { return _suggestions.count; } +- (NSView *)tableView:(NSTableView *)tv viewForTableColumn:(NSTableColumn *)col row:(NSInteger)row { + NSTableCellView *cell=[tv makeViewWithIdentifier:@"dd" owner:self]; + if (!cell) { + cell=[[NSTableCellView alloc]initWithFrame:NSMakeRect(0,0,560,40)]; cell.identifier=@"dd"; + NSTextField *badge=[[NSTextField alloc]initWithFrame:NSMakeRect(8,10,20,20)]; + badge.tag=1; badge.bordered=NO; badge.editable=NO; badge.selectable=NO; + badge.backgroundColor=[NSColor clearColor]; badge.font=[NSFont systemFontOfSize:11]; + badge.textColor=[NSColor tertiaryLabelColor]; badge.alignment=NSTextAlignmentCenter; + [cell addSubview:badge]; + NSTextField *title=[[NSTextField alloc]initWithFrame:NSMakeRect(32,22,496,16)]; + title.tag=2; title.bordered=NO; title.editable=NO; title.selectable=NO; + title.backgroundColor=[NSColor clearColor]; title.font=[NSFont systemFontOfSize:12 weight:NSFontWeightMedium]; + title.lineBreakMode=NSLineBreakByTruncatingTail; [cell addSubview:title]; + NSTextField *urlLbl=[[NSTextField alloc]initWithFrame:NSMakeRect(32,4,496,16)]; + urlLbl.tag=3; urlLbl.bordered=NO; urlLbl.editable=NO; urlLbl.selectable=NO; + urlLbl.backgroundColor=[NSColor clearColor]; urlLbl.font=[NSFont systemFontOfSize:10]; + urlLbl.textColor=[NSColor secondaryLabelColor]; urlLbl.lineBreakMode=NSLineBreakByTruncatingTail; + [cell addSubview:urlLbl]; + } + BBAddressSuggestion *s=_suggestions[row]; + ((NSTextField *)[cell viewWithTag:1]).stringValue=s.badge?:@""; + ((NSTextField *)[cell viewWithTag:2]).stringValue=s.title?:@""; + ((NSTextField *)[cell viewWithTag:3]).stringValue=s.urlString?:@""; + return cell; +} +- (CGFloat)tableView:(NSTableView *)tv heightOfRow:(NSInteger)row { return 40; } +- (BOOL)tableView:(NSTableView *)tv shouldSelectRow:(NSInteger)row { return YES; } +- (void)tableViewSelectionDidChange:(NSNotification *)n { + // Mouse click in the table — navigate immediately + NSInteger row=_table.selectedRow; + if(row>=0&&row<(NSInteger)_suggestions.count&&!_overlay.hidden) + [self.delegate dropdownSelectedURL:_suggestions[row].urlString]; +} +@end + +// ── BBHistoryPanelDS ────────────────────────────────────────────────────────── +// A lightweight datasource/delegate for the history NSTableView. +@interface BBHistoryPanelDS : NSObject +@property(strong) NSMutableArray *all, *shown; +@property(strong) NSTableView *tv; +@property(weak) NSWindow *win; +@property(weak) WKWebView *webView; +- (instancetype)initWithEntries:(NSMutableArray *)e tableView:(NSTableView *)tv searchField:(NSSearchField *)sf window:(NSWindow *)w webView:(WKWebView *)wv; +@end +@implementation BBHistoryPanelDS +- (instancetype)initWithEntries:(NSMutableArray *)e tableView:(NSTableView *)tv searchField:(NSSearchField *)sf window:(NSWindow *)w webView:(WKWebView *)wv { + self=[super init]; _all=e; _shown=[e mutableCopy]; _tv=tv; _win=w; _webView=wv; return self; +} +- (NSInteger)numberOfRowsInTableView:(NSTableView *)tv { return _shown.count; } +- (NSView *)tableView:(NSTableView *)tv viewForTableColumn:(NSTableColumn *)col row:(NSInteger)row { + NSTextField *f=[tv makeViewWithIdentifier:col.identifier owner:self]; + if (!f) { f=[[NSTextField alloc]init]; f.identifier=col.identifier; f.bordered=NO; f.editable=NO; f.selectable=NO; f.backgroundColor=[NSColor clearColor]; f.lineBreakMode=NSLineBreakByTruncatingTail; } + BBHistoryEntry *e=_shown[row]; + NSDateFormatter *df=[NSDateFormatter new]; df.timeStyle=NSDateFormatterShortStyle; df.dateStyle=NSDateFormatterShortStyle; + if ([col.identifier isEqualToString:@"title"]) f.stringValue=e.title.length?e.title:e.urlString; + else if ([col.identifier isEqualToString:@"url"]) f.stringValue=e.urlString; + else f.stringValue=[df stringFromDate:e.visitedAt]?:@""; + return f; +} +- (void)tableViewSelectionDidChange:(NSNotification *)n { + NSInteger row=_tv.selectedRow; + if(row<0||row>=(NSInteger)_shown.count) return; + NSURL *u=[NSURL URLWithString:_shown[row].urlString]; if(!u) return; + [_webView loadRequest:[NSURLRequest requestWithURL:u]]; + [_win.sheetParent endSheet:_win]; [_win orderOut:nil]; +} +- (void)controlTextDidChange:(NSNotification *)n { + NSString *q=((NSSearchField *)n.object).stringValue; + if(!q.length) { _shown=[_all mutableCopy]; [_tv reloadData]; return; } + NSString *ql=q.lowercaseString; + _shown=[[_all objectsAtIndexes:[_all indexesOfObjectsPassingTest:^BOOL(BBHistoryEntry *e,NSUInteger i,BOOL *s){ + return [[e.urlString lowercaseString] containsString:ql]||[[e.title lowercaseString] containsString:ql]; + }]] mutableCopy]; + [_tv reloadData]; +} +@end + +// ── BBConnectionRecord ──────────────────────────────────────────────────────── +typedef NS_ENUM(NSInteger,BBConnCategory){ + BBConnCategoryFirstParty=0,BBConnCategoryTracker,BBConnCategoryAnalytics, + BBConnCategoryCDN,BBConnCategoryUnknown +}; +@interface BBConnectionRecord : NSObject +@property(copy) NSString *domain; +@property(copy) NSString *pageURL; +@property(copy) NSString *resourceType; +@property(strong) NSDate *timestamp; +@property(assign) BOOL blocked; +@property(assign) BBConnCategory category; ++(NSString*)etldForHost:(NSString*)host; ++(BBConnCategory)classify:(NSString*)etld; +@end +@implementation BBConnectionRecord ++(NSString*)etldForHost:(NSString*)host { + if(!host.length) return @""; + NSArray *p=[host componentsSeparatedByString:@"."]; + if(p.count<2) return host; + return [NSString stringWithFormat:@"%@.%@",p[p.count-2],p[p.count-1]]; +} ++(BBConnCategory)classify:(NSString*)etld { + static NSSet *trackers=nil,*analytics=nil,*cdns=nil; + static dispatch_once_t once; + dispatch_once(&once,^{ + trackers=[NSSet setWithArray:@[@"doubleclick.net",@"googlesyndication.com",@"connect.facebook.net", + @"criteo.com",@"adnxs.com",@"rubiconproject.com",@"pubmatic.com",@"openx.net", + @"taboola.com",@"outbrain.com",@"moatads.com",@"scorecardresearch.com", + @"quantserve.com",@"turn.com",@"bidswitch.net",@"casalemedia.com"]]; + analytics=[NSSet setWithArray:@[@"google-analytics.com",@"googletagmanager.com",@"mixpanel.com", + @"amplitude.com",@"segment.io",@"segment.com",@"heap.io",@"hotjar.com", + @"fullstory.com",@"logrocket.com",@"smartlook.com"]]; + cdns=[NSSet setWithArray:@[@"cloudflare.com",@"fastly.net",@"cloudfront.net", + @"akamaized.net",@"jsdelivr.net",@"unpkg.com",@"amazonaws.com", + @"googleapis.com",@"gstatic.com",@"bootstrapcdn.com",@"jquery.com", + @"cdnjs.cloudflare.com",@"azureedge.net",@"stackpath.bootstrapcdn.com"]]; + }); + if([trackers containsObject:etld]) return BBConnCategoryTracker; + if([analytics containsObject:etld]) return BBConnCategoryAnalytics; + if([cdns containsObject:etld]) return BBConnCategoryCDN; + return BBConnCategoryUnknown; +} +@end + +// ── BBNetworkMonitor ────────────────────────────────────────────────────────── +@interface BBNetworkMonitor : NSObject ++(instancetype)shared; +-(void)record:(NSString*)domain page:(NSString*)page type:(NSString*)type blocked:(BOOL)blocked; +-(NSArray*)snapshot; +-(void)clear; +@property(copy) void(^onNewRecord)(BBConnectionRecord*); +@end +@implementation BBNetworkMonitor { + NSMutableArray *_recs; + dispatch_queue_t _q; +} ++(instancetype)shared{static BBNetworkMonitor*s;static dispatch_once_t o;dispatch_once(&o,^{s=[[self alloc]init];});return s;} +-(instancetype)init{ + self=[super init]; + _recs=[NSMutableArray arrayWithCapacity:2000]; + _q=dispatch_queue_create("io.bearbrowser.netmon",DISPATCH_QUEUE_SERIAL); + return self; +} +-(void)record:(NSString*)domain page:(NSString*)page type:(NSString*)type blocked:(BOOL)blocked { + if(!domain.length) return; + NSString *etld=[BBConnectionRecord etldForHost:domain]; + BBConnectionRecord *r=[BBConnectionRecord new]; + r.domain=etld; r.pageURL=page?:@""; r.resourceType=type?:@""; + r.timestamp=[NSDate date]; r.blocked=blocked; + r.category=[BBConnectionRecord classify:etld]; + dispatch_async(_q,^{ + if(_recs.count>=5000)[_recs removeObjectsInRange:NSMakeRange(0,500)]; + [_recs addObject:r]; + }); + if(self.onNewRecord) dispatch_async(dispatch_get_main_queue(),^{self.onNewRecord(r);}); +} +-(NSArray*)snapshot{__block NSArray*s;dispatch_sync(_q,^{s=[_recs copy];});return s;} +-(void)clear{dispatch_async(_q,^{[_recs removeAllObjects];});} +@end + +// ── BBSecurityMonitor ───────────────────────────────────────────────────────── +typedef NS_ENUM(NSInteger,BBSecSeverity){BBSecLow=0,BBSecMedium,BBSecHigh,BBSecCritical}; + +@interface BBSecurityEvent : NSObject +@property(copy) NSString *type; // eval, script_inject, beacon, form_submit, … +@property(copy) NSString *pageURL; +@property(copy) NSString *detail; // truncated snippet / url / field list +@property(strong) NSDate *timestamp; +@property(assign) BBSecSeverity severity; +@end +@implementation BBSecurityEvent @end + +@interface BBSecurityMonitor : NSObject ++(instancetype)shared; +-(void)record:(NSString*)type page:(NSString*)page detail:(NSString*)detail severity:(BBSecSeverity)sev; +-(NSArray*)snapshot; +@property(copy) void(^onNewEvent)(BBSecurityEvent*); +@end + +@implementation BBSecurityMonitor { + NSMutableArray *_evts; + dispatch_queue_t _q; +} ++(instancetype)shared{static BBSecurityMonitor*s;static dispatch_once_t o;dispatch_once(&o,^{s=[[self alloc]init];});return s;} +-(instancetype)init{self=[super init];_evts=[NSMutableArray array];_q=dispatch_queue_create("io.bearbrowser.secmon",DISPATCH_QUEUE_SERIAL);return self;} +-(void)record:(NSString*)type page:(NSString*)page detail:(NSString*)detail severity:(BBSecSeverity)sev { + BBSecurityEvent *e=[BBSecurityEvent new]; + e.type=type; e.pageURL=page; e.detail=detail; e.severity=sev; e.timestamp=[NSDate date]; + dispatch_async(_q,^{ + [_evts addObject:e]; + if(_evts.count>2000)[_evts removeObjectAtIndex:0]; + if(self.onNewEvent) dispatch_async(dispatch_get_main_queue(),^{self.onNewEvent(e);}); + }); +} +-(NSArray*)snapshot{__block NSArray*s;dispatch_sync(_q,^{s=[_evts copy];});return s;} +@end + +// Heuristic severity classifier — runs on JS-side metadata, not full AST parse +static BBSecSeverity BBSecClassify(NSString *type, NSString *detail) { + if ([type isEqualToString:@"credentials_get"] || + [type isEqualToString:@"credentials_create"]) return BBSecCritical; + if ([type isEqualToString:@"eval"] || [type isEqualToString:@"Function"]) { + // Obfuscation / exfil patterns in eval'd body → critical + NSArray *hotPats = @[@"atob(", @"btoa(", @"document.cookie", @"localStorage", + @"sendBeacon", @"keydown", @"keypress", @"fromCharCode"]; + for (NSString *p in hotPats) + if ([detail containsString:p]) return BBSecCritical; + return BBSecHigh; + } + if ([type isEqualToString:@"beacon"]) return BBSecHigh; + if ([type isEqualToString:@"keylistener"]) return BBSecCritical; + if ([type isEqualToString:@"script_inject"]) { + // Inline script with obfuscation patterns + NSArray *obfPats = @[@"eval(", @"atob(", @"String.fromCharCode", @"\\x", @"unescape("]; + for (NSString *p in obfPats) + if ([detail containsString:p]) return BBSecHigh; + return BBSecMedium; + } + if ([type isEqualToString:@"form_submit"]) return BBSecMedium; + if ([type isEqualToString:@"document_write"]) return BBSecMedium; + if ([type isEqualToString:@"localstorage_auth"])return BBSecMedium; + if ([type isEqualToString:@"cookie_auth"]) return BBSecMedium; + return BBSecLow; +} + +// ── BBFirewall ──────────────────────────────────────────────────────────────── +typedef NS_ENUM(NSInteger,BBFirewallDecision){BBFWAsk=0,BBFWAllow,BBFWBlock}; +@interface BBFirewall : NSObject ++(instancetype)shared; +-(BBFirewallDecision)decisionFor:(NSString*)domain; +-(void)set:(BBFirewallDecision)d for:(NSString*)domain; +-(NSDictionary*)allRules; +@end +@implementation BBFirewall{NSMutableDictionary*_rules;} ++(instancetype)shared{static BBFirewall*s;static dispatch_once_t o;dispatch_once(&o,^{s=[[self alloc]init];});return s;} +-(instancetype)init{ + self=[super init]; + NSDictionary *saved=[[NSUserDefaults standardUserDefaults]dictionaryForKey:@"BBFirewallRules"]; + _rules=[NSMutableDictionary dictionaryWithDictionary:saved?:@{}]; + return self; +} +-(BBFirewallDecision)decisionFor:(NSString*)d{NSNumber*n=_rules[d];return n?n.integerValue:BBFWAsk;} +-(void)set:(BBFirewallDecision)d for:(NSString*)domain{ + if(d==BBFWAsk)[_rules removeObjectForKey:domain]; else _rules[domain]=@(d); + [[NSUserDefaults standardUserDefaults]setObject:[_rules copy] forKey:@"BBFirewallRules"]; +} +-(NSDictionary*)allRules{return[_rules copy];} +@end + +// ── BBPacketCapture ─────────────────────────────────────────────────────────── +@interface BBPacketCapture : NSObject ++(BOOL)available; // tshark or tcpdump present? ++(NSString*)captureBinary; +-(void)startCapturingHost:(NSString*)host output:(void(^)(NSString*))lineHandler; +-(void)stop; +-(NSURL*)savePcapAndGetURL; // write captured data to ~/Desktop and return URL +@end +@implementation BBPacketCapture{ + NSTask *_task; + NSFileHandle *_fh; + NSMutableData *_pcapBuf; +} ++(BOOL)available{return [self captureBinary].length>0;} ++(NSString*)captureBinary{ + for(NSString*p in @[@"/opt/homebrew/bin/tshark",@"/usr/local/bin/tshark", + @"/opt/homebrew/bin/tcpdump",@"/usr/sbin/tcpdump"]) + if([[NSFileManager defaultManager]isExecutableFileAtPath:p]) return p; + return @""; +} +-(void)startCapturingHost:(NSString*)host output:(void(^)(NSString*))handler{ + [self stop]; + _pcapBuf=[NSMutableData data]; + NSString *bin=[BBPacketCapture captureBinary]; + if(!bin.length) return; + _task=[[NSTask alloc]init]; + _task.launchPath=bin; + BOOL isTshark=[bin containsString:@"tshark"]; + if(isTshark){ + _task.arguments=host.length + ? @[@"-i",@"any",@"-Y",[NSString stringWithFormat:@"ip.host contains \"%@\"",host], + @"-T",@"fields",@"-e",@"frame.time_relative",@"-e",@"ip.src",@"-e",@"ip.dst", + @"-e",@"tcp.dstport",@"-e",@"frame.len",@"-E",@"separator= | "] + : @[@"-i",@"any",@"-T",@"fields",@"-e",@"frame.time_relative",@"-e",@"ip.src", + @"-e",@"ip.dst",@"-e",@"tcp.dstport",@"-e",@"frame.len",@"-E",@"separator= | "]; + } else { + _task.arguments=host.length + ? @[@"-l",@"-n",@"-i",@"any",@"host",host] + : @[@"-l",@"-n",@"-i",@"any",@"port",@"443",@"or",@"port",@"80"]; + } + NSPipe *pipe=[NSPipe pipe]; + _task.standardOutput=pipe; _task.standardError=pipe; + __weak BBPacketCapture *weakCapture=self; + [[NSNotificationCenter defaultCenter]addObserverForName:NSFileHandleDataAvailableNotification + object:pipe.fileHandleForReading queue:[NSOperationQueue mainQueue] usingBlock:^(NSNotification*n){ + BBPacketCapture *strong=weakCapture; if(!strong) return; + NSData *d=pipe.fileHandleForReading.availableData; + if(d.length){ + [strong->_pcapBuf appendData:d]; + NSString *s=[[NSString alloc]initWithData:d encoding:NSUTF8StringEncoding]?:@""; + for(NSString *line in[s componentsSeparatedByString:@"\n"]) + if(line.length) handler(line); + [pipe.fileHandleForReading waitForDataInBackgroundAndNotify]; + } + }]; + [pipe.fileHandleForReading waitForDataInBackgroundAndNotify]; + NSError *err=nil; + [_task launchAndReturnError:&err]; + if(err) handler([NSString stringWithFormat:@"Error: %@",err.localizedDescription]); +} +-(void)stop{if(_task.isRunning)[_task terminate]; _task=nil;} +-(NSURL*)savePcapAndGetURL{ + if(!_pcapBuf.length) return nil; + NSString *ts=[NSString stringWithFormat:@"bearbrowser-capture-%ld.txt",(long)[[NSDate date]timeIntervalSince1970]]; + NSURL *dest=[NSURL fileURLWithPath:[[@"~/Desktop" stringByExpandingTildeInPath] stringByAppendingPathComponent:ts]]; + [_pcapBuf writeToURL:dest atomically:YES]; + return dest; +} +@end + +// ── BBNetworkMapPanel ───────────────────────────────────────────────────────── +@interface BBNetworkMapPanel : NSObject ++(instancetype)shared; +-(void)showOrFocus; +-(void)pushRecord:(BBConnectionRecord*)r; +@end + +static NSString *kMapHTML(void) { + return + @"" + @"" + @"
" + @"
" + @"
First-party
" + @"
CDN
" + @"
Analytics
" + @"
Tracker
" + @"
Unknown
" + @"
Blocked
" + @"
" + @"
" + @""; +} + +@implementation BBNetworkMapPanel { + NSPanel *_panel; + NSTableView *_table; + WKWebView *_graphView; + NSTextField *_statsLabel; + NSButton *_monitorBtn; + BBPacketCapture *_capture; + NSPanel *_capturePanel; + NSTextView *_captureOutput; + // Domain-level aggregation for the table + NSMutableArray *_domains; // [{domain,count,blocked,cat}] + NSMutableDictionary *_domainMap; +} ++(instancetype)shared{static BBNetworkMapPanel*s;static dispatch_once_t o;dispatch_once(&o,^{s=[[self alloc]init];});return s;} +-(instancetype)init{ + self=[super init]; + _domains=[NSMutableArray array]; + _domainMap=[NSMutableDictionary dictionary]; + _capture=[[BBPacketCapture alloc]init]; + return self; +} +-(void)buildPanelIfNeeded { + if(_panel) return; + NSRect r=NSMakeRect(200,200,900,560); + _panel=[[NSPanel alloc]initWithContentRect:r + styleMask:(NSWindowStyleMaskTitled|NSWindowStyleMaskClosable|NSWindowStyleMaskResizable|NSWindowStyleMaskMiniaturizable) + backing:NSBackingStoreBuffered defer:NO]; + _panel.title=@"BearBrowser Network Monitor"; + _panel.minSize=NSMakeSize(600,400); + _panel.becomesKeyOnlyIfNeeded=YES; + + NSView *cv=_panel.contentView; cv.wantsLayer=YES; + cv.layer.backgroundColor=[NSColor colorWithWhite:0.11 alpha:1].CGColor; + CGFloat W=cv.bounds.size.width, H=cv.bounds.size.height; + + // ── Top toolbar ── + NSView *bar=[[NSView alloc]initWithFrame:NSMakeRect(0,H-44,W,44)]; + bar.autoresizingMask=NSViewWidthSizable|NSViewMinYMargin; + bar.wantsLayer=YES; bar.layer.backgroundColor=[NSColor colorWithWhite:0.14 alpha:1].CGColor; + [cv addSubview:bar]; + + _statsLabel=[[NSTextField alloc]initWithFrame:NSMakeRect(12,12,260,20)]; + _statsLabel.editable=NO; _statsLabel.bordered=NO; _statsLabel.backgroundColor=[NSColor clearColor]; + _statsLabel.textColor=[NSColor colorWithWhite:0.6 alpha:1]; + _statsLabel.font=[NSFont monospacedSystemFontOfSize:11 weight:NSFontWeightRegular]; + _statsLabel.stringValue=@"No connections yet"; [bar addSubview:_statsLabel]; + + // Clear button + NSButton *clearBtn=[NSButton buttonWithTitle:@"Clear" target:self action:@selector(clearAll:)]; + clearBtn.frame=NSMakeRect(W-280,8,60,26); clearBtn.autoresizingMask=NSViewMinXMargin; + clearBtn.bezelStyle=NSBezelStyleRounded; + clearBtn.font=[NSFont systemFontOfSize:12]; [bar addSubview:clearBtn]; + + // Firewall button + NSButton *fwBtn=[NSButton buttonWithTitle:@"Firewall Rules" target:self action:@selector(openFirewall:)]; + fwBtn.frame=NSMakeRect(W-210,8,100,26); fwBtn.autoresizingMask=NSViewMinXMargin; + fwBtn.bezelStyle=NSBezelStyleRounded; + fwBtn.font=[NSFont systemFontOfSize:12]; [bar addSubview:fwBtn]; + + // Capture button + _monitorBtn=[NSButton buttonWithTitle:@"Start Capture" target:self action:@selector(toggleCapture:)]; + _monitorBtn.frame=NSMakeRect(W-100,8,90,26); _monitorBtn.autoresizingMask=NSViewMinXMargin; + _monitorBtn.bezelStyle=NSBezelStyleRounded; + _monitorBtn.font=[NSFont systemFontOfSize:12]; [bar addSubview:_monitorBtn]; + + // ── Split view ── + NSSplitView *split=[[NSSplitView alloc]initWithFrame:NSMakeRect(0,0,W,H-44)]; + split.autoresizingMask=NSViewWidthSizable|NSViewHeightSizable; + split.vertical=YES; split.dividerStyle=NSSplitViewDividerStyleThin; + [cv addSubview:split]; + + // Left: domain table + NSScrollView *sv=[[NSScrollView alloc]initWithFrame:NSMakeRect(0,0,220,H-44)]; + sv.hasVerticalScroller=YES; sv.autohidesScrollers=YES; + sv.drawsBackground=NO; + _table=[[NSTableView alloc]initWithFrame:sv.contentView.bounds]; + _table.backgroundColor=[NSColor colorWithWhite:0.13 alpha:1]; + _table.gridColor=[NSColor colorWithWhite:0.18 alpha:1]; + _table.gridStyleMask=NSTableViewSolidHorizontalGridLineMask; + _table.rowHeight=26; _table.headerView=nil; + _table.dataSource=self; _table.delegate=self; + _table.allowsEmptySelection=YES; + NSTableColumn *domCol=[[NSTableColumn alloc]initWithIdentifier:@"domain"]; + domCol.width=120; [_table addTableColumn:domCol]; + NSTableColumn *cntCol=[[NSTableColumn alloc]initWithIdentifier:@"count"]; + cntCol.width=40; [_table addTableColumn:cntCol]; + NSTableColumn *stCol=[[NSTableColumn alloc]initWithIdentifier:@"status"]; + stCol.width=16; [_table addTableColumn:stCol]; + sv.documentView=_table; + [split addSubview:sv]; + + // Right: network graph + WKWebViewConfiguration *cfg=[[WKWebViewConfiguration alloc]init]; + [cfg.userContentController addScriptMessageHandler:self name:@"mapAction"]; + _graphView=[[WKWebView alloc]initWithFrame:NSMakeRect(0,0,W-220,H-44) configuration:cfg]; + _graphView.autoresizingMask=NSViewWidthSizable|NSViewHeightSizable; + [_graphView loadHTMLString:kMapHTML() baseURL:nil]; + [split addSubview:_graphView]; + [split setPosition:220 ofDividerAtIndex:0]; +} +-(void)showOrFocus { + [self buildPanelIfNeeded]; + if(!_panel.visible)[_panel orderFront:nil]; + [_panel makeKeyAndOrderFront:nil]; + [self refreshTable]; +} +-(void)pushRecord:(BBConnectionRecord*)r { + dispatch_async(dispatch_get_main_queue(),^{ + NSMutableDictionary *entry=self->_domainMap[r.domain]; + if(!entry){ + entry=[NSMutableDictionary dictionaryWithDictionary:@{ + @"domain":r.domain, @"count":@1, @"blocked":@(r.blocked), + @"cat":[self catString:r.category], @"page":r.pageURL + }]; + self->_domainMap[r.domain]=entry; + [self->_domains addObject:entry]; + } else { + entry[@"count"]=@([entry[@"count"] integerValue]+1); + if(r.blocked) entry[@"blocked"]=@YES; + } + if(self->_panel.visible){ + [self refreshTable]; + [self pushGraphUpdate]; + } + }); +} +-(NSString*)catString:(BBConnCategory)c { + switch(c){ + case BBConnCategoryFirstParty: return @"first-party"; + case BBConnCategoryTracker: return @"tracker"; + case BBConnCategoryAnalytics: return @"analytics"; + case BBConnCategoryCDN: return @"cdn"; + default: return @"unknown"; + } +} +-(void)refreshTable { + // Sort by count desc + [_domains sortUsingComparator:^NSComparisonResult(NSDictionary *a,NSDictionary *b){ + return [b[@"count"] compare:a[@"count"]]; + }]; + [_table reloadData]; + NSInteger total=0,blocked=0; + for(NSDictionary *d in _domains){total+=((NSNumber*)d[@"count"]).integerValue;if([d[@"blocked"]boolValue])blocked++;} + _statsLabel.stringValue=[NSString stringWithFormat:@"%ld domains · %ld reqs · %ld blocked",(long)_domains.count,(long)total,(long)blocked]; +} +-(void)pushGraphUpdate { + NSMutableArray *nodeArr=[NSMutableArray array]; + NSMutableArray *edgeArr=[NSMutableArray array]; + // find the current page domain for the center node + NSString *center=@"this-page"; + for(NSDictionary *d in _domains){ + [nodeArr addObject:@{@"id":d[@"domain"],@"cat":d[@"cat"], + @"count":d[@"count"],@"blocked":d[@"blocked"],@"page":d[@"page"]}]; + if(![d[@"domain"] isEqualToString:center]) + [edgeArr addObject:@{@"s":center,@"t":d[@"domain"]}]; + } + if(nodeArr.count){ + [nodeArr insertObject:@{@"id":center,@"cat":@"first-party",@"count":@1,@"blocked":@NO,@"page":@""} atIndex:0]; + } + NSData *json=[NSJSONSerialization dataWithJSONObject:@{@"nodes":nodeArr,@"edges":edgeArr} options:0 error:nil]; + NSString *js=[NSString stringWithFormat:@"updateGraph(%@);", + [[NSString alloc]initWithData:json encoding:NSUTF8StringEncoding]]; + [_graphView evaluateJavaScript:js completionHandler:nil]; +} +-(void)clearAll:(id)s { + [_domains removeAllObjects]; + [_domainMap removeAllObjects]; + [BBNetworkMonitor.shared clear]; + [_table reloadData]; + _statsLabel.stringValue=@"Cleared"; + [_graphView evaluateJavaScript:@"nodes={};edges=[];" completionHandler:nil]; +} + +// ── Firewall panel ─────────────────────────────────────────────────────────── +-(void)openFirewall:(id)s { + NSWindow *fw=[[NSWindow alloc]initWithContentRect:NSMakeRect(0,0,480,400) + styleMask:(NSWindowStyleMaskTitled|NSWindowStyleMaskClosable|NSWindowStyleMaskResizable) + backing:NSBackingStoreBuffered defer:NO]; + fw.title=@"BearBrowser Firewall Rules"; + NSScrollView *sv=[[NSScrollView alloc]initWithFrame:fw.contentView.bounds]; + sv.autoresizingMask=NSViewWidthSizable|NSViewHeightSizable; + sv.hasVerticalScroller=YES; + NSTableView *tv=[[NSTableView alloc]initWithFrame:sv.contentView.bounds]; + tv.rowHeight=22; + NSTableColumn *dc=[[NSTableColumn alloc]initWithIdentifier:@"dom"]; dc.title=@"Domain"; dc.width=250; [tv addTableColumn:dc]; + NSTableColumn *ac=[[NSTableColumn alloc]initWithIdentifier:@"act"]; ac.title=@"Rule"; ac.width=100; [tv addTableColumn:ac]; + NSTableColumn *xc=[[NSTableColumn alloc]initWithIdentifier:@"del"]; xc.title=@""; xc.width=60; [tv addTableColumn:xc]; + NSDictionary *rules=[BBFirewall.shared allRules]; + NSArray *ruleKeys=rules.allKeys; + // Simple static datasource closure + __block NSArray *keys=ruleKeys; + tv.dataSource=(id)[[NSObject alloc]init]; + // Can't easily do inline datasource; use a quick-and-dirty approach + // Just show an NSAlert with the rules list for now + [sv removeFromSuperview]; + NSMutableString *summary=[NSMutableString string]; + for(NSString *k in ruleKeys){ + NSInteger d=rules[k].integerValue; + [summary appendFormat:@"%@ → %@\n", k, d==BBFWAllow?@"ALLOW":d==BBFWBlock?@"BLOCK":@"ASK"]; + } + if(!summary.length)[summary appendString:@"No custom rules yet.\nDomain rules are set from the Network Monitor by clicking on a domain."]; + NSAlert *a=[[NSAlert alloc]init]; a.messageText=@"Firewall Rules"; + NSScrollView *scr=[[NSScrollView alloc]initWithFrame:NSMakeRect(0,0,400,200)]; + scr.hasVerticalScroller=YES; + NSTextView *txt=[[NSTextView alloc]initWithFrame:scr.contentView.bounds]; + txt.string=summary; txt.editable=NO; txt.font=[NSFont monospacedSystemFontOfSize:11 weight:NSFontWeightRegular]; + scr.documentView=txt; a.accessoryView=scr; + [a addButtonWithTitle:@"OK"]; + [a addButtonWithTitle:@"Clear All Rules"]; + [a beginSheetModalForWindow:_panel completionHandler:^(NSModalResponse r){ + if(r==NSAlertSecondButtonReturn){ + for(NSString *k in keys)[BBFirewall.shared set:BBFWAsk for:k]; + } + }]; +} + +// ── Packet capture panel ───────────────────────────────────────────────────── +-(void)toggleCapture:(id)s { + if(_capture && _capturePanel.visible){ + [_capture stop]; + [_capturePanel close]; _capturePanel=nil; + _monitorBtn.title=@"Start Capture"; return; + } + if(![BBPacketCapture available]){ + NSAlert *a=[[NSAlert alloc]init]; + a.messageText=@"Packet Capture Not Available"; + a.informativeText=@"Install Wireshark (tshark) or ensure tcpdump is accessible:\n\nbrew install wireshark\n\nYou may also need to run:\nsudo chmod +r /dev/bpf*\nor add yourself to the access_bpf group."; + [a addButtonWithTitle:@"OK"]; + [a addButtonWithTitle:@"Open Wireshark Website"]; + [a beginSheetModalForWindow:_panel completionHandler:^(NSModalResponse r){ + if(r==NSAlertSecondButtonReturn) + [[NSWorkspace sharedWorkspace]openURL:[NSURL URLWithString:@"https://www.wireshark.org/download.html"]]; + }]; return; + } + // Build capture output panel + NSRect pr=NSMakeRect(_panel.frame.origin.x+_panel.frame.size.width+8, + _panel.frame.origin.y,440,_panel.frame.size.height); + _capturePanel=[[NSPanel alloc]initWithContentRect:pr + styleMask:(NSWindowStyleMaskTitled|NSWindowStyleMaskClosable|NSWindowStyleMaskResizable) + backing:NSBackingStoreBuffered defer:NO]; + _capturePanel.title=@"Packet Capture"; + NSView *cpv=_capturePanel.contentView; + NSScrollView *csvw=[[NSScrollView alloc]initWithFrame:NSMakeRect(0,36,cpv.bounds.size.width,cpv.bounds.size.height-36)]; + csvw.autoresizingMask=NSViewWidthSizable|NSViewHeightSizable; + csvw.hasVerticalScroller=YES; csvw.hasHorizontalScroller=YES; + _captureOutput=[[NSTextView alloc]initWithFrame:csvw.contentView.bounds]; + _captureOutput.autoresizingMask=NSViewWidthSizable|NSViewHeightSizable; + _captureOutput.editable=NO; _captureOutput.backgroundColor=[NSColor colorWithWhite:0.1 alpha:1]; + _captureOutput.textColor=[NSColor colorWithRed:0.3 green:1 blue:0.3 alpha:1]; + _captureOutput.font=[NSFont monospacedSystemFontOfSize:10 weight:NSFontWeightRegular]; + csvw.documentView=_captureOutput; [cpv addSubview:csvw]; + NSButton *saveBtn=[NSButton buttonWithTitle:@"Save to Desktop" target:self action:@selector(saveCapture:)]; + saveBtn.frame=NSMakeRect(8,6,120,24); saveBtn.bezelStyle=NSBezelStyleRounded; [cpv addSubview:saveBtn]; + NSButton *wiresharkBtn=[NSButton buttonWithTitle:@"Open in Wireshark" target:self action:@selector(openInWireshark:)]; + wiresharkBtn.frame=NSMakeRect(136,6,140,24); wiresharkBtn.bezelStyle=NSBezelStyleRounded; [cpv addSubview:wiresharkBtn]; + [_capturePanel orderFront:nil]; + _monitorBtn.title=@"Stop Capture"; + __weak BBNetworkMapPanel *weakPanel=self; + [_capture startCapturingHost:@"" output:^(NSString*line){ + dispatch_async(dispatch_get_main_queue(),^{ + BBNetworkMapPanel *strong=weakPanel; if(!strong||!strong->_captureOutput) return; + NSString *appended=[NSString stringWithFormat:@"%@\n",line]; + NSAttributedString *as=[[NSAttributedString alloc]initWithString:appended + attributes:@{NSFontAttributeName:[NSFont monospacedSystemFontOfSize:10 weight:NSFontWeightRegular], + NSForegroundColorAttributeName:[NSColor colorWithRed:0.3 green:1 blue:0.3 alpha:1]}]; + [strong->_captureOutput.textStorage appendAttributedString:as]; + [strong->_captureOutput scrollToEndOfDocument:nil]; + }); + }]; +} +-(void)saveCapture:(id)s { NSURL *u=[_capture savePcapAndGetURL]; if(u)[[NSWorkspace sharedWorkspace]activateFileViewerSelectingURLs:@[u]]; } +-(void)openInWireshark:(id)s { + NSURL *u=[_capture savePcapAndGetURL]; if(!u) return; + if(![[NSWorkspace sharedWorkspace]openURL:u]){ + NSAlert *a=[[NSAlert alloc]init]; a.messageText=@"Wireshark Not Found"; + a.informativeText=@"File saved to Desktop. Open it manually in Wireshark."; + [a addButtonWithTitle:@"OK"]; [a runModal]; + } +} + +// ── WKScriptMessageHandler (mapAction from graph) ──────────────────────────── +-(void)userContentController:(WKUserContentController*)ucc didReceiveScriptMessage:(WKScriptMessage*)msg { + if(![msg.name isEqualToString:@"mapAction"]) return; + NSDictionary *body=[msg.body isKindOfClass:[NSDictionary class]]?msg.body:@{}; + NSString *domain=body[@"domain"]?:@""; + if(!domain.length) return; + BOOL currentlyBlocked=[body[@"blocked"] boolValue]; + NSAlert *a=[[NSAlert alloc]init]; + a.messageText=[NSString stringWithFormat:@"%@",domain]; + BBFirewallDecision current=[BBFirewall.shared decisionFor:domain]; + a.informativeText=[NSString stringWithFormat:@"Current rule: %@\n\nSet a firewall rule for this domain:", + current==BBFWAllow?@"Always allow":current==BBFWBlock?@"Always block":@"Default (follow blocklist)"]; + [a addButtonWithTitle:@"Block Always"]; + [a addButtonWithTitle:@"Allow Always"]; + [a addButtonWithTitle:@"Reset to Default"]; + [a beginSheetModalForWindow:_panel completionHandler:^(NSModalResponse r){ + if(r==NSAlertFirstButtonReturn) [BBFirewall.shared set:BBFWBlock for:domain]; + else if(r==NSAlertSecondButtonReturn)[BBFirewall.shared set:BBFWAllow for:domain]; + else [BBFirewall.shared set:BBFWAsk for:domain]; + }]; +} + +// ── NSTableViewDataSource / Delegate ───────────────────────────────────────── +-(NSInteger)numberOfRowsInTableView:(NSTableView*)tv { return _domains.count; } +-(id)tableView:(NSTableView*)tv objectValueForTableColumn:(NSTableColumn*)col row:(NSInteger)row { + if(row>=(NSInteger)_domains.count) return @""; + NSDictionary *d=_domains[row]; + if([col.identifier isEqualToString:@"domain"]) return d[@"domain"]; + if([col.identifier isEqualToString:@"count"]) return d[@"count"]; + if([col.identifier isEqualToString:@"status"]) return [d[@"blocked"] boolValue]?@"🔴":@"🟢"; + return @""; +} +-(NSView*)tableView:(NSTableView*)tv viewForTableColumn:(NSTableColumn*)col row:(NSInteger)row { + if(row>=(NSInteger)_domains.count) return nil; + NSDictionary *d=_domains[row]; + NSTextField *cell=[[NSTextField alloc]init]; + cell.editable=NO; cell.bordered=NO; cell.backgroundColor=[NSColor clearColor]; + cell.textColor=[NSColor colorWithWhite:0.85 alpha:1]; + cell.font=[NSFont systemFontOfSize:11]; + if([col.identifier isEqualToString:@"domain"]){ + cell.stringValue=d[@"domain"]?:@""; + // color by category + NSString *cat=d[@"cat"]?:@""; + if([cat isEqualToString:@"tracker"]) cell.textColor=[NSColor colorWithRed:0.89 green:0.29 blue:0.29 alpha:1]; + else if([cat isEqualToString:@"analytics"]) cell.textColor=[NSColor colorWithRed:0.94 green:0.62 blue:0.15 alpha:1]; + else if([cat isEqualToString:@"cdn"]) cell.textColor=[NSColor colorWithRed:0.22 green:0.54 blue:0.87 alpha:1]; + else if([cat isEqualToString:@"first-party"]) cell.textColor=[NSColor colorWithRed:0.11 green:0.62 blue:0.46 alpha:1]; + } else if([col.identifier isEqualToString:@"count"]){ + cell.alignment=NSTextAlignmentRight; cell.stringValue=[d[@"count"] stringValue]; + cell.textColor=[NSColor colorWithWhite:0.5 alpha:1]; + } else { + cell.stringValue=[d[@"blocked"] boolValue]?@"●":@"○"; + cell.textColor=[d[@"blocked"] boolValue]?[NSColor systemRedColor]:[NSColor colorWithWhite:0.3 alpha:1]; + } + return cell; +} +-(void)tableViewSelectionDidChange:(NSNotification*)n { + NSInteger row=_table.selectedRow; + if(row<0||row>=(NSInteger)_domains.count) return; + NSDictionary *d=_domains[row]; + NSString *dom=d[@"domain"]?:@""; + // highlight in graph + NSString *js=[NSString stringWithFormat:@"selected=nodes['%@'];", + [dom stringByAddingPercentEncodingWithAllowedCharacters:[NSCharacterSet URLQueryAllowedCharacterSet]]]; + [_graphView evaluateJavaScript:js completionHandler:nil]; +} +@end + +// ── BBSecurityPanel ─────────────────────────────────────────────────────────── +@interface BBSecurityPanel : NSObject ++(instancetype)shared; +-(void)show; +-(void)pushEvent:(BBSecurityEvent*)e; +@end + +@implementation BBSecurityPanel { + NSPanel *_panel; + NSTableView *_table; + NSMutableArray *_rows; + NSTextField *_badge; // live count +} ++(instancetype)shared{static BBSecurityPanel*s;static dispatch_once_t o;dispatch_once(&o,^{s=[[self alloc]init];});return s;} + +-(instancetype)init { + self=[super init]; _rows=[NSMutableArray array]; + // Wire up live feed from monitor + __weak BBSecurityPanel *weak=self; + BBSecurityMonitor.shared.onNewEvent=^(BBSecurityEvent *e){ + BBSecurityPanel *s=weak; if(!s) return; + [s pushEvent:e]; }; - BBAppendLine(BBProvenancePath(), BBJSON(event)); -} - -static void BBProposeAction(NSString *actionType, NSString *targetKind, NSString *targetLabel, NSString *targetURL, NSString *risk, NSString *decision, BOOL requiresApproval, NSString *reason) { - NSMutableDictionary *target = [@{ @"kind": targetKind ?: @"page" } mutableCopy]; - if (targetLabel.length > 0) { target[@"label"] = targetLabel; } - if (targetURL.length > 0) { target[@"url"] = targetURL; } - NSDictionary *action = @{ - @"schemaVersion": @"bearbrowser.policy_action.v1", - @"actionId": [@"act-" stringByAppendingString:BBRandomHex(16)], - @"timestamp": BBTimestamp(), - @"actionType": actionType, - @"requestedBy": @{ @"type": @"human", @"id": NSUserName() ?: @"local-user" }, - @"target": target, - @"risk": @{ @"level": risk, @"requiresUserApproval": @(requiresApproval), @"reason": reason }, - @"decision": @{ @"state": decision, @"decisionId": [@"local-" stringByAppendingString:BBRandomHex(8)], @"mode": @"local-default", @"reason": reason } + return self; +} + +-(void)show { + if (!_panel) [self buildPanel]; + // Sync existing events + NSArray *snap=[BBSecurityMonitor.shared snapshot]; + [_rows removeAllObjects]; + [_rows addObjectsFromArray:snap]; + [_table reloadData]; + if (_rows.count) [_table scrollRowToVisible:_rows.count-1]; + [_panel makeKeyAndOrderFront:nil]; +} + +-(void)buildPanel { + CGFloat W=820,H=540; + _panel=[[NSPanel alloc]initWithContentRect:NSMakeRect(200,200,W,H) + styleMask:NSWindowStyleMaskTitled|NSWindowStyleMaskResizable| + NSWindowStyleMaskClosable|NSWindowStyleMaskMiniaturizable + backing:NSBackingStoreBuffered defer:NO]; + _panel.title=@"Security Monitor"; + _panel.minSize=NSMakeSize(600,340); + + NSView *root=_panel.contentView; + + // Toolbar strip + NSView *bar=[[NSView alloc]initWithFrame:NSMakeRect(0,H-38,W,38)]; + bar.wantsLayer=YES; bar.layer.backgroundColor=[[NSColor colorWithWhite:0.13 alpha:1] CGColor]; + bar.autoresizingMask=NSViewWidthSizable|NSViewMinYMargin; + [root addSubview:bar]; + + NSTextField *title=[NSTextField labelWithString:@"JS Security Monitor"]; + title.font=[NSFont boldSystemFontOfSize:12]; + title.textColor=[NSColor colorWithWhite:0.9 alpha:1]; + title.frame=NSMakeRect(12,8,200,20); + [bar addSubview:title]; + + _badge=[NSTextField labelWithString:@"0 events"]; + _badge.font=[NSFont monospacedSystemFontOfSize:11 weight:NSFontWeightRegular]; + _badge.textColor=[NSColor colorWithRed:0.9 green:0.6 blue:0.1 alpha:1]; + _badge.frame=NSMakeRect(220,8,200,20); + [bar addSubview:_badge]; + + NSButton *clr=[NSButton buttonWithTitle:@"Clear" target:self action:@selector(clearEvents:)]; + clr.frame=NSMakeRect(W-80,6,68,26); + clr.autoresizingMask=NSViewMinXMargin; + [bar addSubview:clr]; + + // Table + NSScrollView *scroll=[[NSScrollView alloc]initWithFrame:NSMakeRect(0,0,W,H-38)]; + scroll.autoresizingMask=NSViewWidthSizable|NSViewHeightSizable; + scroll.hasVerticalScroller=YES; scroll.hasHorizontalScroller=NO; + scroll.autohidesScrollers=YES; + [root addSubview:scroll]; + + _table=[[NSTableView alloc]init]; + _table.dataSource=self; _table.delegate=self; + _table.usesAlternatingRowBackgroundColors=NO; + _table.backgroundColor=[NSColor colorWithWhite:0.10 alpha:1]; + _table.gridStyleMask=NSTableViewSolidHorizontalGridLineMask; + _table.gridColor=[NSColor colorWithWhite:0.18 alpha:1]; + _table.rowHeight=18; + _table.allowsMultipleSelection=NO; + + struct { NSString *id; NSString *title; CGFloat w; } cols[] = { + {@"sev", @"", 26}, + {@"time", @"Time", 66}, + {@"type", @"Type", 120}, + {@"page", @"Page", 200}, + {@"detail",@"Detail", 0}, // flexible }; - BBAppendLine(BBPolicyPath(), BBJSON(action)); + for (int i=0;i<5;i++) { + NSTableColumn *col=[[NSTableColumn alloc]initWithIdentifier:cols[i].id]; + col.title=cols[i].title; + col.minWidth=cols[i].w; col.width=cols[i].w; + if (i==4) col.resizingMask=NSTableColumnAutoresizingMask; + else col.resizingMask=NSTableColumnNoResizing; + [_table addTableColumn:col]; + } + scroll.documentView=_table; +} + +-(void)pushEvent:(BBSecurityEvent*)e { + dispatch_async(dispatch_get_main_queue(),^{ + [_rows addObject:e]; + if (_panel&&_panel.isVisible) { + [_table reloadData]; + [_table scrollRowToVisible:_rows.count-1]; + } + NSInteger crit=(NSInteger)[[_rows filteredArrayUsingPredicate: + [NSPredicate predicateWithFormat:@"severity >= %d",BBSecHigh]] count]; + _badge.stringValue=[NSString stringWithFormat:@"%ld events · %ld high/critical", + (long)_rows.count,(long)crit]; + if (e.severity>=BBSecHigh) + _badge.textColor=[NSColor colorWithRed:1 green:0.3 blue:0.3 alpha:1]; + }); } -static BOOL BBMemoryLooksSensitive(NSString *text) { - NSArray *markers = @[@"password", @"secret", @"token", @"cookie", @"credential", @"payment"]; - NSString *lower = [text lowercaseString]; - for (NSString *marker in markers) { - if ([lower containsString:marker]) { return YES; } +-(void)clearEvents:(id)s { + [_rows removeAllObjects]; + [_table reloadData]; + _badge.stringValue=@"0 events"; + _badge.textColor=[NSColor colorWithRed:0.9 green:0.6 blue:0.1 alpha:1]; +} + +// NSTableViewDataSource +-(NSInteger)numberOfRowsInTableView:(NSTableView*)tv { return _rows.count; } + +-(NSView*)tableView:(NSTableView*)tv viewForTableColumn:(NSTableColumn*)col row:(NSInteger)row { + if (row>=(NSInteger)_rows.count) return nil; + BBSecurityEvent *e=_rows[row]; + + NSTextField *cell=[tv makeViewWithIdentifier:col.identifier owner:self]; + if (!cell) { + cell=[NSTextField labelWithString:@""]; + cell.identifier=col.identifier; + cell.font=[NSFont monospacedSystemFontOfSize:11 weight:NSFontWeightRegular]; + cell.textColor=[NSColor colorWithWhite:0.85 alpha:1]; } + + static NSColor *cCrit,*cHigh,*cMed,*cLow; + if (!cCrit) { + cCrit=[NSColor colorWithRed:1.0 green:0.3 blue:0.3 alpha:1]; + cHigh=[NSColor colorWithRed:1.0 green:0.6 blue:0.1 alpha:1]; + cMed =[NSColor colorWithRed:1.0 green:0.9 blue:0.2 alpha:1]; + cLow =[NSColor colorWithWhite:0.5 alpha:1]; + } + NSColor *sevColor = e.severity==BBSecCritical?cCrit: + e.severity==BBSecHigh?cHigh: + e.severity==BBSecMedium?cMed:cLow; + + if ([col.identifier isEqualToString:@"sev"]) { + cell.stringValue = e.severity==BBSecCritical?@"●": + e.severity==BBSecHigh?@"●": + e.severity==BBSecMedium?@"◑":@"○"; + cell.textColor=sevColor; + } else if ([col.identifier isEqualToString:@"time"]) { + NSDateFormatter *f=[[NSDateFormatter alloc]init]; + f.dateFormat=@"HH:mm:ss"; + cell.stringValue=[f stringFromDate:e.timestamp]; + cell.textColor=[NSColor colorWithWhite:0.5 alpha:1]; + } else if ([col.identifier isEqualToString:@"type"]) { + cell.stringValue=e.type?:@""; + cell.textColor=sevColor; + } else if ([col.identifier isEqualToString:@"page"]) { + NSString *pg=e.pageURL?:@""; + NSURL *u=[NSURL URLWithString:pg]; + cell.stringValue=u.host?:[pg lastPathComponent]?:pg; + cell.textColor=[NSColor colorWithWhite:0.65 alpha:1]; + } else { + cell.stringValue=e.detail?:@""; + cell.textColor=[NSColor colorWithWhite:0.78 alpha:1]; + } + return cell; +} + +-(CGFloat)tableView:(NSTableView*)tv heightOfRow:(NSInteger)row { return 18; } + +-(BOOL)tableView:(NSTableView*)tv shouldSelectRow:(NSInteger)row { + if (row>=(NSInteger)_rows.count) return NO; + BBSecurityEvent *e=_rows[row]; + // Click → show full detail in NSAlert so developer can read the full payload + NSAlert *a=[[NSAlert alloc]init]; + a.messageText=[NSString stringWithFormat:@"%@ — %@",e.type,e.pageURL?:@""]; + a.informativeText=e.detail?:@"(no detail)"; + a.alertStyle=(e.severity>=BBSecHigh)?NSAlertStyleWarning:NSAlertStyleInformational; + [a addButtonWithTitle:@"OK"]; + [a runModal]; return NO; } +@end + +// ── BBAgentServer ───────────────────────────────────────────────────────────── +// +// Secure local Unix socket that lets agent processes (Claude Code, agent-plane, +// sidecar scripts) observe and propose browser actions. +// +// Security model — matches agent-sidecar/contract.yaml: +// • Unix socket at BBSupportDir()/agent.sock — OS enforces 0600, owner-only +// • Per-session token (256-bit) written to BBSupportDir()/.agent-token (0600) +// • Every command classified by risk level from bearbrowser-propose-action defaults +// • "observe.*" actions execute immediately (no mutation, no approval needed) +// • "propose.*" actions go through BBProposeAction + native approval sheet +// • All commands logged via BBEmitEvent with actor.type = "agent" +// • credentials, cookies, secrets are never returned — redacted at boundary +// +// Wire protocol: newline-delimited JSON +// → {"v":1,"token":"","action":"observe.url"} +// ← {"status":"ok","result":{"url":"https://..."}} +// +// → {"v":1,"token":"","action":"propose.navigate","url":"https://..."} +// ← {"status":"hold","actionId":"act-xxx","message":"Awaiting user approval"} +// (then after user approves/denies, a second line is sent) +// ← {"status":"ok","result":{"navigated":true}} |OR| {"status":"denied"} + +// Minimal protocol so BBAgentServer doesn't depend on the full BBDelegate @interface +@protocol BBAgentBrowserDelegate +@property(readonly) WKWebView *webView; +@property(readonly) NSWindow *window; +@property(readonly) NSArray *tabs; +-(void)addTabPrivate:(BOOL)isPrivate; +@end + +// Risk classification matching bearbrowser-propose-action.py DEFAULTS + AGENT_RUNTIME_OVERRIDES +typedef NS_ENUM(NSInteger, BBAgentRisk) { + BBAgentRiskObserve = 0, // no mutation, no approval — immediate + BBAgentRiskLow, // mutation allowed, but agent-runtime → hold + BBAgentRiskHigh, // holds, must approve + BBAgentRiskCritical, // always deny for agent-runtime +}; + +static BBAgentRisk BBRiskForAction(NSString *action) { + if ([action hasPrefix:@"observe."]) return BBAgentRiskObserve; + if ([action isEqualToString:@"propose.navigate"]) return BBAgentRiskLow; + if ([action isEqualToString:@"propose.new_tab"]) return BBAgentRiskLow; + if ([action isEqualToString:@"propose.evaluate_js"])return BBAgentRiskHigh; + if ([action isEqualToString:@"propose.fill_form"]) return BBAgentRiskHigh; + if ([action isEqualToString:@"propose.click"]) return BBAgentRiskHigh; + if ([action isEqualToString:@"propose.screenshot"]) return BBAgentRiskLow; + if ([action isEqualToString:@"propose.credential"]) return BBAgentRiskCritical; + return BBAgentRiskHigh; // unknown → high +} + +@interface BBAgentServer : NSObject ++(instancetype)shared; +-(void)startWithDelegate:(id)delegate; +-(void)stop; +-(NSString*)tokenPath; +-(NSString*)socketPath; +@end + +@implementation BBAgentServer { + __weak id _del; + int _serverFd; + dispatch_source_t _acceptSource; + NSString *_token; +} + ++(instancetype)shared{static BBAgentServer*s;static dispatch_once_t o;dispatch_once(&o,^{s=[[self alloc]init];});return s;} + +-(NSString*)socketPath { return [BBSupportDir() stringByAppendingPathComponent:@"agent.sock"]; } +-(NSString*)tokenPath { return [BBSupportDir() stringByAppendingPathComponent:@".agent-token"]; } + +-(void)startWithDelegate:(id)delegate { + _del=delegate; + // Generate per-session token + _token=[self generateToken]; + [self writeToken:_token]; + [self listenOnSocket]; +} + +-(NSString*)generateToken { + uint8_t buf[32]; (void)SecRandomCopyBytes(kSecRandomDefault,32,buf); + NSMutableString *hex=[NSMutableString stringWithCapacity:64]; + for(int i=0;i<32;i++)[hex appendFormat:@"%02x",buf[i]]; + return hex; +} + +-(void)writeToken:(NSString*)token { + [[NSFileManager defaultManager]createDirectoryAtPath:BBSupportDir() + withIntermediateDirectories:YES attributes:nil error:nil]; + NSString *p=self.tokenPath; + [token writeToFile:p atomically:YES encoding:NSUTF8StringEncoding error:nil]; + // 0600 — owner read/write only + [[NSFileManager defaultManager]setAttributes:@{NSFilePosixPermissions:@(0600)} + ofItemAtPath:p error:nil]; +} -static void BBCreateMemoryCandidate(NSString *text, NSString *sourceURL, NSString *sourceLabel) { - BOOL sensitive = BBMemoryLooksSensitive(text ?: @""); - NSString *memoryId = [@"mem-" stringByAppendingString:BBRandomHex(16)]; - NSString *storedText = sensitive ? @"" : (text ?: @""); - NSString *payloadClass = sensitive ? @"secret-blocked" : @"metadata"; - NSMutableDictionary *source = [@{ @"kind": @"page" } mutableCopy]; - if (sourceURL.length > 0) { source[@"url"] = sourceURL; } - if (sourceLabel.length > 0) { source[@"label"] = sourceLabel; } - NSDictionary *memory = @{ - @"schemaVersion": @"bearbrowser.memory_candidate.v1", - @"memoryId": memoryId, - @"timestamp": BBTimestamp(), - @"product": @"BearBrowser", - @"state": @"candidate", - @"actor": @{ @"type": @"human", @"id": NSUserName() ?: @"local-user" }, - @"source": source, - @"classification": @{ - @"payloadClass": payloadClass, - @"secretLikeDetected": @(sensitive), - @"persistentWriteRequiresApproval": @YES - }, - @"text": storedText, - @"policy": @{ - @"decision": @"hold", - @"decisionId": [@"local-" stringByAppendingString:BBRandomHex(8)], - @"mode": @"local-default", - @"reason": @"Memory candidates must be previewed and explicitly committed or rejected." +-(void)listenOnSocket { + // Remove stale socket + [[NSFileManager defaultManager]removeItemAtPath:self.socketPath error:nil]; + + _serverFd=socket(AF_UNIX,SOCK_STREAM,0); + if(_serverFd<0) return; + + struct sockaddr_un addr; + memset(&addr,0,sizeof(addr)); + addr.sun_family=AF_UNIX; + strlcpy(addr.sun_path,self.socketPath.UTF8String,sizeof(addr.sun_path)); + + if(bind(_serverFd,(struct sockaddr*)&addr,sizeof(addr))<0){close(_serverFd);return;} + // 0600 on the socket file + [[NSFileManager defaultManager]setAttributes:@{NSFilePosixPermissions:@(0600)} + ofItemAtPath:self.socketPath error:nil]; + + if(listen(_serverFd,8)<0){close(_serverFd);return;} + + _acceptSource=dispatch_source_create(DISPATCH_SOURCE_TYPE_READ,_serverFd,0, + dispatch_get_global_queue(QOS_CLASS_UTILITY,0)); + __weak BBAgentServer *weak=self; + dispatch_source_set_event_handler(_acceptSource,^{ + BBAgentServer *s=weak; if(!s) return; + int clientFd=accept(s->_serverFd,NULL,NULL); + if(clientFd>=0)[s handleClient:clientFd]; + }); + dispatch_resume(_acceptSource); +} + +-(void)handleClient:(int)fd { + // Read until newline (one command per connection — simple, no framing complexity) + dispatch_async(dispatch_get_global_queue(QOS_CLASS_UTILITY,0),^{ + NSMutableData *buf=[NSMutableData data]; + uint8_t byte; ssize_t n; + while((n=read(fd,&byte,1))>0){ + if(byte=='\n') break; + [buf appendBytes:&byte length:1]; + if(buf.length>65536) break; // cap at 64KB } - }; - BBAppendLine(BBMemoryPath(), BBJSON(memory)); - BBEmitEvent(@"memory.candidate_created", @"hold", @"Native shell created a held memory candidate.", @{ @"memoryId": memoryId, @"url": sourceURL ?: @"" }); + if(!buf.length){close(fd);return;} + NSDictionary *cmd=[NSJSONSerialization JSONObjectWithData:buf options:0 error:nil]; + [self dispatch:cmd fd:fd]; + }); } -@interface BBDelegate : NSObject +// Validate token and version, then route +-(void)dispatch:(NSDictionary*)cmd fd:(int)fd { + if(![cmd isKindOfClass:[NSDictionary class]]){[self respond:fd status:@"error" result:@{@"message":@"invalid JSON"}];return;} + if(![cmd[@"v"] isEqual:@1]){[self respond:fd status:@"error" result:@{@"message":@"unsupported version"}];return;} + NSString *tok=cmd[@"token"]?:@""; + if(![tok isEqualToString:_token]){ + [self respond:fd status:@"denied" result:@{@"message":@"invalid token"}]; + BBEmitEventStatic(@"security.agent_auth_failure",@"deny",@"Agent connection with bad token.",@{}); + close(fd); return; + } + NSString *action=cmd[@"action"]?:@""; + BBAgentRisk risk=BBRiskForAction(action); + if(risk==BBAgentRiskCritical){ + [self respond:fd status:@"denied" result:@{@"message":@"action denied by policy — credential access not available to agent-runtime"}]; + BBEmitEventStatic([NSString stringWithFormat:@"automation.action_denied"],@"deny", + [NSString stringWithFormat:@"Critical-risk agent action '%@' denied.",action],@{@"action":action}); + close(fd); return; + } + [self route:action cmd:cmd fd:fd risk:risk]; +} + +-(void)route:(NSString*)action cmd:(NSDictionary*)cmd fd:(int)fd risk:(BBAgentRisk)risk { + id d=_del; if(!d){[self respond:fd status:@"error" result:@{@"message":@"browser not ready"}];close(fd);return;} + + BBEmitEventStatic(@"automation.observed",@"observe", + [NSString stringWithFormat:@"Agent command received: %@",action], + @{@"action":action,@"risk":@[@"observe",@"low",@"high",@"critical"][risk]}); + + // ── Observe actions — no mutation, immediate response ────────────────────── + if([action isEqualToString:@"observe.url"]) { + dispatch_async(dispatch_get_main_queue(),^{ + NSString *u=d.webView.URL.absoluteString?:@""; + [self respond:fd status:@"ok" result:@{@"url":u}]; close(fd); + }); return; + } + if([action isEqualToString:@"observe.title"]) { + dispatch_async(dispatch_get_main_queue(),^{ + NSString *t=d.webView.title?:@""; + [self respond:fd status:@"ok" result:@{@"title":t}]; close(fd); + }); return; + } + if([action isEqualToString:@"observe.tabs"]) { + dispatch_async(dispatch_get_main_queue(),^{ + NSMutableArray *tabs=[NSMutableArray array]; + for(BBTab *t in d.tabs) + [tabs addObject:@{@"url":t.webView.URL.absoluteString?:@"", + @"title":t.title?:@"",@"private":@(t.isPrivate)}]; + [self respond:fd status:@"ok" result:@{@"tabs":tabs}]; close(fd); + }); return; + } + if([action isEqualToString:@"observe.page_text"]) { + dispatch_async(dispatch_get_main_queue(),^{ + [d.webView evaluateJavaScript: + @"(document.body&&document.body.innerText?document.body.innerText:'').slice(0,32000)" + completionHandler:^(id r,NSError*e){ + NSString *text=[r isKindOfClass:[NSString class]]?r:@""; + [self respond:fd status:@"ok" result:@{@"text":text}]; close(fd); + }]; + }); return; + } + if([action isEqualToString:@"observe.page_html"]) { + dispatch_async(dispatch_get_main_queue(),^{ + [d.webView evaluateJavaScript:@"document.documentElement.outerHTML.slice(0,256000)" + completionHandler:^(id r,NSError*e){ + NSString *html=[r isKindOfClass:[NSString class]]?r:@""; + [self respond:fd status:@"ok" result:@{@"html":html}]; close(fd); + }]; + }); return; + } + if([action isEqualToString:@"observe.network_events"]) { + dispatch_async(dispatch_get_main_queue(),^{ + NSArray *snap=[BBNetworkMonitor.shared snapshot]; + NSMutableArray *out=[NSMutableArray array]; + NSInteger lim=MIN((NSInteger)snap.count,200); + for(NSInteger i=snap.count-lim;i<(NSInteger)snap.count;i++){ + BBConnectionRecord *r=snap[i]; + [out addObject:@{@"domain":r.domain,@"type":r.resourceType, + @"blocked":@(r.blocked),@"ts":@(r.timestamp.timeIntervalSince1970)}]; + } + [self respond:fd status:@"ok" result:@{@"events":out}]; close(fd); + }); return; + } + if([action isEqualToString:@"observe.provenance_tail"]) { + dispatch_async(dispatch_get_global_queue(QOS_CLASS_UTILITY,0),^{ + // Return last 50 lines of provenance JSONL — redacted values only + NSString *path=BBProvenancePath(); + NSString *raw=[NSString stringWithContentsOfFile:path encoding:NSUTF8StringEncoding error:nil]?:@""; + NSArray *lines=[raw componentsSeparatedByString:@"\n"]; + NSInteger start=MAX(0,(NSInteger)lines.count-50); + NSArray *tail=[lines subarrayWithRange:NSMakeRange(start,lines.count-start)]; + [self respond:fd status:@"ok" result:@{@"lines":tail}]; close(fd); + }); return; + } + if([action isEqualToString:@"observe.security_events"]) { + dispatch_async(dispatch_get_main_queue(),^{ + NSArray *snap=[BBSecurityMonitor.shared snapshot]; + NSInteger lim=MIN((NSInteger)snap.count,100); + NSMutableArray *out=[NSMutableArray array]; + for(NSInteger i=snap.count-lim;i<(NSInteger)snap.count;i++){ + BBSecurityEvent *e=snap[i]; + [out addObject:@{@"type":e.type,@"page":e.pageURL?:@"", + @"detail":e.detail?:@"", + @"severity":@[@"low",@"medium",@"high",@"critical"][e.severity], + @"ts":@(e.timestamp.timeIntervalSince1970)}]; + } + [self respond:fd status:@"ok" result:@{@"events":out}]; close(fd); + }); return; + } + if([action isEqualToString:@"observe.dom_snapshot"]) { + dispatch_async(dispatch_get_main_queue(),^{ + NSString *domJS= + @"(function(){" + @"function snap(el,depth){" + @" if(depth>4||!el)return null;" + @" var r={tag:(el.tagName||'#text').toLowerCase()};" + @" if(el.id)r.id=el.id;" + @" if(el.className&&typeof el.className==='string')r.cls=el.className.slice(0,60);" + @" if(el.href)r.href=el.href;" + @" if(el.src)r.src=el.src;" + @" if(el.type)r.type=el.type;" + @" if(el.name)r.name=el.name;" + @" if(el.getAttribute&&el.getAttribute('role'))r.role=el.getAttribute('role');" + @" if(el.getAttribute&&el.getAttribute('aria-label'))r.label=el.getAttribute('aria-label');" + @" var txt=(el.innerText||el.textContent||'').trim().slice(0,120);" + @" if(txt)r.text=txt;" + @" var kids=Array.from(el.children||[]).slice(0,12)" + @" .map(function(c){return snap(c,depth+1)}).filter(Boolean);" + @" if(kids.length)r.children=kids;" + @" return r;}" + @"return JSON.stringify(snap(document.body,0));})()"; + [d.webView evaluateJavaScript:domJS completionHandler:^(id r,NSError*e){ + NSString *json=[r isKindOfClass:[NSString class]]?r:@"{}"; + [self respond:fd status:@"ok" result:@{@"dom":json}]; close(fd); + }]; + }); return; + } + + // ── Propose actions — require user approval ──────────────────────────────── + NSString *actionId=[NSString stringWithFormat:@"act-%@",BBRandomHexStatic(16)]; + // Immediately ACK with "hold" — user approval shown on main thread + [self respond:fd status:@"hold" result:@{@"actionId":actionId, + @"message":@"awaiting user approval"}]; + + dispatch_async(dispatch_get_main_queue(),^{ + [self presentApproval:action cmd:cmd actionId:actionId fd:fd delegate:d]; + }); +} + +-(void)presentApproval:(NSString*)action cmd:(NSDictionary*)cmd + actionId:(NSString*)actionId fd:(int)fd delegate:(id)d { + NSString *detail=@""; + if([action isEqualToString:@"propose.navigate"]) + detail=[NSString stringWithFormat:@"Navigate to: %@",cmd[@"url"]?:@"?"]; + else if([action isEqualToString:@"propose.evaluate_js"]) + {NSString *sc=cmd[@"script"]?:@"?"; + detail=[NSString stringWithFormat:@"Run JS:\n%@",sc.length>200?[sc substringToIndex:200]:sc];} + else if([action isEqualToString:@"propose.fill_form"]) + detail=@"Fill form fields on current page"; + else if([action isEqualToString:@"propose.click"]) + detail=[NSString stringWithFormat:@"Click element: %@",cmd[@"selector"]?:@"?"]; + else if([action isEqualToString:@"propose.new_tab"]) + detail=@"Open a new tab"; + else if([action isEqualToString:@"propose.screenshot"]) + detail=@"Capture a screenshot of the current page"; + + NSAlert *a=[[NSAlert alloc]init]; + a.messageText=@"Agent Action Request"; + a.informativeText=[NSString stringWithFormat:@"An agent wants to:\n\n%@\n\nAllow this action?",detail]; + a.alertStyle=NSAlertStyleWarning; + [a addButtonWithTitle:@"Allow"]; [a addButtonWithTitle:@"Deny"]; + [a beginSheetModalForWindow:d.window completionHandler:^(NSModalResponse r){ + BOOL allowed=(r==NSAlertFirstButtonReturn); + BBEmitEventStatic(allowed?@"automation.action_approved":@"automation.action_denied", + allowed?@"allow":@"deny", + [NSString stringWithFormat:@"User %@ agent action '%@'",allowed?@"approved":@"denied",action], + @{@"actionId":actionId,@"action":action}); + if(!allowed){ + [self respond:fd status:@"denied" result:@{@"actionId":actionId,@"message":@"denied by user"}]; + close(fd); return; + } + [self execute:action cmd:cmd fd:fd actionId:actionId delegate:d]; + }]; +} + +-(void)execute:(NSString*)action cmd:(NSDictionary*)cmd + fd:(int)fd actionId:(NSString*)actionId delegate:(id)d { + if([action isEqualToString:@"propose.navigate"]) { + NSString *url=cmd[@"url"]?:@""; + NSURL *u=[NSURL URLWithString:url]; + if(!u||(!u.scheme)){[self respond:fd status:@"error" result:@{@"message":@"invalid url"}];close(fd);return;} + [d.webView loadRequest:[NSURLRequest requestWithURL:u]]; + [self respond:fd status:@"ok" result:@{@"actionId":actionId,@"navigated":@YES}]; close(fd); + } else if([action isEqualToString:@"propose.new_tab"]) { + [d addTabPrivate:NO]; + [self respond:fd status:@"ok" result:@{@"actionId":actionId}]; close(fd); + } else if([action isEqualToString:@"propose.evaluate_js"]) { + NSString *script=cmd[@"script"]?:@""; + [d.webView evaluateJavaScript:script completionHandler:^(id r,NSError*e){ + if(e){[self respond:fd status:@"error" result:@{@"message":e.localizedDescription}];} + else { + // Stringify result — never return DOM references or live objects + NSString *res=[r isKindOfClass:[NSString class]]?r: + ([r isKindOfClass:[NSNumber class]]?[r stringValue]: + ([r isKindOfClass:[NSDictionary class]]||[r isKindOfClass:[NSArray class]]? + ([[NSString alloc]initWithData:[NSJSONSerialization dataWithJSONObject:r options:0 error:nil] + encoding:NSUTF8StringEncoding]?:@"[object]"):@"null")); + [self respond:fd status:@"ok" result:@{@"actionId":actionId,@"result":res}]; + } + close(fd); + }]; + } else if([action isEqualToString:@"propose.screenshot"]) { + WKSnapshotConfiguration *cfg=[[WKSnapshotConfiguration alloc]init]; + [d.webView takeSnapshotWithConfiguration:cfg completionHandler:^(NSImage *img,NSError*e){ + if(!img||e){[self respond:fd status:@"error" result:@{@"message":e.localizedDescription?:@"snapshot failed"}];close(fd);return;} + NSBitmapImageRep *rep=[[NSBitmapImageRep alloc]initWithData:img.TIFFRepresentation]; + NSData *png=[rep representationUsingType:NSBitmapImageFileTypePNG properties:@{}]; + NSString *b64=[png base64EncodedStringWithOptions:0]; + [self respond:fd status:@"ok" result:@{@"actionId":actionId,@"png_base64":b64?:@""}]; close(fd); + }]; + } else { + [self respond:fd status:@"error" result:@{@"message":@"unknown action"}]; close(fd); + } +} + +-(void)respond:(int)fd status:(NSString*)status result:(NSDictionary*)result { + NSMutableDictionary *r=[NSMutableDictionary dictionaryWithDictionary:result]; + r[@"status"]=status; + NSData *json=[NSJSONSerialization dataWithJSONObject:r options:0 error:nil]; + if(!json) return; + NSMutableData *line=[NSMutableData dataWithData:json]; + uint8_t nl='\n'; [line appendBytes:&nl length:1]; + write(fd,line.bytes,line.length); +} + +-(void)stop { + if(_acceptSource){dispatch_source_cancel(_acceptSource);_acceptSource=nil;} + if(_serverFd>0){close(_serverFd);_serverFd=0;} + [[NSFileManager defaultManager]removeItemAtPath:self.socketPath error:nil]; +} + +// Static wrappers so BBAgentServer can call helpers defined at file scope +static void BBEmitEventStatic(NSString*type,NSString*dec,NSString*reason,NSDictionary*payload){ + BBEmitEvent(type,dec,reason,payload); +} +static NSString* BBRandomHexStatic(NSUInteger n){return BBRandomHex(n);} + +@end + +// ── BBDelegate ──────────────────────────────────────────────────────────────── +@interface BBDelegate : NSObject @property(strong) NSWindow *window; -@property(strong) WKWebView *webView; -@property(strong) NSTextField *address; +@property(strong) NSMutableArray *tabs; +@property(strong) NSMutableArray *closedTabURLs; +@property(assign) NSInteger activeTabIndex; +@property(strong) NSView *root; +@property(strong) NSView *toolbarBg; +@property(strong) BBTabBarView *tabBarView; +@property(strong) NSTextField *address; +@property(strong) NSButton *backButton, *forwardButton, *reloadButton, *securityButton; +@property(strong) NSProgressIndicator *progressBar; +@property(strong) BBFindBar *findBar; +@property(assign) BOOL findBarVisible; +@property(strong) WKContentRuleList *contentRuleList; +// New +@property(strong) BBDownloadPanel *downloadPanel; +@property(strong) BBAddressDropdown *addressDropdown; +@property(strong) NSView *bookmarksBar; +@property(assign) BOOL bookmarksBarVisible; +@property(strong) NSCache *dnsBlockCache; // Quad9 NXDOMAIN results +@property(assign) SecTrustRef currentTrust; // TLS trust for current page cert inspector @end @implementation BBDelegate -- (void)applicationDidFinishLaunching:(NSNotification *)notification { - BBLog(@"BearBrowser native WebKit shell start"); - BBEmitEvent(@"app.launch", @"allow", @"Native BearBrowser shell launched.", @{ @"bundleId": @"dev.sourceos.BearBrowser" }); +- (BBTab *)activeTab { return (self.activeTabIndex<(NSInteger)self.tabs.count)?self.tabs[self.activeTabIndex]:nil; } +- (WKWebView *)webView { return self.activeTab.webView; } +- (NSString *)currentURLString { return self.activeTab.webView.URL.absoluteString?:@"bearbrowser://start"; } + +// ── Menu ────────────────────────────────────────────────────────────────────── +- (void)buildMenu { + NSMenu *bar=[[NSMenu alloc]init]; [NSApp setMainMenu:bar]; + void(^mi)(NSMenu*,NSString*,SEL,NSString*,NSUInteger)=^(NSMenu *m,NSString *t,SEL a,NSString *k,NSUInteger mod){ + NSMenuItem *i=[m addItemWithTitle:t action:a keyEquivalent:k]; if(mod) i.keyEquivalentModifierMask=mod; + }; + // BearBrowser + NSMenuItem *appI=[[NSMenuItem alloc]init]; [bar addItem:appI]; + NSMenu *appM=[[NSMenu alloc]initWithTitle:@"BearBrowser"]; appI.submenu=appM; + [appM addItemWithTitle:@"About BearBrowser" action:@selector(orderFrontStandardAboutPanel:) keyEquivalent:@""]; + [appM addItem:[NSMenuItem separatorItem]]; + mi(appM,@"Search Engine…",@selector(openSearchPreferences:),@",",NSEventModifierFlagCommand); + [appM addItem:[NSMenuItem separatorItem]]; + mi(appM,@"Hide BearBrowser",@selector(hide:),@"h",NSEventModifierFlagCommand); + [appM addItemWithTitle:@"Hide Others" action:@selector(hideOtherApplications:) keyEquivalent:@"h"].keyEquivalentModifierMask=NSEventModifierFlagCommand|NSEventModifierFlagOption; + [appM addItemWithTitle:@"Show All" action:@selector(unhideAllApplications:) keyEquivalent:@""]; + [appM addItem:[NSMenuItem separatorItem]]; + mi(appM,@"Quit BearBrowser",@selector(terminate:),@"q",NSEventModifierFlagCommand); + // File + NSMenuItem *fileI=[[NSMenuItem alloc]init]; [bar addItem:fileI]; + NSMenu *fileM=[[NSMenu alloc]initWithTitle:@"File"]; fileI.submenu=fileM; + mi(fileM,@"New Tab",@selector(newTab:),@"t",NSEventModifierFlagCommand); + mi(fileM,@"New Private Tab",@selector(newPrivateTab:),@"t",NSEventModifierFlagCommand|NSEventModifierFlagShift); + mi(fileM,@"New Window",@selector(newWindow:),@"n",NSEventModifierFlagCommand); + [fileM addItem:[NSMenuItem separatorItem]]; + mi(fileM,@"Open File…",@selector(openFile:),@"o",NSEventModifierFlagCommand); + [fileM addItem:[NSMenuItem separatorItem]]; + mi(fileM,@"Add Bookmark…",@selector(addBookmark:),@"d",NSEventModifierFlagCommand); + mi(fileM,@"Show Bookmarks Bar",@selector(toggleBookmarksBar:),@"b",NSEventModifierFlagCommand|NSEventModifierFlagShift); + [fileM addItem:[NSMenuItem separatorItem]]; + mi(fileM,@"Close Tab",@selector(closeCurrentTab:),@"w",NSEventModifierFlagCommand); + mi(fileM,@"Reopen Closed Tab",@selector(reopenClosedTab:),@"t",NSEventModifierFlagCommand|NSEventModifierFlagShift|NSEventModifierFlagOption); + [fileM addItem:[NSMenuItem separatorItem]]; + mi(fileM,@"Save Page As…",@selector(savePage:),@"s",NSEventModifierFlagCommand); + mi(fileM,@"Print…",@selector(printPage:),@"p",NSEventModifierFlagCommand); + // Edit + NSMenuItem *editI=[[NSMenuItem alloc]init]; [bar addItem:editI]; + NSMenu *editM=[[NSMenu alloc]initWithTitle:@"Edit"]; editI.submenu=editM; + mi(editM,@"Undo",@selector(undo:),@"z",NSEventModifierFlagCommand); + [editM addItemWithTitle:@"Redo" action:@selector(redo:) keyEquivalent:@"z"].keyEquivalentModifierMask=NSEventModifierFlagCommand|NSEventModifierFlagShift; + [editM addItem:[NSMenuItem separatorItem]]; + mi(editM,@"Cut",@selector(cut:),@"x",NSEventModifierFlagCommand); + mi(editM,@"Copy",@selector(copy:),@"c",NSEventModifierFlagCommand); + mi(editM,@"Paste",@selector(paste:),@"v",NSEventModifierFlagCommand); + mi(editM,@"Paste and Go",@selector(pasteAndGo:),@"v",NSEventModifierFlagCommand|NSEventModifierFlagShift); + mi(editM,@"Select All",@selector(selectAll:),@"a",NSEventModifierFlagCommand); + [editM addItem:[NSMenuItem separatorItem]]; + mi(editM,@"Find on Page…",@selector(toggleFind:),@"f",NSEventModifierFlagCommand); + // View + NSMenuItem *viewI=[[NSMenuItem alloc]init]; [bar addItem:viewI]; + NSMenu *viewM=[[NSMenu alloc]initWithTitle:@"View"]; viewI.submenu=viewM; + mi(viewM,@"Reload Page",@selector(reloadOrStop:),@"r",NSEventModifierFlagCommand); + mi(viewM,@"Hard Reload (Skip Cache)",@selector(hardReload:),@"r",NSEventModifierFlagCommand|NSEventModifierFlagShift); + mi(viewM,@"Focus Address Bar",@selector(focusAddressBar:),@"l",NSEventModifierFlagCommand); + [viewM addItem:[NSMenuItem separatorItem]]; + mi(viewM,@"Zoom In",@selector(zoomIn:),@"+",NSEventModifierFlagCommand); + mi(viewM,@"Zoom Out",@selector(zoomOut:),@"-",NSEventModifierFlagCommand); + mi(viewM,@"Actual Size",@selector(zoomReset:),@"0",NSEventModifierFlagCommand); + [viewM addItem:[NSMenuItem separatorItem]]; + mi(viewM,@"View Page Source",@selector(viewSource:),@"u",NSEventModifierFlagCommand); + mi(viewM,@"Developer Tools",@selector(openDevTools:),@"i",NSEventModifierFlagCommand|NSEventModifierFlagOption); + [viewM addItem:[NSMenuItem separatorItem]]; + mi(viewM,@"Enter Full Screen",@selector(toggleFullScreen:),@"f",NSEventModifierFlagCommand|NSEventModifierFlagControl); + [viewM addItem:[NSMenuItem separatorItem]]; + mi(viewM,@"History",@selector(showHistory:),@"y",NSEventModifierFlagCommand); + mi(viewM,@"Downloads",@selector(toggleDownloadPanel:),@"j",NSEventModifierFlagCommand); + [viewM addItem:[NSMenuItem separatorItem]]; + mi(viewM,@"Network Monitor",@selector(openNetworkMonitor:),@"m",NSEventModifierFlagCommand|NSEventModifierFlagShift); + [viewM addItem:[NSMenuItem separatorItem]]; + mi(viewM,@"Read Aloud",@selector(readAloud:),@"r",NSEventModifierFlagCommand|NSEventModifierFlagOption); + // History + NSMenuItem *histI=[[NSMenuItem alloc]init]; [bar addItem:histI]; + NSMenu *histM=[[NSMenu alloc]initWithTitle:@"History"]; histI.submenu=histM; + mi(histM,@"Back",@selector(goBack:),@"[",NSEventModifierFlagCommand); + mi(histM,@"Forward",@selector(goForward:),@"]",NSEventModifierFlagCommand); + // Window + NSMenuItem *winI=[[NSMenuItem alloc]init]; [bar addItem:winI]; + NSMenu *winM=[[NSMenu alloc]initWithTitle:@"Window"]; winI.submenu=winM; + [NSApp setWindowsMenu:winM]; + mi(winM,@"Minimize",@selector(performMiniaturize:),@"m",NSEventModifierFlagCommand); + [winM addItemWithTitle:@"Zoom" action:@selector(performZoom:) keyEquivalent:@""]; + [winM addItem:[NSMenuItem separatorItem]]; + mi(winM,@"Next Tab",@selector(nextTab:),@"\t",NSEventModifierFlagControl); + [winM addItemWithTitle:@"Previous Tab" action:@selector(prevTab:) keyEquivalent:@"\t"].keyEquivalentModifierMask=NSEventModifierFlagControl|NSEventModifierFlagShift; + [winM addItem:[NSMenuItem separatorItem]]; + for (NSInteger i=1;i<=9;i++) { + NSMenuItem *ti=[winM addItemWithTitle:[NSString stringWithFormat:@"Tab %ld",(long)i] + action:@selector(switchToTabByMenuItem:) + keyEquivalent:[NSString stringWithFormat:@"%ld",(long)i]]; + ti.keyEquivalentModifierMask=NSEventModifierFlagCommand; ti.tag=i-1; + } + [winM addItem:[NSMenuItem separatorItem]]; + [winM addItemWithTitle:@"Bring All to Front" action:@selector(arrangeInFront:) keyEquivalent:@""]; +} + +// ── App launch ──────────────────────────────────────────────────────────────── +- (void)applicationDidFinishLaunching:(NSNotification *)n { + BBLog(@"BearBrowser start"); + BBEmitEvent(@"app.launch",@"allow",@"Native shell launched.",@{@"bundleId":@"dev.sourceos.BearBrowser"}); + [[BBAgentServer shared] startWithDelegate:self]; + BBLog([NSString stringWithFormat:@"Agent socket: %@",[BBAgentServer shared].socketPath]); + BBLog([NSString stringWithFormat:@"Agent token: %@",[BBAgentServer shared].tokenPath]); [NSApp setActivationPolicy:NSApplicationActivationPolicyRegular]; + [self buildMenu]; + self.closedTabURLs=[NSMutableArray array]; + // Compile content rules on a background queue; tabs added after compilation get rules applied. + // Tabs opened immediately (below) get rules applied once compile finishes via the shared property. + [BBContentBlocker loadRulesInto:[[WKWebViewConfiguration alloc]init] completion:^{ + // Store compiled rule list for future webviews. We re-compile to get the actual list object. + WKContentRuleListStore *store=[WKContentRuleListStore defaultStore]; + [store lookUpContentRuleListForIdentifier:@"bb-baseline" completionHandler:^(WKContentRuleList *list, NSError *e){ + if (list) { dispatch_async(dispatch_get_main_queue(),^{ self.contentRuleList=list; }); } + }]; + }]; + + // Use visibleFrame (excludes menu bar + Dock) so default placement is never + // behind system chrome. Clamp to 85% of available space on smaller screens. + NSRect vf=[NSScreen mainScreen].visibleFrame; + CGFloat defW=MIN(1280, floor(vf.size.width*0.9)); + CGFloat defH=MIN(800, floor(vf.size.height*0.9)); + NSRect contentFrame=NSMakeRect(0,0,defW,defH); // used for initWithContentRect: + BOOL useCenter=YES; + NSString *saved=[[NSUserDefaults standardUserDefaults] stringForKey:@"BBWindowFrame"]; + if (saved) { + NSRect r=NSRectFromString(saved); + // Validate saved frame: must have reasonable size AND fit on a visible screen + if (!NSIsEmptyRect(r)&&r.size.width>400&&r.size.height>300) { + // Ensure the top of the saved window is below the menu bar + CGFloat screenTop=vf.origin.y+vf.size.height; + if (r.origin.y+r.size.height <= screenTop+50) { // allow 50pt overshoot + contentFrame=r; useCenter=NO; + } + } + } + + self.window=[[NSWindow alloc]initWithContentRect:contentFrame + styleMask:(NSWindowStyleMaskTitled|NSWindowStyleMaskClosable|NSWindowStyleMaskResizable| + NSWindowStyleMaskMiniaturizable|NSWindowStyleMaskFullSizeContentView) + backing:NSBackingStoreBuffered defer:NO]; + self.window.title=@"BearBrowser"; + self.window.titlebarAppearsTransparent=YES; + self.window.titleVisibility=NSWindowTitleHidden; + self.window.minSize=NSMakeSize(640,480); + self.window.delegate=self; + if (useCenter) [self.window center]; else [self.window setFrame:contentFrame display:NO]; + [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(windowWillClose:) + name:NSWindowWillCloseNotification object:self.window]; + + self.root=[[NSView alloc]initWithFrame:self.window.contentView.bounds]; + self.root.autoresizingMask=NSViewWidthSizable|NSViewHeightSizable; + self.window.contentView=self.root; + + CGFloat W=self.root.bounds.size.width, H=self.root.bounds.size.height; + + // Toolbar — NSVisualEffectView with Sidebar material (NOT Titlebar, so it does + // not register as a titlebar zone and does not intercept mouse events). + NSVisualEffectView *tbVE=[[NSVisualEffectView alloc]initWithFrame:NSMakeRect(0,H-kToolbarH,W,kToolbarH)]; + tbVE.material=NSVisualEffectMaterialSidebar; + tbVE.blendingMode=NSVisualEffectBlendingModeWithinWindow; + tbVE.state=NSVisualEffectStateActive; + tbVE.autoresizingMask=NSViewWidthSizable|NSViewMinYMargin; + self.toolbarBg=tbVE; + NSBox *tbSep=[[NSBox alloc]initWithFrame:NSMakeRect(0,0,W,1)]; + tbSep.autoresizingMask=NSViewWidthSizable; tbSep.boxType=NSBoxSeparator; + [self.toolbarBg addSubview:tbSep]; + [self.root addSubview:self.toolbarBg]; + + CGFloat btnY=(kToolbarH-32)/2, x=80; + self.backButton =[self navBtn:@"chevron.left" tip:@"Back" sel:@selector(goBack:) x:x y:btnY]; x+=34; + self.forwardButton=[self navBtn:@"chevron.right" tip:@"Forward" sel:@selector(goForward:) x:x y:btnY]; x+=34; + self.reloadButton =[self navBtn:@"arrow.clockwise" tip:@"Reload" sel:@selector(reloadOrStop:) x:x y:btnY]; x+=40; + self.backButton.enabled=NO; self.forwardButton.enabled=NO; + for (NSButton *b in @[self.backButton,self.forwardButton,self.reloadButton]) + [self.toolbarBg addSubview:b]; + + // Security indicator + self.securityButton=[[NSButton alloc]initWithFrame:NSMakeRect(x,btnY+2,26,26)]; x+=28; + self.securityButton.bezelStyle=NSBezelStyleToolbar; self.securityButton.bordered=NO; + self.securityButton.target=self; self.securityButton.action=@selector(showSecurityInfo:); + [self updateSecurityIndicator:nil]; [self.toolbarBg addSubview:self.securityButton]; + + // Address bar + CGFloat rightR=48; + self.address=[[NSTextField alloc]initWithFrame:NSMakeRect(x,btnY+1,W-x-rightR-12,28)]; + self.address.autoresizingMask=NSViewWidthSizable; + self.address.bezelStyle=NSTextFieldRoundedBezel; + self.address.placeholderString=@"Search or enter address"; + self.address.font=[NSFont systemFontOfSize:13.5]; self.address.stringValue=@""; + self.address.delegate=self; [self.address.cell setWraps:NO]; [self.address.cell setScrollable:YES]; + [self.toolbarBg addSubview:self.address]; + + // Network monitor button + NSButton *netBtn=[self navBtn:@"network" tip:@"Network Monitor (⇧⌘M)" sel:@selector(openNetworkMonitor:) x:W-rightR-58 y:btnY]; + netBtn.autoresizingMask=NSViewMinXMargin; [self.toolbarBg addSubview:netBtn]; + // Read aloud button + NSButton *voiceBtn=[self navBtn:@"waveform" tip:@"Read Aloud (⌥⌘R)" sel:@selector(readAloud:) x:W-rightR-30 y:btnY]; + voiceBtn.autoresizingMask=NSViewMinXMargin; [self.toolbarBg addSubview:voiceBtn]; + // Bear panel button + NSButton *bearBtn=[self navBtn:@"ellipsis.circle" tip:@"BearBrowser Panel" sel:@selector(showBearPanel:) x:W-rightR+4 y:btnY]; + bearBtn.autoresizingMask=NSViewMinXMargin; [self.toolbarBg addSubview:bearBtn]; + + // Tab bar + self.tabBarView=[[BBTabBarView alloc]initWithFrame:NSMakeRect(0,H-kToolbarH-kTabBarH,W,kTabBarH) delegate:self]; + self.tabBarView.autoresizingMask=NSViewWidthSizable|NSViewMinYMargin; + self.tabBarView.addTabButton.target=self; self.tabBarView.addTabButton.action=@selector(newTab:); + [self.root addSubview:self.tabBarView]; + + // Progress bar — real percentage + CGFloat chromH=kToolbarH+kTabBarH; + self.progressBar=[[NSProgressIndicator alloc]initWithFrame:NSMakeRect(0,H-chromH-2,W,2)]; + self.progressBar.autoresizingMask=NSViewWidthSizable|NSViewMinYMargin; + self.progressBar.style=NSProgressIndicatorStyleBar; + self.progressBar.indeterminate=NO; self.progressBar.minValue=0; self.progressBar.maxValue=1; + self.progressBar.controlSize=NSControlSizeSmall; self.progressBar.hidden=YES; + [self.root addSubview:self.progressBar]; + + // Find bar + self.findBar=[[BBFindBar alloc]initWithFrame:NSMakeRect(0,0,W,kFindBarH)]; + self.findBar.autoresizingMask=NSViewWidthSizable; self.findBar.hidden=YES; + self.findBar.closeButton.target=self; self.findBar.closeButton.action=@selector(closeFind:); + self.findBar.prevButton.target=self; self.findBar.prevButton.action=@selector(findPrev:); + self.findBar.nextButton.target=self; self.findBar.nextButton.action=@selector(findNext:); + self.findBar.queryField.delegate=self; + [self.root addSubview:self.findBar]; + + // Bookmarks bar (hidden by default, Cmd+Shift+B toggles) + self.bookmarksBar=[[NSView alloc]initWithFrame:NSMakeRect(0,H-kToolbarH-kTabBarH-kBMBarH,W,kBMBarH)]; + self.bookmarksBar.autoresizingMask=NSViewWidthSizable|NSViewMinYMargin; + self.bookmarksBar.wantsLayer=YES; self.bookmarksBar.layer.backgroundColor=[NSColor windowBackgroundColor].CGColor; + self.bookmarksBar.hidden=YES; + [self.root addSubview:self.bookmarksBar]; + + // Download panel (right edge, hidden by default) + self.downloadPanel=[[BBDownloadPanel alloc]initWithFrame:NSMakeRect(W-kDLPanelW,0,kDLPanelW,H-kToolbarH-kTabBarH)]; + self.downloadPanel.autoresizingMask=NSViewMinXMargin|NSViewHeightSizable; + self.downloadPanel.hidden=YES; [self.root addSubview:self.downloadPanel]; + + // Address autocomplete dropdown + self.addressDropdown=[[BBAddressDropdown alloc]init]; + self.addressDropdown.delegate=self; + + self.dnsBlockCache=[[NSCache alloc]init]; + // (decoyViews removed — popup timing gate was a per-browser fingerprint vector) + self.dnsBlockCache.countLimit=2000; + + self.tabs=[NSMutableArray array]; self.activeTabIndex=0; + // Session restore — reopen tabs from last session + NSArray *savedURLs=[[NSUserDefaults standardUserDefaults] arrayForKey:@"BBSessionURLs"]; + BOOL restored=NO; + if (savedURLs.count) { + for (NSString *u in savedURLs) { + if (!u.length) continue; + [self addTabPrivate:NO]; + [self.webView loadRequest:[NSURLRequest requestWithURL:[NSURL URLWithString:u]]]; + restored=YES; + } + } + if (!restored) [self addTabPrivate:NO]; + [self.window makeKeyAndOrderFront:nil]; [NSApp activateIgnoringOtherApps:YES]; + BBLog([NSString stringWithFormat:@"window frame=%@ root=%@ toolbar=%@", + NSStringFromRect(self.window.frame), NSStringFromRect(self.root.bounds), NSStringFromRect(self.toolbarBg.frame)]); + // Focus the address bar immediately on launch — user can type a URL right away. + dispatch_async(dispatch_get_main_queue(),^{ + [self.window makeFirstResponder:self.address]; + [self.address selectText:nil]; + }); + // Dismiss the address dropdown when the user clicks outside it or the address field. + [NSEvent addLocalMonitorForEventsMatchingMask: + NSEventMaskLeftMouseDown|NSEventMaskRightMouseDown|NSEventMaskKeyDown + handler:^NSEvent*(NSEvent *e){ + if (e.type==NSEventTypeLeftMouseDown && e.window==self.window) { + NSView *overlay=self.addressDropdown.overlay; + NSPoint pt=[self.root convertPoint:e.locationInWindow fromView:nil]; + NSView *hit=[self.root hitTest:pt]; + BOOL inOverlay = overlay && !overlay.hidden && (hit==overlay || [hit isDescendantOf:overlay]); + BOOL inAddress = (hit==self.address || [hit isDescendantOf:self.address] || + hit==[self.address currentEditor] || [hit isDescendantOf:[self.address currentEditor]]); + if (!inOverlay && !inAddress) [self.addressDropdown hide]; + } + return e; + }]; + [self installContextMenuMonitor]; +} + +// Returns YES for URLs that should show as blank in the address bar (start page, new-tab). +- (BOOL)isInternalURL:(NSString *)url { + if (!url.length) return YES; + if ([url hasPrefix:@"bearbrowser://"]) return YES; + // Bundled start page + NSString *startPath=[[[NSBundle mainBundle] resourcePath] stringByAppendingPathComponent:@"BearBrowser-start.html"]; + NSString *startURL=[[NSURL fileURLWithPath:startPath] absoluteString]; + return [url isEqualToString:startURL]; +} + +// ── Nav button factory ──────────────────────────────────────────────────────── +- (NSButton *)navBtn:(NSString *)sym tip:(NSString *)tip sel:(SEL)sel x:(CGFloat)x y:(CGFloat)y { + NSButton *b=[[NSButton alloc]initWithFrame:NSMakeRect(x,y,32,32)]; + NSImage *img=[NSImage imageWithSystemSymbolName:sym accessibilityDescription:tip]; + img=[img imageWithSymbolConfiguration:[NSImageSymbolConfiguration configurationWithPointSize:14 weight:NSFontWeightMedium]]; + [img setTemplate:YES]; b.image=img; b.imagePosition=NSImageOnly; + b.bezelStyle=NSBezelStyleToolbar; b.bordered=NO; b.target=self; b.action=sel; b.toolTip=tip; + return b; +} + +// ── Security indicator ──────────────────────────────────────────────────────── +- (void)updateSecurityIndicator:(NSURL *)url { + NSString *sym=@"globe"; NSColor *tint=[NSColor tertiaryLabelColor]; + NSString *tip=@""; + if (url) { + if ([url.scheme isEqualToString:@"https"]) { + sym=@"lock.fill"; tint=[NSColor systemGreenColor]; tip=@"Secure connection (HTTPS)"; + } else if ([url.scheme isEqualToString:@"http"]) { + sym=@"exclamationmark.triangle.fill"; tint=[NSColor systemOrangeColor]; tip=@"Not secure — connection is not encrypted"; + } else if ([url.scheme isEqualToString:@"file"]) { + sym=@"doc.fill"; tint=[NSColor secondaryLabelColor]; tip=@"Local file"; + } + } + NSImage *img=[NSImage imageWithSystemSymbolName:sym accessibilityDescription:tip]; + img=[img imageWithSymbolConfiguration:[NSImageSymbolConfiguration configurationWithPointSize:13 weight:NSFontWeightMedium]]; + // Apply tint via symbol color config + NSImageSymbolConfiguration *colorCfg=[NSImageSymbolConfiguration configurationWithHierarchicalColor:tint]; + img=[img imageWithSymbolConfiguration:colorCfg]; + self.securityButton.image=img; self.securityButton.imagePosition=NSImageOnly; + self.securityButton.toolTip=tip.length?tip:@"Security info"; +} +- (void)showSecurityInfo:(id)s { + NSURL *url=self.webView.URL; + NSString *host=url.host?:@""; + BOOL isHTTPS=[url.scheme isEqualToString:@"https"]; + NSMutableString *info=[NSMutableString string]; - NSRect frame = NSMakeRect(0, 0, 1440, 820); - self.window = [[NSWindow alloc] initWithContentRect:frame - styleMask:(NSWindowStyleMaskTitled | NSWindowStyleMaskClosable | NSWindowStyleMaskResizable | NSWindowStyleMaskMiniaturizable) - backing:NSBackingStoreBuffered - defer:NO]; - [self.window setTitle:@"BearBrowser"]; - [self.window center]; - - NSView *root = [[NSView alloc] initWithFrame:frame]; - [root setAutoresizingMask:NSViewWidthSizable | NSViewHeightSizable]; - [self.window setContentView:root]; - - CGFloat toolbarH = 48.0; - NSView *toolbar = [[NSView alloc] initWithFrame:NSMakeRect(0, frame.size.height - toolbarH, frame.size.width, toolbarH)]; - [toolbar setAutoresizingMask:NSViewWidthSizable | NSViewMinYMargin]; - [root addSubview:toolbar]; - - NSButton *back = [NSButton buttonWithTitle:@"‹" target:self action:@selector(goBack:)]; - NSButton *fwd = [NSButton buttonWithTitle:@"›" target:self action:@selector(goForward:)]; - NSButton *reload = [NSButton buttonWithTitle:@"↻" target:self action:@selector(reload:)]; - NSButton *summary = [NSButton buttonWithTitle:@"Summarize Page" target:self action:@selector(summarizePage:)]; - NSButton *share = [NSButton buttonWithTitle:@"Propose Share" target:self action:@selector(proposePageShare:)]; - NSButton *memory = [NSButton buttonWithTitle:@"Memory Candidate" target:self action:@selector(createMemoryCandidate:)]; - NSButton *resolve = [NSButton buttonWithTitle:@"Resolve Held" target:self action:@selector(resolveHeld:)]; - NSButton *sidecar = [NSButton buttonWithTitle:@"Sidecar Status" target:self action:@selector(openSidecarStatus:)]; - - CGFloat x = 12.0; - for (NSButton *button in @[back, fwd, reload]) { - [button setFrame:NSMakeRect(x, 9, 38, 30)]; - [button setBezelStyle:NSBezelStyleRounded]; - [toolbar addSubview:button]; - x += 44.0; - } - - CGFloat right = frame.size.width - 810; - [summary setFrame:NSMakeRect(right, 9, 132, 30)]; - [share setFrame:NSMakeRect(right + 140, 9, 124, 30)]; - [memory setFrame:NSMakeRect(right + 272, 9, 150, 30)]; - [resolve setFrame:NSMakeRect(right + 430, 9, 118, 30)]; - [sidecar setFrame:NSMakeRect(right + 556, 9, 132, 30)]; - for (NSButton *button in @[summary, share, memory, resolve, sidecar]) { - [button setAutoresizingMask:NSViewMinXMargin]; - [button setBezelStyle:NSBezelStyleRounded]; - [toolbar addSubview:button]; - } - - self.address = [[NSTextField alloc] initWithFrame:NSMakeRect(x + 6, 9, frame.size.width - x - 826, 30)]; - [self.address setAutoresizingMask:NSViewWidthSizable]; - [self.address setDelegate:self]; - [self.address setStringValue:@"bearbrowser://start"]; - [toolbar addSubview:self.address]; - - WKWebViewConfiguration *config = [[WKWebViewConfiguration alloc] init]; - self.webView = [[WKWebView alloc] initWithFrame:NSMakeRect(0, 0, frame.size.width, frame.size.height - toolbarH) configuration:config]; - [self.webView setAutoresizingMask:NSViewWidthSizable | NSViewHeightSizable]; - [self.webView setNavigationDelegate:self]; - [root addSubview:self.webView]; - - NSURL *landing = [[NSBundle mainBundle] URLForResource:@"BearBrowser-start" withExtension:@"html"]; - if (landing) { - [self.webView loadFileURL:landing allowingReadAccessToURL:[landing URLByDeletingLastPathComponent]]; - BBLog([NSString stringWithFormat:@"loaded %@", landing.path]); + if (!isHTTPS) { + [info appendString:url?@"⚠️ Connection is NOT encrypted (HTTP)\nData sent to this site can be intercepted.\n":@"Internal page"]; } else { - [self.webView loadHTMLString:@"

BearBrowser

Landing page missing.

" baseURL:nil]; - BBLog(@"landing page missing"); - } - - [self.window makeKeyAndOrderFront:nil]; - [NSApp activateIgnoringOtherApps:YES]; -} - -- (BOOL)applicationShouldTerminateAfterLastWindowClosed:(NSApplication *)sender { return YES; } -- (void)goBack:(id)sender { if (self.webView.canGoBack) [self.webView goBack]; } -- (void)goForward:(id)sender { if (self.webView.canGoForward) [self.webView goForward]; } -- (void)reload:(id)sender { [self.webView reload]; } - -- (NSString *)currentURLString { - return self.webView.URL.absoluteString ?: @"bearbrowser://start"; -} - -- (NSString *)runCommandAndCaptureOutput:(NSString *)command status:(int *)status { - NSTask *task = [[NSTask alloc] init]; - NSPipe *pipe = [NSPipe pipe]; - task.launchPath = @"/bin/bash"; - task.arguments = @[@"-lc", [NSString stringWithFormat:@"PATH=/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin; %@", command]]; - task.standardOutput = pipe; - task.standardError = pipe; - [task launch]; - [task waitUntilExit]; - if (status) { *status = task.terminationStatus; } - NSData *data = [[pipe fileHandleForReading] readDataToEndOfFile]; - NSString *output = [[NSString alloc] initWithData:data encoding:NSUTF8StringEncoding] ?: @""; - return [output stringByTrimmingCharactersInSet:[NSCharacterSet whitespaceAndNewlineCharacterSet]]; -} - -- (void)runCommand:(NSString *)command successMessage:(NSString *)successMessage { - int status = 0; - NSString *output = [self runCommandAndCaptureOutput:command status:&status]; - BBLog([NSString stringWithFormat:@"command exit=%d command=%@ output=%@", status, command, output]); - [self openSidecarStatus:nil]; - NSAlert *alert = [[NSAlert alloc] init]; - alert.messageText = status == 0 ? successMessage : @"BearBrowser command failed"; - alert.informativeText = status == 0 ? @"Interactive Sidecar has been refreshed." : @"Check ~/Library/Logs/BearBrowser/launcher.log and the local governance logs."; - [alert addButtonWithTitle:@"OK"]; - [alert beginSheetModalForWindow:self.window completionHandler:nil]; -} - -- (void)summarizePage:(id)sender { - NSString *js = @"(document.body && document.body.innerText ? document.body.innerText : (document.documentElement && document.documentElement.innerText ? document.documentElement.innerText : '')).slice(0, 12000)"; - [self.webView evaluateJavaScript:js completionHandler:^(id result, NSError *error) { - if (error) { - BBLog([NSString stringWithFormat:@"summary extraction error=%@", error.localizedDescription]); - NSAlert *alert = [[NSAlert alloc] init]; - alert.messageText = @"Could not summarize page"; - alert.informativeText = @"BearBrowser could not read visible page text from the current WebKit page."; - [alert addButtonWithTitle:@"OK"]; - [alert beginSheetModalForWindow:self.window completionHandler:nil]; - return; + [info appendString:@"🔒 Connection is encrypted (TLS)\n\n"]; + // Walk cert chain from stored trust + SecTrustRef trust=self.currentTrust; + if (trust) { + CFArrayRef chain=SecTrustCopyCertificateChain(trust); + CFIndex count=chain?CFArrayGetCount(chain):0; + for (CFIndex i=0;i_sw,configurable:false}," + @" height:{get:()=>_sh,configurable:false}," + @" availWidth:{get:()=>_sw,configurable:false}," + @" availHeight:{get:()=>_sh,configurable:false}," + // availLeft/Top expose dock/taskbar size and multi-monitor offsets + @" availLeft:{get:()=>0,configurable:false}," + @" availTop:{get:()=>0,configurable:false}," + // isExtended reveals if a second display is attached (strong monitor fingerprint) + @" isExtended:{get:()=>false,configurable:false}," + @" colorDepth:{get:()=>24,configurable:false}," + @" pixelDepth:{get:()=>24,configurable:false}" + @"});}catch(e){}" + // devicePixelRatio — 2.0 is dominant Safari/Retina value, not 1.0 (which is rare) + @"try{Object.defineProperty(window,'devicePixelRatio',{get:()=>2,configurable:false});}catch(e){}" + // outerWidth/outerHeight = innerWidth/innerHeight (no chrome height leak) + @"try{Object.defineProperty(window,'outerWidth',{get:()=>window.innerWidth,configurable:false});}catch(e){}" + @"try{Object.defineProperty(window,'outerHeight',{get:()=>window.innerHeight,configurable:false});}catch(e){}" + // screenX/screenY — window position on desktop; reveals monitor layout and multi-display + @"try{Object.defineProperty(window,'screenX',{get:()=>0,configurable:false});}catch(e){}" + @"try{Object.defineProperty(window,'screenY',{get:()=>0,configurable:false});}catch(e){}" + @"try{Object.defineProperty(window,'screenLeft',{get:()=>0,configurable:false});}catch(e){}" + @"try{Object.defineProperty(window,'screenTop',{get:()=>0,configurable:false});}catch(e){}" + // ── Navigator hardening ──────────────────────────────────────────────── + @"try{Object.defineProperties(navigator,{" + @" hardwareConcurrency:{get:()=>4,configurable:false}," + @" deviceMemory:{get:()=>4,configurable:false}," + @" languages:{get:()=>Object.freeze(['en-US','en']),configurable:false}," + @" platform:{get:()=>'MacIntel',configurable:false}," + @" maxTouchPoints:{get:()=>0,configurable:false}" + @"});}catch(e){}" + // UA-CH (navigator.userAgentData) — delete entirely; Safari doesn't expose it + @"try{Object.defineProperty(navigator,'userAgentData',{get:()=>undefined,configurable:false});}catch(e){}" + // navigator.connection / NetworkInformation — remove network quality signal + @"try{Object.defineProperty(navigator,'connection',{get:()=>undefined,configurable:false});}catch(e){}" + // Battery API removed + @"if(navigator.getBattery)try{delete navigator.__proto__.getBattery;}catch(e){navigator.getBattery=undefined;}" + // ── Full navigator identity — must match claimed UA (Safari 17.6 / macOS) ─ + // WebKit already sets vendor and productSub correctly, but explicitly + // asserting them makes the values immune to override by site scripts. + @"try{Object.defineProperties(navigator,{" + @" vendor:{get:()=>'Apple Computer, Inc.',configurable:false}," + @" vendorSub:{get:()=>'',configurable:false}," + @" productSub:{get:()=>'20030107',configurable:false}," + @" appName:{get:()=>'Netscape',configurable:false}," + @" product:{get:()=>'Gecko',configurable:false}," + // pdfViewerEnabled: Chrome 104+ reports true; WKWebView omits it entirely. + // fingerprintjs and creepjs probe this as a Chrome-vs-other signal. + @" pdfViewerEnabled:{get:()=>true,configurable:false}" + @"});}catch(e){}" + // Firefox-only properties that leak Gecko even when UA claims Safari + @"try{if('oscpu'in navigator)Object.defineProperty(navigator,'oscpu',{get:()=>undefined,configurable:false});}catch(e){}" + @"try{if('buildID'in navigator)Object.defineProperty(navigator,'buildID',{get:()=>undefined,configurable:false});}catch(e){}" + // plugins / mimeTypes — replicate the standard Chrome/Safari 5-entry PDF plugin set. + // An empty PluginArray is the single clearest WKWebView signal to fingerprinters. + // All modern browsers (Chrome 109+, Safari 17+) return exactly these 5 plugins. + @"try{(function(){" + @" function _mkMT(type,plug){" + @" return{type:type,suffixes:'pdf',description:'Portable Document Format'," + @" enabledPlugin:plug};}" + @" function _mkP(name,file){" + @" const p=Object.create(null);" + @" p.name=name;p.description='Portable Document Format';p.filename=file;" + @" const mt0=_mkMT('application/pdf',p);const mt1=_mkMT('text/pdf',p);" + @" p[0]=mt0;p[1]=mt1;p.length=2;" + @" p.item=function(i){return p[i];};p.namedItem=function(n){" + @" return n==='application/pdf'?mt0:n==='text/pdf'?mt1:null;};" + @" p[Symbol.iterator]=function*(){yield p[0];yield p[1];};" + @" return p;}" + @" const _pl=['PDF Viewer','Chrome PDF Viewer','Chromium PDF Viewer'," + @" 'Microsoft Edge PDF Viewer','WebKit built-in PDF'];" + @" const _pa=Object.create(null);" + @" const _plugins=_pl.map(function(n){return _mkP(n,'internal-pdf-viewer');});" + @" _plugins.forEach(function(p,i){_pa[i]=p;});_pa.length=_plugins.length;" + @" _pa.item=function(i){return _pa[i];};" + @" _pa.namedItem=function(n){for(let i=0;i<_pa.length;i++)if(_pa[i].name===n)return _pa[i];return null;};" + @" _pa.refresh=function(){};_pa[Symbol.iterator]=function*(){" + @" for(let i=0;i<_pa.length;i++)yield _pa[i];};" + @" Object.defineProperty(navigator,'plugins',{get:function(){return _pa;},configurable:false});" + // MimeTypeArray — application/pdf and text/pdf pointing back to first plugin + @" const _ma=Object.create(null);const _mt0=_mkMT('application/pdf',_plugins[0]);" + @" const _mt1=_mkMT('text/pdf',_plugins[0]);" + @" _ma[0]=_mt0;_ma[1]=_mt1;_ma.length=2;" + @" _ma.item=function(i){return _ma[i];};" + @" _ma.namedItem=function(n){return n==='application/pdf'?_mt0:n==='text/pdf'?_mt1:null;};" + @" _ma[Symbol.iterator]=function*(){yield _ma[0];yield _ma[1];};" + @" Object.defineProperty(navigator,'mimeTypes',{get:function(){return _ma;},configurable:false});" + @"})();}catch(e){}" + // ── performance.now() — 1ms integer floor, no sub-ms jitter ───────────── + // Pure floor to integer ms. Adding random jitter within the bucket is + // counterproductive: it leaks the bucket boundary via averaging attacks. + // Fixed-bucket rounding is what Firefox RFP and Tor Browser actually do. + @"const _pNow=performance.now.bind(performance);" + @"performance.now=_nat(function(){return Math.floor(_pNow());},'now');" + // performance.timeOrigin — the epoch-relative page-start timestamp. + // Unclamped it is a high-precision float (sub-ms) unique per page load. + // Tor Browser clamps to 100ms buckets. Match that here. + @"try{" + @" const _origTO=performance.timeOrigin;" + @" Object.defineProperty(performance,'timeOrigin',{" + @" get:_nat(function(){return Math.floor(_origTO/100)*100;},'timeOrigin')," + @" configurable:false});" + @"}catch(e){}" + // Date.now: 100ms buckets — coarser than performance.now to prevent + // timer reconstruction by subtracting a Date.now baseline. + @"const _dNow=Date.now;" + @"Date.now=_nat(function(){return Math.floor(_dNow()/100)*100;},'now');" + // ── Timezone: normalize to UTC (Firefox RFP: browser_timezone.js) ───── + @"try{" + @" const _rO=Intl.DateTimeFormat.prototype.resolvedOptions;" + @" Intl.DateTimeFormat.prototype.resolvedOptions=function(){" + @" const r=_rO.call(this);" + @" return {...r,timeZone:'UTC'};};" + @" const _DTF=Intl.DateTimeFormat;" + @" window.Intl=Object.assign(Object.create(Intl),{DateTimeFormat:function(loc,opts){" + @" return new _DTF(loc,{...opts,timeZone:'UTC'});}});" + @" window.Intl.DateTimeFormat.prototype=_DTF.prototype;" + @" window.Intl.DateTimeFormat.supportedLocalesOf=_DTF.supportedLocalesOf;" + @"}catch(e){}" + // Date.prototype.getTimezoneOffset → always 0 (UTC) + @"try{Date.prototype.getTimezoneOffset=function(){return 0;};}catch(e){}" + // ── AudioContext fingerprinting (Mozilla bug 1358149) ───────────────── + // Normalize sampleRate to 44100 and add ±1 LSB noise to analyser output + @"if(window.AudioContext||window.webkitAudioContext){" + @" const _AC=window.AudioContext||window.webkitAudioContext;" + @" const _ACp=_AC.prototype;" + @" const _cAC=function(opts){" + @" const ctx=new _AC(opts);" + @" Object.defineProperty(ctx,'sampleRate',{get:()=>44100,configurable:false});" + @" Object.defineProperty(ctx,'baseLatency',{get:()=>0.01,configurable:false});" + @" const _ca=ctx.createAnalyser.bind(ctx);" + @" ctx.createAnalyser=function(){" + @" const an=_ca();" + @" const _gFD=an.getFloatFrequencyData.bind(an);" + @" an.getFloatFrequencyData=function(arr){_gFD(arr);" + @" for(let i=0;iundefined,configurable:false});}catch(e){}" + @"try{window.GPU=undefined;window.GPUAdapter=undefined;window.GPUDevice=undefined;" + @" window.GPUBuffer=undefined;window.GPUTexture=undefined;}catch(e){}" + // ── Font enumeration via measureText (Bug 1336208 — Firefox WONTFIX, we fix) ─ + // fingerprinters call measureText() with text in each candidate font and compare + // widths against a baseline. Per-session noise makes each session's widths unique + // but consistent within the session (same font+text → same delta). + @"(function(){" + @" const _s=Math.random().toString(36).slice(2);" // per-session salt + @" function _fh(str){" // FNV-1a 32-bit hash + @" let h=0x811c9dc5;" + @" for(let i=0;i>>0;}" + @" return h;}" + @" const _mT=CanvasRenderingContext2D.prototype.measureText;" + @" CanvasRenderingContext2D.prototype.measureText=function(text){" + @" const r=_mT.call(this,text);" + @" const _key=(text||'')+(this.font||'')+_s;" + @" const noise=((_fh(_key)%5)-2)*0.1;" // ±0.2px, consistent per (font,text,session) + @" try{Object.defineProperty(r,'width',{value:Math.max(0,r.width+noise)});}catch(e){}" + // Also noise TextMetrics bounding box props — these are precise enough to + // fingerprint the font renderer independently of .width. + @" const _bbProps=['actualBoundingBoxLeft','actualBoundingBoxRight'," + @" 'actualBoundingBoxAscent','actualBoundingBoxDescent'," + @" 'fontBoundingBoxAscent','fontBoundingBoxDescent'," + @" 'emHeightAscent','emHeightDescent'];" + @" for(const p of _bbProps){try{if(p in r){" + @" const n=((_fh(_key+p)%5)-2)*0.05;" // ±0.1px on box props + @" Object.defineProperty(r,p,{value:Math.max(0,r[p]+n)});}}catch(e){}}" + @" return r;};" + // Also block document.fonts.check() — CSS local() font presence oracle + // Override on FontFaceSet.prototype: instance-level assignment is blocked + // when the descriptor is non-configurable/non-writable on the instance. + @" try{FontFaceSet.prototype.check=function(){return false;};}catch(e){}" + @" if(document.fonts&&document.fonts.check)" + @" try{document.fonts.check=function(){return false;};}catch(e){}" + // document.fonts.load() — fingerprinters probe font presence by calling + // load() and checking whether it resolves with an entry. Return [] always. + @" try{FontFaceSet.prototype.load=function(){return Promise.resolve([]);};}catch(e){}" + @"})();" + // ── requestAnimationFrame timing (Firefox browser_animationapi_iframes.js) ─ + // rAF timestamps are high-resolution and can be used to fingerprint frame timing + // characteristics. Truncate to 1ms, matching our performance.now() precision. + @"(function(){" + @" const _rAF=window.requestAnimationFrame.bind(window);" + @" window.requestAnimationFrame=function(cb){" + @" return _rAF(function(ts){cb(Math.floor(ts));});};" + // Also clamp the AnimationFrameProvider in workers if present + @"})();" + // ── Device sensors (Firefox browser_device_sensor_event.js) ────────── + // DeviceOrientationEvent and DeviceMotionEvent expose hardware accelerometer/gyro + // readings which are device-unique. Block listener registration for these types. + // Generic Sensor API (Accelerometer etc.) — delete entirely. + @"(function(){" + @" const _sensorEvents=new Set([" + @" 'deviceorientation','devicemotion','deviceorientationabsolute'," + @" 'compassneedscalibration']);" + @" const _ael=EventTarget.prototype.addEventListener;" + @" EventTarget.prototype.addEventListener=function(type,fn,opts){" + @" if(typeof type==='string'&&_sensorEvents.has(type.toLowerCase()))return;" + @" return _ael.call(this,type,fn,opts);};" + // Dispatch a fake zero-value orientation event to satisfy sites that wait for one + @" Object.defineProperty(window,'DeviceOrientationEvent',{" + @" get:function(){return undefined;},configurable:false});" + @" Object.defineProperty(window,'DeviceMotionEvent',{" + @" get:function(){return undefined;},configurable:false});" + // Generic Sensor API (W3C spec, Chrome-origin) — all expose hardware characteristics + @" ['Accelerometer','Gyroscope','Magnetometer','AbsoluteOrientationSensor'," + @" 'RelativeOrientationSensor','LinearAccelerationSensor','GravitySensor'," + @" 'AmbientLightSensor'].forEach(function(n){" + @" try{Object.defineProperty(window,n,{get:()=>undefined,configurable:false});}catch(e){}});" + // screen.orientation — exposes display rotation, hardware form-factor signal + @" try{Object.defineProperty(screen,'orientation',{get:()=>({" + @" type:'landscape-primary',angle:0," + @" addEventListener:function(){},removeEventListener:function(){}" + @" }),configurable:false});}catch(e){}" + @"})();" + // ── Navigator / window identity normalization ──────────────────────── + // doNotTrack and webdriver: define on Navigator.prototype so the override + // works even when the instance property is non-configurable (Playwright, + // some WebKit builds). Prototype-level override takes precedence over a + // missing or undefined instance property. + @"try{Object.defineProperty(Navigator.prototype,'doNotTrack',{get:()=>'1',configurable:true});}catch(e){}" + @"try{Object.defineProperty(Navigator.prototype,'webdriver',{get:()=>undefined,configurable:true});}catch(e){}" + // window.chrome: Safari/WKWebView does not expose this object. Its absence + // is consistent with our Safari UA. If WebKit ever adds a chrome property, + // hide it to prevent leaking WebKit internals. + @"try{if('chrome'in window)Object.defineProperty(window,'chrome',{get:()=>undefined,configurable:false});}catch(e){}" + // ── Intl locale normalization ───────────────────────────────────────── + // Intl APIs can expose OS locale even when navigator.languages is spoofed. + // Collator, NumberFormat, ListFormat all expose locale via resolvedOptions(). + @"try{" + @" const _IC=Intl.Collator.prototype.resolvedOptions;" + @" Intl.Collator.prototype.resolvedOptions=_nat(function(){" + @" const r=_IC.call(this);return{...r,locale:'en-US'};},'resolvedOptions');" + @" if(Intl.NumberFormat){" + @" const _IN=Intl.NumberFormat.prototype.resolvedOptions;" + @" Intl.NumberFormat.prototype.resolvedOptions=_nat(function(){" + @" const r=_IN.call(this);return{...r,locale:'en-US'};},'resolvedOptions');}" + @" if(Intl.ListFormat){" + @" const _IL=Intl.ListFormat.prototype.resolvedOptions;" + @" Intl.ListFormat.prototype.resolvedOptions=_nat(function(){" + @" const r=_IL.call(this);return{...r,locale:'en-US'};},'resolvedOptions');}" + @" if(Intl.PluralRules){" + @" const _IP=Intl.PluralRules.prototype.resolvedOptions;" + @" Intl.PluralRules.prototype.resolvedOptions=_nat(function(){" + @" const r=_IP.call(this);return{...r,locale:'en-US'};},'resolvedOptions');}" + // Segmenter, DisplayNames, RelativeTimeFormat — newer Intl APIs that also + // expose the OS locale. Normalize each resolvedOptions() return to en-US. + @" if(Intl.Segmenter){" + @" const _ISg=Intl.Segmenter.prototype.resolvedOptions;" + @" Intl.Segmenter.prototype.resolvedOptions=_nat(function(){" + @" const r=_ISg.call(this);return{...r,locale:'en-US'};},'resolvedOptions');}" + @" if(Intl.DisplayNames){" + @" const _IDN=Intl.DisplayNames.prototype.resolvedOptions;" + @" Intl.DisplayNames.prototype.resolvedOptions=_nat(function(){" + @" const r=_IDN.call(this);return{...r,locale:'en-US'};},'resolvedOptions');}" + @" if(Intl.RelativeTimeFormat){" + @" const _IRF=Intl.RelativeTimeFormat.prototype.resolvedOptions;" + @" Intl.RelativeTimeFormat.prototype.resolvedOptions=_nat(function(){" + @" const r=_IRF.call(this);return{...r,locale:'en-US'};},'resolvedOptions');}" + // Intl.Locale constructor — passing any tag always returns an en-US Locale so + // navigator.language probing via new Intl.Locale(navigator.language).language + // is neutralised. + @" if(Intl.Locale){" + @" const _ILo=Intl.Locale;" + @" const _ILoW=function(tag,opts){return new _ILo('en-US',opts);};" + @" _ILoW.prototype=_ILo.prototype;" + @" try{window.Intl=Object.assign(Object.create(Intl),{Locale:_ILoW});}catch(e){}}" + // Intl.supportedValuesOf — returns the ICU-version-specific list of supported + // timezone/calendar/currency names. JSC and V8 may differ by ICU era; return + // fixed Chrome-matching lists for the short enumerations (calendar, collation, + // numberingSystem, unit) and sort the native list for timeZone/currency so ICU + // version reordering cannot be used as a fingerprint. + @" if(typeof Intl.supportedValuesOf==='function'){" + @" const _sVO=Intl.supportedValuesOf.bind(Intl);" + @" const _sVOCal=['buddhist','chinese','coptic','dangi','ethioaa','ethiopic'," + @" 'gregory','hebrew','indian','islamic','islamic-civil','islamic-rgsa'," + @" 'islamic-tbla','islamic-umalqura','iso8601','japanese','persian','roc'];" + @" const _sVOCol=['compat','dict','emoji','eor','phonebk','phonetic','pinyin'," + @" 'reformed','searchjl','stroke','trad','unihan','zhuyin'];" + @" const _sVONS=['adlm','ahom','arab','arabext','bali','beng','bhks','brah'," + @" 'cakm','cham','deva','diak','fullwide','gong','gonm','gujr','guru','hanidec'," + @" 'hmng','hmnp','java','kali','khmr','knda','kthi','laoo','latn','lepc','limb'," + @" 'mathbold','mathdbl','mathmonobold','mathrm','mathsans','mathsansbold','modi'," + @" 'mong','mroo','mtei','mymr','mymrshan','mymrtlng','newa','nkoo','olck','orya'," + @" 'osma','rohg','saur','segment','shrd','sind','sinh','sora','sund','takr','talu'," + @" 'tamldec','telu','thai','tibt','tirh','vaii','wara','wcho'];" + @" Intl.supportedValuesOf=_nat(function(key){" + @" if(key==='calendar')return _sVOCal.slice();" + @" if(key==='collation')return _sVOCol.slice();" + @" if(key==='numberingSystem')return _sVONS.slice();" + @" try{return _sVO(key).slice().sort();}catch(e){return [];}" + @" },'supportedValuesOf');}" + @"}catch(e){}" + // ── Resource timing — clamp to prevent network topology fingerprinting ─ + // Resource timing entries expose precise transfer durations (sub-ms) that + // reveal network path characteristics unique to the user's connection. + // setResourceTimingBufferSize(0) prevents new entries from accumulating; + // clear existing ones that loaded before this script ran. + @"try{" + @" if(performance.setResourceTimingBufferSize)performance.setResourceTimingBufferSize(0);" + @" if(performance.clearResourceTimings)performance.clearResourceTimings();" + @" if(window.PerformanceObserver){" + @" const _PObs=window.PerformanceObserver;" + @" window.PerformanceObserver=_nat(function(cb){" + @" return new _PObs(function(list,obs){" + @" const _bl=new Set(['resource','navigation','paint']);" + @" const entries=list.getEntries().filter(function(e){" + @" return !_bl.has(e.entryType);});" // strip timing entries that fingerprint load path + @" if(entries.length)cb({getEntries:function(){return entries;},getEntriesByType:function(t){return entries.filter(function(e){return e.entryType===t;});},getEntriesByName:function(n){return entries.filter(function(e){return e.name===n;});}},obs);" + @" });},'PerformanceObserver');" + @" window.PerformanceObserver.prototype=_PObs.prototype;" + @" window.PerformanceObserver.supportedEntryTypes=_PObs.supportedEntryTypes;}" + @"}catch(e){}" + // ── Web Worker timing precision ─────────────────────────────────────── + // WKUserScript injects only into document (main-thread) JS contexts. + // Workers have a separate global scope that our shield cannot reach. + // We wrap Worker() to prepend a timing-precision patch via a blob URL + // that importScripts() the original script after our patch runs. + // Falls back to the original Worker() if the blob approach fails + // (e.g. module workers, data: URLs, or restrictive CSP). + @"(function(){" + @" const _W=window.Worker;" + @" if(!_W)return;" + @" const _WP='const _wp=performance.now.bind(performance);'" + @" +'performance.now=function(){return Math.floor(_wp());};'" + @" +'const _wd=Date.now;Date.now=function(){return Math.floor(_wd()/100)*100;};';" + @" window.Worker=function(url,opts){" + @" if(typeof url==='string'&&url.indexOf('blob:')<0){" + @" try{" + @" const blob=new Blob([_WP+'importScripts('+JSON.stringify(url)+');']," + @" {type:'application/javascript'});" + @" const burl=URL.createObjectURL(blob);" + @" const w=new _W(burl,opts);" + @" setTimeout(function(){URL.revokeObjectURL(burl);},10000);" + @" return w;" + @" }catch(e){}" + @" }" + @" return new _W(url,opts);" + @" };" + @" window.Worker.prototype=_W.prototype;" + @"})();" + // ── eval honeypot ───────────────────────────────────────────────────── + @"const _eval=window.eval;" + @"window.eval=function(code){" + @" if(typeof code==='string'&&(" + @" code.includes('document.cookie')||code.includes('localStorage')||" + @" code.includes('sessionStorage')||code.includes('XMLHttpRequest')||" + @" code.length>2000)){" + @" try{if(window.webkit&&window.webkit.messageHandlers&&window.webkit.messageHandlers.honeypot)" + @" window.webkit.messageHandlers.honeypot.postMessage({trap:'eval',url:location.href,len:code.length,time:Date.now()});}" + @" catch(e){}" + @" }" + @" return _eval.call(this,code);};" + // postMessage origin guard + @"const _pM=window.postMessage.bind(window);" + @"window.postMessage=function(data,origin,transfer){" + @" if(origin==='*'&&window.top!==window)" + @" try{if(window.webkit&&window.webkit.messageHandlers&&window.webkit.messageHandlers.honeypot)" + @" window.webkit.messageHandlers.honeypot.postMessage({trap:'wildcard_postmessage',url:location.href,time:Date.now()});}" + @" catch(e){}" + @" return _pM(data,origin,transfer);};" + // iFrame sandbox hardening + @"document.addEventListener('DOMContentLoaded',function(){" + @" document.querySelectorAll('iframe').forEach(function(f){" + @" try{" + @" var fsrc=new URL(f.src||'',location.href);" + @" if(fsrc.origin!==location.origin&&!f.hasAttribute('sandbox')){" + @" f.setAttribute('sandbox','allow-scripts allow-same-origin allow-forms allow-popups');" + @" }" + @" }catch(e){}" + @" });" + @"},false);" + // ── Hardware / peripheral API deletion ─────────────────────────────────── + // Each API exposes unique hardware identifiers or device presence bitmaps. + @"try{Object.defineProperty(navigator,'usb',{get:()=>undefined,configurable:true});}catch(e){}" + @"try{Object.defineProperty(navigator,'bluetooth',{get:()=>undefined,configurable:true});}catch(e){}" + @"try{Object.defineProperty(navigator,'hid',{get:()=>undefined,configurable:true});}catch(e){}" + @"try{Object.defineProperty(navigator,'serial',{get:()=>undefined,configurable:true});}catch(e){}" + @"try{Object.defineProperty(navigator,'xr',{get:()=>undefined,configurable:true});}catch(e){}" + @"try{Object.defineProperty(navigator,'keyboard',{get:()=>undefined,configurable:true});}catch(e){}" + @"try{Object.defineProperty(navigator,'credentials',{get:()=>undefined,configurable:true});}catch(e){}" + // getGamepads: returns empty array (no controller serials exposed) + @"try{navigator.getGamepads=_nat(function(){return [];},'getGamepads');}catch(e){}" + // ── navigator.mediaDevices — block device enumeration ───────────────── + // enumerateDevices() reveals camera/microphone presence + ephemeral device IDs. + @"try{if(navigator.mediaDevices){" + @" Object.defineProperty(navigator.mediaDevices,'enumerateDevices',{" + @" value:_nat(function(){return Promise.resolve([]);},'enumerateDevices')," + @" writable:true,configurable:true" + @" });" + @" if(typeof MediaDevices!=='undefined')" + @" try{MediaDevices.prototype.enumerateDevices=_nat(function(){return Promise.resolve([]);},'enumerateDevices');}catch(e){}" + @"}}catch(e){}" + // ── navigator.permissions — normalize permission state ──────────────── + // Per-user permission grants are unique. Force 'prompt' so sites can't + // use prior-granted states as a stable identifier. + @"try{if(navigator.permissions){" + @" const _pQ=navigator.permissions.query.bind(navigator.permissions);" + @" navigator.permissions.query=_nat(function(desc){" + @" if(desc&&typeof desc.name==='string'&&" + @" ['camera','microphone','geolocation','notifications'," + @" 'midi','push','speaker-selection'].indexOf(desc.name)>=0){" + @" return Promise.resolve({state:'prompt',onchange:null," + @" addEventListener:function(){},removeEventListener:function(){}});" + @" }" + @" return _pQ(desc);" + @" },'query');" + @"}}catch(e){}" + // Notification.permission — static property on the Notification constructor. + // WKWebView defaults to 'default'; Brave/Tor return 'denied'. The permission + // cluster (camera/mic/geo/notifications) is hashed by creepjs as a unit. + @"try{if(window.Notification){" + @" Object.defineProperty(Notification,'permission',{" + @" get:_nat(function(){return 'denied';},'permission')," + @" configurable:false});" + @"}}catch(e){}" + // ── StorageManager.estimate — fixed quota prevents storage fingerprinting ─ + // Real quota and usage vary by device, OS, and profile state. + @"try{if(navigator.storage&&window.StorageManager){" + @" StorageManager.prototype.estimate=_nat(function(){" + @" return Promise.resolve({quota:120*1024*1024*1024,usage:4096*1024});" + @" },'estimate');" + @"}}catch(e){}" + // ── window.matchMedia — normalize privacy-sensitive CSS media features ─ + // Responses to prefers-color-scheme, prefers-reduced-motion, etc. are + // system-level settings that create unique fingerprint signals. + @"(function(){" + @" try{" + @" const _mM=window.matchMedia.bind(window);" + // Map of pattern → forced matches value (our canonical "standard" profile). + @" const _mQNorms=[" + @" [/prefers-color-scheme\s*:\s*dark/i,false]," // we say light + @" [/prefers-color-scheme\s*:\s*light/i,true]," + @" [/prefers-reduced-motion\s*:\s*reduce/i,false]," + @" [/prefers-contrast\s*:\s*(more|less|forced)/i,false]," + @" [/forced-colors\s*:\s*active/i,false]," + @" [/inverted-colors\s*:\s*inverted/i,false]," + @" [/any-hover\s*:\s*none/i,false]," + @" [/any-pointer\s*:\s*coarse/i,false]," + @" [/pointer\s*:\s*coarse/i,false]," + @" [/hover\s*:\s*none/i,false]," + @" [/update\s*:\s*slow/i,false]," + @" [/prefers-reduced-transparency\s*:\s*reduce/i,false]," + @" [/dynamic-range\s*:\s*high/i,false]," // HDR presence + @" [/video-dynamic-range\s*:\s*high/i,false]," + @" [/color-gamut\s*:\s*(p3|rec2020)/i,false]," // wide gamut reveals display + @" ];" + @" window.matchMedia=_nat(function(query){" + @" const q=String(query);" + @" for(const[re,matches]of _mQNorms){" + @" if(re.test(q)){" + @" const base=_mM(q);" + @" return Object.create(base,{matches:{get:()=>matches,enumerable:true}});" + @" }" + @" }" + @" return _mM(q);" + @" },'matchMedia');" + @" }catch(e){}" + @"})();" + // ── Element.getBoundingClientRect — sub-pixel layout fingerprinting ──── + // Fingerprinters measure text element bounds to infer font rendering at + // sub-pixel precision. A per-session ±0.1px position offset prevents this + // while being imperceptible to layout calculations. + @"(function(){" + @" try{" + @" const _bbOff=(Math.random()-0.5)*0.2;" // ±0.1px, stable for session + @" const _gBCR=Element.prototype.getBoundingClientRect;" + @" Element.prototype.getBoundingClientRect=_nat(function(){" + @" const r=_gBCR.call(this);" + @" if(!r.width&&!r.height)return r;" // don't noise empty rects + @" try{return new DOMRect(r.x+_bbOff,r.y+_bbOff,r.width,r.height);}catch(e){return r;}" + @" },'getBoundingClientRect');" + // Range text measurements are used for sub-pixel font fingerprinting. Apply + // the same session-stable offset so fingerprinters read consistent but + // non-identifying values regardless of whether they measure via Element or Range. + @" if(window.Range){" + @" const _rBCR=Range.prototype.getBoundingClientRect;" + @" Range.prototype.getBoundingClientRect=_nat(function(){" + @" const r=_rBCR.call(this);" + @" if(!r.width&&!r.height)return r;" + @" try{return new DOMRect(r.x+_bbOff,r.y+_bbOff,r.width,r.height);}catch(e){return r;}" + @" },'getBoundingClientRect');" + @" const _rGCR=Range.prototype.getClientRects;" + @" Range.prototype.getClientRects=_nat(function(){" + @" return Array.from(_rGCR.call(this)).map(function(r){" + @" try{return new DOMRect(r.x+_bbOff,r.y+_bbOff,r.width,r.height);}catch(e){return r;}" + @" });" + @" },'getClientRects');" + @" }" + @" }catch(e){}" + @"})();" + // ── performance direct-API filtering ───────────────────────────────── + // PerformanceObserver is already patched above. The direct performance.* + // methods bypass it and would still return navigation/paint/resource entries + // that uniquely identify load timing per-user. + @"try{" + @" const _gE=performance.getEntries.bind(performance);" + @" performance.getEntries=_nat(function(){" + @" const _bl=new Set(['resource','navigation','paint']);" + @" return _gE().filter(function(e){return !_bl.has(e.entryType);});" + @" },'getEntries');" + @" const _gEBT=performance.getEntriesByType.bind(performance);" + @" performance.getEntriesByType=_nat(function(type){" + @" if(type==='resource'||type==='navigation'||type==='paint')return [];" + @" return _gEBT(type);" + @" },'getEntriesByType');" + @" const _gEBN=performance.getEntriesByName.bind(performance);" + @" performance.getEntriesByName=_nat(function(name,type){" + @" const _bl=new Set(['resource','navigation','paint']);" + @" if(type&&_bl.has(type))return [];" + @" return _gEBN(name,type).filter(function(e){return !_bl.has(e.entryType);});" + @" },'getEntriesByName');" + @"}catch(e){}" + // ── navigator.mediaCapabilities — codec support fingerprinting ──────── + // decodingInfo/encodingInfo map to hardware GPU/codec ASICs and vary + // significantly across device models. Return a stable generic response. + @"try{if(navigator.mediaCapabilities&&window.MediaCapabilities){" + @" MediaCapabilities.prototype.decodingInfo=_nat(function(){" + @" return Promise.resolve({supported:true,smooth:true,powerEfficient:true});" + @" },'decodingInfo');" + @" MediaCapabilities.prototype.encodingInfo=_nat(function(){" + @" return Promise.resolve({supported:true,smooth:true,powerEfficient:true});" + @" },'encodingInfo');" + @"}}catch(e){}" + // ── RTCRtpSender/Receiver.getCapabilities — codec list fingerprinting ─ + // Static methods that return browser codec lists without needing a + // peer connection — a common alternative to the SDP-parsing approach. + // Filter to a stable, cross-platform baseline (VP8/VP9/H264 + Opus). + @"try{if(window.RTCRtpSender&&RTCRtpSender.getCapabilities){" + @" const _rSC=RTCRtpSender.getCapabilities;" + @" const _rFilter=function(kind,caps){" + @" if(!caps)return null;" + @" const ok=kind==='video'?['vp8','vp9','h264']:['opus'];" + @" caps.codecs=caps.codecs.filter(function(x){" + @" return ok.some(function(a){return x.mimeType.toLowerCase().includes(a);});});" + @" return caps;};" + @" RTCRtpSender.getCapabilities=_nat(function(kind){" + @" return _rFilter(kind,_rSC.call(RTCRtpSender,kind));},'getCapabilities');" + @" if(window.RTCRtpReceiver&&RTCRtpReceiver.getCapabilities){" + @" const _rRC=RTCRtpReceiver.getCapabilities;" + @" RTCRtpReceiver.getCapabilities=_nat(function(kind){" + @" return _rFilter(kind,_rRC.call(RTCRtpReceiver,kind));},'getCapabilities');}}" + @"}catch(e){}" + // ── Math precision — JSC vs V8 divergence (creepjs ULP fingerprint) ─ + // JavaScriptCore and V8 produce different float64 results for ~14 Math + // calls due to differences in their underlying libm implementations. + // creepjs hashes all results to detect "fake Chrome on JSC" (WKWebView). + // Override each diverging function with a lookup table that returns V8's + // exact float64 bit pattern for the probed inputs. + @"(function(){" + @" try{" + @" var _S2=Math.SQRT2,_LN2=Math.LN2,_L2E=Math.LOG2E,_L10=Math.LOG10E,_PI=Math.PI;" + @" function _mPatch(fn,tbl){" + @" var orig=Math[fn];" + @" Math[fn]=_nat(function(a,b){" + @" for(var i=0;i';" + @" return' at '+fn+' ('+m[2]+')';" + @" }).join(_NL);}," + @" configurable:true});" + @" }" + @"}catch(e){}" + // ── document.fonts enumeration — font presence oracle ──────────────── + // Iterating FontFaceSet reveals which system fonts were matched by CSS. + // We already return false from check(); also block iteration so list-based + // probing (forEach, for..of, entries, size) gets an empty view. + @"try{if(window.FontFaceSet){" + @" FontFaceSet.prototype.forEach=function(){};" + @" FontFaceSet.prototype[Symbol.iterator]=function*(){};" + @" FontFaceSet.prototype.values=function*(){};" + @" FontFaceSet.prototype.entries=function*(){};" + @" FontFaceSet.prototype.keys=function*(){};" + @" try{Object.defineProperty(FontFaceSet.prototype,'size',{" + @" get:function(){return 0;},configurable:true});}catch(e){}" + @"}}catch(e){}" + // ── Retroactive native registration ─────────────────────────────────── + // Prototype methods assigned above are on the real prototype objects now; + // register each with _nat so fn.toString() returns "[native code]". + @"(function(){" + // RTCPeerConnection is absent in headless/iframe contexts; evaluating + // RTCPeerConnection.prototype in the array literal would throw and leave + // _reg undefined, aborting the entire forEach. It is already guarded below. + @" var _reg=[" + @" [HTMLCanvasElement.prototype,'toDataURL']," + @" [HTMLCanvasElement.prototype,'toBlob']," + @" [CanvasRenderingContext2D.prototype,'getImageData']," + @" [CanvasRenderingContext2D.prototype,'measureText']," + @" [WebGLRenderingContext.prototype,'getParameter']," + @" [WebGLRenderingContext.prototype,'getSupportedExtensions']," + @" [Intl.DateTimeFormat.prototype,'resolvedOptions']," + @" [Intl.Collator.prototype,'resolvedOptions']," + @" [Date.prototype,'getTimezoneOffset']," + @" [EventTarget.prototype,'addEventListener']," + @" [Element.prototype,'getBoundingClientRect']," + @" ];" + @" _reg.forEach(function(pair){" + @" try{var fn=pair[0][pair[1]];if(fn&&typeof fn==='function')_nat(fn,pair[1]);}catch(e){}});" + @" try{_nat(window.matchMedia,'matchMedia');}catch(e){}" + @" try{if(navigator.mediaDevices&&navigator.mediaDevices.enumerateDevices)_nat(navigator.mediaDevices.enumerateDevices,'enumerateDevices');}catch(e){}" + @" try{if(navigator.permissions&&navigator.permissions.query)_nat(navigator.permissions.query,'query');}catch(e){}" + @" try{if(navigator.storage&&window.StorageManager)_nat(StorageManager.prototype.estimate,'estimate');}catch(e){}" + @" try{_nat(window.requestAnimationFrame,'requestAnimationFrame');}catch(e){}" + @" try{_nat(window.eval,'eval');}catch(e){}" + @" try{_nat(window.postMessage,'postMessage');}catch(e){}" + @" try{if(window.Worker)_nat(window.Worker,'Worker');}catch(e){}" + @" try{if(window.PerformanceObserver)_nat(window.PerformanceObserver,'PerformanceObserver');}catch(e){}" + @" try{if(window.RTCPeerConnection)_nat(window.RTCPeerConnection,'RTCPeerConnection');}catch(e){}" + @" try{_nat(performance.getEntries,'getEntries');}catch(e){}" + @" try{_nat(performance.getEntriesByType,'getEntriesByType');}catch(e){}" + @" try{_nat(performance.getEntriesByName,'getEntriesByName');}catch(e){}" + @" try{if(navigator.mediaCapabilities&&window.MediaCapabilities){" + @" _nat(MediaCapabilities.prototype.decodingInfo,'decodingInfo');" + @" _nat(MediaCapabilities.prototype.encodingInfo,'encodingInfo');}}catch(e){}" + @" try{if(window.RTCRtpSender&&RTCRtpSender.getCapabilities)_nat(RTCRtpSender.getCapabilities,'getCapabilities');}catch(e){}" + @" try{if(window.RTCRtpReceiver&&RTCRtpReceiver.getCapabilities)_nat(RTCRtpReceiver.getCapabilities,'getCapabilities');}catch(e){}" + @" try{if(window.Range){" + @" _nat(Range.prototype.getBoundingClientRect,'getBoundingClientRect');" + @" _nat(Range.prototype.getClientRects,'getClientRects');}}catch(e){}" + @" try{if(window.AudioBuffer){" + @" _nat(AudioBuffer.prototype.getChannelData,'getChannelData');" + @" _nat(AudioBuffer.prototype.copyFromChannel,'copyFromChannel');}}catch(e){}" + @" try{if(window.SVGGraphicsElement)" + @" _nat(SVGGraphicsElement.prototype.getBBox,'getBBox');}catch(e){}" + @" try{if(window.SVGTextContentElement){" + @" _nat(SVGTextContentElement.prototype.getComputedTextLength,'getComputedTextLength');" + @" _nat(SVGTextContentElement.prototype.getSubStringLength,'getSubStringLength');}}catch(e){}" + @" try{if(window.FontFaceSet){" + @" _nat(FontFaceSet.prototype.load,'load');" + @" _nat(FontFaceSet.prototype.check,'check');" + @" _nat(FontFaceSet.prototype.forEach,'forEach');}}catch(e){}" + @" try{if(window.Intl&&typeof Intl.supportedValuesOf==='function')" + @" _nat(Intl.supportedValuesOf,'supportedValuesOf');}catch(e){}" + @" if(window.WebGL2RenderingContext){" + @" try{_nat(WebGL2RenderingContext.prototype.getParameter,'getParameter');}catch(e){}" + @" try{_nat(WebGL2RenderingContext.prototype.getSupportedExtensions,'getSupportedExtensions');}catch(e){}}" + @" try{_nat(WebGLRenderingContext.prototype.getShaderPrecisionFormat,'getShaderPrecisionFormat');}catch(e){}" + @" try{if(window.WebGL2RenderingContext)" + @" _nat(WebGL2RenderingContext.prototype.getShaderPrecisionFormat,'getShaderPrecisionFormat');}catch(e){}" + @" try{if(window.AudioContext)_nat(window.AudioContext,'AudioContext');}catch(e){}" + @"})();" + @"})();"; + WKUserScript *shieldScript=[[WKUserScript alloc] + initWithSource:shield + injectionTime:WKUserScriptInjectionTimeAtDocumentStart + forMainFrameOnly:NO]; + [config.userContentController addUserScript:shieldScript]; + // ── Honeypot canary — alerts when scrapers/exploits probe well-known targets ─ + NSString *canary= + @"(function(){'use strict';" + @"function trap(name,fake){" + @" Object.defineProperty(window,name,{get:function(){" + @" try{if(window.webkit&&window.webkit.messageHandlers&&window.webkit.messageHandlers.honeypot)" + @" window.webkit.messageHandlers.honeypot.postMessage({trap:name,url:location.href,time:Date.now()});}" + @" catch(e){}" + @" return fake;}," + @" configurable:false,enumerable:false});}" + // Fake credential properties — only automated tools probe these + @"trap('__bb_admin_token','eyJhbGciOiJIUzI1NiJ9.HONEYPOT.TRAP');" + @"trap('__bb_session_key','sk-bear-0000000000000000-TRAP');" + @"trap('__bb_api_base','https://api.bearbrowser.internal/v1');" + @"trap('__bb_config',{debug:false,admin:false,env:'production'});" + // Watch for document.cookie bulk harvest attempts + @"const _cookieDesc=Object.getOwnPropertyDescriptor(Document.prototype,'cookie')||" + @" Object.getOwnPropertyDescriptor(HTMLDocument.prototype,'cookie');" + @"if(_cookieDesc&&_cookieDesc.get){" + @" let _hc=0;" + @" const _origGet=_cookieDesc.get;" + @" Object.defineProperty(document,'cookie',{get:function(){" + @" _hc++;if(_hc>20&&_hc%10===0)" + @" try{if(window.webkit&&window.webkit.messageHandlers&&window.webkit.messageHandlers.honeypot)" + @" window.webkit.messageHandlers.honeypot.postMessage({trap:'cookie_harvest',url:location.href,count:_hc,time:Date.now()});}" + @" catch(e){}" + @" return _origGet.call(document);}," + @" set:_cookieDesc.set,configurable:true});" + @"}" + @"})();"; + WKUserScript *canaryScript=[[WKUserScript alloc] + initWithSource:canary + injectionTime:WKUserScriptInjectionTimeAtDocumentStart + forMainFrameOnly:YES]; + [config.userContentController addUserScript:canaryScript]; + // Network monitor — wraps fetch/XHR and uses PerformanceObserver to report all resource loads + NSString *netmonJS= + @"(function(){'use strict';" + @"function _rep(url,type){" + @" try{" + @" var h=new URL(url,location.href).hostname||'';" + @" window.webkit.messageHandlers.netmon.postMessage({domain:h,page:location.href,type:type});" + @" }catch(e){}" + @"}" + @"var _f=window.fetch;" + @"window.fetch=function(input,init){" + @" _rep(typeof input==='string'?input:(input&&input.url)||'','fetch');" + @" return _f.apply(this,arguments);" + @"};" + @"var _xo=XMLHttpRequest.prototype.open;" + @"XMLHttpRequest.prototype.open=function(m,url){" + @" _rep(String(url),'xhr');" + @" return _xo.apply(this,arguments);" + @"};" + @"if(typeof PerformanceObserver!=='undefined'){" + @" try{" + @" var po=new PerformanceObserver(function(list){" + @" list.getEntries().forEach(function(e){" + @" if(e.name&&e.initiatorType)_rep(e.name,e.initiatorType);" + @" });" + @" });" + @" po.observe({entryTypes:['resource']});" + @" }catch(ex){}" + @"}" + @"})();"; + WKUserScript *netmonScript=[[WKUserScript alloc] + initWithSource:netmonJS + injectionTime:WKUserScriptInjectionTimeAtDocumentStart + forMainFrameOnly:NO]; + [config.userContentController addUserScript:netmonScript]; + + // ── Security monitor — JS-side injection detection ──────────────────────── + NSString *secmonJS= + @"(function(){'use strict';" + @"function _sm(type,detail){" + @" try{if(window.webkit&&window.webkit.messageHandlers&&window.webkit.messageHandlers.secmon)" + @" window.webkit.messageHandlers.secmon.postMessage({type:type,page:location.href,detail:String(detail).slice(0,1000)});}" + @" catch(e){}}" + // eval monitoring — already hooked in shield for honeypot; extend for secmon + @"const _ev=window.eval;" + @"window.eval=function(code){" + @" _sm('eval',typeof code==='string'?code.slice(0,800):typeof code);" + @" return _ev.call(this,code);};" + // Function constructor + @"const _Fn=window.Function;" + @"window.Function=function(){var a=Array.from(arguments);" + @" _sm('Function',a.join('|').slice(0,800));" + @" return _Fn.apply(this,a);};" + @"window.Function.prototype=_Fn.prototype;" + // MutationObserver — detect dynamic