Files
BrowserOS/packages/browseros/build/common/context.py
2026-03-02 14:02:23 -08:00

586 lines
20 KiB
Python
Generated

#!/usr/bin/env python3
"""
Build context dataclass to hold all build state
REFACTOR NOTE: This module is being refactored to use sub-components (PathConfig,
BuildConfig, ArtifactRegistry, EnvConfig) to avoid god object anti-pattern.
The old interface is maintained for backward compatibility during the migration.
"""
import time
from pathlib import Path
from dataclasses import dataclass, field
from typing import Dict, List, Optional
from .utils import (
get_platform,
get_platform_arch,
get_executable_extension,
join_paths,
IS_WINDOWS,
IS_MACOS,
)
from .env import EnvConfig
from .paths import get_package_root
# =============================================================================
# Sub-Components - New modular structure
# =============================================================================
class ArtifactRegistry:
"""
Simple artifact tracking registry
Tracks artifacts produced during the build process. Each artifact has a unique
name (string) and a path (Path object). If you need to track multiple paths
for the same logical artifact, use different names (e.g., "signed_app_arm64",
"signed_app_x64").
Example:
artifacts = ArtifactRegistry()
artifacts.add("built_app", Path("/path/to/BrowserOS.app"))
app_path = artifacts.get("built_app")
if artifacts.has("signed_app"):
...
"""
def __init__(self):
self._artifacts: Dict[str, Path] = {}
def add(self, name: str, path: Path) -> None:
"""
Register an artifact
Args:
name: Unique artifact name (e.g., "built_app", "signed_dmg")
path: Path to the artifact
Note:
If an artifact with the same name already exists, it will be overwritten.
"""
self._artifacts[name] = path
def get(self, name: str) -> Path:
"""
Get artifact path by name
Args:
name: Artifact name
Returns:
Path to the artifact
Raises:
KeyError: If artifact not found
"""
return self._artifacts[name]
def has(self, name: str) -> bool:
"""
Check if artifact exists
Args:
name: Artifact name
Returns:
True if artifact exists, False otherwise
"""
return name in self._artifacts
def all(self) -> Dict[str, Path]:
"""Get all artifacts as a dictionary"""
return self._artifacts.copy()
class PathConfig:
"""
Path-related configuration
Centralizes all path construction and validation logic. This prevents the
BuildContext from becoming a god object with dozens of path-related methods.
"""
def __init__(
self,
root_dir: Path,
chromium_src: Optional[Path] = None,
gn_flags_file: Optional[Path] = None,
):
self.root_dir = root_dir
self._chromium_src = chromium_src or Path()
self._out_dir = "out/Default"
self.gn_flags_file = gn_flags_file
@property
def chromium_src(self) -> Path:
"""Chromium source directory"""
return self._chromium_src
@chromium_src.setter
def chromium_src(self, value: Path):
"""Set chromium source directory"""
self._chromium_src = value
@property
def out_dir(self) -> str:
"""Output directory (relative to chromium_src)"""
return self._out_dir
@out_dir.setter
def out_dir(self, value: str):
"""Set output directory"""
self._out_dir = value
class BuildConfig:
"""
Build-specific configuration
Contains all build-related settings like architecture, build type, versions, etc.
"""
def __init__(
self,
architecture: Optional[str] = None,
build_type: str = "debug",
):
self.architecture = architecture or get_platform_arch()
self.build_type = build_type
self.chromium_version = ""
self.browseros_version = ""
self.browseros_chromium_version = ""
# App names - will be set based on platform
self.CHROMIUM_APP_NAME = ""
self.BROWSEROS_APP_NAME = ""
self.BROWSEROS_APP_BASE_NAME = "BrowserOS"
# Third party versions
self.SPARKLE_VERSION = "2.7.0"
# Set platform-specific app names
self._set_app_names()
def _set_app_names(self):
"""Set platform-specific application names"""
if IS_WINDOWS():
self.CHROMIUM_APP_NAME = f"chrome{get_executable_extension()}"
self.BROWSEROS_APP_NAME = (
f"{self.BROWSEROS_APP_BASE_NAME}{get_executable_extension()}"
)
elif IS_MACOS():
self.CHROMIUM_APP_NAME = "Chromium.app"
self.BROWSEROS_APP_NAME = f"{self.BROWSEROS_APP_BASE_NAME}.app"
else:
self.CHROMIUM_APP_NAME = "chrome"
self.BROWSEROS_APP_NAME = self.BROWSEROS_APP_BASE_NAME.lower()
@dataclass
class Context:
"""
Context Object pattern - ONE place for all build state
"""
root_dir: Path = field(default_factory=get_package_root)
chromium_src: Path = Path()
out_dir: str = "out/Default"
architecture: str = "" # Will be set in __post_init__
build_type: str = "debug"
chromium_version: str = ""
browseros_build_offset: str = ""
browseros_chromium_version: str = ""
semantic_version: str = "" # e.g., "0.31.0" from resources/BROWSEROS_VERSION
release_version: str = (
"" # Explicit version for release operations (overrides semantic_version)
)
github_repo: str = "" # GitHub repo for release operations (owner/repo)
start_time: float = 0.0
# App names - will be set based on platform
CHROMIUM_APP_NAME: str = ""
BROWSEROS_APP_NAME: str = ""
BROWSEROS_APP_BASE_NAME: str = "BrowserOS" # Base name without extension
# Third party
SPARKLE_VERSION: str = "2.7.0"
# Legacy artifacts dict - kept for backward compatibility
# New code should use ctx.artifacts (ArtifactRegistry) instead
artifacts: Dict[str, List[Path]] = field(default_factory=dict)
# Fixed app path - used by UniversalBuildModule to prevent auto-detection
# When set, get_app_path() returns this directly instead of auto-detecting
_fixed_app_path: Optional[Path] = None
# New sub-components (initialized in __post_init__)
paths: PathConfig = field(init=False)
build: BuildConfig = field(init=False)
artifact_registry: ArtifactRegistry = field(init=False) # New artifact system
env: EnvConfig = field(init=False)
def __post_init__(self):
"""Load version files and set platform/architecture-specific configurations"""
# Initialize new sub-components
self.paths = PathConfig(self.root_dir, self.chromium_src)
self.build = BuildConfig(self.architecture, self.build_type)
self.artifact_registry = ArtifactRegistry() # New artifact system
self.env = EnvConfig()
# Set default gn_flags_file if not provided
if not self.paths.gn_flags_file:
self.paths.gn_flags_file = self.get_gn_flags_file()
# Set platform-specific defaults
if not self.architecture:
self.architecture = get_platform_arch()
self.build.architecture = self.architecture
# Set platform-specific app names
if IS_WINDOWS():
self.CHROMIUM_APP_NAME = f"chrome{get_executable_extension()}"
self.BROWSEROS_APP_NAME = (
f"{self.BROWSEROS_APP_BASE_NAME}{get_executable_extension()}"
)
elif IS_MACOS():
self.CHROMIUM_APP_NAME = "Chromium.app"
self.BROWSEROS_APP_NAME = f"{self.BROWSEROS_APP_BASE_NAME}.app"
else:
self.CHROMIUM_APP_NAME = "chrome"
self.BROWSEROS_APP_NAME = self.BROWSEROS_APP_BASE_NAME.lower()
# Sync with BuildConfig
self.build.CHROMIUM_APP_NAME = self.CHROMIUM_APP_NAME
self.build.BROWSEROS_APP_NAME = self.BROWSEROS_APP_NAME
# Set architecture-specific output directory with platform separator
if IS_WINDOWS():
self.out_dir = f"out\\Default_{self.architecture}"
else:
self.out_dir = f"out/Default_{self.architecture}"
# Sync with PathConfig
self.paths.out_dir = self.out_dir
# Load version information using static methods
if not self.chromium_version:
self.chromium_version, version_dict = self._load_chromium_version(
self.root_dir
)
else:
# If chromium_version was provided, we still need to parse it for version_dict
version_dict = {}
if not self.browseros_build_offset:
self.browseros_build_offset = self._load_browseros_build_offset(
self.root_dir
)
# Load semantic version from resources/BROWSEROS_VERSION
if not self.semantic_version:
self.semantic_version = self._load_semantic_version(self.root_dir)
# Set nxtscape_chromium_version as chromium version with BUILD + nxtscape_version
if self.chromium_version and self.browseros_build_offset and version_dict:
# Calculate new BUILD number by adding nxtscape_version to original BUILD
new_build = int(version_dict["BUILD"]) + int(self.browseros_build_offset)
self.browseros_chromium_version = f"{version_dict['MAJOR']}.{version_dict['MINOR']}.{new_build}.{version_dict['PATCH']}"
# Sync versions with BuildConfig
self.build.chromium_version = self.chromium_version
self.build.browseros_version = self.browseros_build_offset
self.build.browseros_chromium_version = self.browseros_chromium_version
# Sync chromium_src with PathConfig (validation done by resolver)
self.paths.chromium_src = self.chromium_src
self.start_time = time.time()
# === Initialization ===
@classmethod
def init_context(cls, config: Dict) -> "Context":
"""
Initialize context from config
Replaces __post_init__ logic for better testability
Note: root_dir is always computed from package location, never from config.
"""
chromium_src = (
Path(config.get("chromium_src", ""))
if config.get("chromium_src")
else Path()
)
# Get architecture or use platform default
arch = config.get("architecture") or get_platform_arch()
# Create instance - root_dir uses default_factory (get_package_root)
ctx = cls(
chromium_src=chromium_src,
architecture=arch,
build_type=config.get("build_type", "debug"),
)
return ctx
@staticmethod
def _load_chromium_version(root_dir: Path):
"""
Load chromium version from CHROMIUM_VERSION file
Returns: (version_string, version_dict)
"""
version_dict = {}
version_file = join_paths(root_dir, "CHROMIUM_VERSION")
if version_file.exists():
# Parse VERSION file format: MAJOR=137\nMINOR=0\nBUILD=7151\nPATCH=69
for line in version_file.read_text().strip().split("\n"):
key, value = line.split("=")
version_dict[key] = value
# Construct chromium_version as MAJOR.MINOR.BUILD.PATCH
chromium_version = f"{version_dict['MAJOR']}.{version_dict['MINOR']}.{version_dict['BUILD']}.{version_dict['PATCH']}"
return chromium_version, version_dict
return "", version_dict
@staticmethod
def _load_browseros_build_offset(root_dir: Path) -> str:
"""Load browseros build offset from config/BROWSEROS_BUILD_OFFSET"""
version_file = join_paths(root_dir, "build", "config", "BROWSEROS_BUILD_OFFSET")
if version_file.exists():
return version_file.read_text().strip()
return ""
@staticmethod
def _load_semantic_version(root_dir: Path) -> str:
"""Load semantic version from resources/BROWSEROS_VERSION
File format:
BROWSEROS_MAJOR=0
BROWSEROS_MINOR=31
BROWSEROS_BUILD=0
BROWSEROS_PATCH=0
Returns: "0.31.0" (PATCH only included if non-zero)
"""
version_file = join_paths(root_dir, "resources", "BROWSEROS_VERSION")
if not version_file.exists():
return ""
version_dict = {}
for line in version_file.read_text().strip().split("\n"):
line = line.strip()
if not line or "=" not in line:
continue
key, value = line.split("=", 1)
version_dict[key.strip()] = value.strip()
major = version_dict.get("BROWSEROS_MAJOR", "0")
minor = version_dict.get("BROWSEROS_MINOR", "0")
build = version_dict.get("BROWSEROS_BUILD", "0")
patch = version_dict.get("BROWSEROS_PATCH", "0")
# Include patch only if non-zero
if patch != "0":
return f"{major}.{minor}.{build}.{patch}"
elif build != "0":
return f"{major}.{minor}.{build}"
else:
return f"{major}.{minor}.0"
# Path getter methods
def get_config_dir(self) -> Path:
"""Get build config directory"""
return join_paths(self.root_dir, "build", "config")
def get_gn_config_dir(self) -> Path:
"""Get GN config directory"""
return join_paths(self.get_config_dir(), "gn")
def get_gn_flags_file(self) -> Path:
"""Get GN flags file for current build type"""
platform = get_platform()
return join_paths(
self.get_gn_config_dir(), f"flags.{platform}.{self.build_type}.gn"
)
def get_copy_resources_config(self) -> Path:
"""Get copy resources configuration file"""
return join_paths(self.get_config_dir(), "copy_resources.yaml")
def get_download_resources_config(self) -> Path:
"""Get download resources configuration file"""
return join_paths(self.get_config_dir(), "download_resources.yaml")
def get_sparkle_dir(self) -> Path:
"""Get Sparkle directory"""
return join_paths(self.chromium_src, "third_party", "sparkle")
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"
return "https://cdn.browseros.com/extensions/update-manifest.alpha.xml"
def get_entitlements_dir(self) -> Path:
"""Get entitlements directory"""
return join_paths(self.root_dir, "resources", "entitlements")
def get_pkg_dmg_path(self) -> Path:
"""Get pkg-dmg tool path (macOS only)"""
return join_paths(self.chromium_src, "chrome", "installer", "mac", "pkg-dmg")
def get_app_path(self) -> Path:
"""Get built app path
For universal builds, checks if out/Default_universal/BrowserOS.app exists
and returns that instead of the architecture-specific path.
This allows downstream modules (sign, package) to work on the universal
binary after UniversalBuildModule has run.
Note: If _fixed_app_path is set, returns that directly (used by
UniversalBuildModule to prevent auto-detection during arch-specific ops).
"""
# If fixed path is set (for arch-specific operations), use it directly
if self._fixed_app_path:
return self._fixed_app_path
# Check for universal binary first (macOS only)
if IS_MACOS():
universal_app = join_paths(
self.chromium_src, "out/Default_universal", self.BROWSEROS_APP_NAME
)
if universal_app.exists():
return universal_app
# For debug builds, check if the app has a different name
if self.build_type == "debug" and IS_MACOS():
# Check for debug-branded app name
debug_app_name = f"{self.BROWSEROS_APP_BASE_NAME} Dev.app"
debug_app_path = join_paths(self.chromium_src, self.out_dir, debug_app_name)
if debug_app_path.exists():
return debug_app_path
# Return architecture-specific path
return join_paths(self.chromium_src, self.out_dir, self.BROWSEROS_APP_NAME)
def get_chromium_app_path(self) -> Path:
"""Get original Chromium app path"""
return join_paths(self.chromium_src, self.out_dir, self.CHROMIUM_APP_NAME)
def get_gn_args_file(self) -> Path:
"""Get GN args file path"""
return join_paths(self.chromium_src, self.out_dir, "args.gn")
def get_notarization_zip(self) -> Path:
"""Get notarization zip path (macOS only)"""
return join_paths(self.chromium_src, self.out_dir, "notarize.zip")
def get_artifact_name(self, artifact_type: str) -> str:
"""Get standardized artifact filename
Args:
artifact_type: One of "dmg", "appimage", "deb", "installer", "installer_zip"
Returns:
Standardized filename, e.g., "BrowserOS_v0.31.0_arm64.dmg"
"""
if not self.semantic_version:
raise ValueError("semantic_version is not set to generate artifact name")
version = self.semantic_version
base = self.BROWSEROS_APP_BASE_NAME
arch = self.architecture
match artifact_type:
case "dmg":
return f"{base}_v{version}_{arch}.dmg"
case "appimage":
return f"{base}_v{version}_{arch}.AppImage"
case "deb":
deb_arch = {"x64": "amd64", "arm64": "arm64"}.get(arch, arch)
return f"{base}_v{version}_{deb_arch}.deb"
case "installer":
return f"{base}_v{version}_{arch}_installer.exe"
case "installer_zip":
return f"{base}_v{version}_{arch}_installer.zip"
case _:
raise ValueError(f"Unknown artifact type: {artifact_type}")
def get_browseros_chromium_version(self) -> str:
"""Get browseros chromium version string"""
return self.browseros_chromium_version
def get_browseros_version(self) -> str:
"""Get browseros version string (build offset)"""
return self.browseros_build_offset
def get_semantic_version(self) -> str:
"""Get semantic version from resources/BROWSEROS_VERSION
Returns: e.g., "0.31.0"
"""
return self.semantic_version
def get_sparkle_version(self) -> str:
"""Get Sparkle-compatible version from browseros_chromium_version
Sparkle uses BUILD.PATCH format for version comparison.
Returns: e.g., "7231.69"
"""
if not self.browseros_chromium_version:
raise ValueError("browseros_chromium_version is not set")
parts = self.browseros_chromium_version.split(".")
if len(parts) < 4:
raise ValueError(
f"Invalid browseros_chromium_version format: {self.browseros_chromium_version}"
)
return f"{parts[2]}.{parts[3]}"
def get_release_path(self, platform: str) -> str:
"""Get R2 path for release artifacts
Args:
platform: "macos", "win", or "linux"
Returns: e.g., "releases/0.31.0/macos/"
"""
return f"releases/{self.semantic_version}/{platform}/"
def get_app_base_name(self) -> str:
"""Get app base name without extension"""
return self.BROWSEROS_APP_BASE_NAME
def get_dist_dir(self) -> Path:
"""Get distribution output directory with semantic version"""
return join_paths(self.root_dir, "releases", self.semantic_version)
# Dev CLI specific methods
def get_patches_dir(self) -> Path:
"""Get individual patches directory"""
return join_paths(self.root_dir, "chromium_patches")
def get_chromium_replace_files_dir(self) -> Path:
"""Get chromium files replacement directory"""
return join_paths(self.root_dir, "chromium_files")
def get_features_yaml_path(self) -> Path:
"""Get features.yaml file path"""
return join_paths(self.root_dir, "build", "features.yaml")
def get_patch_path_for_file(self, file_path: str) -> Path:
"""Convert a chromium file path to patch file path"""
return join_paths(self.get_patches_dir(), file_path)
def get_series_patches_dir(self) -> Path:
"""Get series patches directory (GNU Quilt format)"""
return join_paths(self.root_dir, "series_patches")