Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions packages/browseros/build/cli/build.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@
from ..modules.resources.chromium_replace import ChromiumReplaceModule
from ..modules.resources.string_replaces import StringReplacesModule
from ..modules.resources.resources import ResourcesModule
from ..modules.extensions import BundledExtensionsModule
from ..modules.upload import UploadModule

# Platform-specific modules (imported unconditionally - validation handles platform checks)
Expand All @@ -66,6 +67,7 @@
"chromium_replace": ChromiumReplaceModule,
"string_replaces": StringReplacesModule,
"resources": ResourcesModule,
"bundled_extensions": BundledExtensionsModule,
# Build
"compile": CompileModule,
"universal_build": UniversalBuildModule, # macOS universal binary (arm64 + x64)
Expand Down Expand Up @@ -118,6 +120,7 @@ def _get_package_module():
"prep",
[
"resources",
"bundled_extensions",
"chromium_replace",
"string_replaces",
"patches",
Expand Down
4 changes: 4 additions & 0 deletions packages/browseros/build/common/context.py
Original file line number Diff line number Diff line change
Expand Up @@ -417,6 +417,10 @@ def get_sparkle_url(self) -> str:
"""Get Sparkle download URL"""
return f"https://github.com/sparkle-project/Sparkle/releases/download/{self.SPARKLE_VERSION}/Sparkle-{self.SPARKLE_VERSION}.tar.xz"

def get_extensions_manifest_url(self) -> str:
"""Get CDN URL for bundled extensions update manifest"""
return "https://cdn.browseros.com/extensions/update-manifest.xml"

def get_entitlements_dir(self) -> Path:
"""Get entitlements directory"""
return join_paths(self.root_dir, "resources", "entitlements")
Expand Down
1 change: 1 addition & 0 deletions packages/browseros/build/config/release.linux.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ modules:

# Phase 2: Patches & Resources
- resources
- bundled_extensions
- chromium_replace
- string_replaces
- series_patches
Expand Down
1 change: 1 addition & 0 deletions packages/browseros/build/config/release.macos.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ modules:
- sparkle_setup

# Phase 2: Patches & Resources
- bundled_extensions
- chromium_replace
- string_replaces
- series_patches
Expand Down
1 change: 1 addition & 0 deletions packages/browseros/build/config/release.windows.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ modules:

# Phase 2: Patches & Resources
- resources
- bundled_extensions
- chromium_replace
- string_replaces
- series_patches
Expand Down
6 changes: 6 additions & 0 deletions packages/browseros/build/modules/extensions/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
#!/usr/bin/env python3
"""Extensions modules for BrowserOS build system"""

from .bundled_extensions import BundledExtensionsModule

__all__ = ["BundledExtensionsModule"]
171 changes: 171 additions & 0 deletions packages/browseros/build/modules/extensions/bundled_extensions.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,171 @@
#!/usr/bin/env python3
"""Bundled Extensions Module - Download and bundle extensions from CDN manifest"""

import json
import sys
import xml.etree.ElementTree as ET
from pathlib import Path
from typing import Dict, List, NamedTuple

import requests

from ...common.context import Context
from ...common.module import CommandModule, ValidationError
from ...common.utils import log_info, log_success, log_error


class ExtensionInfo(NamedTuple):
"""Extension metadata parsed from update manifest"""
id: str
version: str
codebase: str


class BundledExtensionsModule(CommandModule):
"""Download extensions from CDN manifest and create bundled_extensions.json"""

produces = ["bundled_extensions"]
requires = []
description = "Download and bundle extensions from CDN update manifest"

def validate(self, ctx: Context) -> None:
if not ctx.chromium_src or not ctx.chromium_src.exists():
raise ValidationError(
f"Chromium source directory not found: {ctx.chromium_src}"
)

def execute(self, ctx: Context) -> None:
log_info("\n📦 Bundling extensions from CDN manifest...")

manifest_url = ctx.get_extensions_manifest_url()
output_dir = self._get_output_dir(ctx)

output_dir.mkdir(parents=True, exist_ok=True)
log_info(f" Output: {output_dir}")

extensions = self._fetch_and_parse_manifest(manifest_url)
if not extensions:
raise RuntimeError("No extensions found in manifest")

log_info(f" Found {len(extensions)} extensions in manifest")

for ext in extensions:
self._download_extension(ext, output_dir)

self._generate_json(extensions, output_dir)

log_success(f"Bundled {len(extensions)} extensions successfully")

def _get_output_dir(self, ctx: Context) -> Path:
"""Get the bundled extensions output directory in Chromium source"""
return ctx.chromium_src / "chrome" / "browser" / "browseros" / "bundled_extensions"

def _fetch_and_parse_manifest(self, url: str) -> List[ExtensionInfo]:
"""Fetch XML manifest and parse extension information"""
log_info(f" Fetching manifest: {url}")

try:
response = requests.get(url, timeout=30)
response.raise_for_status()
except requests.RequestException as e:
raise RuntimeError(f"Failed to fetch manifest: {e}")

return self._parse_manifest_xml(response.text)

def _parse_manifest_xml(self, xml_content: str) -> List[ExtensionInfo]:
"""Parse Google Update protocol XML manifest

Expected format (with namespace):
<gupdate xmlns="http://www.google.com/update2/response" protocol='2.0'>
<app appid='extension_id'>
<updatecheck codebase='https://...' version='1.0.0' />
</app>
</gupdate>
"""
extensions = []

try:
root = ET.fromstring(xml_content)
except ET.ParseError as e:
raise RuntimeError(f"Failed to parse manifest XML: {e}")

ns = {"gupdate": "http://www.google.com/update2/response"}

# Try with namespace first, then without (for flexibility)
apps = root.findall(".//gupdate:app", ns)
if not apps:
apps = root.findall(".//app")

for app in apps:
app_id = app.get("appid")
if not app_id:
continue

updatecheck = app.find("gupdate:updatecheck", ns)
if updatecheck is None:
updatecheck = app.find("updatecheck")
if updatecheck is None:
continue

version = updatecheck.get("version")
codebase = updatecheck.get("codebase")

if version and codebase:
extensions.append(ExtensionInfo(
id=app_id,
version=version,
codebase=codebase,
))

return extensions

def _download_extension(self, ext: ExtensionInfo, output_dir: Path) -> None:
"""Download a single extension .crx file"""
dest_filename = f"{ext.id}.crx"
dest_path = output_dir / dest_filename

log_info(f" Downloading {ext.id} v{ext.version}...")

try:
response = requests.get(ext.codebase, stream=True, timeout=60)
response.raise_for_status()

total_size = int(response.headers.get("content-length", 0))
downloaded = 0

with open(dest_path, "wb") as f:
for chunk in response.iter_content(chunk_size=65536):
f.write(chunk)
downloaded += len(chunk)
if total_size:
percent = (downloaded / total_size * 100)
sys.stdout.write(
f"\r {dest_filename}: {percent:.0f}% "
)
sys.stdout.flush()

if total_size:
sys.stdout.write(f"\r {dest_filename}: done ({total_size / 1024:.0f} KB)\n")
else:
sys.stdout.write(f"\r {dest_filename}: done\n")
sys.stdout.flush()

except requests.RequestException as e:
raise RuntimeError(f"Failed to download {ext.id}: {e}")

def _generate_json(self, extensions: List[ExtensionInfo], output_dir: Path) -> None:
"""Generate bundled_extensions.json"""
json_path = output_dir / "bundled_extensions.json"

data: Dict[str, Dict[str, str]] = {}
for ext in extensions:
data[ext.id] = {
"external_crx": f"{ext.id}.crx",
"external_version": ext.version,
}

with open(json_path, "w") as f:
json.dump(data, f, indent=2)
f.write("\n")

log_info(f" Generated {json_path.name}")
16 changes: 12 additions & 4 deletions packages/browseros/chromium_patches/chrome/BUILD.gn
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
diff --git a/chrome/BUILD.gn b/chrome/BUILD.gn
index 0753724487493..bf40f3bb50e37 100644
index 0753724487493..e27ad963c66b5 100644
--- a/chrome/BUILD.gn
+++ b/chrome/BUILD.gn
@@ -18,6 +18,7 @@ import("//build/config/win/manifest.gni")
Expand All @@ -10,23 +10,31 @@ index 0753724487493..bf40f3bb50e37 100644
import("//chrome/chrome_paks.gni")
import("//chrome/common/features.gni")
import("//chrome/process_version_rc_template.gni")
@@ -369,6 +370,7 @@ if (!is_android && !is_mac) {
@@ -369,6 +370,8 @@ if (!is_android && !is_mac) {
}

data_deps += [
+ "//chrome/browser/browseros/bundled_extensions",
+ "//chrome/browser/browseros/server:browseros_server_resources",
"//chrome/browser/resources/media/mei_preload:component",
"//components/privacy_sandbox/privacy_sandbox_attestations/preload:component",
"//components/webapps/isolated_web_apps/preload:component",
@@ -525,6 +527,7 @@ if (is_win) {
@@ -525,6 +528,7 @@ if (is_win) {
":chrome_versioned_bundle_data",
"//base/allocator:early_zone_registration_apple",
"//build:branding_buildflags",
+ "//chrome/browser/browseros/server:browseros_server_resources",
"//chrome/common:buildflags",
"//chrome/common:version_header",
]
@@ -1201,6 +1204,12 @@ if (is_win) {
@@ -1197,10 +1201,19 @@ if (is_win) {
bundle_deps += [ ":preinstalled_apps" ]
}

+ # BrowserOS bundled extensions for immediate install on first run
+ bundle_deps += [ "//chrome/browser/browseros/bundled_extensions" ]
+
if (!use_static_angle) {
bundle_deps += [ ":angle_binaries" ]
}

Expand Down
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
diff --git a/chrome/browser/browseros/BUILD.gn b/chrome/browser/browseros/BUILD.gn
new file mode 100644
index 0000000000000..39cb2962b009e
index 0000000000000..a7e89ea2d1c47
--- /dev/null
+++ b/chrome/browser/browseros/BUILD.gn
@@ -0,0 +1,15 @@
@@ -0,0 +1,21 @@
+# Copyright 2024 The Chromium Authors
+# Use of this source code is governed by a BSD-style license that can be
+# found in the LICENSE file.
Expand All @@ -19,3 +19,9 @@ index 0000000000000..39cb2962b009e
+ "//chrome/browser/browseros/server",
+ ]
+}
+
+# Bundled extensions are copied separately to ensure they're available
+# in the output directory at runtime.
+group("browseros_bundled_extensions") {
+ deps = [ "//chrome/browser/browseros/bundled_extensions" ]
+}
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
diff --git a/chrome/browser/browseros/bundled_extensions/BUILD.gn b/chrome/browser/browseros/bundled_extensions/BUILD.gn
new file mode 100644
index 0000000000000..baac689c55368
--- /dev/null
+++ b/chrome/browser/browseros/bundled_extensions/BUILD.gn
@@ -0,0 +1,32 @@
+# Copyright 2024 The Chromium Authors
+# Use of this source code is governed by a BSD-style license that can be
+# found in the LICENSE file.
+
+# Bundled BrowserOS extensions for immediate installation on first run.
+# These CRX files are installed locally before fetching remote updates.
+#
+# To add a new bundled extension:
+# 1. Add the .crx file to this directory
+# 2. Add an entry to bundled_extensions.json with the extension ID, filename, and version
+# 3. Add the .crx file to the sources list below
+# 4. Ensure the extension's manifest.json has an update_url for future updates
+
+_bundled_extensions_sources = [
+ "bundled_extensions.json",
+ "bflpfmnmnokmjhmgnolecpppdbdophmk.crx", # Agent V2
+ "adlpneommgkgeanpaekgoaolcpncohkf.crx", # Bug Reporter
+ "nlnihljpboknmfagkikhkdblbedophja.crx", # Controller
+]
+
+if (!is_mac) {
+ copy("bundled_extensions") {
+ sources = _bundled_extensions_sources
+ outputs = [ "$root_out_dir/browseros_extensions/{{source_file_part}}" ]
+ }
+} else {
+ # On Mac, bundle data goes into the framework bundle
+ bundle_data("bundled_extensions") {
+ sources = _bundled_extensions_sources
+ outputs = [ "{{bundle_contents_dir}}/Resources/browseros_extensions/{{source_file_part}}" ]
+ }
+}
Loading