diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
new file mode 100644
index 0000000..d662ce5
--- /dev/null
+++ b/.github/workflows/ci.yml
@@ -0,0 +1,72 @@
+name: CI
+
+on:
+ push:
+ branches: [main, master]
+ pull_request:
+ branches: [main, master]
+ workflow_dispatch:
+
+jobs:
+ test:
+ name: Unit Tests (${{ matrix.os }})
+ runs-on: ${{ matrix.os }}
+ strategy:
+ fail-fast: false
+ matrix:
+ os: [ubuntu-latest, macos-latest, windows-latest]
+ python-version: ['3.12']
+
+ steps:
+ - name: Checkout repository
+ uses: actions/checkout@v6
+
+ - name: Set up Python
+ uses: actions/setup-python@v6
+ with:
+ python-version: ${{ matrix.python-version }}
+
+ - name: Install package
+ run: python -m pip install --upgrade pip && python -m pip install .
+
+ - name: Run tests
+ run: python -m unittest discover -v
+
+ - name: Build distributions
+ run: python -m pip install build && python -m build
+
+ - name: Verify built wheel in clean environment
+ shell: python
+ run: |
+ import glob
+ import os
+ import pathlib
+ import subprocess
+ import venv
+
+ env_dir = pathlib.Path(".pkg-test")
+ venv.EnvBuilder(with_pip=True, clear=True).create(env_dir)
+
+ if os.name == "nt":
+ env_python = env_dir / "Scripts" / "python.exe"
+ env_cli = env_dir / "Scripts" / "brave-updater.exe"
+ else:
+ env_python = env_dir / "bin" / "python"
+ env_cli = env_dir / "bin" / "brave-updater"
+
+ wheel = sorted(glob.glob("dist/*.whl"))
+ if not wheel:
+ raise SystemExit("No wheel built in dist/")
+
+ subprocess.check_call([str(env_python), "-m", "pip", "install", wheel[-1]])
+ subprocess.check_call(
+ [
+ str(env_python),
+ "-c",
+ (
+ "import importlib.metadata, brave_updater; "
+ "assert importlib.metadata.version('brave-updater') == brave_updater.__version__"
+ ),
+ ]
+ )
+ subprocess.check_call([str(env_cli), "--help"])
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
new file mode 100644
index 0000000..b9e41cf
--- /dev/null
+++ b/.github/workflows/release.yml
@@ -0,0 +1,51 @@
+name: Release
+
+on:
+ push:
+ tags:
+ - "v*"
+
+jobs:
+ build:
+ runs-on: ubuntu-latest
+ permissions:
+ contents: write
+
+ steps:
+ - name: Checkout repository
+ uses: actions/checkout@v6
+
+ - name: Set up Python
+ uses: actions/setup-python@v6
+ with:
+ python-version: "3.12"
+
+ - name: Validate tag matches package version
+ shell: python
+ run: |
+ import os
+ import pathlib
+ import sys
+
+ sys.path.insert(0, str(pathlib.Path("src").resolve()))
+ from brave_updater import __version__
+
+ tag = os.environ["GITHUB_REF_NAME"]
+ expected = tag[1:] if tag.startswith("v") else tag
+ if __version__ != expected:
+ raise SystemExit(
+ "Release tag {} does not match brave_updater.__version__ {}".format(tag, __version__)
+ )
+ print("Validated release tag {} against package version {}".format(tag, __version__))
+
+ - name: Build distributions
+ run: |
+ python -m pip install --upgrade pip
+ python -m pip install build twine
+ python -m build
+ python -m twine check dist/*
+
+ - name: Upload release assets
+ uses: softprops/action-gh-release@v2
+ with:
+ files: dist/*
diff --git a/.github/workflows/smoke.yml b/.github/workflows/smoke.yml
new file mode 100644
index 0000000..ac36df1
--- /dev/null
+++ b/.github/workflows/smoke.yml
@@ -0,0 +1,32 @@
+name: Live Smoke Test
+
+on:
+ workflow_dispatch:
+ schedule:
+ - cron: "0 8 * * 1"
+
+jobs:
+ smoke:
+ runs-on: ubuntu-latest
+
+ steps:
+ - name: Checkout repository
+ uses: actions/checkout@v6
+
+ - name: Set up Python
+ uses: actions/setup-python@v6
+ with:
+ python-version: "3.12"
+
+ - name: Install package
+ run: python -m pip install --upgrade pip && python -m pip install .
+
+ - name: Legacy URL selection
+ shell: bash
+ run: |
+ URL=$(python braver.py --print-only)
+ echo "Selected URL: $URL"
+ [[ "$URL" =~ ^https:// ]] || exit 1
+
+ - name: JSON health check
+ run: brave-updater check --json --source github
diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
deleted file mode 100644
index f8315c0..0000000
--- a/.github/workflows/test.yml
+++ /dev/null
@@ -1,139 +0,0 @@
-name: Test Braver Script
-
-on:
- push:
- branches: [ main, master ]
- pull_request:
- branches: [ main, master ]
- workflow_dispatch:
-
-jobs:
- test-linux:
- name: Test on Linux
- runs-on: ubuntu-latest
- env:
- GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- steps:
- - name: Checkout repository
- uses: actions/checkout@v6
-
- - name: Set up Python
- uses: actions/setup-python@v5
- with:
- python-version: '3.12'
-
- - name: Test print-only (URL selection)
- run: |
- URL=$(python3 braver.py --print-only)
- echo "Selected URL: $URL"
- if [[ -z "$URL" ]] || [[ ! "$URL" =~ ^https://.*github.* ]]; then
- echo "ERROR: Invalid URL returned"
- exit 1
- fi
-
- - name: Test download
- run: |
- python3 braver.py --dir ./test-downloads
- FILE=$(ls ./test-downloads/*.deb | head -n 1)
- if [ -z "$FILE" ] || [ ! -f "$FILE" ]; then
- echo "ERROR: Download file not found"
- exit 1
- fi
- SIZE=$(stat -c%s "$FILE")
- if [ "$SIZE" -lt 1000000 ]; then
- echo "ERROR: File too small ($SIZE bytes)"
- exit 1
- fi
- echo "Download successful: $FILE ($SIZE bytes)"
-
- - name: Cleanup
- if: always()
- run: rm -rf ./test-downloads
-
- test-macos:
- name: Test on macOS
- runs-on: macos-latest
- env:
- GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- steps:
- - name: Checkout repository
- uses: actions/checkout@v6
-
- - name: Set up Python
- uses: actions/setup-python@v5
- with:
- python-version: '3.14'
-
- - name: Test print-only (URL selection)
- run: |
- URL=$(python3 braver.py --print-only)
- echo "Selected URL: $URL"
- if [[ -z "$URL" ]] || [[ ! "$URL" =~ ^https://.*github.* ]]; then
- echo "ERROR: Invalid URL returned"
- exit 1
- fi
-
- - name: Test download
- run: |
- python3 braver.py --dir ./test-downloads
- FILE=$(ls ./test-downloads/*.dmg ./test-downloads/*.pkg 2>/dev/null | head -n 1)
- if [ -z "$FILE" ] || [ ! -f "$FILE" ]; then
- echo "ERROR: Download file not found"
- exit 1
- fi
- SIZE=$(stat -f%z "$FILE")
- if [ "$SIZE" -lt 1000000 ]; then
- echo "ERROR: File too small ($SIZE bytes)"
- exit 1
- fi
- echo "Download successful: $FILE ($SIZE bytes)"
-
- - name: Cleanup
- if: always()
- run: |
- rm -rf ./test-downloads
- hdiutil detach /Volumes/BraveTmpMount 2>/dev/null || true
-
- test-windows:
- name: Test on Windows
- runs-on: windows-latest
- env:
- GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- steps:
- - name: Checkout repository
- uses: actions/checkout@v6
-
- - name: Set up Python
- uses: actions/setup-python@v5
- with:
- python-version: '3.9'
-
- - name: Test print-only (URL selection)
- run: |
- $URL = python braver.py --print-only
- Write-Host "Selected URL: $URL"
- if ([string]::IsNullOrEmpty($URL) -or $URL -notmatch "^https://.*github.*") {
- Write-Host "ERROR: Invalid URL returned"
- exit 1
- }
-
- - name: Test download
- run: |
- $downloadDir = "$env:RUNNER_TEMP\brave-downloads"
- python braver.py --dir "$downloadDir"
- $file = Get-ChildItem "$downloadDir\*.exe" | Select-Object -First 1
- if ($null -eq $file) {
- Write-Host "ERROR: Download file not found"
- exit 1
- }
- $size = $file.Length
- if ($size -lt 1000000) {
- Write-Host "ERROR: File too small ($size bytes)"
- exit 1
- }
- Write-Host "Download successful: $($file.Name) ($size bytes) from $downloadDir"
-
- - name: Cleanup
- if: always()
- run: Remove-Item -Recurse -Force "$env:RUNNER_TEMP\brave-downloads" -ErrorAction SilentlyContinue
-
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..d263db8
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,10 @@
+__pycache__/
+*.py[cod]
+*.egg-info/
+.coverage
+.DS_Store
+.pytest_cache/
+.venv/
+build/
+dist/
+test-downloads/
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 3ac290a..0b6d3dc 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -7,37 +7,50 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
-### Added
-- Automatic SHA256 hash verification using asset digest from GitHub API
+### Changed
+- Updated repository ownership references to `HackEAC/braver-tz`
+- Removed Brave-logo-based branding from the documentation and repository root
+
+## [1.1.0] - 2026-03-16
-### Improved
-- Enhanced installer selection logic with better fallback mechanisms
-- Comprehensive error handling with retry logic, timeout handling, and validation
-- Windows platform testing completed
+### Added
+- Packaged Python CLI with `pyproject.toml`, `setup.py`, and `src/brave_updater/`
+- `brave-updater` console entrypoint
+- Explicit `check`, `download`, `update`, and `install` commands
+- Installed-version detection for macOS, Linux, and Windows
+- Unit tests for provider logic, version detection, download safety, and CLI compatibility
+- Deterministic CI workflow across Linux, macOS, and Windows
+- Release workflow for building and publishing wheels/sdists
+- Live smoke workflow for read-only endpoint validation
+- Homebrew formula starter files and repository transfer checklist docs
+
+### Changed
+- Retained `python3 braver.py ...` as a compatibility wrapper instead of the primary implementation
+- Reworked download logic to use resumable `.part` files and atomic final replacement
+- Added redirect validation against allowed hosts
+- Added safer macOS app replacement flow using staged copies
+- Updated documentation to describe the new CLI and the `HackEAC` ownership path
+
+### Fixed
+- Resume downloads no longer append corrupt data when a server ignores `Range` and returns a full `200`
+- Version comparisons now treat `1.2.3` and `1.2.3.0` as equivalent
## [1.0.0] - 2024-12-19
### Added
-
- Cross-platform Python script to download latest stable Brave Browser from GitHub Releases
- Automatic OS detection (macOS, Windows, Linux)
- Automatic CPU architecture detection (x64, ARM64, x86)
- Linux distribution family detection (Debian-based, RHEL-based, Arch-based)
- Intelligent installer selection:
- - `.dmg` / `.pkg` for macOS (prefers universal DMG, falls back to arch-specific)
- - `.exe` for Windows (prefers StandaloneSetup offline installer)
+ - `.dmg` / `.pkg` for macOS
+ - `.exe` for Windows
- `.deb` for Debian-based Linux distributions
- `.rpm` for RHEL-based Linux distributions
- Download functionality with progress indication
- Optional installation step via `--install` flag
- `--print-only` flag to output download URL for automation
- `--dir` flag to specify custom download directory
-- GitHub API token support via `GITHUB_TOKEN` environment variable
-- macOS installation support (DMG mounting and app copying to /Applications)
-- Windows installation support (launches installer)
-- Linux installation support (dpkg for .deb, dnf/yum/rpm for .rpm)
-- Interactive prompts before destructive operations
-- Error handling for missing assets and unsupported systems
-- Uses only Python standard library (no external dependencies)
-- Python 3.8+ compatibility
-
+- GitHub API token support via `GITHUB_TOKEN`
+- Basic macOS, Windows, and Linux installation support
+- Uses only Python standard library
diff --git a/README.md b/README.md
index cc50fd8..e698898 100644
--- a/README.md
+++ b/README.md
@@ -1,146 +1,152 @@
-# Braver-tz
+# brave-updater
-
-
-
+`brave-updater` is an unofficial cross-platform CLI for checking, downloading, and updating Brave Browser from Brave-controlled release sources.
-A cross-platform Python script that automatically downloads the **latest stable release of Brave Browser** directly from **GitHub Releases**, without relying on brave.com.
+This project is intended for environments where the usual Brave installation flow is inconvenient or blocked, but GitHub-hosted Brave releases are still reachable.
-This project is useful in environments where the official Brave website is blocked or inaccessible, but GitHub remains reachable.
+Important:
+- This project is not affiliated with or endorsed by Brave Software.
+- It does not rehost Brave binaries.
+- It downloads Brave installers directly from Brave-controlled GitHub release assets at runtime.
+- Homebrew already ships the official macOS `brave-browser` cask. This project is a separate updater/downloader CLI, not a replacement for that cask.
----
+## Current status
-## ✨ Features
+- Packaged Python CLI with a `src/` layout and console entrypoint: `brave-updater`
+- Backward-compatible wrapper retained: `python3 braver.py ...`
+- True updater behavior:
+ - Detects installed Brave version when possible
+ - Compares it to the latest stable release
+ - Skips update work when the installed version is already current
+- Cross-platform installer selection:
+ - macOS: `.dmg` / `.pkg`
+ - Windows: `.exe`
+ - Linux: `.deb` / `.rpm` on Debian-like and RHEL-like systems
+- Safer downloads:
+ - SHA256 verification
+ - redirect host validation
+ - temp-file download + atomic replace
+ - safe recovery when resume requests are ignored
-- Fetches the **latest stable Brave release** from the official GitHub repository
-- Automatically detects:
- - Operating system (macOS, Windows, Linux)
- - CPU architecture (x64, ARM64, etc.)
- - Linux distribution family (Debian-based, RHEL-based when possible)
-- Selects the most appropriate installer:
- - `.dmg` / `.pkg` for macOS
- - `.exe` for Windows
- - `.deb` or `.rpm` for Linux
-- Downloads the installer automatically
-- **Automatic SHA256 hash verification** for download integrity
-- Optional interactive installation step
-- Uses only Python standard library (no external dependencies)
+## Repository transfer
----
+The canonical repository is now `HackEAC/braver-tz`.
-## 🚀 Usage
+Post-transfer notes:
+- keep `origin` pointed at `git@github.com:HackEAC/braver-tz.git`
+- do not recreate the previous repository path after the transfer, because that would break GitHub's redirect
+- follow the post-transfer checklist in [`docs/repository-transfer.md`](docs/repository-transfer.md)
-Download the script:
+## Installation
+
+### From source
```bash
-git clone https://github.com/maotora/braver-tz.git
+git clone git@github.com:HackEAC/braver-tz.git
cd braver-tz
+python3 -m pip install .
```
-Run to download only:
+After installation:
```bash
-python3 braver.py
+brave-updater check
```
-Download **and** install (you will be prompted):
+### Legacy wrapper
+
+You can still use the old entrypoint directly from the repo:
```bash
+python3 braver.py --print-only
python3 braver.py --install
```
-Print the direct download URL only (useful for automation):
+### Homebrew tap
+
+The intended tap command once the tap is published is:
```bash
-python3 braver.py --print-only
+brew install HackEAC/brave-updater/brave-updater
```
-Specify a custom download directory:
+The starter formula and tap notes live under [`packaging/homebrew`](packaging/homebrew).
-```bash
-python3 braver.py --dir /path/to/downloads
-```
+## Usage
----
+### Check installed vs latest version
-## 🔧 Requirements
+```bash
+brave-updater check
+brave-updater check --json
+```
-- Python **3.8+**
-- Internet access to `api.github.com`
-- Administrator privileges **only if installing** (platform-dependent)
+### Download the latest installer
-No third-party Python packages are required.
+```bash
+brave-updater download
+brave-updater download --dir ~/Downloads
+```
----
+### Update only when needed
-## 🖥️ Platform Support Status
+```bash
+brave-updater update
+brave-updater update --yes
+```
-| Platform | Status |
-|--------|--------|
-| macOS | ✅ Tested |
-| Windows | ✅ Tested |
-| Linux (Ubuntu) | ✅ Tested |
-| Linux (Other distros) | ⚠️ Untested |
+### Install a package you already downloaded
-> **Important:**
-> This script has been tested on **macOS, Windows, and Ubuntu**.
-> Testing on other Linux distributions (Debian, Fedora, Arch, etc.) is encouraged.
+```bash
+brave-updater install /path/to/Brave-Browser-universal.dmg
+```
-Please open an issue if you encounter:
-- Incorrect installer selection
-- Architecture mismatches
-- Installation failures
-- Any unexpected behavior
+### Source strategy
----
+`brave-updater` currently defaults to GitHub Releases because that is the supported product direction for this project.
-## 🧠 How It Works
+Available source modes:
+- `github`
+- `official`
+- `auto`
-1. Detects the local system OS and CPU architecture
-2. Queries the GitHub Releases API for:
- ```
- brave/brave-browser
- ```
-3. Selects the **latest stable release**
-4. Chooses the best-matching installer asset
-5. Downloads the installer
-6. Optionally runs the installer using native OS mechanisms
+At the moment, `official` and `auto` resolve through Brave's official GitHub release feed. The code is structured so distinct official non-GitHub providers can be added later without redesigning the CLI.
-The script intentionally avoids:
-- Beta releases
-- Nightly releases
-- Release candidates
+### Linux support
----
+Linux installs are currently supported on:
+- Debian-like distributions
+- RHEL-like distributions
-## 🔐 GitHub API Rate Limits
+Arch-like and other unsupported Linux families fail fast with a clear error instead of downloading the wrong package format.
-GitHub enforces unauthenticated rate limits.
+## Development
-If you hit a rate limit, set a token:
+Run the test suite:
```bash
-export GITHUB_TOKEN=your_token_here
-python3 braver.py
+python3 -m unittest discover -v
```
----
+Build distributions:
-## 🤝 Contributing
+```bash
+python3 -m pip install build
+python3 -m build
+```
-Contributions are welcome!
+## CI and release flow
-Especially needed:
-- Linux distro compatibility testing
+- CI runs deterministic unit tests on Linux, macOS, and Windows.
+- A scheduled smoke workflow hits the live Brave release endpoint with read-only checks.
+- Tagging `v*` builds and uploads sdist/wheel artifacts to GitHub Releases.
-Please:
-1. Open an issue describing the problem
-2. Include OS, architecture, and installer name
-3. Attach logs or error output when possible
+## Legal and policy notes
----
+- This project should avoid Brave logos and branded artwork unless you have explicit permission.
+- The project should continue to present itself as unofficial and non-affiliated.
+- The project should continue to download installers from Brave-controlled endpoints rather than mirror them.
-## 📄 License
+## License
-This project is licensed under the **MIT License**.
-See the `LICENSE` file for details.
+MIT. See [`LICENSE`](LICENSE).
diff --git a/braver-tz-banner.png b/braver-tz-banner.png
deleted file mode 100644
index 32a1f5c..0000000
Binary files a/braver-tz-banner.png and /dev/null differ
diff --git a/braver.py b/braver.py
index 3ba8836..9fbcb73 100644
--- a/braver.py
+++ b/braver.py
@@ -1,707 +1,23 @@
#!/usr/bin/env python3
"""
-brave_stable_fetch.py
+Compatibility wrapper for the packaged brave-updater CLI.
-Cross-platform downloader (and optional installer) for the latest *stable* Brave release
-from GitHub Releases: brave/brave-browser.
-
-- Detects OS + CPU arch
-- Chooses best-matching asset (.dmg, .exe, .deb, .rpm)
-- Downloads it
-- Optional install step: --install (prompts before doing anything destructive)
-
-Notes:
-- GitHub API is rate-limited. If you hit limits, set env var GITHUB_TOKEN to a personal token.
+This keeps `python3 braver.py ...` working during the transition away from the
+original single-file script layout.
"""
-from __future__ import annotations
-
-import argparse
-import hashlib
-import json
-import os
-import platform
-import re
-import shutil
-import socket
-import subprocess
-import sys
-import time
-import urllib.parse
-import urllib.request
-from dataclasses import dataclass
from pathlib import Path
-from typing import Any, Dict, List, Optional, Tuple
-from urllib.error import URLError, HTTPError
-
-GITHUB_API_LATEST = "https://api.github.com/repos/brave/brave-browser/releases/latest"
-ALLOWED_DOMAINS = {"github.com", "githubusercontent.com"}
-
-
-@dataclass
-class SystemInfo:
- os_name: str # "macos" | "windows" | "linux"
- arch: str # "arm64" | "x64" | "x86" | "unknown"
- linux_family: str # "debian" | "rhel" | "arch" | "unknown" (only used on linux)
-
-
-# Global flag to control log output destination
-_PRINT_ONLY_MODE = False
-
-def log(msg: str) -> None:
- """Log message to stdout (or stderr if in print-only mode)."""
- if _PRINT_ONLY_MODE:
- print(msg, file=sys.stderr, flush=True)
- else:
- print(msg, flush=True)
-
-
-def validate_url(url: str) -> bool:
- """Validate that URL is from allowed domain."""
- try:
- parsed = urllib.parse.urlparse(url)
- domain = parsed.netloc.lower()
- # Remove port if present
- if ":" in domain:
- domain = domain.split(":")[0]
- return any(domain.endswith(f".{allowed}") or domain == allowed for allowed in ALLOWED_DOMAINS)
- except Exception:
- return False
-
-
-def sanitize_filename(filename: str) -> str:
- """Sanitize filename to prevent path traversal."""
- # Remove path components
- filename = os.path.basename(filename)
- # Remove dangerous characters
- dangerous = ["..", "/", "\\", "\x00"]
- for d in dangerous:
- filename = filename.replace(d, "")
- # Limit length
- if len(filename) > 255:
- name, ext = os.path.splitext(filename)
- filename = name[:255-len(ext)] + ext
- return filename
-
-
-def verify_file_hash(file_path: Path, expected_hash: str) -> bool:
- """Verify file SHA256 hash."""
- if not expected_hash:
- return False
- sha256 = hashlib.sha256()
- try:
- with open(file_path, "rb") as f:
- while True:
- chunk = f.read(8192)
- if not chunk:
- break
- sha256.update(chunk)
- computed = sha256.hexdigest().lower()
- expected = expected_hash.lower().strip()
- return computed == expected
- except Exception as e:
- log(f"Error computing hash: {e}")
- return False
-
-
-def http_get_json(url: str, timeout: int = 30, max_retries: int = 3) -> Dict[str, Any]:
- """Fetch JSON with retry logic and timeout."""
- headers = {
- "Accept": "application/vnd.github+json",
- "User-Agent": "brave-stable-fetch/1.0",
- }
- token = os.environ.get("GITHUB_TOKEN")
- if token:
- # Basic validation - token should not contain spaces or newlines
- token = token.strip()
- if not token or "\n" in token or " " in token:
- log("Warning: GITHUB_TOKEN appears invalid, ignoring.")
- else:
- headers["Authorization"] = f"Bearer {token}"
-
- last_error = None
- for attempt in range(max_retries):
- try:
- req = urllib.request.Request(url, headers=headers)
- with urllib.request.urlopen(req, timeout=timeout) as resp:
- if resp.status != 200:
- raise URLError(f"HTTP {resp.status}: {resp.reason}")
- data = resp.read().decode("utf-8")
- result = json.loads(data)
- # Validate response structure
- if not isinstance(result, dict):
- raise ValueError("API response is not a JSON object")
- return result
- except (URLError, socket.timeout, socket.error, json.JSONDecodeError, ValueError) as e:
- last_error = e
- if attempt < max_retries - 1:
- wait = 2 ** attempt # Exponential backoff
- log(f"Attempt {attempt + 1} failed: {e}. Retrying in {wait}s...")
- time.sleep(wait)
- else:
- log(f"All {max_retries} attempts failed.")
-
- raise RuntimeError(f"Failed to fetch {url} after {max_retries} attempts: {last_error}")
-
-
-def validate_release_data(rel: Dict[str, Any]) -> None:
- """Validate GitHub release API response structure."""
- required_fields = ["tag_name", "assets"]
- for field in required_fields:
- if field not in rel:
- raise ValueError(f"Invalid API response: missing field '{field}'")
-
- if not isinstance(rel["assets"], list):
- raise ValueError("Invalid API response: 'assets' must be a list")
-
- for asset in rel["assets"]:
- if not isinstance(asset, dict):
- raise ValueError("Invalid API response: asset must be a dict")
- if "name" not in asset or "browser_download_url" not in asset:
- raise ValueError("Invalid API response: asset missing required fields")
-
-
-def detect_linux_family() -> str:
- # Best-effort detection using /etc/os-release
- osr = Path("/etc/os-release")
- if not osr.exists():
- return "unknown"
-
- text = osr.read_text(errors="ignore")
- # Parse like KEY=VALUE
- vals = {}
- for line in text.splitlines():
- line = line.strip()
- if not line or line.startswith("#") or "=" not in line:
- continue
- k, v = line.split("=", 1)
- vals[k.strip()] = v.strip().strip('"')
-
- distro_id = (vals.get("ID") or "").lower()
- like = (vals.get("ID_LIKE") or "").lower()
-
- def has_any(s: str, words: List[str]) -> bool:
- return any(w in s for w in words)
-
- if has_any(distro_id, ["debian", "ubuntu", "linuxmint", "pop"]) or has_any(like, ["debian", "ubuntu"]):
- return "debian"
- if has_any(distro_id, ["fedora", "rhel", "centos", "rocky", "almalinux", "opensuse", "sles"]) or has_any(like, ["rhel", "fedora", "suse"]):
- return "rhel"
- if has_any(distro_id, ["arch", "manjaro", "endeavouros"]) or has_any(like, ["arch"]):
- return "arch"
-
- return "unknown"
-
-
-def detect_system() -> SystemInfo:
- sys_plat = sys.platform.lower()
- machine = platform.machine().lower()
-
- # OS
- if sys_plat.startswith("darwin"):
- os_name = "macos"
- elif sys_plat.startswith("win"):
- os_name = "windows"
- elif sys_plat.startswith("linux"):
- os_name = "linux"
- else:
- os_name = "unknown"
-
- # Arch
- # Normalize common values
- if machine in ("arm64", "aarch64"):
- arch = "arm64"
- elif machine in ("x86_64", "amd64"):
- arch = "x64"
- elif machine in ("i386", "i686", "x86"):
- arch = "x86"
- else:
- arch = "unknown"
-
- linux_family = detect_linux_family() if os_name == "linux" else "unknown"
- return SystemInfo(os_name=os_name, arch=arch, linux_family=linux_family)
-
-
-def pick_asset(assets: List[Dict[str, Any]], si: SystemInfo) -> Tuple[Dict[str, Any], str]:
- """
- Returns (asset, reason).
- Picks conservatively and robustly by filename patterns.
- """
- names = [a.get("name", "") for a in assets]
-
- def find_by_pred(pred) -> Optional[Dict[str, Any]]:
- for a in assets:
- n = (a.get("name") or "").lower()
- if pred(n):
- return a
- return None
-
- # macOS: prefer universal dmg, then arch-specific dmg, then pkg
- if si.os_name == "macos":
- # Brave commonly uses "Brave-Browser-universal.dmg" in releases.
- a = find_by_pred(lambda n: n.endswith(".dmg") and "universal" in n)
- if a:
- return a, "macOS: chose universal .dmg"
- if si.arch == "arm64":
- a = find_by_pred(lambda n: n.endswith(".dmg") and ("arm64" in n or "aarch64" in n))
- if a:
- return a, "macOS ARM: chose arm64 .dmg"
- if si.arch == "x64":
- a = find_by_pred(lambda n: n.endswith(".dmg") and ("x64" in n or "amd64" in n or "x86_64" in n))
- if a:
- return a, "macOS Intel: chose x64 .dmg"
- a = find_by_pred(lambda n: n.endswith(".dmg"))
- if a:
- return a, "macOS: chose first available .dmg"
- a = find_by_pred(lambda n: n.endswith(".pkg"))
- if a:
- return a, "macOS: chose .pkg (no .dmg found)"
-
- # Windows: prefer StandaloneSetup (offline installer), then any .exe
- if si.os_name == "windows":
- a = find_by_pred(lambda n: n.endswith(".exe") and "standalone" in n and "setup" in n)
- if a:
- return a, "Windows: chose StandaloneSetup.exe (offline installer)"
- a = find_by_pred(lambda n: n.endswith(".exe") and "setup" in n)
- if a:
- return a, "Windows: chose Setup.exe"
- a = find_by_pred(lambda n: n.endswith(".exe"))
- if a:
- return a, "Windows: chose first available .exe"
-
- # Linux: prefer .deb for Debian-like, .rpm for RHEL-like; try to match arch
- if si.os_name == "linux":
- arch_tokens = []
- if si.arch == "x64":
- arch_tokens = ["amd64", "x86_64"]
- elif si.arch == "arm64":
- arch_tokens = ["arm64", "aarch64"]
-
- if si.linux_family == "debian":
- # Prefer matching arch if present in filename
- for tok in arch_tokens:
- a = find_by_pred(lambda n, t=tok: n.endswith(".deb") and t in n)
- if a:
- return a, f"Linux Debian-like: chose .deb matching {tok}"
- a = find_by_pred(lambda n: n.endswith(".deb"))
- if a:
- return a, "Linux Debian-like: chose first available .deb"
-
- if si.linux_family == "rhel":
- for tok in arch_tokens:
- a = find_by_pred(lambda n, t=tok: n.endswith(".rpm") and t in n)
- if a:
- return a, f"Linux RHEL-like: chose .rpm matching {tok}"
- a = find_by_pred(lambda n: n.endswith(".rpm"))
- if a:
- return a, "Linux RHEL-like: chose first available .rpm"
-
- # Fallbacks: try .deb then .rpm then .tar.gz/.zip
- a = find_by_pred(lambda n: n.endswith(".deb"))
- if a:
- return a, "Linux: fallback to .deb"
- a = find_by_pred(lambda n: n.endswith(".rpm"))
- if a:
- return a, "Linux: fallback to .rpm"
- a = find_by_pred(lambda n: n.endswith(".tar.gz") or n.endswith(".zip"))
- if a:
- return a, "Linux: fallback to archive"
-
- raise RuntimeError(
- "Could not find a suitable asset in this release.\n"
- f"Detected system: {si}\n"
- f"Assets seen (first 30): {names[:30]}"
- )
-
-
-def download_file(url: str, dest: Path, expected_hash: Optional[str] = None,
- timeout: int = 60, max_retries: int = 3) -> None:
- """Download file with retry, resume, and integrity verification."""
- if not validate_url(url):
- raise ValueError(f"URL not from allowed domain: {url}")
-
- dest.parent.mkdir(parents=True, exist_ok=True)
-
- # Check disk space (rough estimate - need at least 500MB free)
- try:
- stat = shutil.disk_usage(dest.parent)
- free_mb = stat.free / (1024 * 1024)
- if free_mb < 500:
- raise RuntimeError(f"Insufficient disk space: {free_mb:.0f}MB free (need at least 500MB)")
- except Exception as e:
- log(f"Warning: Could not check disk space: {e}")
-
- # Resume partial download
- resume_pos = 0
- if dest.exists():
- resume_pos = dest.stat().st_size
- if resume_pos > 0:
- log(f"Resuming download from byte {resume_pos}...")
-
- last_error = None
- for attempt in range(max_retries):
- try:
- headers = {}
- if resume_pos > 0:
- headers["Range"] = f"bytes={resume_pos}-"
-
- req = urllib.request.Request(url, headers=headers)
- with urllib.request.urlopen(req, timeout=timeout) as resp:
- # Check if server supports resume
- if resume_pos > 0 and resp.status not in (206, 200):
- log("Server doesn't support resume, starting from beginning...")
- resume_pos = 0
- dest.unlink()
- headers.pop("Range", None)
- req = urllib.request.Request(url, headers=headers)
- resp = urllib.request.urlopen(req, timeout=timeout)
-
- total = int(resp.headers.get("Content-Length", 0)) or (resp.length or 0)
- if resume_pos > 0 and total > 0:
- total += resume_pos
-
- mode = "ab" if resume_pos > 0 else "wb"
- log(f"Downloading:\n {url}\n-> {dest}")
- with open(dest, mode) as f:
- read = resume_pos
- chunk = 1024 * 256
- while True:
- b = resp.read(chunk)
- if not b:
- break
- f.write(b)
- read += len(b)
- if total:
- pct = (read / total) * 100
- print(f"\r{pct:6.2f}% ({read}/{total} bytes)", end="", flush=True)
- if total:
- print()
-
- # Verify file size if Content-Length was provided
- actual_size = dest.stat().st_size
- if total > 0 and actual_size != total:
- raise RuntimeError(f"Size mismatch: expected {total}, got {actual_size}")
-
- # Verify hash if provided
- if expected_hash:
- log("Verifying download integrity...")
- if not verify_file_hash(dest, expected_hash):
- raise RuntimeError("Download integrity check failed! File may be corrupted.")
- log("Integrity check passed.")
-
- log("Download complete.")
- return
-
- except HTTPError as e:
- # Handle 416 Range Not Satisfiable - file may have changed or range invalid
- if e.code == 416:
- log(f"Range request failed (416), deleting partial file and restarting...")
- if dest.exists():
- dest.unlink()
- resume_pos = 0
- last_error = e
- if attempt < max_retries - 1:
- wait = 2 ** attempt
- log(f"Download attempt {attempt + 1} failed: {e}. Retrying in {wait}s...")
- time.sleep(wait)
- continue
- else:
- last_error = e
- if attempt < max_retries - 1:
- wait = 2 ** attempt
- log(f"Download attempt {attempt + 1} failed: {e}. Retrying in {wait}s...")
- time.sleep(wait)
- else:
- # Clean up partial download on final failure if empty
- if dest.exists() and dest.stat().st_size == 0:
- dest.unlink()
- except (URLError, socket.timeout, socket.error, OSError) as e:
- last_error = e
- if attempt < max_retries - 1:
- wait = 2 ** attempt
- log(f"Download attempt {attempt + 1} failed: {e}. Retrying in {wait}s...")
- time.sleep(wait)
- else:
- # Clean up partial download on final failure if empty
- if dest.exists() and dest.stat().st_size == 0:
- dest.unlink()
-
- raise RuntimeError(f"Failed to download {url} after {max_retries} attempts: {last_error}")
-
-
-def prompt_yes_no(question: str, default_no: bool = True) -> bool:
- # Check if stdin is available (interactive terminal)
- if not sys.stdin.isatty():
- # Non-interactive environment (CI, pipes, etc.) - return default
- return not default_no
- suffix = " [y/N]: " if default_no else " [Y/n]: "
- ans = input(question + suffix).strip().lower()
- if not ans:
- return not default_no
- return ans in ("y", "yes")
-
-
-def install_macos(pkg_or_dmg: Path) -> None:
- """Install with better error handling and cleanup."""
- if not pkg_or_dmg.exists():
- raise FileNotFoundError(f"Install file not found: {pkg_or_dmg}")
-
- if pkg_or_dmg.suffix.lower() == ".pkg":
- log("Installing .pkg via macOS installer (will prompt for admin password if needed)...")
- try:
- subprocess.check_call(
- ["sudo", "installer", "-pkg", str(pkg_or_dmg), "-target", "/"],
- timeout=600 # 10 minute timeout
- )
- log("Install finished.")
- return
- except subprocess.TimeoutExpired:
- raise RuntimeError("Installation timed out after 10 minutes")
- except subprocess.CalledProcessError as e:
- raise RuntimeError(f"Installation failed with exit code {e.returncode}")
-
- if pkg_or_dmg.suffix.lower() != ".dmg":
- raise RuntimeError("macOS install expects .dmg or .pkg")
-
- mountpoint = Path("/Volumes/BraveTmpMount")
- mounted = False
-
- try:
- # Clean up any existing mount
- if mountpoint.exists():
- try:
- subprocess.run(["hdiutil", "detach", str(mountpoint), "-quiet"],
- capture_output=True, timeout=5)
- except Exception:
- pass
-
- mountpoint.mkdir(parents=True, exist_ok=True)
- log("Mounting DMG...")
- subprocess.check_call(
- ["hdiutil", "attach", str(pkg_or_dmg), "-mountpoint", str(mountpoint),
- "-nobrowse", "-quiet"],
- timeout=60
- )
- mounted = True
-
- app = mountpoint / "Brave Browser.app"
- if not app.exists():
- apps = list(mountpoint.glob("*.app"))
- if apps:
- app = apps[0]
- else:
- raise RuntimeError("Could not find .app inside the DMG.")
-
- target = Path("/Applications") / app.name
- log(f"Copying {app.name} to /Applications ...")
-
- if target.exists():
- subprocess.check_call(
- ["sudo", "rm", "-rf", str(target)],
- timeout=30
- )
-
- subprocess.check_call(
- ["sudo", "cp", "-R", str(app), str(target)],
- timeout=300
- )
- log("Brave copied to /Applications.")
-
- except subprocess.TimeoutExpired:
- raise RuntimeError("Operation timed out")
- except subprocess.CalledProcessError as e:
- raise RuntimeError(f"Installation command failed: {e}")
- finally:
- if mounted:
- log("Unmounting DMG...")
- for _ in range(3): # Try up to 3 times
- try:
- subprocess.run(
- ["hdiutil", "detach", str(mountpoint), "-quiet"],
- timeout=10,
- capture_output=True
- )
- break
- except Exception as e:
- if _ == 2:
- log(f"Warning: Could not unmount DMG: {e}")
- else:
- time.sleep(1)
-
-
-def install_windows(exe_path: Path) -> None:
- """Runs the installer normally."""
- if not exe_path.exists():
- raise FileNotFoundError(f"Install file not found: {exe_path}")
- log("Launching installer...")
- os.startfile(str(exe_path)) # type: ignore[attr-defined]
-
-
-def install_linux(pkg: Path, family: str) -> None:
- """Install Linux package with better error handling."""
- if not pkg.exists():
- raise FileNotFoundError(f"Install file not found: {pkg}")
-
- if pkg.suffix.lower() == ".deb":
- log("Installing .deb (will prompt for sudo password if needed)...")
- try:
- subprocess.check_call(
- ["sudo", "dpkg", "-i", str(pkg)],
- timeout=600
- )
- log("Install finished. (If dependencies failed, run: sudo apt -f install)")
- return
- except subprocess.TimeoutExpired:
- raise RuntimeError("Installation timed out after 10 minutes")
- except subprocess.CalledProcessError as e:
- raise RuntimeError(f"Installation failed with exit code {e.returncode}")
-
- if pkg.suffix.lower() == ".rpm":
- log("Installing .rpm (will prompt for sudo password if needed)...")
- # dnf is preferred if available; otherwise rpm
- try:
- if shutil.which("dnf"):
- subprocess.check_call(
- ["sudo", "dnf", "install", "-y", str(pkg)],
- timeout=600
- )
- elif shutil.which("yum"):
- subprocess.check_call(
- ["sudo", "yum", "install", "-y", str(pkg)],
- timeout=600
- )
- else:
- subprocess.check_call(
- ["sudo", "rpm", "-Uvh", str(pkg)],
- timeout=600
- )
- log("Install finished.")
- return
- except subprocess.TimeoutExpired:
- raise RuntimeError("Installation timed out after 10 minutes")
- except subprocess.CalledProcessError as e:
- raise RuntimeError(f"Installation failed with exit code {e.returncode}")
-
- raise RuntimeError(f"Don't know how to install {pkg.name} on Linux (family={family}).")
-
-
-def main() -> int:
- global _PRINT_ONLY_MODE
- ap = argparse.ArgumentParser(description="Download latest stable Brave from GitHub Releases (and optionally install).")
- ap.add_argument("--dir", default=str(Path.home() / "Downloads"), help="Download directory (default: ~/Downloads)")
- ap.add_argument("--install", action="store_true", help="After download, prompt and attempt to install")
- ap.add_argument("--print-only", action="store_true", help="Only print chosen version and asset URL; do not download")
- ap.add_argument("--skip-verify", action="store_true", help="Skip integrity verification (not recommended)")
- args = ap.parse_args()
-
- # Set global flag for log output redirection
- _PRINT_ONLY_MODE = args.print_only
-
- try:
- si = detect_system()
- log(f"Detected system: os={si.os_name}, arch={si.arch}, linux_family={si.linux_family}")
-
- rel = http_get_json(GITHUB_API_LATEST)
- validate_release_data(rel)
-
- tag = rel.get("tag_name", "")
- name = rel.get("name", "")
- body = rel.get("body", "")
- assets = rel.get("assets", [])
-
- if not assets:
- log("Release has no assets. Exiting.")
- return 2
-
- asset, reason = pick_asset(assets, si)
- asset_name = asset.get("name", "unknown")
- url = asset.get("browser_download_url", "")
-
- # Get hash if available from asset's digest field
- expected_hash = None
- if not args.skip_verify:
- # Extract SHA256 from asset's digest field (format: "sha256:hash")
- digest = asset.get("digest", "")
- if digest and digest.startswith("sha256:"):
- expected_hash = digest[7:] # Remove "sha256:" prefix
- log(f"Found SHA256 hash for verification: {expected_hash[:16]}...")
- else:
- # Fallback: Look for checksums file in release assets
- for a in assets:
- name_lower = a.get("name", "").lower()
- if "checksum" in name_lower or "sha256" in name_lower:
- # Could download and parse checksums file here if needed
- pass
-
- # Sanitize filename
- asset_name = sanitize_filename(asset_name)
-
- log(f"Latest stable release: {tag} ({name})")
- log(f"Chosen asset: {asset_name}")
- log(f"Reason: {reason}")
- if body:
- m = re.search(r"Chromium:\s*([0-9]+\.[0-9]+\.[0-9]+\.[0-9]+)", body)
- if m:
- log(f"Chromium base: {m.group(1)}")
-
- if not url:
- log("No download URL found for chosen asset. Exiting.")
- return 3
-
- if not validate_url(url):
- log(f"ERROR: Download URL failed validation: {url}")
- return 5
-
- if args.print_only:
- print(url)
- return 0
+import sys
- dest_dir = Path(args.dir).expanduser().resolve()
- # Ensure destination is within user's home, current working directory, or explicitly allowed absolute path
- cwd = Path.cwd()
- is_in_home = str(dest_dir).startswith(str(Path.home()))
- is_in_cwd = str(dest_dir).startswith(str(cwd))
- is_absolute_allowed = args.dir.startswith("/") or (os.name == "nt" and len(args.dir) > 1 and args.dir[1] == ":")
-
- if not (is_in_home or is_in_cwd or is_absolute_allowed):
- log(f"Warning: Download directory is outside home and current directory: {dest_dir}")
- if not prompt_yes_no("Continue anyway?", default_no=True):
- return 1
-
- dest = dest_dir / asset_name
- download_file(url, dest, expected_hash=expected_hash if not args.skip_verify else None)
- if args.install:
- if not prompt_yes_no(f"Install {asset_name} now?", default_no=True):
- log("Install skipped.")
- return 0
+ROOT = Path(__file__).resolve().parent
+SRC = ROOT / "src"
- try:
- if si.os_name == "macos":
- install_macos(dest)
- elif si.os_name == "windows":
- install_windows(dest)
- elif si.os_name == "linux":
- install_linux(dest, si.linux_family)
- else:
- log("Unknown OS; cannot install automatically.")
- return 4
+if str(SRC) not in sys.path:
+ sys.path.insert(0, str(SRC))
- log("Done.")
- except Exception as e:
- log(f"Installation failed: {e}")
- return 6
-
- return 0
- except KeyboardInterrupt:
- log("\nInterrupted by user.")
- return 130
- except Exception as e:
- log(f"Error: {e}")
- import traceback
- traceback.print_exc()
- return 1
+from brave_updater.cli import main
if __name__ == "__main__":
- raise SystemExit(main())
\ No newline at end of file
+ raise SystemExit(main())
diff --git a/docs/repository-transfer.md b/docs/repository-transfer.md
new file mode 100644
index 0000000..c559fe4
--- /dev/null
+++ b/docs/repository-transfer.md
@@ -0,0 +1,34 @@
+# Repository Transfer Checklist
+
+This repository now lives at `HackEAC/braver-tz`.
+
+## Current remote
+
+```bash
+git remote set-url origin git@github.com:HackEAC/braver-tz.git
+git remote -v
+```
+
+## Post-transfer checks
+
+1. Confirm that GitHub redirects the old URL to the new owner.
+2. Verify org-side repository settings:
+ - GitHub Actions enabled
+ - branch protection rules
+ - required secrets and variables
+ - release permissions
+ - admin access for the intended maintainers
+3. Do not recreate the previous repository path, because that breaks GitHub's redirect from the transferred repository.
+4. Update any remaining references that still point to the old owner, including:
+- clone commands
+- release links
+- Homebrew tap references
+- badges or status URLs
+
+Run these local sanity checks:
+
+```bash
+git fetch origin
+python3 braver.py --help
+python3 -m unittest discover -v
+```
diff --git a/packaging/homebrew/README.md b/packaging/homebrew/README.md
new file mode 100644
index 0000000..8928d56
--- /dev/null
+++ b/packaging/homebrew/README.md
@@ -0,0 +1,32 @@
+# Homebrew Tap Starter
+
+This repository is not itself the Homebrew tap.
+
+The intended tap repository is:
+
+- `HackEAC/homebrew-brave-updater`
+
+## Intended install command
+
+```bash
+brew install HackEAC/brave-updater/brave-updater
+```
+
+## How to use these files
+
+1. Create the tap repository `HackEAC/homebrew-brave-updater`.
+2. Copy [`brave-updater.rb`](brave-updater.rb) into the tap repo's `Formula/` directory.
+3. Replace the release URL and `sha256` with the actual tagged release archive and checksum.
+4. Test with:
+
+```bash
+brew tap HackEAC/brave-updater /path/to/homebrew-brave-updater
+brew install brave-updater
+brew test brave-updater
+```
+
+## Notes
+
+- Homebrew already provides the official `brave-browser` cask on macOS.
+- This formula packages the updater CLI, not the browser itself.
+- The formula uses Homebrew-managed Python so end users do not need to manually install Python first.
diff --git a/packaging/homebrew/brave-updater.rb b/packaging/homebrew/brave-updater.rb
new file mode 100644
index 0000000..d83d860
--- /dev/null
+++ b/packaging/homebrew/brave-updater.rb
@@ -0,0 +1,20 @@
+class BraveUpdater < Formula
+ include Language::Python::Virtualenv
+
+ desc "Unofficial CLI to download and update Brave Browser from Brave-controlled release sources"
+ homepage "https://github.com/HackEAC/braver-tz"
+ url "https://github.com/HackEAC/braver-tz/archive/refs/tags/v1.1.0.tar.gz"
+ sha256 "REPLACE_WITH_RELEASE_SHA256"
+ license "MIT"
+
+ depends_on "python@3.12"
+
+ def install
+ virtualenv_install_with_resources
+ end
+
+ test do
+ output = shell_output("#{bin}/brave-updater --help")
+ assert_match "update", output
+ end
+end
diff --git a/pyproject.toml b/pyproject.toml
new file mode 100644
index 0000000..11c9967
--- /dev/null
+++ b/pyproject.toml
@@ -0,0 +1,44 @@
+[build-system]
+requires = ["setuptools>=68"]
+build-backend = "setuptools.build_meta"
+
+[project]
+name = "brave-updater"
+dynamic = ["version"]
+description = "Unofficial CLI to download and update Brave Browser from official Brave-controlled release sources."
+readme = "README.md"
+requires-python = ">=3.8"
+license = "MIT"
+authors = [
+ { name = "HackEAC" }
+]
+keywords = ["brave", "browser", "updater", "installer", "cli"]
+classifiers = [
+ "Development Status :: 4 - Beta",
+ "Environment :: Console",
+ "Intended Audience :: End Users/Desktop",
+ "Operating System :: MacOS",
+ "Operating System :: Microsoft :: Windows",
+ "Operating System :: POSIX :: Linux",
+ "Programming Language :: Python :: 3",
+ "Programming Language :: Python :: 3 :: Only",
+ "Programming Language :: Python :: 3.8",
+ "Programming Language :: Python :: 3.9",
+ "Programming Language :: Python :: 3.10",
+ "Programming Language :: Python :: 3.11",
+ "Programming Language :: Python :: 3.12",
+ "Topic :: Internet :: WWW/HTTP",
+ "Topic :: System :: Installation/Setup",
+]
+
+[project.scripts]
+brave-updater = "brave_updater.cli:main"
+
+[tool.setuptools]
+package-dir = {"" = "src"}
+
+[tool.setuptools.packages.find]
+where = ["src"]
+
+[tool.setuptools.dynamic]
+version = {attr = "brave_updater.__version__"}
diff --git a/setup.py b/setup.py
new file mode 100644
index 0000000..b024da8
--- /dev/null
+++ b/setup.py
@@ -0,0 +1,4 @@
+from setuptools import setup
+
+
+setup()
diff --git a/src/brave_updater/__init__.py b/src/brave_updater/__init__.py
new file mode 100644
index 0000000..ba23d19
--- /dev/null
+++ b/src/brave_updater/__init__.py
@@ -0,0 +1,3 @@
+"""brave_updater package."""
+
+__version__ = "1.1.0"
diff --git a/src/brave_updater/__main__.py b/src/brave_updater/__main__.py
new file mode 100644
index 0000000..3c3fe91
--- /dev/null
+++ b/src/brave_updater/__main__.py
@@ -0,0 +1,5 @@
+from brave_updater.cli import main
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/src/brave_updater/cli.py b/src/brave_updater/cli.py
new file mode 100644
index 0000000..558c836
--- /dev/null
+++ b/src/brave_updater/cli.py
@@ -0,0 +1,176 @@
+"""CLI entrypoint for brave-updater."""
+
+from __future__ import annotations
+
+import argparse
+import json
+from pathlib import Path
+from typing import List, Optional
+
+from brave_updater.console import make_logger, prompt_yes_no
+from brave_updater.download import sanitize_filename
+from brave_updater.providers import extract_chromium_version, pick_asset
+from brave_updater.service import BraveUpdater
+from brave_updater.system import detect_system
+
+
+def build_parser() -> argparse.ArgumentParser:
+ parser = argparse.ArgumentParser(
+ description="Download and update Brave Browser from Brave-controlled release sources."
+ )
+ parser.add_argument("--dir", default=str(Path.home() / "Downloads"), help="Download directory (default: ~/Downloads)")
+ parser.add_argument("--install", action="store_true", help="Legacy mode: prompt to install after download")
+ parser.add_argument("--print-only", action="store_true", help="Legacy mode: print the selected download URL only")
+ parser.add_argument("--skip-verify", action="store_true", help="Skip integrity verification (not recommended)")
+ parser.add_argument("--source", default="github", choices=("github", "official", "auto"), help="Release source strategy")
+
+ subparsers = parser.add_subparsers(dest="command")
+
+ check_parser = subparsers.add_parser("check", help="Inspect the installed version and the latest available release")
+ check_parser.add_argument("--json", action="store_true", help="Emit machine-readable output")
+ check_parser.add_argument("--source", default="github", choices=("github", "official", "auto"), help="Release source strategy")
+
+ download_parser = subparsers.add_parser("download", help="Download the best installer for this system")
+ download_parser.add_argument("--dir", default=str(Path.home() / "Downloads"), help="Download directory")
+ download_parser.add_argument("--skip-verify", action="store_true", help="Skip integrity verification")
+ download_parser.add_argument("--force", action="store_true", help="Re-download even if the target file already exists")
+ download_parser.add_argument("--source", default="github", choices=("github", "official", "auto"), help="Release source strategy")
+
+ update_parser = subparsers.add_parser("update", help="Download and optionally install a new version when available")
+ update_parser.add_argument("--dir", default=str(Path.home() / "Downloads"), help="Download directory")
+ update_parser.add_argument("--skip-verify", action="store_true", help="Skip integrity verification")
+ update_parser.add_argument("--force", action="store_true", help="Update even if the installed version matches the latest")
+ update_parser.add_argument("--yes", action="store_true", help="Install without prompting")
+ update_parser.add_argument("--source", default="github", choices=("github", "official", "auto"), help="Release source strategy")
+
+ install_parser = subparsers.add_parser("install", help="Install an already-downloaded Brave package")
+ install_parser.add_argument("path", help="Path to a downloaded installer package")
+
+ return parser
+
+
+def main(argv: Optional[List[str]] = None) -> int:
+ parser = build_parser()
+ args = parser.parse_args(argv)
+
+ if args.command == "check":
+ return run_check(args)
+ if args.command == "download":
+ return run_download(args)
+ if args.command == "update":
+ return run_update(args)
+ if args.command == "install":
+ return run_install(args)
+ return run_legacy(args)
+
+
+def run_check(args: argparse.Namespace) -> int:
+ logger = make_logger(print_only_mode=args.json)
+ updater = BraveUpdater(source=args.source, log=logger)
+ result = updater.check()
+
+ if args.json:
+ payload = {
+ "os": result.system.os_name,
+ "arch": result.system.arch,
+ "linux_family": result.system.linux_family,
+ "installed_version": result.installed_version,
+ "latest_version": result.latest_version,
+ "update_available": result.update_available,
+ "asset_name": result.selected_asset.name,
+ "asset_url": result.selected_asset.url,
+ "selection_reason": result.selection_reason,
+ "release_source": result.release_source,
+ "chromium_version": result.chromium_version,
+ }
+ print(json.dumps(payload, indent=2, sort_keys=True))
+ return 0
+
+ logger(
+ "Detected system: os={}, arch={}, linux_family={}".format(
+ result.system.os_name,
+ result.system.arch,
+ result.system.linux_family,
+ )
+ )
+ logger("Installed version: {}".format(result.installed_version or "not found"))
+ logger("Latest stable release: {}".format(result.latest_version))
+ logger("Chosen asset: {}".format(result.selected_asset.name))
+ logger("Reason: {}".format(result.selection_reason))
+ if result.chromium_version:
+ logger("Chromium base: {}".format(result.chromium_version))
+ if result.update_available:
+ logger("Update available.")
+ else:
+ logger("Already up to date.")
+ return 0
+
+
+def run_download(args: argparse.Namespace) -> int:
+ logger = make_logger()
+ updater = BraveUpdater(source=args.source, log=logger)
+ destination = updater.download_latest(
+ directory=Path(args.dir).expanduser().resolve(),
+ skip_verify=args.skip_verify,
+ force=args.force,
+ )
+ logger("Saved installer to {}".format(destination))
+ return 0
+
+
+def run_update(args: argparse.Namespace) -> int:
+ logger = make_logger()
+ updater = BraveUpdater(source=args.source, log=logger)
+ updater.update(
+ directory=Path(args.dir).expanduser().resolve(),
+ skip_verify=args.skip_verify,
+ force=args.force,
+ assume_yes=args.yes,
+ )
+ return 0
+
+
+def run_install(args: argparse.Namespace) -> int:
+ logger = make_logger()
+ updater = BraveUpdater(source="github", log=logger)
+ updater.install(Path(args.path).expanduser().resolve())
+ return 0
+
+
+def run_legacy(args: argparse.Namespace) -> int:
+ logger = make_logger(print_only_mode=args.print_only)
+ updater = BraveUpdater(source=args.source, log=logger)
+ release = updater.load_release()
+ system_info = detect_system()
+ asset, reason = pick_asset(release.assets, system_info)
+
+ logger(
+ "Detected system: os={}, arch={}, linux_family={}".format(
+ system_info.os_name, system_info.arch, system_info.linux_family
+ )
+ )
+ logger("Latest stable release: {} ({})".format(release.version, release.name))
+ logger("Chosen asset: {}".format(asset.name))
+ logger("Reason: {}".format(reason))
+ chromium_version = extract_chromium_version(release.body)
+ if chromium_version:
+ logger("Chromium base: {}".format(chromium_version))
+
+ if args.print_only:
+ print(asset.url)
+ return 0
+
+ destination = Path(args.dir).expanduser().resolve() / sanitize_filename(asset.name)
+ updater.download_latest(
+ directory=Path(args.dir).expanduser().resolve(),
+ skip_verify=args.skip_verify,
+ force=False,
+ )
+
+ if args.install:
+ if prompt_yes_no("Install {} now?".format(destination.name), default_no=True):
+ updater.install(destination)
+ logger("Done.")
+ else:
+ logger("Install skipped.")
+ return 0
diff --git a/src/brave_updater/console.py b/src/brave_updater/console.py
new file mode 100644
index 0000000..eef0b34
--- /dev/null
+++ b/src/brave_updater/console.py
@@ -0,0 +1,30 @@
+"""Console helpers shared by the CLI and service layer."""
+
+from __future__ import annotations
+
+import sys
+from typing import Callable
+
+LogFn = Callable[[str], None]
+
+
+def make_logger(print_only_mode: bool = False) -> LogFn:
+ """Create a simple line-oriented logger."""
+
+ def log(message: str) -> None:
+ stream = sys.stderr if print_only_mode else sys.stdout
+ print(message, file=stream, flush=True)
+
+ return log
+
+
+def prompt_yes_no(question: str, default_no: bool = True) -> bool:
+ """Prompt for confirmation when stdin is interactive."""
+ if not sys.stdin.isatty():
+ return not default_no
+
+ suffix = " [y/N]: " if default_no else " [Y/n]: "
+ answer = input(question + suffix).strip().lower()
+ if not answer:
+ return not default_no
+ return answer in ("y", "yes")
diff --git a/src/brave_updater/download.py b/src/brave_updater/download.py
new file mode 100644
index 0000000..e94c9b5
--- /dev/null
+++ b/src/brave_updater/download.py
@@ -0,0 +1,15 @@
+"""Local file helpers used by the updater service."""
+
+from __future__ import annotations
+
+import os
+
+
+def sanitize_filename(filename: str) -> str:
+ filename = os.path.basename(filename)
+ for item in ("..", "/", "\\", "\x00"):
+ filename = filename.replace(item, "")
+ if len(filename) > 255:
+ name, extension = os.path.splitext(filename)
+ filename = name[:255 - len(extension)] + extension
+ return filename
diff --git a/src/brave_updater/installers.py b/src/brave_updater/installers.py
new file mode 100644
index 0000000..c74855b
--- /dev/null
+++ b/src/brave_updater/installers.py
@@ -0,0 +1,170 @@
+"""Platform-specific installer adapters."""
+
+from __future__ import annotations
+
+import os
+import shutil
+import subprocess
+import tempfile
+import time
+from pathlib import Path
+from typing import Protocol
+
+from brave_updater.console import LogFn
+from brave_updater.models import SystemInfo
+
+
+class Installer(Protocol):
+ def install(self, package_path: Path) -> None:
+ raise NotImplementedError
+
+
+class MacOSInstaller:
+ def __init__(self, log: LogFn):
+ self.log = log
+
+ def install(self, package_path: Path) -> None:
+ if not package_path.exists():
+ raise FileNotFoundError("Install file not found: {}".format(package_path))
+
+ suffix = package_path.suffix.lower()
+ if suffix == ".pkg":
+ self.log("Installing .pkg via macOS installer...")
+ subprocess.check_call(["sudo", "installer", "-pkg", str(package_path), "-target", "/"], timeout=600)
+ self.log("Install finished.")
+ return
+
+ if suffix != ".dmg":
+ raise RuntimeError("macOS install expects .dmg or .pkg")
+
+ mountpoint = Path("/Volumes/BraveTmpMount")
+ mounted = False
+ try:
+ if mountpoint.exists():
+ subprocess.run(["hdiutil", "detach", str(mountpoint), "-quiet"], capture_output=True, timeout=5, check=False)
+
+ mountpoint.mkdir(parents=True, exist_ok=True)
+ self.log("Mounting DMG...")
+ subprocess.check_call(
+ ["hdiutil", "attach", str(package_path), "-mountpoint", str(mountpoint), "-nobrowse", "-quiet"],
+ timeout=60,
+ )
+ mounted = True
+
+ app_path = mountpoint / "Brave Browser.app"
+ if not app_path.exists():
+ apps = list(mountpoint.glob("*.app"))
+ if not apps:
+ raise RuntimeError("Could not find .app inside the DMG")
+ app_path = apps[0]
+
+ target = Path("/Applications") / app_path.name
+ with tempfile.TemporaryDirectory(prefix="brave-updater-") as temp_dir:
+ staged_app = Path(temp_dir) / app_path.name
+ self.log("Staging application bundle...")
+ subprocess.check_call(["cp", "-R", str(app_path), str(staged_app)], timeout=300)
+
+ temp_target = Path("/Applications") / (app_path.name + ".new")
+ if temp_target.exists():
+ subprocess.check_call(["sudo", "rm", "-rf", str(temp_target)], timeout=30)
+ subprocess.check_call(["sudo", "cp", "-R", str(staged_app), str(temp_target)], timeout=300)
+ _replace_macos_app_bundle(target, temp_target)
+
+ self.log("Brave copied to /Applications.")
+ finally:
+ if mounted:
+ self.log("Unmounting DMG...")
+ for _ in range(3):
+ try:
+ subprocess.run(["hdiutil", "detach", str(mountpoint), "-quiet"], timeout=10, capture_output=True, check=False)
+ break
+ except Exception:
+ time.sleep(1)
+
+
+class WindowsInstaller:
+ def __init__(self, log: LogFn):
+ self.log = log
+
+ def install(self, package_path: Path) -> None:
+ if not package_path.exists():
+ raise FileNotFoundError("Install file not found: {}".format(package_path))
+ self.log("Launching installer...")
+ os.startfile(str(package_path)) # type: ignore[attr-defined]
+
+
+class LinuxInstaller:
+ def __init__(self, log: LogFn, family: str):
+ self.log = log
+ self.family = family
+
+ def install(self, package_path: Path) -> None:
+ if not package_path.exists():
+ raise FileNotFoundError("Install file not found: {}".format(package_path))
+
+ if self.family not in {"debian", "rhel"}:
+ raise RuntimeError(
+ "Unsupported Linux distribution family: {}. Only Debian-like and RHEL-like systems are supported.".format(
+ self.family
+ )
+ )
+
+ suffix = package_path.suffix.lower()
+ if suffix == ".deb":
+ self.log("Installing .deb package...")
+ if shutil.which("apt-get"):
+ subprocess.check_call(["sudo", "apt-get", "install", "-y", str(package_path)], timeout=600)
+ else:
+ subprocess.check_call(["sudo", "dpkg", "-i", str(package_path)], timeout=600)
+ self.log("Install finished.")
+ return
+
+ if suffix == ".rpm":
+ self.log("Installing .rpm package...")
+ if shutil.which("dnf"):
+ subprocess.check_call(["sudo", "dnf", "install", "-y", str(package_path)], timeout=600)
+ elif shutil.which("yum"):
+ subprocess.check_call(["sudo", "yum", "install", "-y", str(package_path)], timeout=600)
+ else:
+ subprocess.check_call(["sudo", "rpm", "-Uvh", str(package_path)], timeout=600)
+ self.log("Install finished.")
+ return
+
+ raise RuntimeError("Unsupported Linux installer: {} ({})".format(package_path.name, self.family))
+
+
+def get_installer(system_info: SystemInfo, log: LogFn) -> Installer:
+ if system_info.os_name == "macos":
+ return MacOSInstaller(log)
+ if system_info.os_name == "windows":
+ return WindowsInstaller(log)
+ if system_info.os_name == "linux":
+ return LinuxInstaller(log, system_info.linux_family)
+ raise RuntimeError("Unsupported operating system: {}".format(system_info.os_name))
+
+
+def _replace_macos_app_bundle(target: Path, temp_target: Path) -> None:
+ """Replace an app bundle while preserving a rollback path on failure."""
+ if not target.exists():
+ subprocess.check_call(["sudo", "mv", str(temp_target), str(target)], timeout=60)
+ return
+
+ backup_target = Path("/Applications") / (target.name + ".old")
+ if backup_target.exists():
+ subprocess.check_call(["sudo", "rm", "-rf", str(backup_target)], timeout=30)
+
+ subprocess.check_call(["sudo", "mv", str(target), str(backup_target)], timeout=60)
+ try:
+ subprocess.check_call(["sudo", "mv", str(temp_target), str(target)], timeout=60)
+ except Exception as exc:
+ try:
+ subprocess.check_call(["sudo", "mv", str(backup_target), str(target)], timeout=60)
+ except Exception as restore_exc:
+ raise RuntimeError(
+ "Failed to replace {} and restore the previous Brave app. Backup remains at {}.".format(
+ target, backup_target
+ )
+ ) from restore_exc
+ raise RuntimeError("Failed to replace {}; restored the previous Brave app.".format(target)) from exc
+
+ subprocess.check_call(["sudo", "rm", "-rf", str(backup_target)], timeout=30)
diff --git a/src/brave_updater/models.py b/src/brave_updater/models.py
new file mode 100644
index 0000000..85ffdf1
--- /dev/null
+++ b/src/brave_updater/models.py
@@ -0,0 +1,47 @@
+"""Data models for brave_updater."""
+
+from __future__ import annotations
+
+from dataclasses import dataclass
+from typing import List, Optional
+
+
+@dataclass(frozen=True)
+class SystemInfo:
+ os_name: str
+ arch: str
+ linux_family: str = "unknown"
+
+
+@dataclass(frozen=True)
+class ReleaseAsset:
+ name: str
+ url: str
+ digest: Optional[str] = None
+
+
+@dataclass(frozen=True)
+class ReleaseInfo:
+ version: str
+ name: str
+ body: str
+ assets: List[ReleaseAsset]
+ source: str = "github"
+
+ def find_asset(self, asset_name: str) -> Optional[ReleaseAsset]:
+ for asset in self.assets:
+ if asset.name == asset_name:
+ return asset
+ return None
+
+
+@dataclass(frozen=True)
+class UpdateCheckResult:
+ system: SystemInfo
+ installed_version: Optional[str]
+ latest_version: str
+ update_available: bool
+ selected_asset: ReleaseAsset
+ selection_reason: str
+ release_source: str
+ chromium_version: Optional[str] = None
diff --git a/src/brave_updater/network.py b/src/brave_updater/network.py
new file mode 100644
index 0000000..65967b4
--- /dev/null
+++ b/src/brave_updater/network.py
@@ -0,0 +1,247 @@
+"""Network and download helpers."""
+
+from __future__ import annotations
+
+import hashlib
+import json
+import os
+import socket
+import time
+import urllib.parse
+import urllib.request
+from pathlib import Path
+from typing import Any, Dict, Iterable, Optional
+from urllib.error import HTTPError, URLError
+import shutil
+
+from brave_updater import __version__
+from brave_updater.console import LogFn
+
+
+DEFAULT_ALLOWED_DOMAINS = {
+ "github.com",
+ "githubusercontent.com",
+}
+USER_AGENT = "brave-updater/{}".format(__version__)
+
+
+def validate_url(url: str, allowed_domains: Optional[Iterable[str]] = None) -> bool:
+ """Validate a URL against the allowlist."""
+ allowed = tuple((allowed_domains or DEFAULT_ALLOWED_DOMAINS))
+ try:
+ parsed = urllib.parse.urlparse(url)
+ domain = parsed.netloc.lower().split(":", 1)[0]
+ return any(domain == item or domain.endswith("." + item) for item in allowed)
+ except Exception:
+ return False
+
+
+def verify_file_hash(file_path: Path, expected_hash: str) -> bool:
+ """Verify a file SHA256 hash."""
+ sha256 = hashlib.sha256()
+ with file_path.open("rb") as handle:
+ while True:
+ chunk = handle.read(8192)
+ if not chunk:
+ break
+ sha256.update(chunk)
+ return sha256.hexdigest().lower() == expected_hash.lower().strip()
+
+
+def http_get_text(
+ url: str,
+ headers: Optional[Dict[str, str]] = None,
+ timeout: int = 30,
+ max_retries: int = 3,
+) -> str:
+ """Fetch text content with retry logic."""
+ request_headers = {"User-Agent": USER_AGENT}
+ if headers:
+ request_headers.update(headers)
+
+ last_error = None
+ for attempt in range(max_retries):
+ try:
+ request = urllib.request.Request(url, headers=request_headers)
+ with urllib.request.urlopen(request, timeout=timeout) as response:
+ return response.read().decode("utf-8")
+ except (URLError, socket.timeout, socket.error, UnicodeDecodeError) as exc:
+ last_error = exc
+ if attempt < max_retries - 1:
+ time.sleep(2 ** attempt)
+ raise RuntimeError("Failed to fetch {}: {}".format(url, last_error))
+
+
+def http_get_json(url: str, timeout: int = 30, max_retries: int = 3) -> Dict[str, Any]:
+ """Fetch JSON from an API endpoint."""
+ headers = {
+ "Accept": "application/vnd.github+json",
+ "User-Agent": USER_AGENT,
+ }
+ token = os.environ.get("GITHUB_TOKEN")
+ if token:
+ token = token.strip()
+ if token and "\n" not in token and " " not in token:
+ headers["Authorization"] = "Bearer {}".format(token)
+
+ text = http_get_text(url, headers=headers, timeout=timeout, max_retries=max_retries)
+ payload = json.loads(text)
+ if not isinstance(payload, dict):
+ raise ValueError("Expected a JSON object from {}".format(url))
+ return payload
+
+
+def partial_download_path(dest: Path) -> Path:
+ """Return the sidecar path used for resumable downloads."""
+ return dest.parent / (dest.name + ".part")
+
+
+def _extract_total_size(content_range: Optional[str], content_length: int, resume_pos: int) -> int:
+ if content_range:
+ parts = content_range.rsplit("/", 1)
+ if len(parts) == 2 and parts[1].isdigit():
+ return int(parts[1])
+ if content_length > 0:
+ return content_length + resume_pos
+ return 0
+
+
+def download_file(
+ url: str,
+ dest: Path,
+ log: LogFn,
+ expected_hash: Optional[str] = None,
+ timeout: int = 60,
+ max_retries: int = 3,
+ force: bool = False,
+ allowed_domains: Optional[Iterable[str]] = None,
+) -> Path:
+ """Download a file safely with checksum verification and resumable partials."""
+ if not validate_url(url, allowed_domains=allowed_domains):
+ raise ValueError("URL not from allowed domain: {}".format(url))
+
+ dest.parent.mkdir(parents=True, exist_ok=True)
+ partial = partial_download_path(dest)
+
+ if force:
+ if dest.exists():
+ dest.unlink()
+ if partial.exists():
+ partial.unlink()
+
+ if dest.exists() and not force:
+ if expected_hash:
+ if verify_file_hash(dest, expected_hash):
+ log("Using existing verified download: {}".format(dest))
+ return dest
+ log("Existing file checksum mismatch, re-downloading: {}".format(dest))
+ else:
+ log("Using existing download: {}".format(dest))
+ return dest
+
+ free_bytes = shutil.disk_usage(dest.parent).free
+ if free_bytes < 500 * 1024 * 1024:
+ raise RuntimeError("Insufficient disk space: need at least 500MB free")
+
+ last_error = None
+ for attempt in range(max_retries):
+ resume_pos = partial.stat().st_size if partial.exists() else 0
+ headers = {}
+ if resume_pos > 0:
+ headers["Range"] = "bytes={}-".format(resume_pos)
+ log("Resuming download from byte {}...".format(resume_pos))
+
+ try:
+ request = urllib.request.Request(url, headers=headers)
+ with urllib.request.urlopen(request, timeout=timeout) as response:
+ final_url = response.geturl()
+ if not validate_url(final_url, allowed_domains=allowed_domains):
+ raise ValueError("Redirected to disallowed host: {}".format(final_url))
+
+ status = getattr(response, "status", 200)
+ if resume_pos > 0 and status == 200:
+ log("Server ignored resume request, restarting from byte 0...")
+ partial.unlink()
+ resume_pos = 0
+ request = urllib.request.Request(url)
+ response.close()
+ with urllib.request.urlopen(request, timeout=timeout) as fresh_response:
+ final_url = fresh_response.geturl()
+ if not validate_url(final_url, allowed_domains=allowed_domains):
+ raise ValueError("Redirected to disallowed host: {}".format(final_url))
+ _write_download_stream(
+ fresh_response,
+ partial,
+ dest,
+ log,
+ expected_hash=expected_hash,
+ resume_pos=resume_pos,
+ )
+ return dest
+
+ _write_download_stream(
+ response,
+ partial,
+ dest,
+ log,
+ expected_hash=expected_hash,
+ resume_pos=resume_pos,
+ )
+ return dest
+ except HTTPError as exc:
+ last_error = exc
+ if exc.code == 416 and partial.exists():
+ log("Partial download no longer matches remote file, restarting...")
+ partial.unlink()
+ continue
+ if attempt < max_retries - 1:
+ log("Download attempt {} failed: {}. Retrying...".format(attempt + 1, exc))
+ time.sleep(2 ** attempt)
+ except (URLError, socket.timeout, socket.error, OSError, ValueError, RuntimeError) as exc:
+ last_error = exc
+ if attempt < max_retries - 1:
+ log("Download attempt {} failed: {}. Retrying...".format(attempt + 1, exc))
+ time.sleep(2 ** attempt)
+
+ raise RuntimeError("Failed to download {} after {} attempts: {}".format(url, max_retries, last_error))
+
+
+def _write_download_stream(
+ response: Any,
+ partial: Path,
+ dest: Path,
+ log: LogFn,
+ expected_hash: Optional[str],
+ resume_pos: int,
+) -> None:
+ content_length = int(response.headers.get("Content-Length", 0) or 0)
+ total = _extract_total_size(response.headers.get("Content-Range"), content_length, resume_pos)
+ mode = "ab" if resume_pos > 0 else "wb"
+ log("Downloading:\n {}\n-> {}".format(response.geturl(), dest))
+
+ read = resume_pos
+ with partial.open(mode) as handle:
+ while True:
+ chunk = response.read(1024 * 256)
+ if not chunk:
+ break
+ handle.write(chunk)
+ read += len(chunk)
+ if total:
+ pct = (read / total) * 100
+ print("\r{:6.2f}% ({}/{})".format(pct, read, total), end="", flush=True)
+ if total:
+ print()
+
+ actual_size = partial.stat().st_size
+ if total and actual_size != total:
+ raise RuntimeError("Size mismatch: expected {}, got {}".format(total, actual_size))
+
+ if expected_hash:
+ log("Verifying download integrity...")
+ if not verify_file_hash(partial, expected_hash):
+ raise RuntimeError("Download integrity check failed")
+ log("Integrity check passed.")
+
+ partial.replace(dest)
+ log("Download complete.")
diff --git a/src/brave_updater/providers.py b/src/brave_updater/providers.py
new file mode 100644
index 0000000..d8982ec
--- /dev/null
+++ b/src/brave_updater/providers.py
@@ -0,0 +1,198 @@
+"""Release-provider implementations and asset selection."""
+
+from __future__ import annotations
+
+import re
+from typing import List, Optional, Protocol, Tuple
+
+from brave_updater.console import LogFn
+from brave_updater.models import ReleaseAsset, ReleaseInfo, SystemInfo
+from brave_updater.network import http_get_json, http_get_text
+
+
+GITHUB_API_LATEST = "https://api.github.com/repos/brave/brave-browser/releases/latest"
+
+
+class ReleaseProvider(Protocol):
+ source_name: str
+
+ def fetch_latest_release(self) -> ReleaseInfo:
+ raise NotImplementedError
+
+
+class GitHubReleaseProvider:
+ source_name = "github"
+
+ def fetch_latest_release(self) -> ReleaseInfo:
+ payload = http_get_json(GITHUB_API_LATEST)
+ return release_from_payload(payload, source=self.source_name)
+
+
+class OfficialReleaseProvider(GitHubReleaseProvider):
+ source_name = "official-github"
+
+
+def resolve_provider(source: str, log: LogFn) -> ReleaseProvider:
+ normalized = source.lower()
+ if normalized == "github":
+ return GitHubReleaseProvider()
+ if normalized in ("official", "auto"):
+ log("Using Brave's official GitHub release feed for source={!r}.".format(source))
+ return OfficialReleaseProvider()
+ raise ValueError("Unsupported source: {}".format(source))
+
+
+def validate_release_data(payload: dict) -> None:
+ required_fields = ("tag_name", "assets")
+ for field in required_fields:
+ if field not in payload:
+ raise ValueError("Invalid release payload: missing {}".format(field))
+ if not isinstance(payload["assets"], list):
+ raise ValueError("Invalid release payload: assets must be a list")
+
+
+def release_from_payload(payload: dict, source: str) -> ReleaseInfo:
+ validate_release_data(payload)
+ assets: List[ReleaseAsset] = []
+ for raw_asset in payload["assets"]:
+ if not isinstance(raw_asset, dict):
+ raise ValueError("Invalid release payload: asset must be a mapping")
+ name = raw_asset.get("name")
+ url = raw_asset.get("browser_download_url")
+ if not name or not url:
+ raise ValueError("Invalid release payload: asset missing name or URL")
+ assets.append(
+ ReleaseAsset(
+ name=name,
+ url=url,
+ digest=raw_asset.get("digest"),
+ )
+ )
+ return ReleaseInfo(
+ version=str(payload.get("tag_name", "")),
+ name=str(payload.get("name", "")),
+ body=str(payload.get("body", "")),
+ assets=assets,
+ source=source,
+ )
+
+
+def pick_asset(assets: List[ReleaseAsset], system_info: SystemInfo) -> Tuple[ReleaseAsset, str]:
+ """Pick the best installer asset for the current platform."""
+ names = [asset.name for asset in assets]
+
+ def find(predicate) -> Optional[ReleaseAsset]:
+ for asset in assets:
+ name = asset.name.lower()
+ if predicate(name):
+ return asset
+ return None
+
+ if system_info.os_name == "macos":
+ asset = find(lambda name: name.endswith(".dmg") and "universal" in name)
+ if asset:
+ return asset, "macOS: chose universal .dmg"
+ if system_info.arch == "arm64":
+ asset = find(lambda name: name.endswith(".dmg") and ("arm64" in name or "aarch64" in name))
+ if asset:
+ return asset, "macOS ARM: chose arm64 .dmg"
+ if system_info.arch == "x64":
+ asset = find(lambda name: name.endswith(".dmg") and ("x64" in name or "amd64" in name or "x86_64" in name))
+ if asset:
+ return asset, "macOS Intel: chose x64 .dmg"
+ asset = find(lambda name: name.endswith(".dmg"))
+ if asset:
+ return asset, "macOS: chose first available .dmg"
+ asset = find(lambda name: name.endswith(".pkg"))
+ if asset:
+ return asset, "macOS: chose .pkg (no .dmg found)"
+
+ if system_info.os_name == "windows":
+ asset = find(lambda name: name.endswith(".exe") and "standalone" in name and "setup" in name)
+ if asset:
+ return asset, "Windows: chose StandaloneSetup.exe (offline installer)"
+ asset = find(lambda name: name.endswith(".exe") and "setup" in name)
+ if asset:
+ return asset, "Windows: chose Setup.exe"
+ asset = find(lambda name: name.endswith(".exe"))
+ if asset:
+ return asset, "Windows: chose first available .exe"
+
+ if system_info.os_name == "linux":
+ if system_info.linux_family not in {"debian", "rhel"}:
+ raise RuntimeError(
+ "Unsupported Linux distribution family: {}. Only Debian-like and RHEL-like systems are currently supported.".format(
+ system_info.linux_family
+ )
+ )
+
+ arch_tokens: List[str] = []
+ if system_info.arch == "x64":
+ arch_tokens = ["amd64", "x86_64"]
+ elif system_info.arch == "arm64":
+ arch_tokens = ["arm64", "aarch64"]
+
+ if system_info.linux_family == "debian":
+ for token in arch_tokens:
+ asset = find(lambda name, item=token: name.endswith(".deb") and item in name)
+ if asset:
+ return asset, "Linux Debian-like: chose .deb matching {}".format(token)
+ asset = find(lambda name: name.endswith(".deb"))
+ if asset:
+ return asset, "Linux Debian-like: chose first available .deb"
+
+ if system_info.linux_family == "rhel":
+ for token in arch_tokens:
+ asset = find(lambda name, item=token: name.endswith(".rpm") and item in name)
+ if asset:
+ return asset, "Linux RHEL-like: chose .rpm matching {}".format(token)
+ asset = find(lambda name: name.endswith(".rpm"))
+ if asset:
+ return asset, "Linux RHEL-like: chose first available .rpm"
+
+ raise RuntimeError(
+ "Could not find a suitable asset for {}. Assets seen: {}".format(system_info, names[:30])
+ )
+
+
+def extract_chromium_version(body: str) -> Optional[str]:
+ match = re.search(r"Chromium:\s*([0-9]+\.[0-9]+\.[0-9]+\.[0-9]+)", body)
+ if match:
+ return match.group(1)
+ return None
+
+
+def parse_sha256_digest(digest: Optional[str]) -> Optional[str]:
+ if digest and digest.startswith("sha256:"):
+ return digest.split(":", 1)[1]
+ return None
+
+
+def parse_checksum_text(text: str, asset_name: str) -> Optional[str]:
+ for raw_line in text.splitlines():
+ line = raw_line.strip()
+ if not line:
+ continue
+ match = re.match(r"([a-fA-F0-9]{64})\s+[* ]?(.+)$", line)
+ if not match:
+ continue
+ checksum, name = match.groups()
+ if name.strip() == asset_name:
+ return checksum.lower()
+ return None
+
+
+def resolve_expected_hash(asset: ReleaseAsset, release: ReleaseInfo, log: LogFn) -> Optional[str]:
+ digest = parse_sha256_digest(asset.digest)
+ if digest:
+ return digest
+
+ checksum_asset = release.find_asset(asset.name + ".sha256")
+ if not checksum_asset:
+ checksum_asset = release.find_asset(asset.name + ".sha256.txt")
+ if not checksum_asset:
+ return None
+
+ log("Fetching checksum sidecar for {}...".format(asset.name))
+ checksum_text = http_get_text(checksum_asset.url)
+ return parse_checksum_text(checksum_text, asset.name)
diff --git a/src/brave_updater/service.py b/src/brave_updater/service.py
new file mode 100644
index 0000000..aedc339
--- /dev/null
+++ b/src/brave_updater/service.py
@@ -0,0 +1,116 @@
+"""High-level update orchestration."""
+
+from __future__ import annotations
+
+from pathlib import Path
+from typing import Optional, Tuple
+
+from brave_updater.console import LogFn, prompt_yes_no
+from brave_updater.download import sanitize_filename
+from brave_updater.installers import get_installer
+from brave_updater.models import ReleaseAsset, ReleaseInfo, SystemInfo, UpdateCheckResult
+from brave_updater.network import download_file
+from brave_updater.providers import (
+ extract_chromium_version,
+ pick_asset,
+ resolve_expected_hash,
+ resolve_provider,
+)
+from brave_updater.system import compare_versions, detect_installed_version, detect_system
+
+
+class BraveUpdater:
+ def __init__(self, source: str, log: LogFn):
+ self.log = log
+ self.provider = resolve_provider(source, log)
+
+ def load_release(self) -> ReleaseInfo:
+ return self.provider.fetch_latest_release()
+
+ def _resolve_target(self) -> Tuple[SystemInfo, ReleaseInfo, ReleaseAsset, str]:
+ system_info = detect_system()
+ release = self.load_release()
+ asset, reason = pick_asset(release.assets, system_info)
+ return system_info, release, asset, reason
+
+ def check(self) -> UpdateCheckResult:
+ system_info, release, asset, reason = self._resolve_target()
+ installed_version = detect_installed_version(system_info)
+ latest_version = release.version.lstrip("v")
+ update_available = installed_version is None or compare_versions(installed_version, latest_version) < 0
+ return UpdateCheckResult(
+ system=system_info,
+ installed_version=installed_version,
+ latest_version=latest_version,
+ update_available=update_available,
+ selected_asset=asset,
+ selection_reason=reason,
+ release_source=release.source,
+ chromium_version=extract_chromium_version(release.body),
+ )
+
+ def download_latest(
+ self,
+ directory: Path,
+ skip_verify: bool = False,
+ force: bool = False,
+ ) -> Path:
+ _, release, asset, _ = self._resolve_target()
+ expected_hash = None if skip_verify else resolve_expected_hash(asset, release, self.log)
+ destination = directory / sanitize_filename(asset.name)
+ return download_file(
+ asset.url,
+ destination,
+ log=self.log,
+ expected_hash=expected_hash,
+ force=force,
+ )
+
+ def update(
+ self,
+ directory: Path,
+ skip_verify: bool = False,
+ force: bool = False,
+ assume_yes: bool = False,
+ ) -> Optional[Path]:
+ system_info, release, asset, reason = self._resolve_target()
+ installed_version = detect_installed_version(system_info)
+ latest_version = release.version.lstrip("v")
+ update_available = installed_version is None or compare_versions(installed_version, latest_version) < 0
+
+ check = UpdateCheckResult(
+ system=system_info,
+ installed_version=installed_version,
+ latest_version=latest_version,
+ update_available=update_available,
+ selected_asset=asset,
+ selection_reason=reason,
+ release_source=release.source,
+ chromium_version=extract_chromium_version(release.body),
+ )
+ if not force and not check.update_available:
+ self.log("Brave is already up to date ({}).".format(check.latest_version))
+ return None
+
+ expected_hash = None if skip_verify else resolve_expected_hash(check.selected_asset, release, self.log)
+ destination = directory / sanitize_filename(check.selected_asset.name)
+ download_file(
+ check.selected_asset.url,
+ destination,
+ log=self.log,
+ expected_hash=expected_hash,
+ force=force,
+ )
+
+ if assume_yes or prompt_yes_no("Install {} now?".format(destination.name), default_no=True):
+ installer = get_installer(check.system, self.log)
+ installer.install(destination)
+ self.log("Done.")
+ else:
+ self.log("Install skipped.")
+
+ return destination
+
+ def install(self, package_path: Path) -> None:
+ installer = get_installer(detect_system(), self.log)
+ installer.install(package_path)
diff --git a/src/brave_updater/system.py b/src/brave_updater/system.py
new file mode 100644
index 0000000..b9d0e16
--- /dev/null
+++ b/src/brave_updater/system.py
@@ -0,0 +1,189 @@
+"""System detection and installed-version helpers."""
+
+from __future__ import annotations
+
+import os
+import platform
+import plistlib
+import re
+import shutil
+import subprocess
+import sys
+from pathlib import Path
+from typing import Iterable, Optional, Sequence, Tuple
+
+from brave_updater.models import SystemInfo
+
+
+def detect_linux_family(os_release_path: Path = Path("/etc/os-release")) -> str:
+ if not os_release_path.exists():
+ return "unknown"
+
+ text = os_release_path.read_text(errors="ignore")
+ values = {}
+ for line in text.splitlines():
+ line = line.strip()
+ if not line or line.startswith("#") or "=" not in line:
+ continue
+ key, value = line.split("=", 1)
+ values[key.strip()] = value.strip().strip('"')
+
+ distro_id = values.get("ID", "").lower()
+ distro_like = values.get("ID_LIKE", "").lower()
+
+ def contains_any(haystack: str, words: Sequence[str]) -> bool:
+ return any(word in haystack for word in words)
+
+ if contains_any(distro_id, ("debian", "ubuntu", "linuxmint", "pop")) or contains_any(distro_like, ("debian", "ubuntu")):
+ return "debian"
+ if contains_any(distro_id, ("fedora", "rhel", "centos", "rocky", "almalinux", "opensuse", "sles")) or contains_any(distro_like, ("rhel", "fedora", "suse")):
+ return "rhel"
+ if contains_any(distro_id, ("arch", "manjaro", "endeavouros")) or contains_any(distro_like, ("arch",)):
+ return "arch"
+ return "unknown"
+
+
+def detect_system() -> SystemInfo:
+ sys_platform = sys.platform.lower()
+ machine = platform.machine().lower()
+
+ if sys_platform.startswith("darwin"):
+ os_name = "macos"
+ elif sys_platform.startswith("win"):
+ os_name = "windows"
+ elif sys_platform.startswith("linux"):
+ os_name = "linux"
+ else:
+ os_name = "unknown"
+
+ if machine in ("arm64", "aarch64"):
+ arch = "arm64"
+ elif machine in ("x86_64", "amd64"):
+ arch = "x64"
+ elif machine in ("i386", "i686", "x86"):
+ arch = "x86"
+ else:
+ arch = "unknown"
+
+ linux_family = detect_linux_family() if os_name == "linux" else "unknown"
+ return SystemInfo(os_name=os_name, arch=arch, linux_family=linux_family)
+
+
+def parse_version_string(text: str) -> Optional[str]:
+ match = re.search(r"(\d+(?:\.\d+)+)", text)
+ if not match:
+ return None
+ return match.group(1)
+
+
+def version_tuple(version: Optional[str]) -> Tuple[int, ...]:
+ if not version:
+ return tuple()
+ parsed = parse_version_string(version) or version
+ return tuple(int(part) for part in parsed.strip().lstrip("v").split(".") if part.isdigit())
+
+
+def compare_versions(current: Optional[str], latest: Optional[str]) -> int:
+ left = version_tuple(current)
+ right = version_tuple(latest)
+ max_len = max(len(left), len(right))
+ padded_left = left + (0,) * (max_len - len(left))
+ padded_right = right + (0,) * (max_len - len(right))
+ if padded_left == padded_right:
+ return 0
+ if padded_left < padded_right:
+ return -1
+ return 1
+
+
+def read_macos_version(plist_paths: Optional[Iterable[Path]] = None) -> Optional[str]:
+ candidates = list(plist_paths or [
+ Path("/Applications/Brave Browser.app/Contents/Info.plist"),
+ Path.home() / "Applications/Brave Browser.app/Contents/Info.plist",
+ ])
+ for plist_path in candidates:
+ if not plist_path.exists():
+ continue
+ with plist_path.open("rb") as handle:
+ data = plistlib.load(handle)
+ version = normalize_macos_app_version(
+ data.get("CFBundleShortVersionString"),
+ data.get("CFBundleVersion"),
+ )
+ if version:
+ return str(version)
+ return None
+
+
+def normalize_macos_app_version(short_version: Optional[str], bundle_version: Optional[str]) -> Optional[str]:
+ short_text = str(short_version) if short_version else None
+ bundle_text = str(bundle_version) if bundle_version else None
+
+ short_tuple = version_tuple(short_text)
+ if short_tuple and short_tuple[0] >= 100 and bundle_text:
+ match = re.fullmatch(r"(\d+)\.(\d+)", bundle_text)
+ if match:
+ major_minor, patch = match.groups()
+ if len(major_minor) > 1:
+ major = int(major_minor[0])
+ minor = int(major_minor[1:])
+ return "{}.{}.{}".format(major, minor, int(patch))
+
+ if short_text:
+ return short_text
+ if bundle_text:
+ return bundle_text
+ return None
+
+
+def read_linux_version(
+ commands: Optional[Iterable[Sequence[str]]] = None,
+ runner=subprocess.run,
+ which=shutil.which,
+) -> Optional[str]:
+ for command in commands or (("brave-browser", "--version"), ("brave-browser-stable", "--version")):
+ executable = command[0]
+ if which(executable) is None:
+ continue
+ result = runner(command, capture_output=True, text=True, check=False)
+ version = parse_version_string(result.stdout or result.stderr or "")
+ if version:
+ return version
+ return None
+
+
+def read_windows_version(
+ executable_paths: Optional[Iterable[Path]] = None,
+ runner=subprocess.run,
+ powershell: str = "powershell",
+) -> Optional[str]:
+ candidates = list(executable_paths or [
+ Path(os.environ.get("ProgramFiles", "")) / "BraveSoftware/Brave-Browser/Application/brave.exe",
+ Path(os.environ.get("ProgramFiles(x86)", "")) / "BraveSoftware/Brave-Browser/Application/brave.exe",
+ Path(os.environ.get("LOCALAPPDATA", "")) / "BraveSoftware/Brave-Browser/Application/brave.exe",
+ ])
+ for executable in candidates:
+ if not executable.exists():
+ continue
+ command = [
+ powershell,
+ "-NoProfile",
+ "-Command",
+ "(Get-Item '{}').VersionInfo.ProductVersion".format(str(executable).replace("'", "''")),
+ ]
+ result = runner(command, capture_output=True, text=True, check=False)
+ version = parse_version_string(result.stdout or result.stderr or "")
+ if version:
+ return version
+ return None
+
+
+def detect_installed_version(system_info: Optional[SystemInfo] = None) -> Optional[str]:
+ info = system_info or detect_system()
+ if info.os_name == "macos":
+ return read_macos_version()
+ if info.os_name == "linux":
+ return read_linux_version()
+ if info.os_name == "windows":
+ return read_windows_version()
+ return None
diff --git a/tests/__init__.py b/tests/__init__.py
new file mode 100644
index 0000000..f823a39
--- /dev/null
+++ b/tests/__init__.py
@@ -0,0 +1,9 @@
+from pathlib import Path
+import sys
+
+
+ROOT = Path(__file__).resolve().parents[1]
+SRC = ROOT / "src"
+
+if str(SRC) not in sys.path:
+ sys.path.insert(0, str(SRC))
diff --git a/tests/test_cli.py b/tests/test_cli.py
new file mode 100644
index 0000000..f8ad20b
--- /dev/null
+++ b/tests/test_cli.py
@@ -0,0 +1,103 @@
+from contextlib import redirect_stderr, redirect_stdout
+from io import StringIO
+import unittest
+from unittest import mock
+
+from brave_updater import cli
+from brave_updater.models import ReleaseAsset, ReleaseInfo, SystemInfo
+
+
+class CliTests(unittest.TestCase):
+ def test_legacy_print_only_outputs_url(self) -> None:
+ release = ReleaseInfo(
+ version="v1.2.3",
+ name="Release v1.2.3",
+ body="Chromium: 120.0.0.1",
+ assets=[ReleaseAsset(name="Brave-Browser-universal.dmg", url="https://github.com/example.dmg", digest="sha256:" + "a" * 64)],
+ )
+
+ stdout = StringIO()
+ stderr = StringIO()
+ with redirect_stdout(stdout), redirect_stderr(stderr):
+ with mock.patch("brave_updater.cli.detect_system", return_value=SystemInfo("macos", "arm64", "unknown")):
+ with mock.patch("brave_updater.cli.pick_asset", return_value=(release.assets[0], "macOS: chose universal .dmg")):
+ with mock.patch("brave_updater.cli.BraveUpdater") as updater_cls:
+ updater = updater_cls.return_value
+ updater.load_release.return_value = release
+ exit_code = cli.main(["--print-only"])
+
+ self.assertEqual(exit_code, 0)
+ self.assertEqual(stdout.getvalue().strip(), "https://github.com/example.dmg")
+ self.assertIn("Chosen asset", stderr.getvalue())
+
+ def test_update_subcommand_delegates_to_service(self) -> None:
+ with mock.patch("brave_updater.cli.BraveUpdater") as updater_cls:
+ exit_code = cli.main(["update", "--dir", "/tmp", "--yes"])
+
+ self.assertEqual(exit_code, 0)
+ updater_cls.return_value.update.assert_called_once()
+
+ def test_download_subcommand_delegates_to_service(self) -> None:
+ stdout = StringIO()
+ with redirect_stdout(stdout):
+ with mock.patch("brave_updater.cli.BraveUpdater") as updater_cls:
+ exit_code = cli.main(["download", "--dir", "/tmp", "--force"])
+
+ self.assertEqual(exit_code, 0)
+ updater_cls.return_value.download_latest.assert_called_once()
+
+ def test_install_subcommand_delegates_to_service(self) -> None:
+ with mock.patch("brave_updater.cli.BraveUpdater") as updater_cls:
+ exit_code = cli.main(["install", "/tmp/brave.pkg"])
+
+ self.assertEqual(exit_code, 0)
+ updater_cls.return_value.install.assert_called_once()
+
+ def test_check_json_emits_machine_readable_output(self) -> None:
+ result = mock.Mock(
+ system=SystemInfo("linux", "x64", "debian"),
+ installed_version="1.2.2",
+ latest_version="1.2.3",
+ update_available=True,
+ selected_asset=ReleaseAsset(name="brave-browser.deb", url="https://github.com/example.deb"),
+ selection_reason="Linux Debian-like: chose first available .deb",
+ release_source="github",
+ chromium_version="120.0.0.1",
+ )
+
+ stdout = StringIO()
+ with redirect_stdout(stdout):
+ with mock.patch("brave_updater.cli.BraveUpdater") as updater_cls:
+ updater_cls.return_value.check.return_value = result
+ exit_code = cli.main(["check", "--json"])
+
+ self.assertEqual(exit_code, 0)
+ output = stdout.getvalue()
+ self.assertIn('"latest_version": "1.2.3"', output)
+ self.assertIn('"update_available": true', output)
+
+ def test_legacy_install_skips_when_prompt_declined(self) -> None:
+ release = ReleaseInfo(
+ version="v1.2.3",
+ name="Release v1.2.3",
+ body="",
+ assets=[ReleaseAsset(name="Brave-Browser-universal.dmg", url="https://github.com/example.dmg")],
+ )
+
+ stdout = StringIO()
+ with redirect_stdout(stdout):
+ with mock.patch("brave_updater.cli.detect_system", return_value=SystemInfo("macos", "arm64", "unknown")):
+ with mock.patch("brave_updater.cli.pick_asset", return_value=(release.assets[0], "reason")):
+ with mock.patch("brave_updater.cli.prompt_yes_no", return_value=False):
+ with mock.patch("brave_updater.cli.BraveUpdater") as updater_cls:
+ updater = updater_cls.return_value
+ updater.load_release.return_value = release
+ exit_code = cli.main(["--install", "--dir", "/tmp"])
+
+ self.assertEqual(exit_code, 0)
+ updater.download_latest.assert_called_once()
+ updater.install.assert_not_called()
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/tests/test_console.py b/tests/test_console.py
new file mode 100644
index 0000000..428720c
--- /dev/null
+++ b/tests/test_console.py
@@ -0,0 +1,54 @@
+from contextlib import redirect_stderr, redirect_stdout
+from io import StringIO
+import unittest
+from unittest import mock
+
+from brave_updater.console import make_logger, prompt_yes_no
+
+
+class ConsoleTests(unittest.TestCase):
+ def test_make_logger_writes_to_stdout_by_default(self) -> None:
+ output = StringIO()
+ logger = make_logger()
+
+ with redirect_stdout(output):
+ logger("hello")
+
+ self.assertEqual(output.getvalue().strip(), "hello")
+
+ def test_make_logger_writes_to_stderr_in_print_only_mode(self) -> None:
+ output = StringIO()
+ logger = make_logger(print_only_mode=True)
+
+ with redirect_stderr(output):
+ logger("hello")
+
+ self.assertEqual(output.getvalue().strip(), "hello")
+
+ def test_prompt_yes_no_uses_default_for_non_interactive_stdin(self) -> None:
+ fake_stdin = mock.Mock()
+ fake_stdin.isatty.return_value = False
+
+ with mock.patch("sys.stdin", fake_stdin):
+ self.assertFalse(prompt_yes_no("Continue?", default_no=True))
+ self.assertTrue(prompt_yes_no("Continue?", default_no=False))
+
+ def test_prompt_yes_no_accepts_yes_in_interactive_mode(self) -> None:
+ fake_stdin = mock.Mock()
+ fake_stdin.isatty.return_value = True
+
+ with mock.patch("sys.stdin", fake_stdin):
+ with mock.patch("builtins.input", return_value="yes"):
+ self.assertTrue(prompt_yes_no("Continue?", default_no=True))
+
+ def test_prompt_yes_no_rejects_no_in_interactive_mode(self) -> None:
+ fake_stdin = mock.Mock()
+ fake_stdin.isatty.return_value = True
+
+ with mock.patch("sys.stdin", fake_stdin):
+ with mock.patch("builtins.input", return_value="n"):
+ self.assertFalse(prompt_yes_no("Continue?", default_no=False))
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/tests/test_download.py b/tests/test_download.py
new file mode 100644
index 0000000..f1f8140
--- /dev/null
+++ b/tests/test_download.py
@@ -0,0 +1,193 @@
+import hashlib
+import threading
+import unittest
+from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
+from pathlib import Path
+from tempfile import TemporaryDirectory
+from unittest import mock
+from urllib.error import HTTPError
+
+from brave_updater.download import sanitize_filename
+from brave_updater.network import download_file, http_get_json, partial_download_path, validate_url
+
+
+CONTENT = b"brave-updater-test-payload" * 128
+
+
+class DownloadHandler(BaseHTTPRequestHandler):
+ def do_GET(self) -> None: # noqa: N802
+ if self.path == "/resume-ignored":
+ self.send_response(200)
+ self.send_header("Content-Length", str(len(CONTENT)))
+ self.end_headers()
+ self.wfile.write(CONTENT)
+ return
+
+ if self.path == "/resume-supported":
+ header = self.headers.get("Range")
+ if header and header.startswith("bytes="):
+ start = int(header.split("=", 1)[1].split("-", 1)[0])
+ body = CONTENT[start:]
+ self.send_response(206)
+ self.send_header("Content-Length", str(len(body)))
+ self.send_header("Content-Range", "bytes {}-{}/{}".format(start, len(CONTENT) - 1, len(CONTENT)))
+ self.end_headers()
+ self.wfile.write(body)
+ return
+
+ self.send_response(200)
+ self.send_header("Content-Length", str(len(CONTENT)))
+ self.end_headers()
+ self.wfile.write(CONTENT)
+
+ def log_message(self, format: str, *args) -> None:
+ return
+
+
+class FakeResponse:
+ def __init__(self, final_url: str) -> None:
+ self.status = 200
+ self.headers = {"Content-Length": "3"}
+ self._final_url = final_url
+ self._sent = False
+
+ def geturl(self) -> str:
+ return self._final_url
+
+ def read(self, size: int = -1) -> bytes:
+ if self._sent:
+ return b""
+ self._sent = True
+ return b"abc"
+
+ def close(self) -> None:
+ return
+
+ def __enter__(self):
+ return self
+
+ def __exit__(self, exc_type, exc, tb) -> None:
+ return None
+
+
+class DownloadTests(unittest.TestCase):
+ @classmethod
+ def setUpClass(cls) -> None:
+ cls.server = ThreadingHTTPServer(("127.0.0.1", 0), DownloadHandler)
+ cls.server_thread = threading.Thread(target=cls.server.serve_forever, daemon=True)
+ cls.server_thread.start()
+ cls.base_url = "http://127.0.0.1:{}".format(cls.server.server_port)
+
+ @classmethod
+ def tearDownClass(cls) -> None:
+ cls.server.shutdown()
+ cls.server.server_close()
+
+ def test_resume_ignored_restarts_from_zero(self) -> None:
+ checksum = hashlib.sha256(CONTENT).hexdigest()
+ with TemporaryDirectory() as temp_dir:
+ dest = Path(temp_dir) / "brave-browser.zip"
+ partial = partial_download_path(dest)
+ partial.write_bytes(CONTENT[:32])
+
+ download_file(
+ self.base_url + "/resume-ignored",
+ dest,
+ log=lambda message: None,
+ expected_hash=checksum,
+ allowed_domains={"127.0.0.1", "localhost"},
+ )
+
+ self.assertEqual(dest.read_bytes(), CONTENT)
+ self.assertFalse(partial.exists())
+
+ def test_resume_supported_keeps_partial_and_finishes(self) -> None:
+ checksum = hashlib.sha256(CONTENT).hexdigest()
+ with TemporaryDirectory() as temp_dir:
+ dest = Path(temp_dir) / "brave-browser.zip"
+ partial = partial_download_path(dest)
+ partial.write_bytes(CONTENT[:64])
+
+ download_file(
+ self.base_url + "/resume-supported",
+ dest,
+ log=lambda message: None,
+ expected_hash=checksum,
+ allowed_domains={"127.0.0.1", "localhost"},
+ )
+
+ self.assertEqual(dest.read_bytes(), CONTENT)
+
+ def test_rejects_disallowed_redirect_target(self) -> None:
+ with TemporaryDirectory() as temp_dir:
+ dest = Path(temp_dir) / "blocked.zip"
+ with mock.patch("urllib.request.urlopen", return_value=FakeResponse("https://example.com/file.zip")):
+ with self.assertRaises(RuntimeError):
+ download_file(
+ "https://github.com/example/file.zip",
+ dest,
+ log=lambda message: None,
+ max_retries=1,
+ )
+
+ def test_validate_url_accepts_allowed_subdomains_and_rejects_other_hosts(self) -> None:
+ self.assertTrue(validate_url("https://github.com/file.zip"))
+ self.assertTrue(validate_url("https://objects.githubusercontent.com/file.zip"))
+ self.assertFalse(validate_url("https://example.com/file.zip"))
+
+ def test_download_reuses_existing_verified_file_without_network(self) -> None:
+ checksum = hashlib.sha256(CONTENT).hexdigest()
+ with TemporaryDirectory() as temp_dir:
+ dest = Path(temp_dir) / "brave-browser.zip"
+ dest.write_bytes(CONTENT)
+
+ with mock.patch("urllib.request.urlopen") as urlopen:
+ result = download_file(
+ self.base_url + "/resume-supported",
+ dest,
+ log=lambda message: None,
+ expected_hash=checksum,
+ allowed_domains={"127.0.0.1", "localhost"},
+ )
+
+ self.assertEqual(result, dest)
+ urlopen.assert_not_called()
+
+ def test_http_get_json_rejects_non_object_payload(self) -> None:
+ with mock.patch("brave_updater.network.http_get_text", return_value="[]"):
+ with self.assertRaises(ValueError):
+ http_get_json("https://api.github.com/repos/brave/brave-browser/releases/latest")
+
+ def test_download_retries_after_416_and_clears_partial(self) -> None:
+ with TemporaryDirectory() as temp_dir:
+ dest = Path(temp_dir) / "retry.zip"
+ partial = partial_download_path(dest)
+ partial.write_bytes(b"old-data")
+
+ response = FakeResponse("https://github.com/example/file.zip")
+ error = HTTPError(
+ url="https://github.com/example/file.zip",
+ code=416,
+ msg="Range Not Satisfiable",
+ hdrs=None,
+ fp=None,
+ )
+
+ with mock.patch("urllib.request.urlopen", side_effect=[error, response]):
+ result = download_file(
+ "https://github.com/example/file.zip",
+ dest,
+ log=lambda message: None,
+ max_retries=2,
+ )
+
+ self.assertEqual(result.read_bytes(), b"abc")
+ self.assertFalse(partial.exists())
+
+ def test_sanitize_filename_strips_path_traversal(self) -> None:
+ self.assertEqual(sanitize_filename("../Brave-Browser.dmg"), "Brave-Browser.dmg")
+ self.assertEqual(sanitize_filename("..\\Brave-Browser.dmg"), "Brave-Browser.dmg")
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/tests/test_installers.py b/tests/test_installers.py
new file mode 100644
index 0000000..1d8d8f7
--- /dev/null
+++ b/tests/test_installers.py
@@ -0,0 +1,235 @@
+from pathlib import Path
+import subprocess
+from tempfile import TemporaryDirectory
+import unittest
+from unittest import mock
+
+from brave_updater.installers import (
+ LinuxInstaller,
+ MacOSInstaller,
+ WindowsInstaller,
+ _replace_macos_app_bundle,
+ get_installer,
+)
+from brave_updater.models import SystemInfo
+
+
+class InstallerTests(unittest.TestCase):
+ def test_get_installer_returns_platform_specific_adapter(self) -> None:
+ self.assertIsInstance(get_installer(SystemInfo("macos", "arm64", "unknown"), lambda _: None), MacOSInstaller)
+ self.assertIsInstance(get_installer(SystemInfo("windows", "x64", "unknown"), lambda _: None), WindowsInstaller)
+ self.assertIsInstance(get_installer(SystemInfo("linux", "x64", "debian"), lambda _: None), LinuxInstaller)
+
+ def test_get_installer_rejects_unknown_platform(self) -> None:
+ with self.assertRaises(RuntimeError):
+ get_installer(SystemInfo("plan9", "x64", "unknown"), lambda _: None)
+
+ def test_macos_pkg_install_runs_installer(self) -> None:
+ with TemporaryDirectory() as temp_dir:
+ pkg = Path(temp_dir) / "Brave.pkg"
+ pkg.write_text("stub")
+ logger = mock.Mock()
+ installer = MacOSInstaller(logger)
+
+ with mock.patch("subprocess.check_call") as check_call:
+ installer.install(pkg)
+
+ check_call.assert_called_once_with(
+ ["sudo", "installer", "-pkg", str(pkg), "-target", "/"],
+ timeout=600,
+ )
+ logger.assert_any_call("Install finished.")
+
+ def test_macos_dmg_install_stages_and_moves_app(self) -> None:
+ with TemporaryDirectory() as temp_dir:
+ dmg = Path(temp_dir) / "Brave.dmg"
+ dmg.write_text("stub")
+ logger = mock.Mock()
+ installer = MacOSInstaller(logger)
+ mountpoint = Path("/Volumes/BraveTmpMount")
+ app_path = mountpoint / "Brave Browser.app"
+ target = Path("/Applications/Brave Browser.app")
+ temp_target = Path("/Applications/Brave Browser.app.new")
+
+ def fake_exists(path_obj):
+ path = str(path_obj)
+ mapping = {
+ str(dmg): True,
+ str(mountpoint): False,
+ str(app_path): True,
+ str(target): False,
+ str(temp_target): False,
+ }
+ return mapping.get(path, False)
+
+ with mock.patch("pathlib.Path.exists", fake_exists):
+ with mock.patch("pathlib.Path.mkdir") as mkdir:
+ with mock.patch("subprocess.check_call") as check_call:
+ with mock.patch("subprocess.run") as run:
+ installer.install(dmg)
+
+ mkdir.assert_called_once()
+ self.assertEqual(
+ check_call.call_args_list[0].args[0],
+ ["hdiutil", "attach", str(dmg), "-mountpoint", str(mountpoint), "-nobrowse", "-quiet"],
+ )
+ self.assertEqual(
+ check_call.call_args_list[-1].args[0],
+ ["sudo", "mv", str(temp_target), str(target)],
+ )
+ run.assert_called_once_with(
+ ["hdiutil", "detach", str(mountpoint), "-quiet"],
+ timeout=10,
+ capture_output=True,
+ check=False,
+ )
+
+ def test_macos_replace_restores_backup_if_swap_fails(self) -> None:
+ target = Path("/Applications/Brave Browser.app")
+ temp_target = Path("/Applications/Brave Browser.app.new")
+ backup_target = Path("/Applications/Brave Browser.app.old")
+ state = {
+ str(target): True,
+ str(temp_target): True,
+ str(backup_target): False,
+ }
+
+ def fake_exists(path_obj):
+ return state.get(str(path_obj), False)
+
+ def fake_check_call(command, timeout):
+ if command == ["sudo", "mv", str(target), str(backup_target)]:
+ state[str(target)] = False
+ state[str(backup_target)] = True
+ return 0
+ if command == ["sudo", "mv", str(temp_target), str(target)]:
+ raise subprocess.CalledProcessError(1, command)
+ if command == ["sudo", "mv", str(backup_target), str(target)]:
+ state[str(target)] = True
+ state[str(backup_target)] = False
+ return 0
+ return 0
+
+ with mock.patch("pathlib.Path.exists", fake_exists):
+ with mock.patch("subprocess.check_call", side_effect=fake_check_call) as check_call:
+ with self.assertRaises(RuntimeError) as error:
+ _replace_macos_app_bundle(target, temp_target)
+
+ self.assertIn("restored the previous Brave app", str(error.exception))
+ self.assertIn(mock.call(["sudo", "mv", str(backup_target), str(target)], timeout=60), check_call.call_args_list)
+
+ def test_macos_replace_raises_clear_error_if_restore_also_fails(self) -> None:
+ target = Path("/Applications/Brave Browser.app")
+ temp_target = Path("/Applications/Brave Browser.app.new")
+ backup_target = Path("/Applications/Brave Browser.app.old")
+ state = {
+ str(target): True,
+ str(temp_target): True,
+ str(backup_target): False,
+ }
+
+ def fake_exists(path_obj):
+ return state.get(str(path_obj), False)
+
+ def fake_check_call(command, timeout):
+ if command == ["sudo", "mv", str(target), str(backup_target)]:
+ state[str(target)] = False
+ state[str(backup_target)] = True
+ return 0
+ if command in (
+ ["sudo", "mv", str(temp_target), str(target)],
+ ["sudo", "mv", str(backup_target), str(target)],
+ ):
+ raise subprocess.CalledProcessError(1, command)
+ return 0
+
+ with mock.patch("pathlib.Path.exists", fake_exists):
+ with mock.patch("subprocess.check_call", side_effect=fake_check_call):
+ with self.assertRaises(RuntimeError) as error:
+ _replace_macos_app_bundle(target, temp_target)
+
+ self.assertIn("Backup remains at", str(error.exception))
+
+ def test_windows_install_launches_startfile(self) -> None:
+ with TemporaryDirectory() as temp_dir:
+ exe = Path(temp_dir) / "BraveSetup.exe"
+ exe.write_text("stub")
+ installer = WindowsInstaller(lambda _: None)
+
+ with mock.patch("os.startfile", create=True) as startfile:
+ installer.install(exe)
+
+ startfile.assert_called_once_with(str(exe))
+
+ def test_linux_deb_prefers_apt_get(self) -> None:
+ with TemporaryDirectory() as temp_dir:
+ pkg = Path(temp_dir) / "brave.deb"
+ pkg.write_text("stub")
+ installer = LinuxInstaller(lambda _: None, "debian")
+
+ with mock.patch("shutil.which", side_effect=lambda name: "/usr/bin/apt-get" if name == "apt-get" else None):
+ with mock.patch("subprocess.check_call") as check_call:
+ installer.install(pkg)
+
+ check_call.assert_called_once_with(["sudo", "apt-get", "install", "-y", str(pkg)], timeout=600)
+
+ def test_linux_deb_falls_back_to_dpkg(self) -> None:
+ with TemporaryDirectory() as temp_dir:
+ pkg = Path(temp_dir) / "brave.deb"
+ pkg.write_text("stub")
+ installer = LinuxInstaller(lambda _: None, "debian")
+
+ with mock.patch("shutil.which", return_value=None):
+ with mock.patch("subprocess.check_call") as check_call:
+ installer.install(pkg)
+
+ check_call.assert_called_once_with(["sudo", "dpkg", "-i", str(pkg)], timeout=600)
+
+ def test_linux_rpm_prefers_dnf_then_yum_then_rpm(self) -> None:
+ with TemporaryDirectory() as temp_dir:
+ pkg = Path(temp_dir) / "brave.rpm"
+ pkg.write_text("stub")
+
+ installer = LinuxInstaller(lambda _: None, "rhel")
+ with mock.patch("shutil.which", side_effect=lambda name: "/usr/bin/dnf" if name == "dnf" else None):
+ with mock.patch("subprocess.check_call") as check_call:
+ installer.install(pkg)
+ check_call.assert_called_once_with(["sudo", "dnf", "install", "-y", str(pkg)], timeout=600)
+
+ installer = LinuxInstaller(lambda _: None, "rhel")
+ with mock.patch("shutil.which", side_effect=lambda name: "/usr/bin/yum" if name == "yum" else None):
+ with mock.patch("subprocess.check_call") as check_call:
+ installer.install(pkg)
+ check_call.assert_called_once_with(["sudo", "yum", "install", "-y", str(pkg)], timeout=600)
+
+ installer = LinuxInstaller(lambda _: None, "rhel")
+ with mock.patch("shutil.which", return_value=None):
+ with mock.patch("subprocess.check_call") as check_call:
+ installer.install(pkg)
+ check_call.assert_called_once_with(["sudo", "rpm", "-Uvh", str(pkg)], timeout=600)
+
+ def test_linux_installer_rejects_unknown_file_type(self) -> None:
+ with TemporaryDirectory() as temp_dir:
+ pkg = Path(temp_dir) / "brave.zip"
+ pkg.write_text("stub")
+ installer = LinuxInstaller(lambda _: None, "unknown")
+
+ with self.assertRaises(RuntimeError):
+ installer.install(pkg)
+
+ def test_linux_installer_rejects_unsupported_distribution_family(self) -> None:
+ with TemporaryDirectory() as temp_dir:
+ pkg = Path(temp_dir) / "brave.deb"
+ pkg.write_text("stub")
+ installer = LinuxInstaller(lambda _: None, "arch")
+
+ with mock.patch("subprocess.check_call") as check_call:
+ with self.assertRaises(RuntimeError) as error:
+ installer.install(pkg)
+
+ self.assertIn("Unsupported Linux distribution family", str(error.exception))
+ check_call.assert_not_called()
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/tests/test_packaging.py b/tests/test_packaging.py
new file mode 100644
index 0000000..550f1d1
--- /dev/null
+++ b/tests/test_packaging.py
@@ -0,0 +1,29 @@
+from pathlib import Path
+import unittest
+
+from brave_updater import __version__
+from brave_updater.network import USER_AGENT
+
+
+ROOT = Path(__file__).resolve().parents[1]
+
+
+class PackagingTests(unittest.TestCase):
+ def test_pyproject_uses_dynamic_version_from_package(self) -> None:
+ pyproject = (ROOT / "pyproject.toml").read_text()
+
+ self.assertIn('dynamic = ["version"]', pyproject)
+ self.assertIn('version = {attr = "brave_updater.__version__"}', pyproject)
+
+ def test_setup_py_defers_metadata_to_pyproject(self) -> None:
+ setup_py = (ROOT / "setup.py").read_text()
+
+ self.assertIn("setup()", setup_py)
+ self.assertNotIn("version=", setup_py)
+
+ def test_network_user_agent_tracks_package_version(self) -> None:
+ self.assertEqual(USER_AGENT, "brave-updater/{}".format(__version__))
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/tests/test_providers.py b/tests/test_providers.py
new file mode 100644
index 0000000..ac677aa
--- /dev/null
+++ b/tests/test_providers.py
@@ -0,0 +1,121 @@
+import unittest
+from unittest import mock
+
+from brave_updater.models import ReleaseAsset, SystemInfo
+from brave_updater.providers import (
+ OfficialReleaseProvider,
+ extract_chromium_version,
+ parse_checksum_text,
+ parse_sha256_digest,
+ pick_asset,
+ release_from_payload,
+ resolve_expected_hash,
+ resolve_provider,
+)
+
+
+class ProviderTests(unittest.TestCase):
+ def test_pick_asset_prefers_macos_universal_dmg(self) -> None:
+ assets = [
+ ReleaseAsset("Brave-Browser-arm64.dmg", "https://github.com/arm64.dmg"),
+ ReleaseAsset("Brave-Browser-universal.dmg", "https://github.com/universal.dmg"),
+ ]
+
+ asset, reason = pick_asset(assets, SystemInfo("macos", "arm64", "unknown"))
+
+ self.assertEqual(asset.name, "Brave-Browser-universal.dmg")
+ self.assertIn("universal", reason)
+
+ def test_pick_asset_prefers_deb_for_debian(self) -> None:
+ assets = [
+ ReleaseAsset("brave-browser-1.2.3-1.x86_64.rpm", "https://github.com/brave.rpm"),
+ ReleaseAsset("brave-browser_1.2.3_amd64.deb", "https://github.com/brave.deb"),
+ ]
+
+ asset, reason = pick_asset(assets, SystemInfo("linux", "x64", "debian"))
+
+ self.assertEqual(asset.name, "brave-browser_1.2.3_amd64.deb")
+ self.assertIn(".deb", reason)
+
+ def test_pick_asset_rejects_unsupported_linux_family(self) -> None:
+ assets = [
+ ReleaseAsset("brave-browser-1.2.3-1.x86_64.rpm", "https://github.com/brave.rpm"),
+ ReleaseAsset("brave-browser_1.2.3_amd64.deb", "https://github.com/brave.deb"),
+ ]
+
+ with self.assertRaises(RuntimeError) as error:
+ pick_asset(assets, SystemInfo("linux", "x64", "arch"))
+
+ self.assertIn("Unsupported Linux distribution family", str(error.exception))
+
+ def test_parse_sha256_digest(self) -> None:
+ digest = parse_sha256_digest("sha256:" + "a" * 64)
+ self.assertEqual(digest, "a" * 64)
+
+ def test_parse_checksum_text(self) -> None:
+ checksum = parse_checksum_text(
+ "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef Brave-Browser.dmg",
+ "Brave-Browser.dmg",
+ )
+ self.assertEqual(checksum, "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef")
+
+ def test_resolve_provider_uses_official_provider_and_logs(self) -> None:
+ logger = mock.Mock()
+ provider = resolve_provider("official", logger)
+
+ self.assertIsInstance(provider, OfficialReleaseProvider)
+ logger.assert_called_once()
+
+ def test_resolve_provider_rejects_unknown_source(self) -> None:
+ with self.assertRaises(ValueError):
+ resolve_provider("mirror", lambda _: None)
+
+ def test_release_from_payload_validates_assets(self) -> None:
+ with self.assertRaises(ValueError):
+ release_from_payload({"tag_name": "v1.2.3", "assets": [{"name": "foo"}]}, source="github")
+
+ def test_resolve_expected_hash_prefers_digest_and_falls_back_to_checksum_sidecar(self) -> None:
+ asset = ReleaseAsset("Brave-Browser.dmg", "https://github.com/file.dmg", digest="sha256:" + "a" * 64)
+ release = release_from_payload(
+ {
+ "tag_name": "v1.2.3",
+ "name": "Release",
+ "body": "",
+ "assets": [
+ {"name": "Brave-Browser.dmg", "browser_download_url": "https://github.com/file.dmg", "digest": "sha256:" + "a" * 64},
+ {"name": "Brave-Browser.dmg.sha256", "browser_download_url": "https://github.com/file.dmg.sha256"},
+ ],
+ },
+ source="github",
+ )
+ self.assertEqual(resolve_expected_hash(asset, release, lambda _: None), "a" * 64)
+
+ asset = ReleaseAsset("Brave-Browser.dmg", "https://github.com/file.dmg")
+ with mock.patch(
+ "brave_updater.providers.http_get_text",
+ return_value="bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb Brave-Browser.dmg",
+ ):
+ checksum = resolve_expected_hash(asset, release, lambda _: None)
+ self.assertEqual(checksum, "b" * 64)
+
+ def test_extract_chromium_version_returns_none_when_missing(self) -> None:
+ self.assertIsNone(extract_chromium_version("No chromium metadata here"))
+
+ def test_resolve_expected_hash_returns_none_without_digest_or_checksum_asset(self) -> None:
+ asset = ReleaseAsset("Brave-Browser.dmg", "https://github.com/file.dmg")
+ release = release_from_payload(
+ {
+ "tag_name": "v1.2.3",
+ "name": "Release",
+ "body": "",
+ "assets": [
+ {"name": "Brave-Browser.dmg", "browser_download_url": "https://github.com/file.dmg"},
+ ],
+ },
+ source="github",
+ )
+ self.assertIsNone(resolve_expected_hash(asset, release, lambda _: None))
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/tests/test_service.py b/tests/test_service.py
new file mode 100644
index 0000000..f8d375d
--- /dev/null
+++ b/tests/test_service.py
@@ -0,0 +1,127 @@
+from pathlib import Path
+import unittest
+from unittest import mock
+
+from brave_updater.models import ReleaseAsset, ReleaseInfo, SystemInfo
+from brave_updater.service import BraveUpdater
+
+
+class ServiceTests(unittest.TestCase):
+ def make_updater(self) -> BraveUpdater:
+ with mock.patch("brave_updater.service.resolve_provider", return_value=mock.Mock()):
+ return BraveUpdater("github", lambda _: None)
+
+ def make_release(self) -> ReleaseInfo:
+ return ReleaseInfo(
+ version="v1.2.3",
+ name="Release v1.2.3",
+ body="Chromium: 120.0.0.1",
+ assets=[ReleaseAsset(name="Brave-Browser-universal.dmg", url="https://github.com/example.dmg", digest="sha256:" + "a" * 64)],
+ source="github",
+ )
+
+ def test_check_reports_up_to_date_when_versions_match(self) -> None:
+ updater = self.make_updater()
+ system_info = SystemInfo("macos", "arm64", "unknown")
+ release = self.make_release()
+ asset = release.assets[0]
+
+ with mock.patch.object(updater, "_resolve_target", return_value=(system_info, release, asset, "reason")):
+ with mock.patch("brave_updater.service.detect_installed_version", return_value="1.2.3"):
+ result = updater.check()
+
+ self.assertFalse(result.update_available)
+ self.assertEqual(result.chromium_version, "120.0.0.1")
+
+ def test_download_latest_uses_resolved_hash_and_sanitized_destination(self) -> None:
+ updater = self.make_updater()
+ release = ReleaseInfo(
+ version="v1.2.3",
+ name="Release",
+ body="",
+ assets=[ReleaseAsset(name="../Brave-Browser-universal.dmg", url="https://github.com/example.dmg")],
+ source="github",
+ )
+ system_info = SystemInfo("macos", "arm64", "unknown")
+ asset = release.assets[0]
+
+ with mock.patch.object(updater, "_resolve_target", return_value=(system_info, release, asset, "reason")):
+ with mock.patch("brave_updater.service.resolve_expected_hash", return_value="b" * 64) as resolve_hash:
+ with mock.patch("brave_updater.service.download_file", return_value=Path("/tmp/Brave-Browser-universal.dmg")) as download:
+ result = updater.download_latest(Path("/tmp"))
+
+ resolve_hash.assert_called_once_with(asset, release, updater.log)
+ download.assert_called_once()
+ self.assertEqual(download.call_args.kwargs["expected_hash"], "b" * 64)
+ self.assertEqual(download.call_args.args[1], Path("/tmp/Brave-Browser-universal.dmg"))
+ self.assertEqual(result, Path("/tmp/Brave-Browser-universal.dmg"))
+
+ def test_update_noops_when_latest_is_already_installed(self) -> None:
+ logs = []
+ with mock.patch("brave_updater.service.resolve_provider", return_value=mock.Mock()):
+ updater = BraveUpdater("github", logs.append)
+ system_info = SystemInfo("macos", "arm64", "unknown")
+ release = self.make_release()
+ asset = release.assets[0]
+
+ with mock.patch.object(updater, "_resolve_target", return_value=(system_info, release, asset, "reason")):
+ with mock.patch("brave_updater.service.detect_installed_version", return_value="1.2.3"):
+ with mock.patch("brave_updater.service.download_file") as download:
+ result = updater.update(Path("/tmp"))
+
+ self.assertIsNone(result)
+ download.assert_not_called()
+ self.assertTrue(any("already up to date" in message for message in logs))
+
+ def test_update_downloads_and_installs_when_confirmed(self) -> None:
+ updater = self.make_updater()
+ system_info = SystemInfo("macos", "arm64", "unknown")
+ release = self.make_release()
+ asset = release.assets[0]
+ installer = mock.Mock()
+
+ with mock.patch.object(updater, "_resolve_target", return_value=(system_info, release, asset, "reason")):
+ with mock.patch("brave_updater.service.detect_installed_version", return_value="1.2.2"):
+ with mock.patch("brave_updater.service.resolve_expected_hash", return_value="c" * 64):
+ with mock.patch("brave_updater.service.download_file") as download:
+ with mock.patch("brave_updater.service.get_installer", return_value=installer):
+ result = updater.update(Path("/tmp"), assume_yes=True)
+
+ self.assertEqual(result, Path("/tmp/Brave-Browser-universal.dmg"))
+ installer.install.assert_called_once_with(Path("/tmp/Brave-Browser-universal.dmg"))
+ self.assertEqual(download.call_args.kwargs["expected_hash"], "c" * 64)
+
+ def test_update_downloads_and_skips_install_when_declined(self) -> None:
+ logs = []
+ with mock.patch("brave_updater.service.resolve_provider", return_value=mock.Mock()):
+ updater = BraveUpdater("github", logs.append)
+ system_info = SystemInfo("macos", "arm64", "unknown")
+ release = self.make_release()
+ asset = release.assets[0]
+
+ with mock.patch.object(updater, "_resolve_target", return_value=(system_info, release, asset, "reason")):
+ with mock.patch("brave_updater.service.detect_installed_version", return_value="1.2.2"):
+ with mock.patch("brave_updater.service.resolve_expected_hash", return_value=None):
+ with mock.patch("brave_updater.service.download_file") as download:
+ with mock.patch("brave_updater.service.prompt_yes_no", return_value=False):
+ with mock.patch("brave_updater.service.get_installer") as get_installer:
+ result = updater.update(Path("/tmp"))
+
+ self.assertEqual(result, Path("/tmp/Brave-Browser-universal.dmg"))
+ download.assert_called_once()
+ get_installer.assert_not_called()
+ self.assertIn("Install skipped.", logs)
+
+ def test_install_delegates_to_platform_installer(self) -> None:
+ updater = self.make_updater()
+ installer = mock.Mock()
+
+ with mock.patch("brave_updater.service.detect_system", return_value=SystemInfo("linux", "x64", "debian")):
+ with mock.patch("brave_updater.service.get_installer", return_value=installer):
+ updater.install(Path("/tmp/brave.deb"))
+
+ installer.install.assert_called_once_with(Path("/tmp/brave.deb"))
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/tests/test_system.py b/tests/test_system.py
new file mode 100644
index 0000000..9c5d3ce
--- /dev/null
+++ b/tests/test_system.py
@@ -0,0 +1,89 @@
+import plistlib
+import subprocess
+import unittest
+from pathlib import Path
+from tempfile import TemporaryDirectory
+from unittest import mock
+
+from brave_updater.system import (
+ compare_versions,
+ detect_installed_version,
+ detect_linux_family,
+ normalize_macos_app_version,
+ read_linux_version,
+ read_macos_version,
+ read_windows_version,
+)
+
+
+class FakeResult:
+ def __init__(self, stdout: str = "", stderr: str = "") -> None:
+ self.stdout = stdout
+ self.stderr = stderr
+
+
+class SystemTests(unittest.TestCase):
+ def test_compare_versions_treats_missing_patch_as_equal_zero(self) -> None:
+ self.assertEqual(compare_versions("1.2.3", "v1.2.3.0"), 0)
+ self.assertEqual(compare_versions("1.2.2", "1.2.3"), -1)
+ self.assertEqual(compare_versions("1.2.4", "1.2.3"), 1)
+
+ def test_read_macos_version_from_plist(self) -> None:
+ with TemporaryDirectory() as temp_dir:
+ plist_path = Path(temp_dir) / "Info.plist"
+ with plist_path.open("wb") as handle:
+ plistlib.dump({"CFBundleShortVersionString": "1.2.3"}, handle)
+
+ version = read_macos_version([plist_path])
+
+ self.assertEqual(version, "1.2.3")
+
+ def test_normalize_macos_app_version_prefers_brave_bundle_version(self) -> None:
+ self.assertEqual(
+ normalize_macos_app_version("143.1.85.118", "185.118"),
+ "1.85.118",
+ )
+
+ def test_read_linux_version_uses_binary_output(self) -> None:
+ def fake_runner(command, capture_output, text, check):
+ self.assertEqual(command, ("brave-browser", "--version"))
+ return FakeResult(stdout="Brave Browser 1.2.3")
+
+ version = read_linux_version(commands=[("brave-browser", "--version")], runner=fake_runner, which=lambda _: "/usr/bin/brave-browser")
+
+ self.assertEqual(version, "1.2.3")
+
+ def test_detect_linux_family_reads_os_release(self) -> None:
+ with TemporaryDirectory() as temp_dir:
+ os_release = Path(temp_dir) / "os-release"
+ os_release.write_text('ID=ubuntu\nID_LIKE="debian"\n')
+
+ family = detect_linux_family(os_release)
+
+ self.assertEqual(family, "debian")
+
+ def test_read_windows_version_uses_powershell_metadata(self) -> None:
+ with TemporaryDirectory() as temp_dir:
+ executable = Path(temp_dir) / "brave.exe"
+ executable.write_text("stub")
+
+ def fake_runner(command, capture_output, text, check):
+ self.assertEqual(command[0], "powershell")
+ return FakeResult(stdout="1.2.3.4")
+
+ version = read_windows_version([executable], runner=fake_runner)
+
+ self.assertEqual(version, "1.2.3.4")
+
+ def test_detect_installed_version_delegates_by_platform(self) -> None:
+ with mock.patch("brave_updater.system.read_macos_version", return_value="1.2.3"):
+ self.assertEqual(detect_installed_version(system_info=mock.Mock(os_name="macos")), "1.2.3")
+ with mock.patch("brave_updater.system.read_linux_version", return_value="1.2.3"):
+ self.assertEqual(detect_installed_version(system_info=mock.Mock(os_name="linux")), "1.2.3")
+ with mock.patch("brave_updater.system.read_windows_version", return_value="1.2.3"):
+ self.assertEqual(detect_installed_version(system_info=mock.Mock(os_name="windows")), "1.2.3")
+ self.assertIsNone(detect_installed_version(system_info=mock.Mock(os_name="unknown")))
+
+
+if __name__ == "__main__":
+ unittest.main()