diff --git a/extensions/README.md b/extensions/README.md new file mode 100644 index 0000000..99a5fcc --- /dev/null +++ b/extensions/README.md @@ -0,0 +1,64 @@ +# BearBrowser Bundled Extensions + +Two first-party extensions ship bundled with every BearBrowser build. +They are force-installed at first launch via enterprise policy — no manual +installation required. + +--- + +## Bear Spaces (`bear-spaces@bearbrowser.local`) + +**Tab workspaces for BearBrowser.** Organises open tabs into named, persistent +workspaces (similar to Arc Spaces). Each workspace maintains its own tab set; +switching workspaces hides the current set and reveals the selected one without +closing any tabs. + +Permissions: `tabs`, `tabHide`, `storage`, `contextMenus`, `nativeMessaging`. + +--- + +## Bear Containers (`bear-containers@mdheller`) + +**Multi-account container isolation.** Each container gets its own cookie jar, +localStorage, IndexedDB, and network cache, preventing cross-site tracking and +enabling simultaneous sessions under different identities (work, personal, research, +etc.) in the same browser window. + +Permissions: `cookies`, `tabs`, `storage`, `contextMenus`, `webRequest`, +`webRequestBlocking`, `contextualIdentities`, ``. + +--- + +## Development: loading via about:debugging + +To iterate on an extension without running a full overlay build: + +1. Open BearBrowser and navigate to `about:debugging#/runtime/this-firefox`. +2. Click **Load Temporary Add-on...**. +3. Select the `manifest.json` inside the extension directory + (`extensions/bear-spaces/manifest.json` or `extensions/bear-containers/manifest.json`). +4. The extension loads for the current session. It is removed on restart. + +For persistent dev loads without a full build, create a developer profile and +add the extension to it directly. + +--- + +## How bundling works in the overlay build + +During `scripts/bearbrowser-overlay-binary.sh` (step 7/10): + +1. **`scripts/bearbrowser-pack-extensions.sh`** zips each directory under + `extensions/` into a `.xpi` named by its gecko ID and writes the files to + `build/extensions/`. + +2. **`scripts/bearbrowser-install-extensions.sh`** copies the `.xpi` files into + `BearBrowser.app/Contents/Resources/distribution/extensions/` and merges the + following into `distribution/policies.json`: + - `Extensions.Install` — the `file://` URLs of each `.xpi` + - `ExtensionSettings..installation_mode = "force_installed"` — so + the extensions install silently even though the wildcard policy blocks + user-initiated installs + +On first browser launch the enterprise policy engine installs both extensions +automatically. No user action is required. diff --git a/scripts/bearbrowser-install-extensions.sh b/scripts/bearbrowser-install-extensions.sh new file mode 100755 index 0000000..89f60ec --- /dev/null +++ b/scripts/bearbrowser-install-extensions.sh @@ -0,0 +1,148 @@ +#!/usr/bin/env bash +# MIT License +# Copyright (c) 2026 @mdheller +# +# bearbrowser-install-extensions.sh — Install packed .xpi extensions into a +# BearBrowser.app bundle and wire them into policies.json as force-installed +# enterprise extensions. +# +# This script runs AFTER bearbrowser-pack-extensions.sh has produced .xpi files +# in build/extensions/ and AFTER the overlay build's profile injection step has +# written distribution/policies.json. It: +# +# 1. Creates /Contents/Resources/distribution/extensions/ +# 2. Copies .xpi files from build/extensions/ into that directory +# 3. Merges Extension and ExtensionSettings policies into the existing +# policies.json (or creates one if absent), preserving all other policies. +# +# The force_installed installation_mode causes Firefox/LibreWolf to install the +# extension silently on first run without any user prompt, even when the wildcard +# ExtensionSettings blocks other installs. +# +# Usage: +# ./scripts/bearbrowser-install-extensions.sh [/path/to/BearBrowser.app] +# +# Arguments: +# /path/to/BearBrowser.app App bundle to install into. +# Default: /Applications/BearBrowser.app +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO="$(cd "$SCRIPT_DIR/.." && pwd)" + +APP="${1:-/Applications/BearBrowser.app}" +XPI_SRC="$REPO/build/extensions" + +if [ ! -d "$APP" ]; then + echo "ERROR: app bundle not found: $APP" >&2 + exit 64 +fi + +if [ ! -d "$XPI_SRC" ]; then + echo "ERROR: packed extensions directory not found: $XPI_SRC" >&2 + echo "Run bearbrowser-pack-extensions.sh first." >&2 + exit 1 +fi + +DIST_DIR="$APP/Contents/Resources/distribution" +EXT_DIR="$DIST_DIR/extensions" +POLICIES_JSON="$DIST_DIR/policies.json" + +mkdir -p "$EXT_DIR" + +echo "bearbrowser-install-extensions: installing into $APP" + +# ── Step 1: Copy .xpi files ───────────────────────────────────────────────── +installed_ids=() +for xpi in "$XPI_SRC"/*.xpi; do + if [ ! -f "$xpi" ]; then + continue + fi + xpi_name="$(basename "$xpi")" + cp "$xpi" "$EXT_DIR/$xpi_name" + # Derive gecko ID from filename (strip .xpi) + gecko_id="${xpi_name%.xpi}" + installed_ids+=("$gecko_id") + echo " copied: $xpi_name → distribution/extensions/" +done + +if [ "${#installed_ids[@]}" -eq 0 ]; then + echo " WARNING: no .xpi files found in $XPI_SRC" >&2 + exit 0 +fi + +# ── Step 2: Merge extension policies into policies.json ────────────────────── +# Absolute install URL paths used in the policy (the app must live at $APP at +# runtime for file:// URLs to resolve; for build-time bundling they are +# relative to the bundle so we compute the canonical path). +# The bundle's canonical install location is the path the user has the app at. +# We write paths relative to the bundle root using a placeholder and let the +# policy mergeread from the distribution/extensions/ dir which is always a +# sibling of the policies.json file. +# +# Firefox policy: install_url in ExtensionSettings must be an https:// or +# file:// URL. For bundled local .xpi files the file:// URL must point to +# where the app is installed. We write the canonical /Applications path here; +# if the app lives elsewhere the admin should re-run this script with the +# correct APP path. + +python3 - "$POLICIES_JSON" "$EXT_DIR" "${installed_ids[@]}" <<'PY' +import json, sys, os, pathlib + +policies_path = sys.argv[1] +ext_dir = sys.argv[2] +gecko_ids = sys.argv[3:] + +# Load existing policies.json if present +if os.path.exists(policies_path): + with open(policies_path, "r", encoding="utf-8") as f: + doc = json.load(f) +else: + doc = {} + +policies = doc.setdefault("policies", {}) + +# Build the Install list and ExtensionSettings entries +install_urls = [] +ext_settings = {} +for gid in gecko_ids: + xpi_name = f"{gid}.xpi" + # Compute the file:// URL. At runtime the .app lives wherever the user + # installed it; the ext_dir argument is the absolute path inside the bundle + # as it sits on disk right now. Use that path so the URL is accurate for + # the current install location. + xpi_path = os.path.realpath(os.path.join(ext_dir, xpi_name)) + xpi_url = pathlib.Path(xpi_path).as_uri() + install_urls.append(xpi_url) + ext_settings[gid] = { + "installation_mode": "force_installed", + "install_url": xpi_url, + } + +# Merge — preserve any pre-existing Extensions.Install entries +existing_install = ( + policies.get("Extensions", {}).get("Install", []) +) +# Deduplicate: remove stale entries for the same gecko IDs, then re-add +existing_install = [ + u for u in existing_install + if not any(gid in u for gid in gecko_ids) +] +existing_install.extend(install_urls) + +policies.setdefault("Extensions", {})["Install"] = existing_install + +# Merge ExtensionSettings: keep existing entries, overlay our force_installed +existing_ext_settings = policies.setdefault("ExtensionSettings", {}) +existing_ext_settings.update(ext_settings) + +with open(policies_path, "w", encoding="utf-8") as f: + json.dump(doc, f, indent=2, ensure_ascii=False) + f.write("\n") + +print(f" policies.json updated: {policies_path}") +for gid in gecko_ids: + print(f" force_installed: {gid}") +PY + +echo "bearbrowser-install-extensions: done (${#installed_ids[@]} extension(s) installed)" diff --git a/scripts/bearbrowser-overlay-binary.sh b/scripts/bearbrowser-overlay-binary.sh index 7bfa242..6f1c080 100644 --- a/scripts/bearbrowser-overlay-binary.sh +++ b/scripts/bearbrowser-overlay-binary.sh @@ -37,9 +37,10 @@ Steps: 4. Write BearBrowser Info.plist from the canonical template. 5. Install the BearBrowser icon. 6. Inject profile settings (user.js, policies.json). - 7. Strip quarantine extended attributes. - 8. Apply ad-hoc code signature. - 9. Run branding and identity verification (unless --skip-verify). + 7. Pack and install bundled extensions (Bear Spaces, Bear Containers). + 8. Strip quarantine extended attributes. + 9. Apply ad-hoc code signature. + 10. Run branding and identity verification (unless --skip-verify). Options: --input-app Path to the source LibreWolf.app bundle. Required. @@ -98,14 +99,14 @@ echo "version=$version" echo # ── Step 1: Copy the base bundle ───────────────────────────────────────────── -echo "[1/9] Copying base bundle..." +echo "[1/10] Copying base bundle..." rm -rf "$out_app" mkdir -p "$(dirname "$out_app")" cp -R "$input_app" "$out_app" echo " done → $out_app" # ── Step 2: Apply text-format branding overlay ─────────────────────────────── -echo "[2/9] Applying BearBrowser text branding overlay..." +echo "[2/10] Applying BearBrowser text branding overlay..." bash "$script_dir/apply-bearbrowser-branding.sh" --workspace "$out_app" # The branding script creates .bearbrowser/branding.json at the workspace root. # Inside an app bundle this file sits outside Contents/ and breaks codesign's @@ -122,7 +123,7 @@ find "$out_app" -maxdepth 1 -not -name "Contents" -not -path "$out_app" -delete echo " done" # ── Step 3: Create the BearBrowser wrapper launcher ────────────────────────── -echo "[3/9] Creating BearBrowser launcher wrapper..." +echo "[3/10] Creating BearBrowser launcher wrapper..." macos_dir="$out_app/Contents/MacOS" # Detect the real Firefox/LibreWolf executable name. The main entry point is @@ -155,7 +156,7 @@ chmod +x "$macos_dir/BearBrowser" echo " created $macos_dir/BearBrowser → exec $real_bin" # ── Step 4: Write BearBrowser Info.plist ────────────────────────────────────── -echo "[4/9] Writing BearBrowser Info.plist..." +echo "[4/10] Writing BearBrowser Info.plist..." if [ ! -f "$info_template" ]; then echo "ERROR: Info.plist template missing: $info_template" >&2 exit 1 @@ -189,7 +190,7 @@ PY echo " done" # ── Step 5: Install BearBrowser icon ───────────────────────────────────────── -echo "[5/9] Installing BearBrowser icon..." +echo "[5/10] Installing BearBrowser icon..." icon_svg="$repo_root/branding/bearbrowser.svg" if [ -f "$icon_svg" ]; then cp "$icon_svg" "$out_app/Contents/Resources/BearBrowser.svg" @@ -204,7 +205,7 @@ else fi # ── Step 6: Inject profile settings ───────────────────────────────────────── -echo "[6/9] Injecting profile settings (profile=$profile)..." +echo "[6/10] Injecting profile settings (profile=$profile)..." profile_dir="$repo_root/settings/profiles/$profile" if [ -d "$profile_dir" ]; then # The packaged app applies shipped prefs from a default-pref file @@ -276,16 +277,22 @@ else echo " WARNING: no bundled fonts in $fonts_src — font allowlist will be a safe no-op" fi -# ── Step 7: Strip quarantine extended attributes ───────────────────────────── -echo "[7/9] Stripping quarantine attributes..." +# ── Step 7: Pack and install bundled extensions ────────────────────────────── +echo "[7/10] Packing and installing bundled extensions..." +"$script_dir/bearbrowser-pack-extensions.sh" "$repo_root/build/extensions" 2>&1 | sed 's/^/ /' +"$script_dir/bearbrowser-install-extensions.sh" "$out_app" 2>&1 | sed 's/^/ /' +echo " done" + +# ── Step 8: Strip quarantine extended attributes ───────────────────────────── +echo "[8/10] Stripping quarantine attributes..." xattr -cr "$out_app" 2>/dev/null || true echo " done" -# ── Step 8: Ad-hoc code signature ──────────────────────────────────────────── +# ── Step 9: Ad-hoc code signature ──────────────────────────────────────────── if [ "$skip_sign" = "true" ]; then - echo "[8/9] Skipping signing (--skip-sign)." + echo "[9/10] Skipping signing (--skip-sign)." else - echo "[8/9] Applying ad-hoc code signature..." + echo "[9/10] Applying ad-hoc code signature..." if command -v codesign >/dev/null 2>&1; then # Ad-hoc signing a modified Gecko bundle requires signing inner components # first (frameworks, helpers, nested apps) then the outer bundle. @@ -306,11 +313,11 @@ else fi fi -# ── Step 9: Verify BearBrowser identity ────────────────────────────────────── +# ── Step 10: Verify BearBrowser identity ───────────────────────────────────── if [ "$skip_verify" = "true" ]; then - echo "[9/9] Skipping verification (--skip-verify)." + echo "[10/10] Skipping verification (--skip-verify)." else - echo "[9/9] Verifying BearBrowser identity..." + echo "[10/10] Verifying BearBrowser identity..." # Pass --skip-signing to the verifier since ad-hoc signatures won't pass # spctl --assess (Gatekeeper requires a notarized Developer ID signature). bash "$script_dir/verify-macos-app.sh" --app "$out_app" --skip-signing 2>&1 | sed 's/^/ /' diff --git a/scripts/bearbrowser-pack-extensions.sh b/scripts/bearbrowser-pack-extensions.sh new file mode 100755 index 0000000..918e576 --- /dev/null +++ b/scripts/bearbrowser-pack-extensions.sh @@ -0,0 +1,87 @@ +#!/usr/bin/env bash +# MIT License +# Copyright (c) 2026 @mdheller +# +# bearbrowser-pack-extensions.sh — Pack BearBrowser extension directories into +# signed-free .xpi files ready for sideloading into the app bundle. +# +# Each extension directory under extensions/ is zipped from the inside (not +# wrapping the directory itself) and named by its gecko extension ID read from +# manifest.json's browser_specific_settings.gecko.id field. +# +# Usage: +# ./scripts/bearbrowser-pack-extensions.sh [output_dir] +# +# Arguments: +# output_dir Directory to write .xpi files into. Default: build/extensions/ +# +# Output: +# /.xpi for each extension in extensions/ +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO="$(cd "$SCRIPT_DIR/.." && pwd)" +_out_arg="${1:-$REPO/build/extensions}" +# Absolutize the output path before any cd changes the working directory +mkdir -p "$_out_arg" +OUT="$(cd "$_out_arg" && pwd)" + +echo "bearbrowser-pack-extensions: packing to $OUT" + +packed=0 +for ext_dir in "$REPO/extensions"/*/; do + if [ ! -d "$ext_dir" ]; then + continue + fi + ext_name="$(basename "$ext_dir")" + manifest="$ext_dir/manifest.json" + + if [ ! -f "$manifest" ]; then + echo " WARNING: no manifest.json in $ext_dir — skipping" >&2 + continue + fi + + # Resolve gecko extension ID from manifest; fall back to @bearbrowser.local + gecko_id="$(python3 - "$manifest" "$ext_name" <<'PY' +import json, sys +manifest_path, fallback_name = sys.argv[1], sys.argv[2] +try: + m = json.load(open(manifest_path)) + gecko_id = ( + m.get("browser_specific_settings", {}) + .get("gecko", {}) + .get("id") + or m.get("applications", {}) + .get("gecko", {}) + .get("id") + ) + if not gecko_id: + gecko_id = f"{fallback_name}@bearbrowser.local" +except Exception as e: + sys.exit(f"ERROR reading {manifest_path}: {e}") +print(gecko_id) +PY +)" + + xpi_path="$OUT/${gecko_id}.xpi" + # Remove stale xpi so zip doesn't append into an existing archive + rm -f "$xpi_path" + ( + cd "$ext_dir" + zip -r "$xpi_path" . \ + -x "*.DS_Store" \ + -x ".git*" \ + -x "__pycache__/*" \ + -x "*.pyc" \ + -x "*.swp" \ + >/dev/null + ) + echo " packed: $ext_name → ${gecko_id}.xpi" + packed=$((packed + 1)) +done + +if [ "$packed" -eq 0 ]; then + echo " WARNING: no extensions found under $REPO/extensions/" >&2 +else + echo "bearbrowser-pack-extensions: $packed extension(s) packed → $OUT" +fi