mirror of
https://github.com/browseros-ai/BrowserOS.git
synced 2026-05-20 04:21:23 +00:00
* feat(ota): bundle full server resources tree (server + third_party bins) The OTA Sparkle payload now ships the complete resources/ tree the agent build produced, not just browseros_server. Every third-party binary (bun, ripgrep, podman, gvproxy, vfkit, krunkit, podman-mac-helper, win-sshproxy) flows to OTA-updated installs so podman integration works for users on the OTA channel, matching fresh Chromium-build installs. Extract the per-binary sign table into build/common/server_binaries.py so the Chromium-build sign path (modules/sign/) and OTA sign path (modules/ota/) share a single source of truth. Adding a new third-party dep is now a one-file edit that both paths pick up automatically; unknown executables under resources/bin/ are a hard error at release time. * fix(ota): address review comments on bundle signing flow - Avoid double-zipping during notarization: add notarize_macos_zip for pre-built Sparkle bundles so notarytool submits the zip directly instead of re-wrapping it through ditto --keepParent (Apple's service does not descend into nested archives). Keep notarize_macos_binary for single-binary callers. Share credential setup + submit logic via internal helpers. - Fail fast on unknown executables in sign_server_bundle_macos: collect the unknown-files list before any codesign call so a missing shared- table entry aborts in seconds, not after a full signing round. - Drop dead get_entitlements_path helper (no callers remain after the bundle refactor). * fix(ota): address PR review comments (greptile + claude) - sign_server_bundle_macos filters to executables only (p.is_file() + not p.is_symlink() + os.access X_OK) before applying the unknown-file guard. Non-Mach-O files (configs, dylibs, etc.) under resources/bin/ no longer cause misleading 'unknown executable' hard failures. - sign_server_bundle_windows now hard-errors on a missing expected binary instead of silently skipping it. Symmetric with the macOS guard — an incomplete bundle must not publish. - ServerOTAModule.execute() uses tempfile.TemporaryDirectory context managers for both the download and staging roots so they are cleaned up on every path, including failures. - Per-platform sign/notarize/Sparkle-sign failures now raise RuntimeError instead of silently skipping the platform — a release pipeline can no longer omit a target while reporting success. - Move import os and import shutil to the top of ota/sign_binary.py. - Drop unused log_error import from ota/server.py. * chore: bump server
924 lines
31 KiB
Python
Generated
924 lines
31 KiB
Python
Generated
#!/usr/bin/env python3
|
|
"""Application signing and notarization module for BrowserOS (macOS)"""
|
|
|
|
import os
|
|
import sys
|
|
import subprocess
|
|
import shutil
|
|
from pathlib import Path
|
|
from typing import Optional, List, Dict, Tuple
|
|
from ...common.module import CommandModule, ValidationError
|
|
from ...common.context import Context
|
|
from ...common.env import EnvConfig
|
|
from ...common.server_binaries import macos_sign_spec_for
|
|
from ...common.utils import (
|
|
run_command as utils_run_command,
|
|
log_info,
|
|
log_error,
|
|
log_success,
|
|
log_warning,
|
|
IS_MACOS,
|
|
join_paths,
|
|
)
|
|
|
|
|
|
def get_browseros_server_binary_info(component_path: Path) -> Optional[Dict[str, str]]:
|
|
"""Return metadata for known BrowserOS Server binaries, if applicable."""
|
|
spec = macos_sign_spec_for(component_path)
|
|
if spec is None:
|
|
return None
|
|
info: Dict[str, str] = {
|
|
"identifier_suffix": spec.identifier_suffix,
|
|
"options": spec.options,
|
|
}
|
|
if spec.entitlements:
|
|
info["entitlements"] = spec.entitlements
|
|
return info
|
|
|
|
|
|
def run_command(
|
|
cmd: List[str],
|
|
cwd: Optional[Path] = None,
|
|
check: bool = True,
|
|
) -> subprocess.CompletedProcess:
|
|
"""Run a command and handle errors"""
|
|
return utils_run_command(cmd, cwd=cwd, check=check)
|
|
|
|
|
|
def unlock_keychain(env: Optional[EnvConfig] = None) -> None:
|
|
"""Unlock the login keychain for non-interactive sessions (SSH, launchd).
|
|
|
|
Without this, codesign and notarytool fail with errSecInternalComponent
|
|
or 'User interaction is not allowed' when running over SSH.
|
|
"""
|
|
keychain_path = Path.home() / "Library" / "Keychains" / "login.keychain-db"
|
|
password = env.macos_keychain_password if env else os.environ.get("MACOS_KEYCHAIN_PASSWORD")
|
|
|
|
if not password:
|
|
log_warning("MACOS_KEYCHAIN_PASSWORD not set — keychain may be locked (will fail over SSH)")
|
|
return
|
|
|
|
if not keychain_path.exists():
|
|
log_warning(f"Keychain not found at {keychain_path}")
|
|
return
|
|
|
|
log_info("🔓 Unlocking login keychain...")
|
|
run_command(
|
|
["security", "unlock-keychain", "-p", password, str(keychain_path)],
|
|
check=False,
|
|
)
|
|
# Prevent auto-lock during long signing + notarization runs
|
|
run_command(
|
|
["security", "set-keychain-settings", "-t", "3600", str(keychain_path)],
|
|
check=False,
|
|
)
|
|
|
|
|
|
class MacOSSignModule(CommandModule):
|
|
produces = ["signed_app"]
|
|
requires = ["built_app"]
|
|
description = "Sign and notarize macOS application"
|
|
|
|
def validate(self, ctx: Context) -> None:
|
|
if not IS_MACOS:
|
|
raise ValidationError("macOS signing requires macOS")
|
|
|
|
app_path = ctx.get_app_path()
|
|
if not app_path.exists():
|
|
raise ValidationError(f"App not found at: {app_path}")
|
|
|
|
env_ok, env_vars = check_environment(ctx.env)
|
|
if not env_ok:
|
|
raise ValidationError("Required signing environment variables not set")
|
|
|
|
def execute(self, ctx: Context) -> None:
|
|
log_info("=" * 70)
|
|
log_info("🚀 Starting signing process for BrowserOS...")
|
|
log_info("=" * 70)
|
|
|
|
unlock_keychain(ctx.env)
|
|
|
|
app_path = ctx.get_app_path()
|
|
env_ok, env_vars = check_environment(ctx.env)
|
|
|
|
self._clear_extended_attributes(app_path)
|
|
self._sign_all_components(app_path, env_vars["certificate_name"], ctx)
|
|
self._verify_signature(app_path)
|
|
self._notarize(app_path, env_vars, ctx)
|
|
|
|
ctx.artifact_registry.add("signed_app", app_path)
|
|
log_success("Application signed and notarized successfully")
|
|
|
|
def _clear_extended_attributes(self, app_path: Path) -> None:
|
|
log_info("🧹 Clearing extended attributes...")
|
|
run_command(["xattr", "-cs", str(app_path)])
|
|
|
|
def _sign_all_components(self, app_path: Path, certificate_name: str, ctx: Context) -> None:
|
|
if not sign_all_components(app_path, certificate_name, ctx.root_dir, ctx):
|
|
raise RuntimeError("Failed to sign all components")
|
|
|
|
def _verify_signature(self, app_path: Path) -> None:
|
|
if not verify_signature(app_path):
|
|
raise RuntimeError("Signature verification failed")
|
|
|
|
def _notarize(self, app_path: Path, env_vars: Dict[str, str], ctx: Context) -> None:
|
|
if not notarize_app(app_path, ctx.root_dir, env_vars, ctx):
|
|
raise RuntimeError("Notarization failed")
|
|
def check_signing_environment(env: Optional[EnvConfig] = None) -> bool:
|
|
"""Check if all required environment variables are set for signing (early check)
|
|
|
|
Args:
|
|
env: Optional EnvConfig instance. If not provided, creates a new one.
|
|
"""
|
|
# Only check on macOS
|
|
if not IS_MACOS:
|
|
return True
|
|
|
|
if env is None:
|
|
env = EnvConfig()
|
|
|
|
missing = []
|
|
|
|
if not env.macos_certificate_name:
|
|
missing.append("MACOS_CERTIFICATE_NAME")
|
|
if not env.macos_notarization_apple_id:
|
|
missing.append("PROD_MACOS_NOTARIZATION_APPLE_ID")
|
|
if not env.macos_notarization_team_id:
|
|
missing.append("PROD_MACOS_NOTARIZATION_TEAM_ID")
|
|
if not env.macos_notarization_password:
|
|
missing.append("PROD_MACOS_NOTARIZATION_PWD")
|
|
|
|
if missing:
|
|
log_error("❌ Signing requires macOS environment variables!")
|
|
log_error(f"Missing environment variables: {', '.join(missing)}")
|
|
log_error("Please set all required environment variables before signing.")
|
|
return False
|
|
|
|
return True
|
|
|
|
|
|
def check_environment(env: Optional[EnvConfig] = None) -> Tuple[bool, Dict[str, str]]:
|
|
"""Check if all required environment variables are set
|
|
|
|
Args:
|
|
env: Optional EnvConfig instance. If not provided, creates a new one.
|
|
"""
|
|
if env is None:
|
|
env = EnvConfig()
|
|
|
|
env_vars = {
|
|
"certificate_name": env.macos_certificate_name or "",
|
|
"apple_id": env.macos_notarization_apple_id or "",
|
|
"team_id": env.macos_notarization_team_id or "",
|
|
"notarization_pwd": env.macos_notarization_password or "",
|
|
}
|
|
|
|
missing = []
|
|
for key, value in env_vars.items():
|
|
if not value:
|
|
env_name = {
|
|
"certificate_name": "MACOS_CERTIFICATE_NAME",
|
|
"apple_id": "PROD_MACOS_NOTARIZATION_APPLE_ID",
|
|
"team_id": "PROD_MACOS_NOTARIZATION_TEAM_ID",
|
|
"notarization_pwd": "PROD_MACOS_NOTARIZATION_PWD",
|
|
}[key]
|
|
missing.append(env_name)
|
|
|
|
if missing:
|
|
log_error(f"Required environment variables not set: {', '.join(missing)}")
|
|
return False, env_vars
|
|
|
|
return True, env_vars
|
|
|
|
|
|
def find_components_to_sign(
|
|
app_path: Path, ctx: Optional[Context] = None
|
|
) -> Dict[str, List[Path]]:
|
|
"""Dynamically find all components that need signing"""
|
|
components = {
|
|
"helpers": [],
|
|
"xpc_services": [],
|
|
"frameworks": [],
|
|
"dylibs": [],
|
|
"executables": [],
|
|
"apps": [],
|
|
}
|
|
|
|
framework_path = join_paths(app_path, "Contents", "Frameworks")
|
|
|
|
# Check both versioned and non-versioned paths for BrowserOS Framework
|
|
# Handle both release and debug framework names
|
|
framework_names = [
|
|
"BrowserOS Framework.framework",
|
|
"BrowserOS Dev Framework.framework",
|
|
]
|
|
nxtscape_framework_paths = []
|
|
|
|
for fw_name in framework_names:
|
|
fw_path = join_paths(framework_path, fw_name)
|
|
if fw_path.exists():
|
|
nxtscape_framework_paths.append(fw_path)
|
|
|
|
# Add versioned path if context is available
|
|
if ctx and ctx.browseros_chromium_version:
|
|
versioned_path = join_paths(
|
|
fw_path, "Versions", ctx.browseros_chromium_version
|
|
)
|
|
if versioned_path.exists():
|
|
nxtscape_framework_paths.insert(
|
|
0, versioned_path
|
|
) # Prioritize versioned path
|
|
|
|
# Find all helper apps
|
|
for nxtscape_fw_path in nxtscape_framework_paths:
|
|
helpers_dir = join_paths(nxtscape_fw_path, "Helpers")
|
|
if helpers_dir.exists():
|
|
# Find all .app helpers
|
|
components["helpers"].extend(helpers_dir.glob("*.app"))
|
|
# Find all executable helpers (files without extension)
|
|
for item in helpers_dir.iterdir():
|
|
if item.is_file() and not item.suffix and os.access(item, os.X_OK):
|
|
components["executables"].append(item)
|
|
break # Use the first valid path found
|
|
|
|
# Find all XPC services
|
|
for xpc_path in framework_path.rglob("*.xpc"):
|
|
components["xpc_services"].append(xpc_path)
|
|
|
|
# Find all frameworks (with special handling for Sparkle)
|
|
for fw_path in framework_path.rglob("*.framework"):
|
|
components["frameworks"].append(fw_path)
|
|
|
|
# Special handling for Sparkle framework versioned structure
|
|
if "Sparkle.framework" in str(fw_path):
|
|
# Look for Sparkle's versioned executables at Versions/B/
|
|
sparkle_version_b = join_paths(fw_path, "Versions", "B")
|
|
if sparkle_version_b.exists():
|
|
# Add Autoupdate executable if it exists
|
|
autoupdate = join_paths(sparkle_version_b, "Autoupdate")
|
|
if autoupdate.exists() and autoupdate.is_file():
|
|
components["executables"].append(autoupdate)
|
|
|
|
# Find all dylibs (check versioned path for BrowserOS Framework libraries)
|
|
for nxtscape_fw_path in nxtscape_framework_paths:
|
|
libraries_dir = join_paths(nxtscape_fw_path, "Libraries")
|
|
if libraries_dir.exists():
|
|
components["dylibs"].extend(libraries_dir.glob("*.dylib"))
|
|
|
|
# Also find dylibs in other frameworks
|
|
for dylib_path in framework_path.rglob("*.dylib"):
|
|
if dylib_path not in components["dylibs"]:
|
|
components["dylibs"].append(dylib_path)
|
|
|
|
# Find all nested apps (like Updater.app in Sparkle)
|
|
for nested_app in framework_path.rglob("*.app"):
|
|
if nested_app not in components["helpers"]:
|
|
components["apps"].append(nested_app)
|
|
|
|
# Find BrowserOS Server binaries
|
|
browseros_server_dir = join_paths(app_path, "Contents", "Resources", "BrowserOSServer")
|
|
if browseros_server_dir.exists():
|
|
for item in browseros_server_dir.rglob("*"):
|
|
if item.is_file() and not item.suffix and os.access(item, os.X_OK):
|
|
components["executables"].append(item)
|
|
|
|
return components
|
|
|
|
|
|
def get_identifier_for_component(
|
|
component_path: Path, base_identifier: str = "com.browseros"
|
|
) -> str:
|
|
"""Generate identifier for a component based on its path and name"""
|
|
name = component_path.stem
|
|
|
|
# Special cases for known components
|
|
special_identifiers = {
|
|
"Downloader": "org.sparkle-project.Downloader",
|
|
"Installer": "org.sparkle-project.Installer",
|
|
"Updater": "org.sparkle-project.Updater",
|
|
"Autoupdate": "org.sparkle-project.Autoupdate",
|
|
"Sparkle": "org.sparkle-project.Sparkle",
|
|
"chrome_crashpad_handler": f"{base_identifier}.crashpad_handler",
|
|
"app_mode_loader": f"{base_identifier}.app_mode_loader",
|
|
"web_app_shortcut_copier": f"{base_identifier}.web_app_shortcut_copier",
|
|
}
|
|
|
|
# Check for special cases
|
|
for key, identifier in special_identifiers.items():
|
|
if key in str(component_path):
|
|
return identifier
|
|
|
|
# BrowserOS Server binaries share the same entitlements/options but need unique identifiers.
|
|
browseros_server_info = get_browseros_server_binary_info(component_path)
|
|
if browseros_server_info:
|
|
suffix = browseros_server_info.get("identifier_suffix", component_path.stem)
|
|
return f"{base_identifier}.{suffix}"
|
|
|
|
# For helper apps
|
|
if "Helper" in name:
|
|
# Extract the helper type (GPU, Renderer, Plugin, Alerts)
|
|
if "(" in name and ")" in name:
|
|
helper_type = name[name.find("(") + 1 : name.find(")")].lower()
|
|
return f"{base_identifier}.helper.{helper_type}"
|
|
else:
|
|
return f"{base_identifier}.helper"
|
|
|
|
# For frameworks
|
|
if component_path.suffix == ".framework":
|
|
if name == "BrowserOS Framework" or name == "BrowserOS Dev Framework":
|
|
return f"{base_identifier}.framework"
|
|
else:
|
|
return f"{base_identifier}.{name.replace(' ', '_').lower()}"
|
|
|
|
# For dylibs
|
|
if component_path.suffix == ".dylib":
|
|
return f"{base_identifier}.{name}"
|
|
|
|
# Default
|
|
return f"{base_identifier}.{name.replace(' ', '_').lower()}"
|
|
|
|
|
|
def get_signing_options(component_path: Path) -> str:
|
|
"""Determine signing options based on component type"""
|
|
name = component_path.name
|
|
|
|
# For Sparkle XPC services and apps - minimal restrictions
|
|
if "sparkle" in str(component_path).lower():
|
|
return "runtime"
|
|
|
|
# For Chromium helper apps with specific sandboxing requirements
|
|
if (
|
|
"Helper (Renderer)" in name
|
|
or "Helper (GPU)" in name
|
|
or "Helper (Plugin)" in name
|
|
):
|
|
return "restrict,kill,runtime"
|
|
|
|
# Known BrowserOS Server binaries share the same relaxed options.
|
|
browseros_server_info = get_browseros_server_binary_info(component_path)
|
|
if browseros_server_info:
|
|
return browseros_server_info.get("options", "runtime")
|
|
|
|
# For dylibs - library flag ONLY for dynamic libraries
|
|
if component_path.suffix == ".dylib":
|
|
return "restrict,library,runtime,kill"
|
|
|
|
# Default for other executables - no library flag
|
|
return "runtime"
|
|
|
|
|
|
def sign_component(
|
|
component_path: Path,
|
|
certificate_name: str,
|
|
identifier: Optional[str] = None,
|
|
options: Optional[str] = None,
|
|
entitlements: Optional[Path] = None,
|
|
) -> bool:
|
|
"""Sign a single component"""
|
|
cmd = ["codesign", "--sign", certificate_name, "--force", "--timestamp"]
|
|
|
|
if identifier:
|
|
cmd.extend(["--identifier", identifier])
|
|
|
|
if options:
|
|
cmd.extend(["--options", options])
|
|
|
|
if entitlements and entitlements.exists():
|
|
cmd.extend(["--entitlements", str(entitlements)])
|
|
|
|
cmd.append(str(component_path))
|
|
|
|
try:
|
|
run_command(cmd)
|
|
return True
|
|
except Exception as e:
|
|
log_error(f"Failed to sign {component_path}: {e}")
|
|
return False
|
|
|
|
|
|
def sign_all_components(
|
|
app_path: Path,
|
|
certificate_name: str,
|
|
root_dir: Path,
|
|
ctx: Optional[Context] = None,
|
|
) -> bool:
|
|
"""Sign all components in the correct order (bottom-up)"""
|
|
log_info("🔍 Discovering components to sign...")
|
|
components = find_components_to_sign(app_path, ctx)
|
|
|
|
# Print summary
|
|
total_components = sum(len(items) for items in components.values())
|
|
log_info(f"Found {total_components} components to sign:")
|
|
for category, items in components.items():
|
|
if items:
|
|
log_info(f" • {category}: {len(items)} items")
|
|
|
|
# Sign in correct order (bottom-up)
|
|
# 1. Sign XPC Services first
|
|
log_info("\n🔏 Signing XPC Services...")
|
|
for xpc in components["xpc_services"]:
|
|
identifier = get_identifier_for_component(xpc)
|
|
options = get_signing_options(xpc)
|
|
if not sign_component(xpc, certificate_name, identifier, options):
|
|
return False
|
|
|
|
# 2. Sign nested apps (like Sparkle's Updater.app)
|
|
if components["apps"]:
|
|
log_info("\n🔏 Signing nested applications...")
|
|
for nested_app in components["apps"]:
|
|
identifier = get_identifier_for_component(nested_app)
|
|
options = get_signing_options(nested_app)
|
|
if not sign_component(nested_app, certificate_name, identifier, options):
|
|
return False
|
|
|
|
# 3. Sign executables
|
|
if components["executables"]:
|
|
log_info("\n🔏 Signing executables...")
|
|
# Get entitlements directory from context
|
|
entitlements_dirs = []
|
|
if ctx:
|
|
entitlements_dirs.append(ctx.get_entitlements_dir())
|
|
|
|
for exe in components["executables"]:
|
|
identifier = get_identifier_for_component(exe)
|
|
options = get_signing_options(exe)
|
|
|
|
# Check for specific entitlements
|
|
entitlements = None
|
|
browseros_server_info = get_browseros_server_binary_info(exe)
|
|
if browseros_server_info:
|
|
entitlements_name = browseros_server_info.get("entitlements")
|
|
if entitlements_name:
|
|
for ent_dir in entitlements_dirs:
|
|
ent_path = join_paths(ent_dir, entitlements_name)
|
|
if ent_path.exists():
|
|
entitlements = ent_path
|
|
break
|
|
|
|
if not sign_component(exe, certificate_name, identifier, options, entitlements):
|
|
return False
|
|
|
|
# 4. Sign dylibs
|
|
if components["dylibs"]:
|
|
log_info("\n🔏 Signing dynamic libraries...")
|
|
for dylib in components["dylibs"]:
|
|
identifier = get_identifier_for_component(dylib)
|
|
if not sign_component(dylib, certificate_name, identifier):
|
|
return False
|
|
|
|
# 5. Sign helper apps
|
|
if components["helpers"]:
|
|
log_info("\n🔏 Signing helper applications...")
|
|
# Get entitlements directory from context
|
|
entitlements_dirs = []
|
|
if ctx:
|
|
entitlements_dirs.append(ctx.get_entitlements_dir())
|
|
|
|
for helper in components["helpers"]:
|
|
identifier = get_identifier_for_component(helper)
|
|
options = get_signing_options(helper)
|
|
|
|
# Check for specific entitlements
|
|
entitlements = None
|
|
entitlements_name = None
|
|
|
|
if "Renderer" in helper.name:
|
|
entitlements_name = "helper-renderer-entitlements.plist"
|
|
elif "GPU" in helper.name:
|
|
entitlements_name = "helper-gpu-entitlements.plist"
|
|
elif "Plugin" in helper.name:
|
|
entitlements_name = "helper-plugin-entitlements.plist"
|
|
|
|
if entitlements_name:
|
|
for ent_dir in entitlements_dirs:
|
|
ent_path = join_paths(ent_dir, entitlements_name)
|
|
if ent_path.exists():
|
|
entitlements = ent_path
|
|
break
|
|
|
|
if not sign_component(
|
|
helper, certificate_name, identifier, options, entitlements
|
|
):
|
|
return False
|
|
|
|
# 6. Sign frameworks (except the main BrowserOS Framework)
|
|
if components["frameworks"]:
|
|
log_info("\n🔏 Signing frameworks...")
|
|
# Sort to sign Sparkle.framework before BrowserOS Framework.framework
|
|
frameworks_sorted = sorted(
|
|
components["frameworks"], key=lambda x: 0 if "Sparkle" in x.name else 1
|
|
)
|
|
for framework in frameworks_sorted:
|
|
identifier = get_identifier_for_component(framework)
|
|
if not sign_component(framework, certificate_name, identifier):
|
|
return False
|
|
|
|
# 7. Sign main executable
|
|
log_info("\n🔏 Signing main executable...")
|
|
# Handle both release and debug executable names
|
|
main_exe_names = ["BrowserOS", "BrowserOS Dev"]
|
|
main_exe = None
|
|
for exe_name in main_exe_names:
|
|
exe_path = join_paths(app_path, "Contents", "MacOS", exe_name)
|
|
if exe_path.exists():
|
|
main_exe = exe_path
|
|
break
|
|
|
|
if not main_exe:
|
|
log_error(
|
|
f"Main executable not found in {join_paths(app_path, 'Contents', 'MacOS')}"
|
|
)
|
|
return False
|
|
|
|
if not sign_component(main_exe, certificate_name, "com.browseros.BrowserOS"):
|
|
return False
|
|
|
|
# 8. Finally sign the app bundle
|
|
log_info("\n🔏 Signing application bundle...")
|
|
requirements = (
|
|
'=designated => identifier "com.browseros.BrowserOS" and '
|
|
"anchor apple generic and certificate 1[field.1.2.840.113635.100.6.2.6] /* exists */ and "
|
|
"certificate leaf[field.1.2.840.113635.100.6.1.13] /* exists */"
|
|
)
|
|
|
|
# Try multiple locations for app entitlements
|
|
entitlements = None
|
|
entitlements_names = ["app-entitlements.plist", "app-entitlements-chrome.plist"]
|
|
entitlements_dirs = []
|
|
if ctx:
|
|
entitlements_dirs.append(ctx.get_entitlements_dir())
|
|
else:
|
|
entitlements_dirs.append(join_paths(root_dir, "resources", "entitlements"))
|
|
# Add fallback locations
|
|
entitlements_dirs.extend(
|
|
[
|
|
join_paths(root_dir, "entitlements"), # Legacy location
|
|
join_paths(root_dir, "build", "src", "chrome", "app"),
|
|
join_paths(
|
|
app_path.parent.parent.parent, "chrome", "app"
|
|
), # Chromium source
|
|
]
|
|
)
|
|
|
|
for ent_name in entitlements_names:
|
|
for ent_dir in entitlements_dirs:
|
|
ent_path = join_paths(ent_dir, ent_name)
|
|
if ent_path.exists():
|
|
entitlements = ent_path
|
|
log_info(f" Using entitlements: {entitlements}")
|
|
break
|
|
if entitlements:
|
|
break
|
|
|
|
cmd = [
|
|
"codesign",
|
|
"--sign",
|
|
certificate_name,
|
|
"--force",
|
|
"--timestamp",
|
|
"--identifier",
|
|
"com.browseros.BrowserOS",
|
|
"--options",
|
|
"restrict,library,runtime,kill",
|
|
"--requirements",
|
|
requirements,
|
|
]
|
|
|
|
if entitlements:
|
|
cmd.extend(["--entitlements", str(entitlements)])
|
|
else:
|
|
log_warning("No app entitlements file found, signing without entitlements")
|
|
|
|
cmd.append(str(app_path))
|
|
|
|
try:
|
|
run_command(cmd)
|
|
except Exception:
|
|
return False
|
|
|
|
return True
|
|
|
|
|
|
def verify_signature(app_path: Path) -> bool:
|
|
"""Verify application signature"""
|
|
log_info("\n🔍 Verifying application signature integrity...")
|
|
|
|
result = run_command(
|
|
["codesign", "--verify", "--deep", "--strict", "--verbose=2", str(app_path)],
|
|
check=False,
|
|
)
|
|
|
|
if result.returncode != 0:
|
|
log_error("Signature verification failed!")
|
|
return False
|
|
|
|
log_success("Signature verification passed")
|
|
return True
|
|
|
|
|
|
def notarize_app(
|
|
app_path: Path,
|
|
root_dir: Path,
|
|
env_vars: Dict[str, str],
|
|
ctx: Optional[Context] = None,
|
|
) -> bool:
|
|
"""Notarize the application"""
|
|
log_info("\n📤 Preparing for notarization...")
|
|
|
|
# Create zip for notarization
|
|
notarize_zip = (
|
|
ctx.get_notarization_zip() if ctx else join_paths(root_dir, "notarize.zip")
|
|
)
|
|
if notarize_zip.exists():
|
|
notarize_zip.unlink()
|
|
|
|
run_command(["ditto", "-c", "-k", "--keepParent", str(app_path), str(notarize_zip)])
|
|
log_success("Archive created for notarization")
|
|
|
|
# Store credentials
|
|
log_info("🔑 Storing notarization credentials...")
|
|
store_result = run_command(
|
|
[
|
|
"xcrun",
|
|
"notarytool",
|
|
"store-credentials",
|
|
"notarytool-profile",
|
|
"--apple-id",
|
|
env_vars["apple_id"],
|
|
"--team-id",
|
|
env_vars["team_id"],
|
|
"--password",
|
|
env_vars["notarization_pwd"],
|
|
],
|
|
check=False,
|
|
)
|
|
|
|
# Submit for notarization — if store-credentials failed, pass creds
|
|
# directly to avoid depending on the keychain profile.
|
|
log_info("📤 Submitting application for notarization (this may take a while)...")
|
|
use_keychain_profile = store_result.returncode == 0
|
|
if use_keychain_profile:
|
|
submit_cmd = [
|
|
"xcrun",
|
|
"notarytool",
|
|
"submit",
|
|
str(notarize_zip),
|
|
"--keychain-profile",
|
|
"notarytool-profile",
|
|
"--wait",
|
|
]
|
|
else:
|
|
log_warning("Keychain profile unavailable — passing credentials directly")
|
|
submit_cmd = [
|
|
"xcrun",
|
|
"notarytool",
|
|
"submit",
|
|
str(notarize_zip),
|
|
"--apple-id",
|
|
env_vars["apple_id"],
|
|
"--team-id",
|
|
env_vars["team_id"],
|
|
"--password",
|
|
env_vars["notarization_pwd"],
|
|
"--wait",
|
|
]
|
|
result = run_command(submit_cmd, check=False)
|
|
|
|
log_info(result.stdout)
|
|
if result.stderr:
|
|
log_error(result.stderr)
|
|
|
|
if result.returncode != 0:
|
|
log_error("Notarization submission failed")
|
|
return False
|
|
|
|
# Check if accepted
|
|
if "status: Accepted" not in result.stdout:
|
|
log_error("App notarization failed - status was not 'Accepted'")
|
|
# Try to extract submission ID for debugging
|
|
for line in result.stdout.split("\n"):
|
|
if "id:" in line:
|
|
submission_id = line.split("id:")[1].strip().split()[0]
|
|
log_info(
|
|
f'Get detailed logs with: xcrun notarytool log {submission_id} --keychain-profile "notarytool-profile"'
|
|
)
|
|
break
|
|
return False
|
|
|
|
log_success("App notarization successful - status: Accepted")
|
|
|
|
# Staple the ticket
|
|
log_info("📎 Stapling notarization ticket to application...")
|
|
result = run_command(["xcrun", "stapler", "staple", str(app_path)], check=False)
|
|
|
|
if result.returncode != 0:
|
|
log_error("Failed to staple notarization ticket!")
|
|
return False
|
|
|
|
log_success("Notarization ticket stapled successfully")
|
|
|
|
# Clean up
|
|
notarize_zip.unlink()
|
|
|
|
# Verify notarization
|
|
log_info("\n🔍 Verifying notarization status...")
|
|
|
|
# Check Gatekeeper
|
|
result = run_command(["spctl", "-a", "-vvv", str(app_path)], check=False)
|
|
|
|
if result.returncode != 0:
|
|
log_error("Gatekeeper check failed!")
|
|
return False
|
|
|
|
# Validate stapling
|
|
result = run_command(["xcrun", "stapler", "validate", str(app_path)], check=False)
|
|
|
|
if result.returncode != 0:
|
|
log_error("Stapler validation failed!")
|
|
return False
|
|
|
|
log_success("Notarization and stapling verification passed")
|
|
return True
|
|
|
|
|
|
def sign_app(ctx: Context, create_dmg: bool = True) -> bool:
|
|
"""Main signing function that uses BuildContext from build.py"""
|
|
log_info("=" * 70)
|
|
log_info("🚀 Starting signing process for BrowserOS...")
|
|
log_info("=" * 70)
|
|
|
|
unlock_keychain(ctx.env if ctx else None)
|
|
|
|
# Error tracking similar to bash script
|
|
error_count = 0
|
|
error_messages = []
|
|
|
|
def track_error(msg: str):
|
|
nonlocal error_count
|
|
error_count += 1
|
|
error_messages.append(f"ERROR {error_count}: {msg}")
|
|
log_error(msg)
|
|
|
|
# Check environment
|
|
env_ok, env_vars = check_environment()
|
|
if not env_ok:
|
|
return False
|
|
|
|
# Setup app path
|
|
app_path = ctx.get_app_path()
|
|
|
|
# Setup DMG path if needed
|
|
dmg_path = None
|
|
if create_dmg:
|
|
dmg_dir = ctx.get_dist_dir()
|
|
dmg_name = ctx.get_artifact_name("dmg")
|
|
dmg_path = join_paths(dmg_dir, dmg_name)
|
|
|
|
# Verify app exists
|
|
if not app_path.exists():
|
|
log_error(f"App not found at: {app_path}")
|
|
return False
|
|
|
|
try:
|
|
# Clear extended attributes
|
|
log_info("🧹 Clearing extended attributes...")
|
|
run_command(["xattr", "-cs", str(app_path)])
|
|
|
|
# Sign all components
|
|
if not sign_all_components(
|
|
app_path, env_vars["certificate_name"], ctx.root_dir, ctx
|
|
):
|
|
return False
|
|
|
|
# Verify signature
|
|
if not verify_signature(app_path):
|
|
return False
|
|
|
|
# Notarize app
|
|
if not notarize_app(app_path, ctx.root_dir, env_vars, ctx):
|
|
return False
|
|
|
|
# Create and notarize DMG if requested
|
|
if create_dmg:
|
|
print("\n" + "=" * 70)
|
|
log_info("📦 Creating and notarizing DMG package")
|
|
log_info("=" * 70)
|
|
|
|
from ..package.macos import create_signed_notarized_dmg
|
|
|
|
# Find pkg-dmg tool
|
|
pkg_dmg_path = ctx.get_pkg_dmg_path()
|
|
|
|
# Create, sign, and notarize DMG
|
|
if dmg_path and not create_signed_notarized_dmg(
|
|
app_path=app_path,
|
|
dmg_path=dmg_path,
|
|
certificate_name=env_vars["certificate_name"],
|
|
volume_name="BrowserOS",
|
|
pkg_dmg_path=pkg_dmg_path,
|
|
keychain_profile="notarytool-profile",
|
|
):
|
|
log_error("DMG creation/notarization failed")
|
|
return False
|
|
|
|
except Exception as e:
|
|
track_error(f"Unexpected error: {e}")
|
|
import traceback
|
|
|
|
traceback.print_exc()
|
|
error_count += 1 # For the exception itself
|
|
|
|
# Summary report (similar to bash script)
|
|
log_info("=" * 70)
|
|
if error_count > 0:
|
|
log_error(f"Process completed with {error_count} errors:")
|
|
for msg in error_messages:
|
|
log_error(f" {msg}")
|
|
log_error("Review the errors above and address them before distribution.")
|
|
if create_dmg:
|
|
log_warning(f"Final DMG created at: {dmg_path} (may have issues)")
|
|
return False
|
|
else:
|
|
log_success("Process completed successfully!")
|
|
if create_dmg:
|
|
log_info(f"Final DMG created at: {dmg_path}")
|
|
log_info("The application is properly signed, notarized, and packaged.")
|
|
log_info("=" * 70)
|
|
return error_count == 0
|
|
|
|
|
|
def sign_universal(contexts: List[Context]) -> bool:
|
|
"""Create universal binary and sign it"""
|
|
log_info("=" * 70)
|
|
log_info("🔄 Creating and signing universal binary...")
|
|
log_info("=" * 70)
|
|
|
|
if len(contexts) < 2:
|
|
log_error("Universal build requires at least 2 architectures")
|
|
return False
|
|
|
|
# Verify all app builds exist
|
|
app_paths = []
|
|
for ctx in contexts:
|
|
app_path = ctx.get_app_path()
|
|
if not app_path.exists():
|
|
log_error(f"App not found for {ctx.architecture}: {app_path}")
|
|
return False
|
|
app_paths.append(app_path)
|
|
log_info(f"✓ Found {ctx.architecture} build: {app_path}")
|
|
|
|
# Create universal output directory
|
|
universal_dir = join_paths(contexts[0].chromium_src, "out", "Default_universal")
|
|
universal_app_path = join_paths(universal_dir, contexts[0].BROWSEROS_APP_NAME)
|
|
|
|
if universal_dir.exists():
|
|
log_info("Removing existing universal directory...")
|
|
shutil.rmtree(universal_dir)
|
|
|
|
universal_dir.mkdir(parents=True, exist_ok=True)
|
|
|
|
# Use universalizer script to merge architectures
|
|
universalizer_script = join_paths(
|
|
contexts[0].root_dir, "build", "modules", "package", "universalizer_patched.py"
|
|
)
|
|
|
|
if not universalizer_script.exists():
|
|
log_error(f"Universalizer script not found: {universalizer_script}")
|
|
return False
|
|
|
|
try:
|
|
cmd = [
|
|
sys.executable,
|
|
str(universalizer_script),
|
|
*[str(app_path) for app_path in app_paths],
|
|
str(universal_app_path),
|
|
]
|
|
|
|
log_info("Running universalizer...")
|
|
log_info(f"Command: {' '.join(cmd)}")
|
|
run_command(cmd)
|
|
|
|
log_success(f"Universal binary created: {universal_app_path}")
|
|
|
|
# Create a temporary context for universal signing
|
|
universal_ctx = Context(
|
|
root_dir=contexts[0].root_dir,
|
|
chromium_src=contexts[0].chromium_src,
|
|
architecture="universal",
|
|
build_type=contexts[0].build_type,
|
|
)
|
|
# Override out_dir for universal
|
|
universal_ctx.out_dir = "out/Default_universal"
|
|
|
|
# Sign the universal binary
|
|
if not sign_app(universal_ctx, create_dmg=False):
|
|
log_error("Failed to sign universal binary")
|
|
return False
|
|
|
|
log_success("Universal binary signed successfully!")
|
|
return True
|
|
|
|
except Exception as e:
|
|
log_error(f"Failed to create universal binary: {e}")
|
|
return False
|