Skip to content

Commit f81120f

Browse files
authored
feat: improve extension installer + updater (#278)
* feat: new extension installer + bundle support * feat: support bundle extension download in cli * chore: update release yaml to include new bundle_extensions module
1 parent 11a00de commit f81120f

22 files changed

+1430
-875
lines changed

packages/browseros/build/cli/build.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@
4343
from ..modules.resources.chromium_replace import ChromiumReplaceModule
4444
from ..modules.resources.string_replaces import StringReplacesModule
4545
from ..modules.resources.resources import ResourcesModule
46+
from ..modules.extensions import BundledExtensionsModule
4647
from ..modules.upload import UploadModule
4748

4849
# Platform-specific modules (imported unconditionally - validation handles platform checks)
@@ -66,6 +67,7 @@
6667
"chromium_replace": ChromiumReplaceModule,
6768
"string_replaces": StringReplacesModule,
6869
"resources": ResourcesModule,
70+
"bundled_extensions": BundledExtensionsModule,
6971
# Build
7072
"compile": CompileModule,
7173
"universal_build": UniversalBuildModule, # macOS universal binary (arm64 + x64)
@@ -118,6 +120,7 @@ def _get_package_module():
118120
"prep",
119121
[
120122
"resources",
123+
"bundled_extensions",
121124
"chromium_replace",
122125
"string_replaces",
123126
"patches",

packages/browseros/build/common/context.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -417,6 +417,10 @@ def get_sparkle_url(self) -> str:
417417
"""Get Sparkle download URL"""
418418
return f"https://github.com/sparkle-project/Sparkle/releases/download/{self.SPARKLE_VERSION}/Sparkle-{self.SPARKLE_VERSION}.tar.xz"
419419

420+
def get_extensions_manifest_url(self) -> str:
421+
"""Get CDN URL for bundled extensions update manifest"""
422+
return "https://cdn.browseros.com/extensions/update-manifest.xml"
423+
420424
def get_entitlements_dir(self) -> Path:
421425
"""Get entitlements directory"""
422426
return join_paths(self.root_dir, "resources", "entitlements")

packages/browseros/build/config/release.linux.yaml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ modules:
1919

2020
# Phase 2: Patches & Resources
2121
- resources
22+
- bundled_extensions
2223
- chromium_replace
2324
- string_replaces
2425
- series_patches

packages/browseros/build/config/release.macos.yaml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ modules:
2222
- sparkle_setup
2323

2424
# Phase 2: Patches & Resources
25+
- bundled_extensions
2526
- chromium_replace
2627
- string_replaces
2728
- series_patches

packages/browseros/build/config/release.windows.yaml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ modules:
1919

2020
# Phase 2: Patches & Resources
2121
- resources
22+
- bundled_extensions
2223
- chromium_replace
2324
- string_replaces
2425
- series_patches
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
#!/usr/bin/env python3
2+
"""Extensions modules for BrowserOS build system"""
3+
4+
from .bundled_extensions import BundledExtensionsModule
5+
6+
__all__ = ["BundledExtensionsModule"]
Lines changed: 171 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,171 @@
1+
#!/usr/bin/env python3
2+
"""Bundled Extensions Module - Download and bundle extensions from CDN manifest"""
3+
4+
import json
5+
import sys
6+
import xml.etree.ElementTree as ET
7+
from pathlib import Path
8+
from typing import Dict, List, NamedTuple
9+
10+
import requests
11+
12+
from ...common.context import Context
13+
from ...common.module import CommandModule, ValidationError
14+
from ...common.utils import log_info, log_success, log_error
15+
16+
17+
class ExtensionInfo(NamedTuple):
18+
"""Extension metadata parsed from update manifest"""
19+
id: str
20+
version: str
21+
codebase: str
22+
23+
24+
class BundledExtensionsModule(CommandModule):
25+
"""Download extensions from CDN manifest and create bundled_extensions.json"""
26+
27+
produces = ["bundled_extensions"]
28+
requires = []
29+
description = "Download and bundle extensions from CDN update manifest"
30+
31+
def validate(self, ctx: Context) -> None:
32+
if not ctx.chromium_src or not ctx.chromium_src.exists():
33+
raise ValidationError(
34+
f"Chromium source directory not found: {ctx.chromium_src}"
35+
)
36+
37+
def execute(self, ctx: Context) -> None:
38+
log_info("\n📦 Bundling extensions from CDN manifest...")
39+
40+
manifest_url = ctx.get_extensions_manifest_url()
41+
output_dir = self._get_output_dir(ctx)
42+
43+
output_dir.mkdir(parents=True, exist_ok=True)
44+
log_info(f" Output: {output_dir}")
45+
46+
extensions = self._fetch_and_parse_manifest(manifest_url)
47+
if not extensions:
48+
raise RuntimeError("No extensions found in manifest")
49+
50+
log_info(f" Found {len(extensions)} extensions in manifest")
51+
52+
for ext in extensions:
53+
self._download_extension(ext, output_dir)
54+
55+
self._generate_json(extensions, output_dir)
56+
57+
log_success(f"Bundled {len(extensions)} extensions successfully")
58+
59+
def _get_output_dir(self, ctx: Context) -> Path:
60+
"""Get the bundled extensions output directory in Chromium source"""
61+
return ctx.chromium_src / "chrome" / "browser" / "browseros" / "bundled_extensions"
62+
63+
def _fetch_and_parse_manifest(self, url: str) -> List[ExtensionInfo]:
64+
"""Fetch XML manifest and parse extension information"""
65+
log_info(f" Fetching manifest: {url}")
66+
67+
try:
68+
response = requests.get(url, timeout=30)
69+
response.raise_for_status()
70+
except requests.RequestException as e:
71+
raise RuntimeError(f"Failed to fetch manifest: {e}")
72+
73+
return self._parse_manifest_xml(response.text)
74+
75+
def _parse_manifest_xml(self, xml_content: str) -> List[ExtensionInfo]:
76+
"""Parse Google Update protocol XML manifest
77+
78+
Expected format (with namespace):
79+
<gupdate xmlns="http://www.google.com/update2/response" protocol='2.0'>
80+
<app appid='extension_id'>
81+
<updatecheck codebase='https://...' version='1.0.0' />
82+
</app>
83+
</gupdate>
84+
"""
85+
extensions = []
86+
87+
try:
88+
root = ET.fromstring(xml_content)
89+
except ET.ParseError as e:
90+
raise RuntimeError(f"Failed to parse manifest XML: {e}")
91+
92+
ns = {"gupdate": "http://www.google.com/update2/response"}
93+
94+
# Try with namespace first, then without (for flexibility)
95+
apps = root.findall(".//gupdate:app", ns)
96+
if not apps:
97+
apps = root.findall(".//app")
98+
99+
for app in apps:
100+
app_id = app.get("appid")
101+
if not app_id:
102+
continue
103+
104+
updatecheck = app.find("gupdate:updatecheck", ns)
105+
if updatecheck is None:
106+
updatecheck = app.find("updatecheck")
107+
if updatecheck is None:
108+
continue
109+
110+
version = updatecheck.get("version")
111+
codebase = updatecheck.get("codebase")
112+
113+
if version and codebase:
114+
extensions.append(ExtensionInfo(
115+
id=app_id,
116+
version=version,
117+
codebase=codebase,
118+
))
119+
120+
return extensions
121+
122+
def _download_extension(self, ext: ExtensionInfo, output_dir: Path) -> None:
123+
"""Download a single extension .crx file"""
124+
dest_filename = f"{ext.id}.crx"
125+
dest_path = output_dir / dest_filename
126+
127+
log_info(f" Downloading {ext.id} v{ext.version}...")
128+
129+
try:
130+
response = requests.get(ext.codebase, stream=True, timeout=60)
131+
response.raise_for_status()
132+
133+
total_size = int(response.headers.get("content-length", 0))
134+
downloaded = 0
135+
136+
with open(dest_path, "wb") as f:
137+
for chunk in response.iter_content(chunk_size=65536):
138+
f.write(chunk)
139+
downloaded += len(chunk)
140+
if total_size:
141+
percent = (downloaded / total_size * 100)
142+
sys.stdout.write(
143+
f"\r {dest_filename}: {percent:.0f}% "
144+
)
145+
sys.stdout.flush()
146+
147+
if total_size:
148+
sys.stdout.write(f"\r {dest_filename}: done ({total_size / 1024:.0f} KB)\n")
149+
else:
150+
sys.stdout.write(f"\r {dest_filename}: done\n")
151+
sys.stdout.flush()
152+
153+
except requests.RequestException as e:
154+
raise RuntimeError(f"Failed to download {ext.id}: {e}")
155+
156+
def _generate_json(self, extensions: List[ExtensionInfo], output_dir: Path) -> None:
157+
"""Generate bundled_extensions.json"""
158+
json_path = output_dir / "bundled_extensions.json"
159+
160+
data: Dict[str, Dict[str, str]] = {}
161+
for ext in extensions:
162+
data[ext.id] = {
163+
"external_crx": f"{ext.id}.crx",
164+
"external_version": ext.version,
165+
}
166+
167+
with open(json_path, "w") as f:
168+
json.dump(data, f, indent=2)
169+
f.write("\n")
170+
171+
log_info(f" Generated {json_path.name}")

packages/browseros/chromium_patches/chrome/BUILD.gn

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
diff --git a/chrome/BUILD.gn b/chrome/BUILD.gn
2-
index 0753724487493..bf40f3bb50e37 100644
2+
index 0753724487493..e27ad963c66b5 100644
33
--- a/chrome/BUILD.gn
44
+++ b/chrome/BUILD.gn
55
@@ -18,6 +18,7 @@ import("//build/config/win/manifest.gni")
@@ -10,23 +10,31 @@ index 0753724487493..bf40f3bb50e37 100644
1010
import("//chrome/chrome_paks.gni")
1111
import("//chrome/common/features.gni")
1212
import("//chrome/process_version_rc_template.gni")
13-
@@ -369,6 +370,7 @@ if (!is_android && !is_mac) {
13+
@@ -369,6 +370,8 @@ if (!is_android && !is_mac) {
1414
}
1515

1616
data_deps += [
17+
+ "//chrome/browser/browseros/bundled_extensions",
1718
+ "//chrome/browser/browseros/server:browseros_server_resources",
1819
"//chrome/browser/resources/media/mei_preload:component",
1920
"//components/privacy_sandbox/privacy_sandbox_attestations/preload:component",
2021
"//components/webapps/isolated_web_apps/preload:component",
21-
@@ -525,6 +527,7 @@ if (is_win) {
22+
@@ -525,6 +528,7 @@ if (is_win) {
2223
":chrome_versioned_bundle_data",
2324
"//base/allocator:early_zone_registration_apple",
2425
"//build:branding_buildflags",
2526
+ "//chrome/browser/browseros/server:browseros_server_resources",
2627
"//chrome/common:buildflags",
2728
"//chrome/common:version_header",
2829
]
29-
@@ -1201,6 +1204,12 @@ if (is_win) {
30+
@@ -1197,10 +1201,19 @@ if (is_win) {
31+
bundle_deps += [ ":preinstalled_apps" ]
32+
}
33+
34+
+ # BrowserOS bundled extensions for immediate install on first run
35+
+ bundle_deps += [ "//chrome/browser/browseros/bundled_extensions" ]
36+
+
37+
if (!use_static_angle) {
3038
bundle_deps += [ ":angle_binaries" ]
3139
}
3240

packages/browseros/chromium_patches/chrome/browser/browseros/BUILD.gn

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
11
diff --git a/chrome/browser/browseros/BUILD.gn b/chrome/browser/browseros/BUILD.gn
22
new file mode 100644
3-
index 0000000000000..39cb2962b009e
3+
index 0000000000000..a7e89ea2d1c47
44
--- /dev/null
55
+++ b/chrome/browser/browseros/BUILD.gn
6-
@@ -0,0 +1,15 @@
6+
@@ -0,0 +1,21 @@
77
+# Copyright 2024 The Chromium Authors
88
+# Use of this source code is governed by a BSD-style license that can be
99
+# found in the LICENSE file.
@@ -19,3 +19,9 @@ index 0000000000000..39cb2962b009e
1919
+ "//chrome/browser/browseros/server",
2020
+ ]
2121
+}
22+
+
23+
+# Bundled extensions are copied separately to ensure they're available
24+
+# in the output directory at runtime.
25+
+group("browseros_bundled_extensions") {
26+
+ deps = [ "//chrome/browser/browseros/bundled_extensions" ]
27+
+}
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
diff --git a/chrome/browser/browseros/bundled_extensions/BUILD.gn b/chrome/browser/browseros/bundled_extensions/BUILD.gn
2+
new file mode 100644
3+
index 0000000000000..baac689c55368
4+
--- /dev/null
5+
+++ b/chrome/browser/browseros/bundled_extensions/BUILD.gn
6+
@@ -0,0 +1,32 @@
7+
+# Copyright 2024 The Chromium Authors
8+
+# Use of this source code is governed by a BSD-style license that can be
9+
+# found in the LICENSE file.
10+
+
11+
+# Bundled BrowserOS extensions for immediate installation on first run.
12+
+# These CRX files are installed locally before fetching remote updates.
13+
+#
14+
+# To add a new bundled extension:
15+
+# 1. Add the .crx file to this directory
16+
+# 2. Add an entry to bundled_extensions.json with the extension ID, filename, and version
17+
+# 3. Add the .crx file to the sources list below
18+
+# 4. Ensure the extension's manifest.json has an update_url for future updates
19+
+
20+
+_bundled_extensions_sources = [
21+
+ "bundled_extensions.json",
22+
+ "bflpfmnmnokmjhmgnolecpppdbdophmk.crx", # Agent V2
23+
+ "adlpneommgkgeanpaekgoaolcpncohkf.crx", # Bug Reporter
24+
+ "nlnihljpboknmfagkikhkdblbedophja.crx", # Controller
25+
+]
26+
+
27+
+if (!is_mac) {
28+
+ copy("bundled_extensions") {
29+
+ sources = _bundled_extensions_sources
30+
+ outputs = [ "$root_out_dir/browseros_extensions/{{source_file_part}}" ]
31+
+ }
32+
+} else {
33+
+ # On Mac, bundle data goes into the framework bundle
34+
+ bundle_data("bundled_extensions") {
35+
+ sources = _bundled_extensions_sources
36+
+ outputs = [ "{{bundle_contents_dir}}/Resources/browseros_extensions/{{source_file_part}}" ]
37+
+ }
38+
+}

0 commit comments

Comments
 (0)