mirror of
https://github.com/browseros-ai/BrowserOS.git
synced 2026-05-13 15:46:22 +00:00
feat: cli support for server ota (#286)
* feat: ota release * chore: clean-up old binaries * fix: ota cli sub-commands, path fixes * chore: browseros server binary update * fix: add sparkle sign_update path as ENV * fix: CLOUDFLARE_API_TOKEN to env * fix: use same upload r2 module * feat: upload appcast is separate * feat: write sparkle sign in python * fix: handle appcast update * fix: add missing sparkle.py file * fix: remove redudant cli options in ota * chore: 0.0.37 macos signed release * chore: linux browseros server ota * fix: copy binaries to temp file and then sign
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
248
packages/browseros/build/cli/ota.py
Normal file
248
packages/browseros/build/cli/ota.py
Normal file
@@ -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()
|
||||
@@ -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
|
||||
|
||||
92
packages/browseros/build/common/sparkle.py
Normal file
92
packages/browseros/build/common/sparkle.py
Normal file
@@ -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
|
||||
@@ -0,0 +1,51 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<rss xmlns:sparkle="http://www.andymatuschak.org/xml-namespaces/sparkle" version="2.0">
|
||||
<channel>
|
||||
<title>BrowserOS Server (Alpha)</title>
|
||||
<link>https://cdn.browseros.com/appcast-server.alpha.xml</link>
|
||||
<description>BrowserOS Server binary updates</description>
|
||||
<language>en</language>
|
||||
|
||||
<item>
|
||||
<sparkle:version>0.0.37</sparkle:version>
|
||||
<pubDate>Tue, 06 Jan 2026 20:01:05 +0000</pubDate>
|
||||
|
||||
<!-- macOS arm64 -->
|
||||
<enclosure
|
||||
url="https://cdn.browseros.com/server/browseros_server_0.0.37_darwin_arm64.zip"
|
||||
sparkle:os="macos"
|
||||
sparkle:arch="arm64"
|
||||
sparkle:edSignature="ScvV9MfdEBmmATKvqYqREiTV9kfy7fta3sBCd8D/KqI2+k09IzwyLUGIQcSsY4RYvF804/TBCBjnYn6Z24dCAQ=="
|
||||
length="33618179"
|
||||
type="application/zip"/>
|
||||
|
||||
<!-- macOS x86_64 -->
|
||||
<enclosure
|
||||
url="https://cdn.browseros.com/server/browseros_server_0.0.37_darwin_x64.zip"
|
||||
sparkle:os="macos"
|
||||
sparkle:arch="x86_64"
|
||||
sparkle:edSignature="/S3D8e027flwh/8DyrPqrK4axiFQDQbZkDpeGuJvHE6M/TG641WF1mXC5mH160SHXIGLcIVz/bZ8iUeK4mUDDw=="
|
||||
length="36160742"
|
||||
type="application/zip"/>
|
||||
|
||||
<!-- Linux arm64 -->
|
||||
<enclosure
|
||||
url="https://cdn.browseros.com/server/browseros_server_0.0.37_linux_arm64.zip"
|
||||
sparkle:os="linux"
|
||||
sparkle:arch="arm64"
|
||||
sparkle:edSignature="tU10l1RWSwZ3hz/WaoP/pVxWS7y+CegSEEGutyOTZqmD/yqiLHk2W+lZBow/fnGMfPf2YURyIINahh46j6rGDQ=="
|
||||
length="49091705"
|
||||
type="application/zip"/>
|
||||
|
||||
<!-- Linux x86_64 -->
|
||||
<enclosure
|
||||
url="https://cdn.browseros.com/server/browseros_server_0.0.37_linux_x64.zip"
|
||||
sparkle:os="linux"
|
||||
sparkle:arch="x86_64"
|
||||
sparkle:edSignature="1t8Cegfpp1V6aykTOVrZvbROCfb4CtOXLEoUsrgZaTFHYXazSXF/DHB0Ra0+8GRzU05uLAWtfb10/FzVQQeyDg=="
|
||||
length="50567580"
|
||||
type="application/zip"/>
|
||||
</item>
|
||||
|
||||
</channel>
|
||||
</rss>
|
||||
@@ -0,0 +1,9 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<rss xmlns:sparkle="http://www.andymatuschak.org/xml-namespaces/sparkle" version="2.0">
|
||||
<channel>
|
||||
<title>BrowserOS Server</title>
|
||||
<link>https://cdn.browseros.com/appcast-server.xml</link>
|
||||
<description>BrowserOS Server binary updates</description>
|
||||
<language>en</language>
|
||||
</channel>
|
||||
</rss>
|
||||
37
packages/browseros/build/modules/ota/__init__.py
Normal file
37
packages/browseros/build/modules/ota/__init__.py
Normal file
@@ -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",
|
||||
]
|
||||
275
packages/browseros/build/modules/ota/common.py
Normal file
275
packages/browseros/build/modules/ota/common.py
Normal file
@@ -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 = """<?xml version="1.0" encoding="utf-8"?>
|
||||
<rss xmlns:sparkle="http://www.andymatuschak.org/xml-namespaces/sparkle" version="2.0">
|
||||
<channel>
|
||||
<title>{title}</title>
|
||||
<link>{appcast_url}</link>
|
||||
<description>BrowserOS Server binary updates</description>
|
||||
<language>en</language>
|
||||
|
||||
<item>
|
||||
<sparkle:version>{version}</sparkle:version>
|
||||
<pubDate>{pub_date}</pubDate>
|
||||
|
||||
{enclosures}
|
||||
</item>
|
||||
|
||||
</channel>
|
||||
</rss>
|
||||
"""
|
||||
|
||||
ENCLOSURE_TEMPLATE = """ <!-- {comment} -->
|
||||
<enclosure
|
||||
url="{url}"
|
||||
sparkle:os="{os}"
|
||||
sparkle:arch="{arch}"
|
||||
sparkle:edSignature="{signature}"
|
||||
length="{length}"
|
||||
type="application/zip"/>"""
|
||||
|
||||
|
||||
@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"
|
||||
238
packages/browseros/build/modules/ota/server.py
Normal file
238
packages/browseros/build/modules/ota/server.py
Normal file
@@ -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
|
||||
304
packages/browseros/build/modules/ota/sign_binary.py
Normal file
304
packages/browseros/build/modules/ota/sign_binary.py
Normal file
@@ -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
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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]
|
||||
|
||||
124
packages/browseros/uv.lock
generated
124
packages/browseros/uv.lock
generated
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user