diff --git a/packages/browseros/.env.example b/packages/browseros/.env.example
index 0f301b5e..baf152d3 100644
--- a/packages/browseros/.env.example
+++ b/packages/browseros/.env.example
@@ -16,6 +16,7 @@ PROD_MACOS_NOTARIZATION_PWD=
R2_ACCOUNT_ID=
R2_ACCESS_KEY_ID=
R2_SECRET_ACCESS_KEY=
+CLOUDFLARE_API_TOKEN=
R2_BUCKET=browseros
R2_CDN_BASE_URL=http://cdn.browseros.com
@@ -24,6 +25,8 @@ SLACK_WEBHOOK_URL=
# Sparkle Signing (macOS only)
SPARKLE_PRIVATE_KEY=
+# Path to sign_update tool
+SPARKLE_SIGN_UPDATE_PATH=
# Optional
# CHROMIUM_SRC=C:/src/chromium/src
diff --git a/packages/browseros/build/browseros.py b/packages/browseros/build/browseros.py
index 7f6bbb0b..138fa11b 100755
--- a/packages/browseros/build/browseros.py
+++ b/packages/browseros/build/browseros.py
@@ -40,6 +40,10 @@ app.add_typer(dev.app, name="dev", help="Dev patch management")
from .cli import release
app.add_typer(release.app, name="release", help="Release automation")
+# OTA update commands
+from .cli import ota
+app.add_typer(ota.app, name="ota", help="OTA update automation")
+
if __name__ == "__main__":
app()
diff --git a/packages/browseros/build/cli/ota.py b/packages/browseros/build/cli/ota.py
new file mode 100644
index 00000000..6126be72
--- /dev/null
+++ b/packages/browseros/build/cli/ota.py
@@ -0,0 +1,248 @@
+#!/usr/bin/env python3
+"""OTA CLI - Over-The-Air update automation for BrowserOS"""
+
+from pathlib import Path
+from typing import Optional
+
+import typer
+
+from ..common.context import Context
+from ..common.env import EnvConfig
+from ..common.module import ValidationError
+from ..common.sparkle import sparkle_sign_file
+from ..common.utils import log_info, log_error, log_success
+
+from ..modules.ota import ServerOTAModule
+from ..modules.ota.common import (
+ get_appcast_path,
+ SERVER_PLATFORMS,
+)
+from ..modules.upload import get_r2_client, upload_file_to_r2
+
+app = typer.Typer(
+ help="OTA (Over-The-Air) update automation",
+ pretty_exceptions_enable=False,
+ pretty_exceptions_show_locals=False,
+)
+
+server_app = typer.Typer(
+ help="BrowserOS Server OTA commands",
+ pretty_exceptions_enable=False,
+ pretty_exceptions_show_locals=False,
+)
+app.add_typer(server_app, name="server")
+
+
+def create_ota_context() -> Context:
+ """Create Context for OTA operations"""
+ return Context(
+ chromium_src=Path(),
+ architecture="",
+ build_type="release",
+ )
+
+
+def execute_module(ctx: Context, module) -> None:
+ """Execute a single module with validation"""
+ try:
+ module.validate(ctx)
+ module.execute(ctx)
+ except ValidationError as e:
+ log_error(f"Validation failed: {e}")
+ raise typer.Exit(1)
+ except Exception as e:
+ log_error(f"Module failed: {e}")
+ raise typer.Exit(1)
+
+
+@server_app.command("release")
+def server_release(
+ version: str = typer.Option(
+ ..., "--version", "-v", help="Version to release (e.g., 0.0.36)"
+ ),
+ channel: str = typer.Option(
+ "alpha", "--channel", "-c", help="Release channel: alpha or prod"
+ ),
+ binaries: Optional[Path] = typer.Option(
+ None, "--binaries", "-b", help="Directory containing server binaries"
+ ),
+ platform: Optional[str] = typer.Option(
+ None, "--platform", "-p",
+ help="Platform(s) to process, comma-separated (darwin_arm64, darwin_x64, linux_arm64, linux_x64, windows_x64)"
+ ),
+):
+ """Release BrowserOS Server OTA update
+
+ \b
+ Full Release (all platforms):
+ browseros ota server release --version 0.0.36 --channel alpha
+
+ \b
+ Single Platform:
+ browseros ota server release --version 0.0.36 --platform darwin_arm64
+
+ \b
+ Multiple Platforms:
+ browseros ota server release --version 0.0.36 --platform darwin_arm64,darwin_x64
+ """
+ log_info(f"š BrowserOS Server OTA v{version}")
+ log_info("=" * 70)
+
+ ctx = create_ota_context()
+
+ module = ServerOTAModule(
+ version=version,
+ channel=channel,
+ binaries_dir=binaries,
+ platform_filter=platform,
+ )
+
+ execute_module(ctx, module)
+
+
+@server_app.command("release-appcast")
+def server_release_appcast(
+ channel: str = typer.Option(
+ "alpha", "--channel", "-c", help="Release channel: alpha or prod"
+ ),
+ appcast_file: Optional[Path] = typer.Option(
+ None, "--file", "-f", help="Custom appcast file to upload"
+ ),
+):
+ """Publish appcast XML to make the release live
+
+ This is the final step after 'release' uploads artifacts.
+ Publishing the appcast makes the update available to clients.
+
+ \b
+ Release alpha appcast:
+ browseros ota server release-appcast --channel alpha
+
+ \b
+ Release production appcast:
+ browseros ota server release-appcast --channel prod
+
+ \b
+ Release custom appcast file:
+ browseros ota server release-appcast --file /path/to/appcast.xml
+ """
+ if appcast_file:
+ if not appcast_file.exists():
+ log_error(f"Appcast file not found: {appcast_file}")
+ raise typer.Exit(1)
+ source_path = appcast_file
+ else:
+ source_path = get_appcast_path(channel)
+ if not source_path.exists():
+ log_error(f"Appcast file not found: {source_path}")
+ log_error("Run 'browseros ota server release' first to generate the appcast")
+ raise typer.Exit(1)
+
+ if channel == "alpha":
+ r2_key = "appcast-server.alpha.xml"
+ else:
+ r2_key = "appcast-server.xml"
+
+ log_info(f"š¤ Uploading {source_path.name} to {r2_key}...")
+
+ env = EnvConfig()
+ if not env.has_r2_config():
+ log_error("R2 configuration not set. Required env vars: R2_ACCOUNT_ID, R2_ACCESS_KEY_ID, R2_SECRET_ACCESS_KEY")
+ raise typer.Exit(1)
+
+ r2_client = get_r2_client(env)
+ if not r2_client:
+ log_error("Failed to create R2 client")
+ raise typer.Exit(1)
+
+ if upload_file_to_r2(r2_client, source_path, r2_key, env.r2_bucket):
+ cdn_url = f"https://cdn.browseros.com/{r2_key}"
+ log_success(f"ā
Published: {cdn_url}")
+ else:
+ log_error("Upload failed")
+ raise typer.Exit(1)
+
+
+@server_app.command("list-platforms")
+def server_list_platforms():
+ """List available server platforms"""
+ log_info("\nš¦ Available Server Platforms:")
+ log_info("-" * 50)
+ for p in SERVER_PLATFORMS:
+ log_info(f" {p['name']:<15} {p['os']:<10} {p['arch']}")
+ log_info("-" * 50)
+
+
+@app.command("test-signing")
+def test_signing(
+ file_path: Path = typer.Argument(..., help="File to sign for testing"),
+):
+ """Test Sparkle Ed25519 signing on a file
+
+ \b
+ Example:
+ browseros ota test-signing /path/to/file.zip
+ """
+ if not file_path.exists():
+ log_error(f"File not found: {file_path}")
+ raise typer.Exit(1)
+
+ env = EnvConfig()
+ if not env.has_sparkle_key():
+ log_error("SPARKLE_PRIVATE_KEY not set")
+ raise typer.Exit(1)
+
+ log_info(f"\nš Testing Sparkle Ed25519 signing")
+ log_info(f"File: {file_path}")
+ log_info("-" * 60)
+
+ sig, length = sparkle_sign_file(file_path, env)
+ if not sig:
+ log_error("Signing failed")
+ raise typer.Exit(1)
+
+ log_success(f"ā
Signed successfully")
+ log_info(f" Signature: {sig[:50]}...")
+ log_info(f" Length: {length}")
+
+
+@server_app.callback(invoke_without_command=True)
+def server_main(ctx: typer.Context):
+ """BrowserOS Server OTA commands
+
+ \b
+ Release (upload artifacts):
+ browseros ota server release --version 0.0.36
+
+ \b
+ Release Appcast (make live):
+ browseros ota server release-appcast --channel alpha
+
+ \b
+ List Platforms:
+ browseros ota server list-platforms
+ """
+ if ctx.invoked_subcommand is None:
+ typer.echo("Use --help for usage information")
+ typer.echo("Available commands: release, release-appcast, list-platforms")
+ raise typer.Exit(0)
+
+
+@app.callback(invoke_without_command=True)
+def main(ctx: typer.Context):
+ """OTA update automation for BrowserOS
+
+ \b
+ Server OTA:
+ browseros ota server release --version 0.0.36
+ browseros ota server release-appcast --channel alpha
+ browseros ota server list-platforms
+ """
+ if ctx.invoked_subcommand is None:
+ typer.echo("Use --help for usage information")
+ typer.echo("Available subcommands: server")
+ raise typer.Exit(0)
+
+
+if __name__ == "__main__":
+ app()
diff --git a/packages/browseros/build/common/env.py b/packages/browseros/build/common/env.py
index d0488a8f..ecdcb5f7 100644
--- a/packages/browseros/build/common/env.py
+++ b/packages/browseros/build/common/env.py
@@ -165,6 +165,11 @@ class EnvConfig:
"""Base64-encoded Sparkle Ed25519 private key for macOS auto-update signing"""
return os.environ.get("SPARKLE_PRIVATE_KEY")
+ @property
+ def sparkle_sign_update_path(self) -> Optional[str]:
+ """Path to Sparkle sign_update tool (overrides auto-detection)"""
+ return os.environ.get("SPARKLE_SIGN_UPDATE_PATH")
+
# === Notifications ===
@property
diff --git a/packages/browseros/build/common/sparkle.py b/packages/browseros/build/common/sparkle.py
new file mode 100644
index 00000000..6f580851
--- /dev/null
+++ b/packages/browseros/build/common/sparkle.py
@@ -0,0 +1,92 @@
+#!/usr/bin/env python3
+"""Sparkle Ed25519 signing for auto-update
+
+Cross-platform Ed25519 signing compatible with Sparkle framework.
+Uses Python cryptography library - works on macOS, Windows, and Linux.
+"""
+
+import base64
+from pathlib import Path
+from typing import Optional, Tuple
+
+from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
+
+from .env import EnvConfig
+from .utils import log_error
+
+
+def _parse_sparkle_private_key(key_data: str) -> Optional[Ed25519PrivateKey]:
+ """Parse Sparkle Ed25519 private key from various formats
+
+ Sparkle key formats:
+ - Raw 64-byte key (32-byte seed + 32-byte public key)
+ - Raw 32-byte seed
+ - Base64 encoded versions of above
+
+ Returns:
+ Ed25519PrivateKey or None on failure
+ """
+ try:
+ # Try base64 decode first (env var might be base64 encoded)
+ try:
+ key_bytes = base64.b64decode(key_data)
+ except Exception:
+ # Not base64, try as raw bytes
+ key_bytes = key_data.encode("latin-1")
+
+ # Sparkle uses 64-byte format: 32-byte seed + 32-byte public key
+ if len(key_bytes) == 64:
+ seed = key_bytes[:32]
+ return Ed25519PrivateKey.from_private_bytes(seed)
+ elif len(key_bytes) == 32:
+ return Ed25519PrivateKey.from_private_bytes(key_bytes)
+ else:
+ log_error(f"Invalid Sparkle key length: {len(key_bytes)} bytes (expected 32 or 64)")
+ return None
+
+ except Exception as e:
+ log_error(f"Failed to parse Sparkle private key: {e}")
+ return None
+
+
+def sparkle_sign_file(
+ file_path: Path,
+ env: Optional[EnvConfig] = None,
+) -> Tuple[Optional[str], int]:
+ """Sign a file with Sparkle Ed25519 key
+
+ Args:
+ file_path: Path to file to sign (typically a zip or dmg)
+ env: Environment config with Sparkle key
+
+ Returns:
+ (signature, length) tuple, or (None, 0) on failure
+ """
+ if env is None:
+ env = EnvConfig()
+
+ if not env.has_sparkle_key():
+ log_error("SPARKLE_PRIVATE_KEY not set")
+ return None, 0
+
+ key_data = env.sparkle_private_key
+ if not key_data:
+ log_error("SPARKLE_PRIVATE_KEY is empty")
+ return None, 0
+
+ private_key = _parse_sparkle_private_key(key_data)
+ if not private_key:
+ return None, 0
+
+ try:
+ file_data = file_path.read_bytes()
+ file_length = len(file_data)
+
+ signature_bytes = private_key.sign(file_data)
+ signature_b64 = base64.b64encode(signature_bytes).decode("ascii")
+
+ return signature_b64, file_length
+
+ except Exception as e:
+ log_error(f"Error signing {file_path.name}: {e}")
+ return None, 0
diff --git a/packages/browseros/build/config/appcast/appcast-server.alpha.xml b/packages/browseros/build/config/appcast/appcast-server.alpha.xml
new file mode 100644
index 00000000..1a002fdc
--- /dev/null
+++ b/packages/browseros/build/config/appcast/appcast-server.alpha.xml
@@ -0,0 +1,51 @@
+
+
+
+ BrowserOS Server (Alpha)
+ https://cdn.browseros.com/appcast-server.alpha.xml
+ BrowserOS Server binary updates
+ en
+
+ -
+ 0.0.37
+ Tue, 06 Jan 2026 20:01:05 +0000
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/packages/browseros/build/config/appcast/appcast-server.xml b/packages/browseros/build/config/appcast/appcast-server.xml
new file mode 100644
index 00000000..008cb8f1
--- /dev/null
+++ b/packages/browseros/build/config/appcast/appcast-server.xml
@@ -0,0 +1,9 @@
+
+
+
+ BrowserOS Server
+ https://cdn.browseros.com/appcast-server.xml
+ BrowserOS Server binary updates
+ en
+
+
diff --git a/packages/browseros/build/modules/ota/__init__.py b/packages/browseros/build/modules/ota/__init__.py
new file mode 100644
index 00000000..b1f614c6
--- /dev/null
+++ b/packages/browseros/build/modules/ota/__init__.py
@@ -0,0 +1,37 @@
+#!/usr/bin/env python3
+"""OTA (Over-The-Air) update modules for BrowserOS Server and Browser"""
+
+from .common import (
+ sparkle_sign_file,
+ generate_server_appcast,
+ parse_existing_appcast,
+ ExistingAppcast,
+ SignedArtifact,
+ SERVER_PLATFORMS,
+ APPCAST_TEMPLATE,
+)
+from .sign_binary import (
+ sign_macos_binary,
+ notarize_macos_binary,
+ sign_windows_binary,
+)
+from .server import ServerOTAModule
+
+AVAILABLE_MODULES = {
+ "server_ota": ServerOTAModule,
+}
+
+__all__ = [
+ "AVAILABLE_MODULES",
+ "ServerOTAModule",
+ "sparkle_sign_file",
+ "generate_server_appcast",
+ "parse_existing_appcast",
+ "ExistingAppcast",
+ "SignedArtifact",
+ "sign_macos_binary",
+ "notarize_macos_binary",
+ "sign_windows_binary",
+ "SERVER_PLATFORMS",
+ "APPCAST_TEMPLATE",
+]
diff --git a/packages/browseros/build/modules/ota/common.py b/packages/browseros/build/modules/ota/common.py
new file mode 100644
index 00000000..233e7649
--- /dev/null
+++ b/packages/browseros/build/modules/ota/common.py
@@ -0,0 +1,275 @@
+#!/usr/bin/env python3
+"""Common utilities for OTA update modules"""
+
+import os
+import re
+import shutil
+import zipfile
+import xml.etree.ElementTree as ET
+from datetime import datetime, timezone
+from pathlib import Path
+from typing import Dict, List, Optional
+from dataclasses import dataclass
+
+from ...common.utils import log_error, log_info, log_success
+
+# Re-export sparkle_sign_file from common module
+from ...common.sparkle import sparkle_sign_file
+
+# Sparkle XML namespace
+SPARKLE_NS = "http://www.andymatuschak.org/xml-namespaces/sparkle"
+ET.register_namespace("sparkle", SPARKLE_NS)
+
+SERVER_PLATFORMS = [
+ {"name": "darwin_arm64", "binary": "browseros-server-darwin-arm64", "os": "macos", "arch": "arm64"},
+ {"name": "darwin_x64", "binary": "browseros-server-darwin-x64", "os": "macos", "arch": "x86_64"},
+ {"name": "linux_arm64", "binary": "browseros-server-linux-arm64", "os": "linux", "arch": "arm64"},
+ {"name": "linux_x64", "binary": "browseros-server-linux-x64", "os": "linux", "arch": "x86_64"},
+ {"name": "windows_x64", "binary": "browseros-server-windows-x64.exe", "os": "windows", "arch": "x86_64"},
+]
+
+APPCAST_TEMPLATE = """
+
+
+ {title}
+ {appcast_url}
+ BrowserOS Server binary updates
+ en
+
+ -
+ {version}
+ {pub_date}
+
+{enclosures}
+
+
+
+
+"""
+
+ENCLOSURE_TEMPLATE = """
+ """
+
+
+@dataclass
+class SignedArtifact:
+ """Represents a signed artifact with Sparkle signature"""
+ platform: str
+ zip_path: Path
+ signature: str
+ length: int
+ os: str
+ arch: str
+
+
+@dataclass
+class ExistingAppcast:
+ """Parsed data from an existing appcast file"""
+ version: str
+ pub_date: str
+ artifacts: Dict[str, SignedArtifact]
+
+
+def parse_existing_appcast(appcast_path: Path) -> Optional[ExistingAppcast]:
+ """Parse existing appcast XML file.
+
+ Args:
+ appcast_path: Path to existing appcast XML file
+
+ Returns:
+ ExistingAppcast with version, pubDate, and artifacts, or None if parsing fails
+ """
+ if not appcast_path.exists():
+ return None
+
+ try:
+ tree = ET.parse(appcast_path)
+ root = tree.getroot()
+
+ # Find the item element (we only support single-item appcasts)
+ channel = root.find("channel")
+ if channel is None:
+ return None
+
+ item = channel.find("item")
+ if item is None:
+ return None
+
+ # Extract version
+ version_elem = item.find(f"{{{SPARKLE_NS}}}version")
+ if version_elem is None or version_elem.text is None:
+ return None
+ version = version_elem.text
+
+ # Extract pubDate
+ pub_date_elem = item.find("pubDate")
+ pub_date = pub_date_elem.text if pub_date_elem is not None and pub_date_elem.text else ""
+
+ # Extract enclosures
+ artifacts: Dict[str, SignedArtifact] = {}
+ for enclosure in item.findall("enclosure"):
+ url = enclosure.get("url", "")
+ os_type = enclosure.get(f"{{{SPARKLE_NS}}}os", "")
+ arch = enclosure.get(f"{{{SPARKLE_NS}}}arch", "")
+ signature = enclosure.get(f"{{{SPARKLE_NS}}}edSignature", "")
+ length_str = enclosure.get("length", "0")
+
+ if not all([url, os_type, arch, signature]):
+ continue
+
+ # Extract platform from URL (e.g., browseros_server_0.0.37_darwin_arm64.zip)
+ filename = url.split("/")[-1]
+ # Match pattern like _darwin_arm64.zip or _windows_x64.zip
+ platform_match = re.search(r"_([a-z]+_[a-z0-9]+)\.zip$", filename)
+ if not platform_match:
+ continue
+
+ platform = platform_match.group(1)
+ artifacts[platform] = SignedArtifact(
+ platform=platform,
+ zip_path=Path(filename),
+ signature=signature,
+ length=int(length_str),
+ os=os_type,
+ arch=arch,
+ )
+
+ return ExistingAppcast(version=version, pub_date=pub_date, artifacts=artifacts)
+
+ except ET.ParseError as e:
+ log_error(f"Malformed appcast XML: {e}")
+ return None
+ except Exception as e:
+ log_error(f"Failed to parse existing appcast: {e}")
+ return None
+
+
+def generate_server_appcast(
+ version: str,
+ artifacts: List[SignedArtifact],
+ channel: str = "alpha",
+ existing: Optional[ExistingAppcast] = None,
+) -> str:
+ """Generate appcast XML for server OTA, merging with existing if same version.
+
+ Args:
+ version: Version string (e.g., "0.0.36")
+ artifacts: List of new SignedArtifact with signature info
+ channel: "alpha" or "prod"
+ existing: Previously parsed appcast to merge with (if same version)
+
+ Returns:
+ Complete appcast XML string
+
+ Merge behavior:
+ - If existing has same version: merge platforms, keep original pubDate
+ - If existing has different version or is None: use only new artifacts
+ """
+ if channel == "alpha":
+ title = "BrowserOS Server (Alpha)"
+ appcast_url = "https://cdn.browseros.com/appcast-server.alpha.xml"
+ else:
+ title = "BrowserOS Server"
+ appcast_url = "https://cdn.browseros.com/appcast-server.xml"
+
+ # Determine pubDate and merged artifacts
+ if existing is not None and existing.version == version:
+ # Same version: merge artifacts, keep original pubDate
+ pub_date = existing.pub_date
+ merged_artifacts = dict(existing.artifacts) # Copy existing
+ for artifact in artifacts:
+ merged_artifacts[artifact.platform] = artifact # New overrides existing
+ final_artifacts = list(merged_artifacts.values())
+ log_info(f"Merging with existing appcast (kept {len(existing.artifacts)} existing, added/updated {len(artifacts)} platforms)")
+ else:
+ # Different version or no existing: start fresh
+ pub_date = datetime.now(timezone.utc).strftime("%a, %d %b %Y %H:%M:%S +0000")
+ final_artifacts = artifacts
+ if existing is not None:
+ log_info(f"Version changed ({existing.version} -> {version}), replacing appcast")
+
+ # Sort artifacts by platform name for consistent output
+ final_artifacts = sorted(final_artifacts, key=lambda a: a.platform)
+
+ enclosures = []
+ for artifact in final_artifacts:
+ comment = f"{artifact.os.capitalize()} {artifact.arch}"
+ if artifact.os == "macos":
+ comment = f"macOS {artifact.arch}"
+
+ zip_filename = f"browseros_server_{version}_{artifact.platform}.zip"
+ url = f"https://cdn.browseros.com/server/{zip_filename}"
+
+ enclosure = ENCLOSURE_TEMPLATE.format(
+ comment=comment,
+ url=url,
+ os=artifact.os,
+ arch=artifact.arch,
+ signature=artifact.signature,
+ length=artifact.length,
+ )
+ enclosures.append(enclosure)
+
+ return APPCAST_TEMPLATE.format(
+ title=title,
+ appcast_url=appcast_url,
+ version=version,
+ pub_date=pub_date,
+ enclosures="\n\n".join(enclosures),
+ )
+
+
+def create_server_zip(
+ binary_path: Path,
+ output_zip: Path,
+ is_windows: bool = False,
+) -> bool:
+ """Create zip with proper structure: resources/bin/browseros_server
+
+ Args:
+ binary_path: Path to the binary to package
+ output_zip: Path for output zip file
+ is_windows: Whether this is Windows binary (affects target name)
+
+ Returns:
+ True on success, False on failure
+ """
+ staging_dir = output_zip.parent / f"staging_{output_zip.stem}"
+ try:
+ staging_dir.mkdir(parents=True, exist_ok=True)
+ bin_dir = staging_dir / "resources" / "bin"
+ bin_dir.mkdir(parents=True, exist_ok=True)
+
+ target_name = "browseros_server.exe" if is_windows else "browseros_server"
+ shutil.copy2(binary_path, bin_dir / target_name)
+
+ with zipfile.ZipFile(output_zip, 'w', zipfile.ZIP_DEFLATED) as zf:
+ for root, _, files in os.walk(staging_dir):
+ for file in files:
+ file_path = Path(root) / file
+ arcname = file_path.relative_to(staging_dir)
+ zf.write(file_path, arcname)
+
+ log_success(f"Created {output_zip.name}")
+ return True
+
+ except Exception as e:
+ log_error(f"Failed to create zip: {e}")
+ return False
+ finally:
+ if staging_dir.exists():
+ shutil.rmtree(staging_dir)
+
+
+def get_appcast_path(channel: str = "alpha") -> Path:
+ """Get path to appcast file in config/appcast directory"""
+ appcast_dir = Path(__file__).parent.parent.parent / "config" / "appcast"
+ if channel == "alpha":
+ return appcast_dir / "appcast-server.alpha.xml"
+ return appcast_dir / "appcast-server.xml"
diff --git a/packages/browseros/build/modules/ota/server.py b/packages/browseros/build/modules/ota/server.py
new file mode 100644
index 00000000..4e011dd9
--- /dev/null
+++ b/packages/browseros/build/modules/ota/server.py
@@ -0,0 +1,238 @@
+#!/usr/bin/env python3
+"""Server OTA module for BrowserOS Server binary updates"""
+
+import shutil
+import tempfile
+from pathlib import Path
+from typing import List, Optional
+
+from ...common.module import CommandModule, ValidationError
+from ...common.context import Context
+from ...common.utils import (
+ log_info,
+ log_error,
+ log_success,
+ log_warning,
+ IS_MACOS,
+ IS_WINDOWS,
+)
+
+from .common import (
+ SERVER_PLATFORMS,
+ SignedArtifact,
+ sparkle_sign_file,
+ generate_server_appcast,
+ parse_existing_appcast,
+ create_server_zip,
+ get_appcast_path,
+)
+from .sign_binary import (
+ sign_macos_binary,
+ notarize_macos_binary,
+ sign_windows_binary,
+ get_entitlements_path,
+)
+from ..upload import get_r2_client, upload_file_to_r2
+
+
+class ServerOTAModule(CommandModule):
+ """OTA update module for BrowserOS Server binaries
+
+ This module handles the full OTA workflow:
+ 1. Sign individual binaries (codesign for macOS, CodeSignTool for Windows)
+ 2. Create zip packages with proper structure
+ 3. Sign zips with Sparkle Ed25519
+ 4. Upload to R2
+ 5. Generate and upload appcast XML
+ """
+
+ produces = ["server_ota_artifacts", "server_appcast"]
+ requires = []
+ description = "Create and upload BrowserOS Server OTA update"
+
+ def __init__(
+ self,
+ version: str = "",
+ channel: str = "alpha",
+ binaries_dir: Optional[Path] = None,
+ platform_filter: Optional[str] = None,
+ ):
+ """
+ Args:
+ version: Version string (e.g., "0.0.36")
+ channel: Release channel ("alpha" or "prod")
+ binaries_dir: Directory containing server binaries
+ platform_filter: Platform(s) to process, comma-separated (e.g., "darwin_arm64,darwin_x64")
+ """
+ self.version = version
+ self.channel = channel
+ self.binaries_dir = binaries_dir
+ self.platform_filter = platform_filter
+
+ def validate(self, context: Context) -> None:
+ ctx = context
+ if not self.version:
+ raise ValidationError("Version is required")
+
+ if self.channel not in ["alpha", "prod"]:
+ raise ValidationError("Channel must be 'alpha' or 'prod'")
+
+ if self.binaries_dir:
+ if not self.binaries_dir.exists():
+ raise ValidationError(f"Binaries directory not found: {self.binaries_dir}")
+ else:
+ default_dir = ctx.root_dir / "resources" / "binaries" / "browseros_server"
+ if not default_dir.exists():
+ raise ValidationError(f"Default binaries directory not found: {default_dir}")
+ self.binaries_dir = default_dir
+
+ platforms = self._get_platforms()
+ for p in platforms:
+ binary_name = p["binary"]
+ binary_path = self.binaries_dir / binary_name
+ if not binary_path.exists():
+ raise ValidationError(f"Binary not found: {binary_path}")
+
+ if IS_MACOS():
+ if not ctx.env.macos_certificate_name:
+ raise ValidationError("MACOS_CERTIFICATE_NAME required for signing")
+ elif IS_WINDOWS():
+ if not ctx.env.code_sign_tool_path:
+ raise ValidationError("CODE_SIGN_TOOL_PATH required for signing")
+
+ if not ctx.env.has_r2_config():
+ raise ValidationError(
+ "R2 configuration not set. Required env vars: "
+ "R2_ACCOUNT_ID, R2_ACCESS_KEY_ID, R2_SECRET_ACCESS_KEY"
+ )
+
+ def _get_platforms(self) -> List[dict]:
+ """Get platforms to process based on filter (supports comma-separated)"""
+ if self.platform_filter:
+ requested = [p.strip() for p in self.platform_filter.split(",")]
+ return [p for p in SERVER_PLATFORMS if p["name"] in requested]
+ return SERVER_PLATFORMS
+
+ def execute(self, context: Context) -> None:
+ ctx = context
+ log_info(f"\nš BrowserOS Server OTA v{self.version} ({self.channel})")
+ log_info("=" * 70)
+
+ platforms = self._get_platforms()
+ temp_dir = Path(tempfile.mkdtemp())
+ log_info(f"Temp directory: {temp_dir}")
+
+ signed_artifacts: List[SignedArtifact] = []
+
+ for platform in platforms:
+ log_info(f"\nš¦ Processing {platform['name']}...")
+
+ binary_name = platform["binary"]
+ source_binary = self.binaries_dir / binary_name
+
+ # Copy binary to temp to preserve original
+ temp_binary = temp_dir / binary_name
+ shutil.copy2(source_binary, temp_binary)
+
+ if not self._sign_binary(temp_binary, platform, ctx):
+ log_warning(f"Skipping {platform['name']} due to signing failure")
+ continue
+
+ zip_name = f"browseros_server_{self.version}_{platform['name']}.zip"
+ zip_path = temp_dir / zip_name
+ is_windows = platform["os"] == "windows"
+
+ if not create_server_zip(temp_binary, zip_path, is_windows):
+ log_error(f"Failed to create zip for {platform['name']}")
+ continue
+
+ log_info(f"Signing {zip_name} with Sparkle...")
+ signature, length = sparkle_sign_file(zip_path, ctx.env)
+
+ if not signature:
+ log_error(f"Failed to sign zip for {platform['name']}")
+ continue
+
+ log_success(f" {platform['name']}: {length} bytes")
+
+ artifact = SignedArtifact(
+ platform=platform["name"],
+ zip_path=zip_path,
+ signature=signature,
+ length=length,
+ os=platform["os"],
+ arch=platform["arch"],
+ )
+ signed_artifacts.append(artifact)
+
+ if not signed_artifacts:
+ log_error("No artifacts were processed successfully")
+ raise RuntimeError("OTA failed - no artifacts")
+
+ log_info("\nš Generating appcast...")
+ appcast_path = get_appcast_path(self.channel)
+ existing_appcast = parse_existing_appcast(appcast_path)
+
+ appcast_content = generate_server_appcast(
+ self.version,
+ signed_artifacts,
+ self.channel,
+ existing=existing_appcast,
+ )
+ appcast_path.parent.mkdir(parents=True, exist_ok=True)
+ appcast_path.write_text(appcast_content)
+ log_success(f"Appcast saved to: {appcast_path}")
+
+ log_info("\nš¤ Uploading artifacts to R2...")
+ r2_client = get_r2_client(ctx.env)
+ if not r2_client:
+ raise RuntimeError("Failed to create R2 client")
+
+ bucket = ctx.env.r2_bucket
+ for artifact in signed_artifacts:
+ r2_key = f"server/{artifact.zip_path.name}"
+ if not upload_file_to_r2(r2_client, artifact.zip_path, r2_key, bucket):
+ raise RuntimeError(f"Failed to upload {artifact.zip_path.name}")
+
+ ctx.artifacts["server_ota_artifacts"] = signed_artifacts
+ ctx.artifacts["server_appcast"] = appcast_path
+
+ log_info("\n" + "=" * 70)
+ log_success(f"ā
Server OTA v{self.version} ({self.channel}) artifacts ready!")
+ log_info("=" * 70)
+
+ log_info("\nArtifact URLs:")
+ for artifact in signed_artifacts:
+ log_info(f" https://cdn.browseros.com/server/{artifact.zip_path.name}")
+
+ log_info(f"\nAppcast saved to: {appcast_path}")
+ log_info("\nš Next step: Run 'browseros ota server release-appcast' to make the release live")
+
+ def _sign_binary(self, binary_path: Path, platform: dict, ctx: Context) -> bool:
+ """Sign binary based on platform"""
+ os_type = platform["os"]
+
+ if os_type == "macos":
+ if not IS_MACOS():
+ log_warning(f"macOS signing requires macOS - skipping {platform['name']}")
+ return True
+
+ entitlements = get_entitlements_path(ctx.root_dir)
+ if not sign_macos_binary(binary_path, ctx.env, entitlements):
+ return False
+
+ log_info("Notarizing...")
+ return notarize_macos_binary(binary_path, ctx.env)
+
+ elif os_type == "windows":
+ if not IS_WINDOWS():
+ log_warning(f"Windows signing requires Windows - skipping {platform['name']}")
+ return True
+
+ return sign_windows_binary(binary_path, ctx.env)
+
+ elif os_type == "linux":
+ log_info(f"No code signing for Linux binaries")
+ return True
+
+ return True
diff --git a/packages/browseros/build/modules/ota/sign_binary.py b/packages/browseros/build/modules/ota/sign_binary.py
new file mode 100644
index 00000000..c3d11db5
--- /dev/null
+++ b/packages/browseros/build/modules/ota/sign_binary.py
@@ -0,0 +1,304 @@
+#!/usr/bin/env python3
+"""Platform-specific binary signing for OTA binaries"""
+
+import subprocess
+import tempfile
+from pathlib import Path
+from typing import Optional
+
+from ...common.env import EnvConfig
+from ...common.utils import (
+ log_info,
+ log_error,
+ log_success,
+ log_warning,
+ IS_MACOS,
+ IS_WINDOWS,
+)
+
+
+def sign_macos_binary(
+ binary_path: Path,
+ env: Optional[EnvConfig] = None,
+ entitlements_path: Optional[Path] = None,
+) -> bool:
+ """Sign a macOS binary with codesign
+
+ Args:
+ binary_path: Path to binary to sign
+ env: Environment config with certificate name
+ entitlements_path: Optional path to entitlements plist
+
+ Returns:
+ True on success, False on failure
+ """
+ if not IS_MACOS():
+ log_error("macOS signing requires macOS")
+ return False
+
+ if env is None:
+ env = EnvConfig()
+
+ certificate_name = env.macos_certificate_name
+ if not certificate_name:
+ log_error("MACOS_CERTIFICATE_NAME not set")
+ return False
+
+ log_info(f"Signing {binary_path.name}...")
+
+ cmd = [
+ "codesign",
+ "--sign", certificate_name,
+ "--force",
+ "--timestamp",
+ "--identifier", f"com.browseros.{binary_path.stem}",
+ "--options", "runtime",
+ ]
+
+ if entitlements_path and entitlements_path.exists():
+ cmd.extend(["--entitlements", str(entitlements_path)])
+
+ cmd.append(str(binary_path))
+
+ try:
+ result = subprocess.run(cmd, capture_output=True, text=True, check=False)
+ if result.returncode != 0:
+ log_error(f"codesign failed: {result.stderr}")
+ return False
+
+ log_success(f"Signed {binary_path.name}")
+ return True
+
+ except Exception as e:
+ log_error(f"Signing failed: {e}")
+ return False
+
+
+def verify_macos_signature(binary_path: Path) -> bool:
+ """Verify macOS binary signature"""
+ if not IS_MACOS():
+ return False
+
+ try:
+ result = subprocess.run(
+ ["codesign", "--verify", "--verbose=2", str(binary_path)],
+ capture_output=True,
+ text=True,
+ check=False,
+ )
+ return result.returncode == 0
+ except Exception:
+ return False
+
+
+def notarize_macos_binary(
+ binary_path: Path,
+ env: Optional[EnvConfig] = None,
+) -> bool:
+ """Notarize a macOS binary with Apple
+
+ The binary must be zipped for notarization submission.
+
+ Args:
+ binary_path: Path to binary to notarize (will be zipped internally)
+ env: Environment config with notarization credentials
+
+ Returns:
+ True on success, False on failure
+ """
+ if not IS_MACOS():
+ log_error("macOS notarization requires macOS")
+ return False
+
+ if env is None:
+ env = EnvConfig()
+
+ apple_id = env.macos_notarization_apple_id
+ team_id = env.macos_notarization_team_id
+ password = env.macos_notarization_password
+
+ if not all([apple_id, team_id, password]):
+ log_error("Missing notarization credentials:")
+ if not apple_id:
+ log_error(" PROD_MACOS_NOTARIZATION_APPLE_ID not set")
+ if not team_id:
+ log_error(" PROD_MACOS_NOTARIZATION_TEAM_ID not set")
+ if not password:
+ log_error(" PROD_MACOS_NOTARIZATION_PWD not set")
+ return False
+
+ log_info(f"Notarizing {binary_path.name}...")
+
+ notarize_zip = None
+ try:
+ fd, tmp_path = tempfile.mkstemp(suffix=".zip")
+ import os
+ os.close(fd)
+ notarize_zip = Path(tmp_path)
+
+ result = subprocess.run(
+ ["ditto", "-c", "-k", "--keepParent", str(binary_path), str(notarize_zip)],
+ capture_output=True,
+ text=True,
+ check=False,
+ )
+ if result.returncode != 0:
+ log_error(f"Failed to create zip: {result.stderr}")
+ return False
+
+ assert apple_id is not None
+ assert team_id is not None
+ assert password is not None
+ subprocess.run(
+ [
+ "xcrun", "notarytool", "store-credentials", "notarytool-profile",
+ "--apple-id", apple_id,
+ "--team-id", team_id,
+ "--password", password,
+ ],
+ capture_output=True,
+ text=True,
+ check=False,
+ )
+
+ log_info("Submitting for notarization (this may take a while)...")
+ result = subprocess.run(
+ [
+ "xcrun", "notarytool", "submit", str(notarize_zip),
+ "--keychain-profile", "notarytool-profile",
+ "--wait",
+ ],
+ capture_output=True,
+ text=True,
+ check=False,
+ )
+
+ if result.returncode != 0:
+ log_error(f"Notarization failed: {result.stderr}")
+ log_error(result.stdout)
+ return False
+
+ if "status: Accepted" not in result.stdout:
+ log_error("Notarization was not accepted")
+ log_error(result.stdout)
+ return False
+
+ log_success(f"Notarized {binary_path.name}")
+ return True
+
+ except Exception as e:
+ log_error(f"Notarization failed: {e}")
+ return False
+ finally:
+ if notarize_zip and notarize_zip.exists():
+ notarize_zip.unlink()
+
+
+def sign_windows_binary(
+ binary_path: Path,
+ env: Optional[EnvConfig] = None,
+) -> bool:
+ """Sign a Windows binary with SSL.com CodeSignTool
+
+ Args:
+ binary_path: Path to binary to sign
+ env: Environment config with eSigner credentials
+
+ Returns:
+ True on success, False on failure
+ """
+ if not IS_WINDOWS():
+ log_warning("Windows signing should be done on Windows")
+ log_warning("Skipping signing - binary will be unsigned")
+ return True
+
+ if env is None:
+ env = EnvConfig()
+
+ if not env.code_sign_tool_path:
+ log_error("CODE_SIGN_TOOL_PATH not set")
+ return False
+
+ if not all([env.esigner_username, env.esigner_password, env.esigner_totp_secret]):
+ log_error("Missing eSigner credentials")
+ return False
+
+ codesigntool_path = Path(env.code_sign_tool_path) / "CodeSignTool.bat"
+ if not codesigntool_path.exists():
+ log_error(f"CodeSignTool.bat not found at: {codesigntool_path}")
+ return False
+
+ log_info(f"Signing {binary_path.name}...")
+
+ try:
+ temp_output_dir = binary_path.parent / "signed_temp"
+ temp_output_dir.mkdir(exist_ok=True)
+
+ cmd = [
+ str(codesigntool_path),
+ "sign",
+ "-username", env.esigner_username,
+ "-password", f'"{env.esigner_password}"',
+ ]
+
+ if env.esigner_credential_id:
+ cmd.extend(["-credential_id", env.esigner_credential_id])
+
+ cmd.extend([
+ "-totp_secret", env.esigner_totp_secret,
+ "-input_file_path", str(binary_path),
+ "-output_dir_path", str(temp_output_dir),
+ "-override",
+ ])
+
+ result = subprocess.run(
+ " ".join(cmd),
+ shell=True,
+ capture_output=True,
+ text=True,
+ cwd=str(codesigntool_path.parent),
+ )
+
+ if result.stdout and "Error:" in result.stdout:
+ log_error(f"Signing failed: {result.stdout}")
+ return False
+
+ signed_file = temp_output_dir / binary_path.name
+ if signed_file.exists():
+ import shutil
+ shutil.move(str(signed_file), str(binary_path))
+
+ try:
+ temp_output_dir.rmdir()
+ except Exception:
+ pass
+
+ verify_cmd = [
+ "powershell", "-Command",
+ f"(Get-AuthenticodeSignature '{binary_path}').Status",
+ ]
+ verify_result = subprocess.run(verify_cmd, capture_output=True, text=True)
+ if "Valid" in verify_result.stdout:
+ log_success(f"Signed and verified {binary_path.name}")
+ return True
+ else:
+ log_error(f"Signature verification failed: {verify_result.stdout.strip()}")
+ return False
+
+ except Exception as e:
+ log_error(f"Signing failed: {e}")
+ return False
+
+
+def get_entitlements_path(root_dir: Path) -> Optional[Path]:
+ """Get path to server binary entitlements file"""
+ candidates = [
+ root_dir / "resources" / "entitlements" / "browseros-executable-entitlements.plist",
+ root_dir / "packages" / "browseros" / "resources" / "entitlements" / "browseros-executable-entitlements.plist",
+ ]
+
+ for candidate in candidates:
+ if candidate.exists():
+ return candidate
+
+ return None
diff --git a/packages/browseros/build/modules/sign/sparkle.py b/packages/browseros/build/modules/sign/sparkle.py
index 1f463426..3264cb15 100644
--- a/packages/browseros/build/modules/sign/sparkle.py
+++ b/packages/browseros/build/modules/sign/sparkle.py
@@ -1,23 +1,16 @@
#!/usr/bin/env python3
"""Sparkle signing module for macOS auto-update"""
-import base64
-import os
-import re
-import subprocess
-import tempfile
from pathlib import Path
-from typing import Dict, Optional, Tuple
+from typing import Dict, Tuple
from ...common.module import CommandModule, ValidationError
from ...common.context import Context
+from ...common.sparkle import sparkle_sign_file
from ...common.utils import (
log_info,
- log_error,
log_success,
log_warning,
- IS_MACOS,
- join_paths,
)
@@ -29,24 +22,11 @@ class SparkleSignModule(CommandModule):
description = "Sign DMG files with Sparkle Ed25519 key for auto-update"
def validate(self, ctx: Context) -> None:
- if not IS_MACOS():
- raise ValidationError("Sparkle signing is macOS only")
-
- # Check sign_update tool exists
- sign_update = self._get_sign_update_path(ctx)
- if not sign_update.exists():
- raise ValidationError(f"sign_update not found: {sign_update}")
-
- # Check Sparkle private key is available
if not ctx.env.has_sparkle_key():
raise ValidationError(
"SPARKLE_PRIVATE_KEY environment variable not set"
)
- def _get_sign_update_path(self, ctx: Context) -> Path:
- """Get path to Sparkle sign_update tool"""
- return join_paths(ctx.chromium_src, "third_party", "sparkle", "bin", "sign_update")
-
def execute(self, ctx: Context) -> None:
log_info("\nš Signing DMGs with Sparkle...")
@@ -88,120 +68,18 @@ def sign_dmgs_with_sparkle(
Returns:
Dict mapping filename to (signature, length) tuple
"""
- env = ctx.env
- sign_update = join_paths(ctx.chromium_src, "third_party", "sparkle", "bin", "sign_update")
-
- if not sign_update.exists():
- log_error(f"sign_update not found: {sign_update}")
- return {}
-
- if not env.has_sparkle_key():
- log_error("SPARKLE_PRIVATE_KEY not set")
- return {}
-
signatures = {}
- # Write private key to temp file
- key_file = None
- try:
- # Decode base64 key if it looks encoded, otherwise use as-is
- key_data = env.sparkle_private_key
- try:
- # Try to decode as base64
- decoded = base64.b64decode(key_data)
- key_data = decoded.decode("utf-8")
- except Exception:
- # Not base64, use as-is
- pass
-
- # Write key to temp file
- key_file = tempfile.NamedTemporaryFile(
- mode="w", suffix=".key", delete=False
- )
- key_file.write(key_data)
- key_file.close()
-
- # Sign each DMG
- for dmg_path in dmg_files:
- sig, length = _sign_single_dmg(sign_update, key_file.name, dmg_path)
- if sig:
- signatures[dmg_path.name] = (sig, length)
-
- finally:
- # Clean up temp key file
- if key_file and os.path.exists(key_file.name):
- os.unlink(key_file.name)
+ for dmg_path in dmg_files:
+ log_info(f"š Signing {dmg_path.name}...")
+ sig, length = sparkle_sign_file(dmg_path, ctx.env)
+ if sig:
+ signatures[dmg_path.name] = (sig, length)
+ log_success(f"ā Signed {dmg_path.name}")
return signatures
-def _sign_single_dmg(
- sign_update: Path,
- key_file: str,
- dmg_path: Path,
-) -> Tuple[Optional[str], int]:
- """Sign a single DMG and parse the output
-
- Args:
- sign_update: Path to sign_update binary
- key_file: Path to temporary key file
- dmg_path: Path to DMG file
-
- Returns:
- (signature, length) tuple, or (None, 0) on failure
- """
- log_info(f"š Signing {dmg_path.name}...")
-
- try:
- result = subprocess.run(
- [str(sign_update), "--ed-key-file", key_file, str(dmg_path)],
- capture_output=True,
- text=True,
- check=False,
- )
-
- if result.returncode != 0:
- log_error(f"sign_update failed: {result.stderr}")
- return None, 0
-
- # Parse output: sparkle:edSignature="..." length="..."
- output = result.stdout.strip()
- sig, length = parse_sparkle_output(output)
-
- if sig:
- log_success(f"ā Signed {dmg_path.name}")
- return sig, length
- else:
- log_error(f"Failed to parse sign_update output: {output}")
- return None, 0
-
- except Exception as e:
- log_error(f"Error signing {dmg_path.name}: {e}")
- return None, 0
-
-
-def parse_sparkle_output(output: str) -> Tuple[Optional[str], int]:
- """Parse sign_update output to extract signature and length
-
- Example output:
- sparkle:edSignature="abc123..." length="126911210"
-
- Args:
- output: Raw output from sign_update
-
- Returns:
- (signature, length) tuple, or (None, 0) if parsing fails
- """
- # Match: sparkle:edSignature="..." length="..."
- sig_match = re.search(r'sparkle:edSignature="([^"]+)"', output)
- len_match = re.search(r'length="(\d+)"', output)
-
- if sig_match and len_match:
- return sig_match.group(1), int(len_match.group(1))
-
- return None, 0
-
-
def get_sparkle_signatures(ctx: Context) -> Dict[str, Tuple[str, int]]:
"""Get stored Sparkle signatures from context
diff --git a/packages/browseros/pyproject.toml b/packages/browseros/pyproject.toml
index 8dcefb1c..232c3f91 100644
--- a/packages/browseros/pyproject.toml
+++ b/packages/browseros/pyproject.toml
@@ -15,6 +15,7 @@ dependencies = [
"boto3>=1.34.0",
"python-dotenv>=1.0.0",
"Pillow>=10.0.0",
+ "cryptography>=41.0.0",
]
[project.scripts]
@@ -34,6 +35,7 @@ packages = [
"build.modules.extract",
"build.modules.apply",
"build.modules.feature",
+ "build.modules.ota",
]
[tool.black]
diff --git a/packages/browseros/uv.lock b/packages/browseros/uv.lock
index e37796ee..deba14d3 100644
--- a/packages/browseros/uv.lock
+++ b/packages/browseros/uv.lock
@@ -41,6 +41,7 @@ source = { editable = "." }
dependencies = [
{ name = "boto3" },
{ name = "click" },
+ { name = "cryptography" },
{ name = "pillow" },
{ name = "python-dotenv" },
{ name = "pyyaml" },
@@ -58,6 +59,7 @@ dev = [
requires-dist = [
{ name = "boto3", specifier = ">=1.34.0" },
{ name = "click", specifier = ">=8.0.0" },
+ { name = "cryptography", specifier = ">=41.0.0" },
{ name = "pillow", specifier = ">=10.0.0" },
{ name = "python-dotenv", specifier = ">=1.0.0" },
{ name = "pyyaml", specifier = ">=5.4.1" },
@@ -80,6 +82,63 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/e4/37/af0d2ef3967ac0d6113837b44a4f0bfe1328c2b9763bd5b1744520e5cfed/certifi-2025.10.5-py3-none-any.whl", hash = "sha256:0f212c2744a9bb6de0c56639a6f68afe01ecd92d91f14ae897c4fe7bbeeef0de", size = 163286, upload-time = "2025-10-05T04:12:14.03Z" },
]
+[[package]]
+name = "cffi"
+version = "2.0.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "pycparser", marker = "implementation_name != 'PyPy'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/eb/56/b1ba7935a17738ae8453301356628e8147c79dbb825bcbc73dc7401f9846/cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529", size = 523588, upload-time = "2025-09-08T23:24:04.541Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/ea/47/4f61023ea636104d4f16ab488e268b93008c3d0bb76893b1b31db1f96802/cffi-2.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6d02d6655b0e54f54c4ef0b94eb6be0607b70853c45ce98bd278dc7de718be5d", size = 185271, upload-time = "2025-09-08T23:22:44.795Z" },
+ { url = "https://files.pythonhosted.org/packages/df/a2/781b623f57358e360d62cdd7a8c681f074a71d445418a776eef0aadb4ab4/cffi-2.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8eca2a813c1cb7ad4fb74d368c2ffbbb4789d377ee5bb8df98373c2cc0dee76c", size = 181048, upload-time = "2025-09-08T23:22:45.938Z" },
+ { url = "https://files.pythonhosted.org/packages/ff/df/a4f0fbd47331ceeba3d37c2e51e9dfc9722498becbeec2bd8bc856c9538a/cffi-2.0.0-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:21d1152871b019407d8ac3985f6775c079416c282e431a4da6afe7aefd2bccbe", size = 212529, upload-time = "2025-09-08T23:22:47.349Z" },
+ { url = "https://files.pythonhosted.org/packages/d5/72/12b5f8d3865bf0f87cf1404d8c374e7487dcf097a1c91c436e72e6badd83/cffi-2.0.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b21e08af67b8a103c71a250401c78d5e0893beff75e28c53c98f4de42f774062", size = 220097, upload-time = "2025-09-08T23:22:48.677Z" },
+ { url = "https://files.pythonhosted.org/packages/c2/95/7a135d52a50dfa7c882ab0ac17e8dc11cec9d55d2c18dda414c051c5e69e/cffi-2.0.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:1e3a615586f05fc4065a8b22b8152f0c1b00cdbc60596d187c2a74f9e3036e4e", size = 207983, upload-time = "2025-09-08T23:22:50.06Z" },
+ { url = "https://files.pythonhosted.org/packages/3a/c8/15cb9ada8895957ea171c62dc78ff3e99159ee7adb13c0123c001a2546c1/cffi-2.0.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:81afed14892743bbe14dacb9e36d9e0e504cd204e0b165062c488942b9718037", size = 206519, upload-time = "2025-09-08T23:22:51.364Z" },
+ { url = "https://files.pythonhosted.org/packages/78/2d/7fa73dfa841b5ac06c7b8855cfc18622132e365f5b81d02230333ff26e9e/cffi-2.0.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3e17ed538242334bf70832644a32a7aae3d83b57567f9fd60a26257e992b79ba", size = 219572, upload-time = "2025-09-08T23:22:52.902Z" },
+ { url = "https://files.pythonhosted.org/packages/07/e0/267e57e387b4ca276b90f0434ff88b2c2241ad72b16d31836adddfd6031b/cffi-2.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3925dd22fa2b7699ed2617149842d2e6adde22b262fcbfada50e3d195e4b3a94", size = 222963, upload-time = "2025-09-08T23:22:54.518Z" },
+ { url = "https://files.pythonhosted.org/packages/b6/75/1f2747525e06f53efbd878f4d03bac5b859cbc11c633d0fb81432d98a795/cffi-2.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2c8f814d84194c9ea681642fd164267891702542f028a15fc97d4674b6206187", size = 221361, upload-time = "2025-09-08T23:22:55.867Z" },
+ { url = "https://files.pythonhosted.org/packages/7b/2b/2b6435f76bfeb6bbf055596976da087377ede68df465419d192acf00c437/cffi-2.0.0-cp312-cp312-win32.whl", hash = "sha256:da902562c3e9c550df360bfa53c035b2f241fed6d9aef119048073680ace4a18", size = 172932, upload-time = "2025-09-08T23:22:57.188Z" },
+ { url = "https://files.pythonhosted.org/packages/f8/ed/13bd4418627013bec4ed6e54283b1959cf6db888048c7cf4b4c3b5b36002/cffi-2.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:da68248800ad6320861f129cd9c1bf96ca849a2771a59e0344e88681905916f5", size = 183557, upload-time = "2025-09-08T23:22:58.351Z" },
+ { url = "https://files.pythonhosted.org/packages/95/31/9f7f93ad2f8eff1dbc1c3656d7ca5bfd8fb52c9d786b4dcf19b2d02217fa/cffi-2.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:4671d9dd5ec934cb9a73e7ee9676f9362aba54f7f34910956b84d727b0d73fb6", size = 177762, upload-time = "2025-09-08T23:22:59.668Z" },
+ { url = "https://files.pythonhosted.org/packages/4b/8d/a0a47a0c9e413a658623d014e91e74a50cdd2c423f7ccfd44086ef767f90/cffi-2.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:00bdf7acc5f795150faa6957054fbbca2439db2f775ce831222b66f192f03beb", size = 185230, upload-time = "2025-09-08T23:23:00.879Z" },
+ { url = "https://files.pythonhosted.org/packages/4a/d2/a6c0296814556c68ee32009d9c2ad4f85f2707cdecfd7727951ec228005d/cffi-2.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:45d5e886156860dc35862657e1494b9bae8dfa63bf56796f2fb56e1679fc0bca", size = 181043, upload-time = "2025-09-08T23:23:02.231Z" },
+ { url = "https://files.pythonhosted.org/packages/b0/1e/d22cc63332bd59b06481ceaac49d6c507598642e2230f201649058a7e704/cffi-2.0.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:07b271772c100085dd28b74fa0cd81c8fb1a3ba18b21e03d7c27f3436a10606b", size = 212446, upload-time = "2025-09-08T23:23:03.472Z" },
+ { url = "https://files.pythonhosted.org/packages/a9/f5/a2c23eb03b61a0b8747f211eb716446c826ad66818ddc7810cc2cc19b3f2/cffi-2.0.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d48a880098c96020b02d5a1f7d9251308510ce8858940e6fa99ece33f610838b", size = 220101, upload-time = "2025-09-08T23:23:04.792Z" },
+ { url = "https://files.pythonhosted.org/packages/f2/7f/e6647792fc5850d634695bc0e6ab4111ae88e89981d35ac269956605feba/cffi-2.0.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f93fd8e5c8c0a4aa1f424d6173f14a892044054871c771f8566e4008eaa359d2", size = 207948, upload-time = "2025-09-08T23:23:06.127Z" },
+ { url = "https://files.pythonhosted.org/packages/cb/1e/a5a1bd6f1fb30f22573f76533de12a00bf274abcdc55c8edab639078abb6/cffi-2.0.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:dd4f05f54a52fb558f1ba9f528228066954fee3ebe629fc1660d874d040ae5a3", size = 206422, upload-time = "2025-09-08T23:23:07.753Z" },
+ { url = "https://files.pythonhosted.org/packages/98/df/0a1755e750013a2081e863e7cd37e0cdd02664372c754e5560099eb7aa44/cffi-2.0.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c8d3b5532fc71b7a77c09192b4a5a200ea992702734a2e9279a37f2478236f26", size = 219499, upload-time = "2025-09-08T23:23:09.648Z" },
+ { url = "https://files.pythonhosted.org/packages/50/e1/a969e687fcf9ea58e6e2a928ad5e2dd88cc12f6f0ab477e9971f2309b57c/cffi-2.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d9b29c1f0ae438d5ee9acb31cadee00a58c46cc9c0b2f9038c6b0b3470877a8c", size = 222928, upload-time = "2025-09-08T23:23:10.928Z" },
+ { url = "https://files.pythonhosted.org/packages/36/54/0362578dd2c9e557a28ac77698ed67323ed5b9775ca9d3fe73fe191bb5d8/cffi-2.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6d50360be4546678fc1b79ffe7a66265e28667840010348dd69a314145807a1b", size = 221302, upload-time = "2025-09-08T23:23:12.42Z" },
+ { url = "https://files.pythonhosted.org/packages/eb/6d/bf9bda840d5f1dfdbf0feca87fbdb64a918a69bca42cfa0ba7b137c48cb8/cffi-2.0.0-cp313-cp313-win32.whl", hash = "sha256:74a03b9698e198d47562765773b4a8309919089150a0bb17d829ad7b44b60d27", size = 172909, upload-time = "2025-09-08T23:23:14.32Z" },
+ { url = "https://files.pythonhosted.org/packages/37/18/6519e1ee6f5a1e579e04b9ddb6f1676c17368a7aba48299c3759bbc3c8b3/cffi-2.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:19f705ada2530c1167abacb171925dd886168931e0a7b78f5bffcae5c6b5be75", size = 183402, upload-time = "2025-09-08T23:23:15.535Z" },
+ { url = "https://files.pythonhosted.org/packages/cb/0e/02ceeec9a7d6ee63bb596121c2c8e9b3a9e150936f4fbef6ca1943e6137c/cffi-2.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:256f80b80ca3853f90c21b23ee78cd008713787b1b1e93eae9f3d6a7134abd91", size = 177780, upload-time = "2025-09-08T23:23:16.761Z" },
+ { url = "https://files.pythonhosted.org/packages/92/c4/3ce07396253a83250ee98564f8d7e9789fab8e58858f35d07a9a2c78de9f/cffi-2.0.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fc33c5141b55ed366cfaad382df24fe7dcbc686de5be719b207bb248e3053dc5", size = 185320, upload-time = "2025-09-08T23:23:18.087Z" },
+ { url = "https://files.pythonhosted.org/packages/59/dd/27e9fa567a23931c838c6b02d0764611c62290062a6d4e8ff7863daf9730/cffi-2.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c654de545946e0db659b3400168c9ad31b5d29593291482c43e3564effbcee13", size = 181487, upload-time = "2025-09-08T23:23:19.622Z" },
+ { url = "https://files.pythonhosted.org/packages/d6/43/0e822876f87ea8a4ef95442c3d766a06a51fc5298823f884ef87aaad168c/cffi-2.0.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:24b6f81f1983e6df8db3adc38562c83f7d4a0c36162885ec7f7b77c7dcbec97b", size = 220049, upload-time = "2025-09-08T23:23:20.853Z" },
+ { url = "https://files.pythonhosted.org/packages/b4/89/76799151d9c2d2d1ead63c2429da9ea9d7aac304603de0c6e8764e6e8e70/cffi-2.0.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:12873ca6cb9b0f0d3a0da705d6086fe911591737a59f28b7936bdfed27c0d47c", size = 207793, upload-time = "2025-09-08T23:23:22.08Z" },
+ { url = "https://files.pythonhosted.org/packages/bb/dd/3465b14bb9e24ee24cb88c9e3730f6de63111fffe513492bf8c808a3547e/cffi-2.0.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d9b97165e8aed9272a6bb17c01e3cc5871a594a446ebedc996e2397a1c1ea8ef", size = 206300, upload-time = "2025-09-08T23:23:23.314Z" },
+ { url = "https://files.pythonhosted.org/packages/47/d9/d83e293854571c877a92da46fdec39158f8d7e68da75bf73581225d28e90/cffi-2.0.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:afb8db5439b81cf9c9d0c80404b60c3cc9c3add93e114dcae767f1477cb53775", size = 219244, upload-time = "2025-09-08T23:23:24.541Z" },
+ { url = "https://files.pythonhosted.org/packages/2b/0f/1f177e3683aead2bb00f7679a16451d302c436b5cbf2505f0ea8146ef59e/cffi-2.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:737fe7d37e1a1bffe70bd5754ea763a62a066dc5913ca57e957824b72a85e205", size = 222828, upload-time = "2025-09-08T23:23:26.143Z" },
+ { url = "https://files.pythonhosted.org/packages/c6/0f/cafacebd4b040e3119dcb32fed8bdef8dfe94da653155f9d0b9dc660166e/cffi-2.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:38100abb9d1b1435bc4cc340bb4489635dc2f0da7456590877030c9b3d40b0c1", size = 220926, upload-time = "2025-09-08T23:23:27.873Z" },
+ { url = "https://files.pythonhosted.org/packages/3e/aa/df335faa45b395396fcbc03de2dfcab242cd61a9900e914fe682a59170b1/cffi-2.0.0-cp314-cp314-win32.whl", hash = "sha256:087067fa8953339c723661eda6b54bc98c5625757ea62e95eb4898ad5e776e9f", size = 175328, upload-time = "2025-09-08T23:23:44.61Z" },
+ { url = "https://files.pythonhosted.org/packages/bb/92/882c2d30831744296ce713f0feb4c1cd30f346ef747b530b5318715cc367/cffi-2.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:203a48d1fb583fc7d78a4c6655692963b860a417c0528492a6bc21f1aaefab25", size = 185650, upload-time = "2025-09-08T23:23:45.848Z" },
+ { url = "https://files.pythonhosted.org/packages/9f/2c/98ece204b9d35a7366b5b2c6539c350313ca13932143e79dc133ba757104/cffi-2.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:dbd5c7a25a7cb98f5ca55d258b103a2054f859a46ae11aaf23134f9cc0d356ad", size = 180687, upload-time = "2025-09-08T23:23:47.105Z" },
+ { url = "https://files.pythonhosted.org/packages/3e/61/c768e4d548bfa607abcda77423448df8c471f25dbe64fb2ef6d555eae006/cffi-2.0.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:9a67fc9e8eb39039280526379fb3a70023d77caec1852002b4da7e8b270c4dd9", size = 188773, upload-time = "2025-09-08T23:23:29.347Z" },
+ { url = "https://files.pythonhosted.org/packages/2c/ea/5f76bce7cf6fcd0ab1a1058b5af899bfbef198bea4d5686da88471ea0336/cffi-2.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7a66c7204d8869299919db4d5069a82f1561581af12b11b3c9f48c584eb8743d", size = 185013, upload-time = "2025-09-08T23:23:30.63Z" },
+ { url = "https://files.pythonhosted.org/packages/be/b4/c56878d0d1755cf9caa54ba71e5d049479c52f9e4afc230f06822162ab2f/cffi-2.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7cc09976e8b56f8cebd752f7113ad07752461f48a58cbba644139015ac24954c", size = 221593, upload-time = "2025-09-08T23:23:31.91Z" },
+ { url = "https://files.pythonhosted.org/packages/e0/0d/eb704606dfe8033e7128df5e90fee946bbcb64a04fcdaa97321309004000/cffi-2.0.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:92b68146a71df78564e4ef48af17551a5ddd142e5190cdf2c5624d0c3ff5b2e8", size = 209354, upload-time = "2025-09-08T23:23:33.214Z" },
+ { url = "https://files.pythonhosted.org/packages/d8/19/3c435d727b368ca475fb8742ab97c9cb13a0de600ce86f62eab7fa3eea60/cffi-2.0.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b1e74d11748e7e98e2f426ab176d4ed720a64412b6a15054378afdb71e0f37dc", size = 208480, upload-time = "2025-09-08T23:23:34.495Z" },
+ { url = "https://files.pythonhosted.org/packages/d0/44/681604464ed9541673e486521497406fadcc15b5217c3e326b061696899a/cffi-2.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:28a3a209b96630bca57cce802da70c266eb08c6e97e5afd61a75611ee6c64592", size = 221584, upload-time = "2025-09-08T23:23:36.096Z" },
+ { url = "https://files.pythonhosted.org/packages/25/8e/342a504ff018a2825d395d44d63a767dd8ebc927ebda557fecdaca3ac33a/cffi-2.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7553fb2090d71822f02c629afe6042c299edf91ba1bf94951165613553984512", size = 224443, upload-time = "2025-09-08T23:23:37.328Z" },
+ { url = "https://files.pythonhosted.org/packages/e1/5e/b666bacbbc60fbf415ba9988324a132c9a7a0448a9a8f125074671c0f2c3/cffi-2.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c6c373cfc5c83a975506110d17457138c8c63016b563cc9ed6e056a82f13ce4", size = 223437, upload-time = "2025-09-08T23:23:38.945Z" },
+ { url = "https://files.pythonhosted.org/packages/a0/1d/ec1a60bd1a10daa292d3cd6bb0b359a81607154fb8165f3ec95fe003b85c/cffi-2.0.0-cp314-cp314t-win32.whl", hash = "sha256:1fc9ea04857caf665289b7a75923f2c6ed559b8298a1b8c49e59f7dd95c8481e", size = 180487, upload-time = "2025-09-08T23:23:40.423Z" },
+ { url = "https://files.pythonhosted.org/packages/bf/41/4c1168c74fac325c0c8156f04b6749c8b6a8f405bbf91413ba088359f60d/cffi-2.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d68b6cef7827e8641e8ef16f4494edda8b36104d79773a334beaa1e3521430f6", size = 191726, upload-time = "2025-09-08T23:23:41.742Z" },
+ { url = "https://files.pythonhosted.org/packages/ae/3a/dbeec9d1ee0844c679f6bb5d6ad4e9f198b1224f4e7a32825f47f6192b0c/cffi-2.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9", size = 184195, upload-time = "2025-09-08T23:23:43.004Z" },
+]
+
[[package]]
name = "charset-normalizer"
version = "3.4.4"
@@ -158,6 +217,62 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
]
+[[package]]
+name = "cryptography"
+version = "46.0.3"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "cffi", marker = "platform_python_implementation != 'PyPy'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/9f/33/c00162f49c0e2fe8064a62cb92b93e50c74a72bc370ab92f86112b33ff62/cryptography-46.0.3.tar.gz", hash = "sha256:a8b17438104fed022ce745b362294d9ce35b4c2e45c1d958ad4a4b019285f4a1", size = 749258, upload-time = "2025-10-15T23:18:31.74Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/1d/42/9c391dd801d6cf0d561b5890549d4b27bafcc53b39c31a817e69d87c625b/cryptography-46.0.3-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:109d4ddfadf17e8e7779c39f9b18111a09efb969a301a31e987416a0191ed93a", size = 7225004, upload-time = "2025-10-15T23:16:52.239Z" },
+ { url = "https://files.pythonhosted.org/packages/1c/67/38769ca6b65f07461eb200e85fc1639b438bdc667be02cf7f2cd6a64601c/cryptography-46.0.3-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:09859af8466b69bc3c27bdf4f5d84a665e0f7ab5088412e9e2ec49758eca5cbc", size = 4296667, upload-time = "2025-10-15T23:16:54.369Z" },
+ { url = "https://files.pythonhosted.org/packages/5c/49/498c86566a1d80e978b42f0d702795f69887005548c041636df6ae1ca64c/cryptography-46.0.3-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:01ca9ff2885f3acc98c29f1860552e37f6d7c7d013d7334ff2a9de43a449315d", size = 4450807, upload-time = "2025-10-15T23:16:56.414Z" },
+ { url = "https://files.pythonhosted.org/packages/4b/0a/863a3604112174c8624a2ac3c038662d9e59970c7f926acdcfaed8d61142/cryptography-46.0.3-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:6eae65d4c3d33da080cff9c4ab1f711b15c1d9760809dad6ea763f3812d254cb", size = 4299615, upload-time = "2025-10-15T23:16:58.442Z" },
+ { url = "https://files.pythonhosted.org/packages/64/02/b73a533f6b64a69f3cd3872acb6ebc12aef924d8d103133bb3ea750dc703/cryptography-46.0.3-cp311-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e5bf0ed4490068a2e72ac03d786693adeb909981cc596425d09032d372bcc849", size = 4016800, upload-time = "2025-10-15T23:17:00.378Z" },
+ { url = "https://files.pythonhosted.org/packages/25/d5/16e41afbfa450cde85a3b7ec599bebefaef16b5c6ba4ec49a3532336ed72/cryptography-46.0.3-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:5ecfccd2329e37e9b7112a888e76d9feca2347f12f37918facbb893d7bb88ee8", size = 4984707, upload-time = "2025-10-15T23:17:01.98Z" },
+ { url = "https://files.pythonhosted.org/packages/c9/56/e7e69b427c3878352c2fb9b450bd0e19ed552753491d39d7d0a2f5226d41/cryptography-46.0.3-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:a2c0cd47381a3229c403062f764160d57d4d175e022c1df84e168c6251a22eec", size = 4482541, upload-time = "2025-10-15T23:17:04.078Z" },
+ { url = "https://files.pythonhosted.org/packages/78/f6/50736d40d97e8483172f1bb6e698895b92a223dba513b0ca6f06b2365339/cryptography-46.0.3-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:549e234ff32571b1f4076ac269fcce7a808d3bf98b76c8dd560e42dbc66d7d91", size = 4299464, upload-time = "2025-10-15T23:17:05.483Z" },
+ { url = "https://files.pythonhosted.org/packages/00/de/d8e26b1a855f19d9994a19c702fa2e93b0456beccbcfe437eda00e0701f2/cryptography-46.0.3-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:c0a7bb1a68a5d3471880e264621346c48665b3bf1c3759d682fc0864c540bd9e", size = 4950838, upload-time = "2025-10-15T23:17:07.425Z" },
+ { url = "https://files.pythonhosted.org/packages/8f/29/798fc4ec461a1c9e9f735f2fc58741b0daae30688f41b2497dcbc9ed1355/cryptography-46.0.3-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:10b01676fc208c3e6feeb25a8b83d81767e8059e1fe86e1dc62d10a3018fa926", size = 4481596, upload-time = "2025-10-15T23:17:09.343Z" },
+ { url = "https://files.pythonhosted.org/packages/15/8d/03cd48b20a573adfff7652b76271078e3045b9f49387920e7f1f631d125e/cryptography-46.0.3-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:0abf1ffd6e57c67e92af68330d05760b7b7efb243aab8377e583284dbab72c71", size = 4426782, upload-time = "2025-10-15T23:17:11.22Z" },
+ { url = "https://files.pythonhosted.org/packages/fa/b1/ebacbfe53317d55cf33165bda24c86523497a6881f339f9aae5c2e13e57b/cryptography-46.0.3-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:a04bee9ab6a4da801eb9b51f1b708a1b5b5c9eb48c03f74198464c66f0d344ac", size = 4698381, upload-time = "2025-10-15T23:17:12.829Z" },
+ { url = "https://files.pythonhosted.org/packages/96/92/8a6a9525893325fc057a01f654d7efc2c64b9de90413adcf605a85744ff4/cryptography-46.0.3-cp311-abi3-win32.whl", hash = "sha256:f260d0d41e9b4da1ed1e0f1ce571f97fe370b152ab18778e9e8f67d6af432018", size = 3055988, upload-time = "2025-10-15T23:17:14.65Z" },
+ { url = "https://files.pythonhosted.org/packages/7e/bf/80fbf45253ea585a1e492a6a17efcb93467701fa79e71550a430c5e60df0/cryptography-46.0.3-cp311-abi3-win_amd64.whl", hash = "sha256:a9a3008438615669153eb86b26b61e09993921ebdd75385ddd748702c5adfddb", size = 3514451, upload-time = "2025-10-15T23:17:16.142Z" },
+ { url = "https://files.pythonhosted.org/packages/2e/af/9b302da4c87b0beb9db4e756386a7c6c5b8003cd0e742277888d352ae91d/cryptography-46.0.3-cp311-abi3-win_arm64.whl", hash = "sha256:5d7f93296ee28f68447397bf5198428c9aeeab45705a55d53a6343455dcb2c3c", size = 2928007, upload-time = "2025-10-15T23:17:18.04Z" },
+ { url = "https://files.pythonhosted.org/packages/f5/e2/a510aa736755bffa9d2f75029c229111a1d02f8ecd5de03078f4c18d91a3/cryptography-46.0.3-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:00a5e7e87938e5ff9ff5447ab086a5706a957137e6e433841e9d24f38a065217", size = 7158012, upload-time = "2025-10-15T23:17:19.982Z" },
+ { url = "https://files.pythonhosted.org/packages/73/dc/9aa866fbdbb95b02e7f9d086f1fccfeebf8953509b87e3f28fff927ff8a0/cryptography-46.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c8daeb2d2174beb4575b77482320303f3d39b8e81153da4f0fb08eb5fe86a6c5", size = 4288728, upload-time = "2025-10-15T23:17:21.527Z" },
+ { url = "https://files.pythonhosted.org/packages/c5/fd/bc1daf8230eaa075184cbbf5f8cd00ba9db4fd32d63fb83da4671b72ed8a/cryptography-46.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:39b6755623145ad5eff1dab323f4eae2a32a77a7abef2c5089a04a3d04366715", size = 4435078, upload-time = "2025-10-15T23:17:23.042Z" },
+ { url = "https://files.pythonhosted.org/packages/82/98/d3bd5407ce4c60017f8ff9e63ffee4200ab3e23fe05b765cab805a7db008/cryptography-46.0.3-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:db391fa7c66df6762ee3f00c95a89e6d428f4d60e7abc8328f4fe155b5ac6e54", size = 4293460, upload-time = "2025-10-15T23:17:24.885Z" },
+ { url = "https://files.pythonhosted.org/packages/26/e9/e23e7900983c2b8af7a08098db406cf989d7f09caea7897e347598d4cd5b/cryptography-46.0.3-cp314-cp314t-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:78a97cf6a8839a48c49271cdcbd5cf37ca2c1d6b7fdd86cc864f302b5e9bf459", size = 3995237, upload-time = "2025-10-15T23:17:26.449Z" },
+ { url = "https://files.pythonhosted.org/packages/91/15/af68c509d4a138cfe299d0d7ddb14afba15233223ebd933b4bbdbc7155d3/cryptography-46.0.3-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:dfb781ff7eaa91a6f7fd41776ec37c5853c795d3b358d4896fdbb5df168af422", size = 4967344, upload-time = "2025-10-15T23:17:28.06Z" },
+ { url = "https://files.pythonhosted.org/packages/ca/e3/8643d077c53868b681af077edf6b3cb58288b5423610f21c62aadcbe99f4/cryptography-46.0.3-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:6f61efb26e76c45c4a227835ddeae96d83624fb0d29eb5df5b96e14ed1a0afb7", size = 4466564, upload-time = "2025-10-15T23:17:29.665Z" },
+ { url = "https://files.pythonhosted.org/packages/0e/43/c1e8726fa59c236ff477ff2b5dc071e54b21e5a1e51aa2cee1676f1c986f/cryptography-46.0.3-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:23b1a8f26e43f47ceb6d6a43115f33a5a37d57df4ea0ca295b780ae8546e8044", size = 4292415, upload-time = "2025-10-15T23:17:31.686Z" },
+ { url = "https://files.pythonhosted.org/packages/42/f9/2f8fefdb1aee8a8e3256a0568cffc4e6d517b256a2fe97a029b3f1b9fe7e/cryptography-46.0.3-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:b419ae593c86b87014b9be7396b385491ad7f320bde96826d0dd174459e54665", size = 4931457, upload-time = "2025-10-15T23:17:33.478Z" },
+ { url = "https://files.pythonhosted.org/packages/79/30/9b54127a9a778ccd6d27c3da7563e9f2d341826075ceab89ae3b41bf5be2/cryptography-46.0.3-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:50fc3343ac490c6b08c0cf0d704e881d0d660be923fd3076db3e932007e726e3", size = 4466074, upload-time = "2025-10-15T23:17:35.158Z" },
+ { url = "https://files.pythonhosted.org/packages/ac/68/b4f4a10928e26c941b1b6a179143af9f4d27d88fe84a6a3c53592d2e76bf/cryptography-46.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:22d7e97932f511d6b0b04f2bfd818d73dcd5928db509460aaf48384778eb6d20", size = 4420569, upload-time = "2025-10-15T23:17:37.188Z" },
+ { url = "https://files.pythonhosted.org/packages/a3/49/3746dab4c0d1979888f125226357d3262a6dd40e114ac29e3d2abdf1ec55/cryptography-46.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:d55f3dffadd674514ad19451161118fd010988540cee43d8bc20675e775925de", size = 4681941, upload-time = "2025-10-15T23:17:39.236Z" },
+ { url = "https://files.pythonhosted.org/packages/fd/30/27654c1dbaf7e4a3531fa1fc77986d04aefa4d6d78259a62c9dc13d7ad36/cryptography-46.0.3-cp314-cp314t-win32.whl", hash = "sha256:8a6e050cb6164d3f830453754094c086ff2d0b2f3a897a1d9820f6139a1f0914", size = 3022339, upload-time = "2025-10-15T23:17:40.888Z" },
+ { url = "https://files.pythonhosted.org/packages/f6/30/640f34ccd4d2a1bc88367b54b926b781b5a018d65f404d409aba76a84b1c/cryptography-46.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:760f83faa07f8b64e9c33fc963d790a2edb24efb479e3520c14a45741cd9b2db", size = 3494315, upload-time = "2025-10-15T23:17:42.769Z" },
+ { url = "https://files.pythonhosted.org/packages/ba/8b/88cc7e3bd0a8e7b861f26981f7b820e1f46aa9d26cc482d0feba0ecb4919/cryptography-46.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:516ea134e703e9fe26bcd1277a4b59ad30586ea90c365a87781d7887a646fe21", size = 2919331, upload-time = "2025-10-15T23:17:44.468Z" },
+ { url = "https://files.pythonhosted.org/packages/fd/23/45fe7f376a7df8daf6da3556603b36f53475a99ce4faacb6ba2cf3d82021/cryptography-46.0.3-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:cb3d760a6117f621261d662bccc8ef5bc32ca673e037c83fbe565324f5c46936", size = 7218248, upload-time = "2025-10-15T23:17:46.294Z" },
+ { url = "https://files.pythonhosted.org/packages/27/32/b68d27471372737054cbd34c84981f9edbc24fe67ca225d389799614e27f/cryptography-46.0.3-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:4b7387121ac7d15e550f5cb4a43aef2559ed759c35df7336c402bb8275ac9683", size = 4294089, upload-time = "2025-10-15T23:17:48.269Z" },
+ { url = "https://files.pythonhosted.org/packages/26/42/fa8389d4478368743e24e61eea78846a0006caffaf72ea24a15159215a14/cryptography-46.0.3-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:15ab9b093e8f09daab0f2159bb7e47532596075139dd74365da52ecc9cb46c5d", size = 4440029, upload-time = "2025-10-15T23:17:49.837Z" },
+ { url = "https://files.pythonhosted.org/packages/5f/eb/f483db0ec5ac040824f269e93dd2bd8a21ecd1027e77ad7bdf6914f2fd80/cryptography-46.0.3-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:46acf53b40ea38f9c6c229599a4a13f0d46a6c3fa9ef19fc1a124d62e338dfa0", size = 4297222, upload-time = "2025-10-15T23:17:51.357Z" },
+ { url = "https://files.pythonhosted.org/packages/fd/cf/da9502c4e1912cb1da3807ea3618a6829bee8207456fbbeebc361ec38ba3/cryptography-46.0.3-cp38-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:10ca84c4668d066a9878890047f03546f3ae0a6b8b39b697457b7757aaf18dbc", size = 4012280, upload-time = "2025-10-15T23:17:52.964Z" },
+ { url = "https://files.pythonhosted.org/packages/6b/8f/9adb86b93330e0df8b3dcf03eae67c33ba89958fc2e03862ef1ac2b42465/cryptography-46.0.3-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:36e627112085bb3b81b19fed209c05ce2a52ee8b15d161b7c643a7d5a88491f3", size = 4978958, upload-time = "2025-10-15T23:17:54.965Z" },
+ { url = "https://files.pythonhosted.org/packages/d1/a0/5fa77988289c34bdb9f913f5606ecc9ada1adb5ae870bd0d1054a7021cc4/cryptography-46.0.3-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:1000713389b75c449a6e979ffc7dcc8ac90b437048766cef052d4d30b8220971", size = 4473714, upload-time = "2025-10-15T23:17:56.754Z" },
+ { url = "https://files.pythonhosted.org/packages/14/e5/fc82d72a58d41c393697aa18c9abe5ae1214ff6f2a5c18ac470f92777895/cryptography-46.0.3-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:b02cf04496f6576afffef5ddd04a0cb7d49cf6be16a9059d793a30b035f6b6ac", size = 4296970, upload-time = "2025-10-15T23:17:58.588Z" },
+ { url = "https://files.pythonhosted.org/packages/78/06/5663ed35438d0b09056973994f1aec467492b33bd31da36e468b01ec1097/cryptography-46.0.3-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:71e842ec9bc7abf543b47cf86b9a743baa95f4677d22baa4c7d5c69e49e9bc04", size = 4940236, upload-time = "2025-10-15T23:18:00.897Z" },
+ { url = "https://files.pythonhosted.org/packages/fc/59/873633f3f2dcd8a053b8dd1d38f783043b5fce589c0f6988bf55ef57e43e/cryptography-46.0.3-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:402b58fc32614f00980b66d6e56a5b4118e6cb362ae8f3fda141ba4689bd4506", size = 4472642, upload-time = "2025-10-15T23:18:02.749Z" },
+ { url = "https://files.pythonhosted.org/packages/3d/39/8e71f3930e40f6877737d6f69248cf74d4e34b886a3967d32f919cc50d3b/cryptography-46.0.3-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:ef639cb3372f69ec44915fafcd6698b6cc78fbe0c2ea41be867f6ed612811963", size = 4423126, upload-time = "2025-10-15T23:18:04.85Z" },
+ { url = "https://files.pythonhosted.org/packages/cd/c7/f65027c2810e14c3e7268353b1681932b87e5a48e65505d8cc17c99e36ae/cryptography-46.0.3-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:3b51b8ca4f1c6453d8829e1eb7299499ca7f313900dd4d89a24b8b87c0a780d4", size = 4686573, upload-time = "2025-10-15T23:18:06.908Z" },
+ { url = "https://files.pythonhosted.org/packages/0a/6e/1c8331ddf91ca4730ab3086a0f1be19c65510a33b5a441cb334e7a2d2560/cryptography-46.0.3-cp38-abi3-win32.whl", hash = "sha256:6276eb85ef938dc035d59b87c8a7dc559a232f954962520137529d77b18ff1df", size = 3036695, upload-time = "2025-10-15T23:18:08.672Z" },
+ { url = "https://files.pythonhosted.org/packages/90/45/b0d691df20633eff80955a0fc7695ff9051ffce8b69741444bd9ed7bd0db/cryptography-46.0.3-cp38-abi3-win_amd64.whl", hash = "sha256:416260257577718c05135c55958b674000baef9a1c7d9e8f306ec60d71db850f", size = 3501720, upload-time = "2025-10-15T23:18:10.632Z" },
+ { url = "https://files.pythonhosted.org/packages/e8/cb/2da4cc83f5edb9c3257d09e1e7ab7b23f049c7962cae8d842bbef0a9cec9/cryptography-46.0.3-cp38-abi3-win_arm64.whl", hash = "sha256:d89c3468de4cdc4f08a57e214384d0471911a3830fcdaf7a8cc587e42a866372", size = 2918740, upload-time = "2025-10-15T23:18:12.277Z" },
+]
+
[[package]]
name = "idna"
version = "3.11"
@@ -275,6 +390,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/c1/70/6b41bdcddf541b437bbb9f47f94d2db5d9ddef6c37ccab8c9107743748a4/pillow-12.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:99353a06902c2e43b43e8ff74ee65a7d90307d82370604746738a1e0661ccca7", size = 2525630, upload-time = "2025-10-15T18:23:57.149Z" },
]
+[[package]]
+name = "pycparser"
+version = "2.23"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/fe/cf/d2d3b9f5699fb1e4615c8e32ff220203e43b248e1dfcc6736ad9057731ca/pycparser-2.23.tar.gz", hash = "sha256:78816d4f24add8f10a06d6f05b4d424ad9e96cfebf68a4ddc99c65c0720d00c2", size = 173734, upload-time = "2025-09-09T13:23:47.91Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/a0/e3/59cd50310fc9b59512193629e1984c1f95e5c8ae6e5d8c69532ccc65a7fe/pycparser-2.23-py3-none-any.whl", hash = "sha256:e5c6e8d3fbad53479cab09ac03729e0a9faf2bee3db8208a550daf5af81a5934", size = 118140, upload-time = "2025-09-09T13:23:46.651Z" },
+]
+
[[package]]
name = "pygments"
version = "2.19.2"