diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b032375484..a662d6d94e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -103,6 +103,10 @@ jobs: - name: checkout tari uses: actions/checkout@v6 + - name: ledger installer python tests + shell: bash + run: python3 applications/minotari_ledger_wallet/wallet/install_scripts/test_install_minotari_ledger.py + - name: ledger build tests shell: bash run: | diff --git a/applications/minotari_ledger_wallet/wallet/README.md b/applications/minotari_ledger_wallet/wallet/README.md index f9b75e01c3..0809a1dd7d 100644 --- a/applications/minotari_ledger_wallet/wallet/README.md +++ b/applications/minotari_ledger_wallet/wallet/README.md @@ -20,6 +20,31 @@ will not start and produce an error with a message to update the firmware. If the firmware needs to be updated, it must be done via Ledger Live. To update the firmware, open Ledger Live, select `My Ledger` and follow the instructions. +## One-step installer + +The release installer in `install_scripts/install_minotari_ledger.py` works on macOS, Windows, and Linux. It detects the +connected Ledger model, downloads the matching Minotari Ledger Wallet release artifact, verifies the `.zip.sha256` +checksum, and installs the app. + +Supported devices are Nano S Plus, Nano X, Stax, and Flex. The original Nano S is not supported by the Minotari Ledger +Wallet. + +Run the installer from the `install_scripts` directory: + +``` +python install_minotari_ledger.py +``` + +Python 3.9 or newer is required. + +To install a specific release: + +``` +python install_minotari_ledger.py --tag v5.4.0-pre.1 +``` + +The per-model scripts under `install_scripts//` are compatibility wrappers around this unified installer. + ## Development environment setup Ledger does not build with the standard library, so we need to install `rust-src`. This can be done with: @@ -145,21 +170,18 @@ ledgerctl delete "MinoTari Wallet" - Installation -The following command has to be run from the root of the Tari ledger wallet repository, i.e. -`/applications/minotari_ledger_wallet/wallet`. +For release artifacts, use the one-step installer above. Current Tari release archives contain +`minotari_ledger_wallet.apdu`, and the installer downloads, verifies, extracts, and loads that APDU file through +Ledger's secure loader. -First locate `app_nanosplus.json`. It will either be in the ledger wallet root -`/applications/minotari_ledger_wallet/wallet` or in its the target directory `./target/nanosplus/release`, -then run one of the following commands to install the application: +For a local development build, install the manifest generated for the selected build target if one is present: ``` -ledgerctl install app_nanosplus.json -``` -``` -ledgerctl install ./target/nanosplus/release/app_nanosplus.json -ledgerctl install ./target/stax/release/app_stax.json +ledgerctl install ./target/{TARGET}/release/app_{TARGET}.json ``` +Replace `{TARGET}` with the Ledger target used for the build, for example `nanosplus`, `nanox`, `stax`, or `flex`. + **Notes for Windows users:** - For a standard Anaconda 3 installation, the Python shell can be started from your development terminal with ``` diff --git a/applications/minotari_ledger_wallet/wallet/install_scripts/README.md b/applications/minotari_ledger_wallet/wallet/install_scripts/README.md new file mode 100644 index 0000000000..f81a44ee58 --- /dev/null +++ b/applications/minotari_ledger_wallet/wallet/install_scripts/README.md @@ -0,0 +1,52 @@ +# Minotari Ledger Wallet Installer + +Use `install_minotari_ledger.py` to install the Minotari Ledger Wallet app on +a supported Ledger device from a Tari GitHub release. + +Supported devices: +- Ledger Nano S Plus +- Ledger Nano X +- Ledger Stax +- Ledger Flex + +The original Ledger Nano S is not supported by the Minotari Ledger Wallet. + +## Usage + +From this directory: + +```bash +python install_minotari_ledger.py +``` + +The installer detects the connected device, finds the newest non-draft Tari +release with a matching `minotari_ledger_wallet--*.zip` asset, verifies +the `.zip.sha256` sidecar, extracts the archive safely, and installs it. + +To install a specific release: + +```bash +python install_minotari_ledger.py --tag v5.4.0-pre.1 +``` + +## Compatibility Wrappers + +The existing per-model scripts remain as thin wrappers: + +- `nanosplus/install_minotari_ledger_nanosplus.sh` +- `nanox/install_minotari_ledger_nanox.sh` +- `stax/install_minotari_ledger_stax.sh` +- `flex/install_minotari_ledger_flex.sh` +- `*/install_ledger_win.ps1` + +They call the same auto-detecting installer so existing entry-point paths keep +working without bypassing device detection. + +## Notes + +- Python 3.9 or newer is required. +- The installer creates an isolated Python environment in the user cache and + installs Ledger tooling there instead of modifying the system Python. +- Tari Ledger release archives must contain `minotari_ledger_wallet.apdu`, + which is loaded through Ledger's secure APDU loader. +- Keep the Ledger connected, unlocked, and approve prompts on the device. diff --git a/applications/minotari_ledger_wallet/wallet/install_scripts/flex/README.md b/applications/minotari_ledger_wallet/wallet/install_scripts/flex/README.md index 69c67b5e05..909feb7bbe 100644 --- a/applications/minotari_ledger_wallet/wallet/install_scripts/flex/README.md +++ b/applications/minotari_ledger_wallet/wallet/install_scripts/flex/README.md @@ -1,100 +1,20 @@ -# Minotari Ledger Flex Installer (macOS) +# Minotari Ledger Flex Installer -This script installs the **Minotari Ledger Wallet app** (`minotari_ledger_wallet-flex`) onto a **Ledger Flex** on macOS. +This directory keeps compatibility entry points for Ledger Flex users. +Both scripts delegate to the auto-detecting unified installer in +`../install_minotari_ledger.py`. -It is fully automated and handles: -- System dependencies -- Python virtual environment setup -- `ledgerctl` installation -- Downloading the **latest** Minotari Ledger release -- Installing the app onto the Ledger device - ---- - -## Supported Platforms - -- **macOS** (Intel & Apple Silicon) -- **Ledger Flex** - ---- - -## What the Script Does - -1. Installs required tools via **Homebrew** -2. Creates a Python **virtual environment** -3. Installs required Python dependencies -4. Automatically installs `ledgerctl` (if missing) -5. Downloads the **latest** `minotari_ledger_wallet-flex` release from GitHub -6. Unzips the release -7. Uploads the app to the Ledger device using `ledgerctl` - -All tooling is isolated inside the virtual environment to avoid polluting system Python. - ---- - -## Prerequisites - -### Homebrew - -```bash -brew --version -``` - -If not installed, see https://brew.sh - -### Ledger Device - -Ensure your **Ledger Flex** is: -- Connected via USB -- Unlocked -- Developer Mode enabled -- Not running another app - ---- - -## Installation +## macOS / Linux ```bash -chmod +x install_minotari_ledger_flex.sh ./install_minotari_ledger_flex.sh ``` ---- - -## Directory Layout +## Windows PowerShell -```text -~/src/tari/ -└── tari-ledger-live/ - ├── bin/ - ├── lib/ - └── tari-downloads/ +```powershell +.\install_ledger_win.ps1 ``` ---- - -## Re-running the Script - -The script is safe to re-run and will always fetch the latest release. - ---- - -## Troubleshooting - -- Ensure the Ledger is unlocked and Developer Mode is enabled -- Close Ledger Live before installing -- Use a data-capable USB cable - ---- - -## Security - -- Downloads are from the official Tari GitHub -- No keys or secrets are accessed -- Installation requires physical confirmation on the Ledger - ---- - -## License - -Provided as-is. Minotari Ledger app is licensed by the Tari Project. +Pass `--tag ` on macOS/Linux or `-Tag ` on Windows to install +a specific Tari release. diff --git a/applications/minotari_ledger_wallet/wallet/install_scripts/flex/install_ledger_win.ps1 b/applications/minotari_ledger_wallet/wallet/install_scripts/flex/install_ledger_win.ps1 index 02b9f85695..724b859897 100644 --- a/applications/minotari_ledger_wallet/wallet/install_scripts/flex/install_ledger_win.ps1 +++ b/applications/minotari_ledger_wallet/wallet/install_scripts/flex/install_ledger_win.ps1 @@ -1,113 +1,27 @@ [CmdletBinding()] param( - # Install a specific release tag (e.g. v5.2.0-pre.7), including pre-releases. - # If omitted, the latest published release is used. - [string]$Tag + [string]$Tag, + [Parameter(ValueFromRemainingArguments = $true)] + [string[]]$RemainingArgs ) $ErrorActionPreference = "Stop" -Write-Host "🚀 Installing Minotari Ledger Wallet (Flex)" -ForegroundColor Cyan +$ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path +$Installer = Join-Path (Split-Path -Parent $ScriptDir) "install_minotari_ledger.py" +$Python = Get-Command python -ErrorAction SilentlyContinue -# ------------------------- -# Prerequisites -# ------------------------- - -if (-not (Get-Command python -ErrorAction SilentlyContinue)) { - Write-Error "Python is not installed or not on PATH. Install Python 3 first." -} - -if (-not (Get-Command pip -ErrorAction SilentlyContinue)) { - Write-Error "pip not found. Ensure Python was installed with pip enabled." -} - -# ------------------------- -# Project setup -# ------------------------- - -$ProjectDir = "$env:USERPROFILE\src\tari" -$VenvDir = "$ProjectDir\tari-ledger-live" -$DownloadDir = "$VenvDir\tari-downloads" - -Write-Host "📁 Setting up project directory at $ProjectDir" -New-Item -ItemType Directory -Force -Path $ProjectDir | Out-Null -Set-Location $ProjectDir - -if (-not (Test-Path $VenvDir)) { - Write-Host "🐍 Creating Python virtual environment..." - python -m venv $VenvDir -} - -# Activate venv -& "$VenvDir\Scripts\Activate.ps1" - -Write-Host "📦 Installing Python dependencies..." -pip install --upgrade pip -pip install protobuf setuptools ecdsa ledgerwallet - -# ------------------------- -# Auto-install ledgerctl -# ------------------------- - -if (-not (Get-Command ledgerctl -ErrorAction SilentlyContinue)) { - Write-Host "🔐 ledgerctl not found — installing..." - pip install ledgerctl -} else { - Write-Host "✅ ledgerctl already installed" +if (-not $Python) { + Write-Error "Python 3 is required to run the Minotari Ledger installer." } -# ------------------------- -# Download latest release -# ------------------------- - +$InstallerArgs = @() if ($Tag) { - Write-Host "🌐 Fetching Minotari Ledger release info for tag '$Tag'..." - $ReleaseUri = "https://api.github.com/repos/tari-project/tari/releases/tags/$Tag" -} else { - Write-Host "🌐 Fetching latest Minotari Ledger release..." - $ReleaseUri = "https://api.github.com/repos/tari-project/tari/releases/latest" + $InstallerArgs += @("--tag", $Tag) } - -New-Item -ItemType Directory -Force -Path $DownloadDir | Out-Null -Set-Location $DownloadDir - -$Release = Invoke-RestMethod ` - -Uri $ReleaseUri ` - -Headers @{ "User-Agent" = "PowerShell" } - -$Asset = $Release.assets | - Where-Object { $_.name -match "minotari_ledger_wallet-flex.*\.zip" } | - Select-Object -First 1 - -if (-not $Asset) { - Write-Error "Could not find flex release asset." +if ($RemainingArgs) { + $InstallerArgs += $RemainingArgs } -Write-Host "⬇️ Downloading $($Asset.name)" -Invoke-WebRequest $Asset.browser_download_url -OutFile $Asset.name - -Write-Host "📦 Extracting archive..." -Expand-Archive -Path $Asset.name -DestinationPath . -Force - -# ------------------------- -# Install onto Ledger -# ------------------------- - -$appJson = Get-ChildItem -Recurse -Filter "app_flex.json" | Select-Object -First 1 - -if (-not $appJson) { - Write-Error "app_flex.json not found after extraction." -} - -Write-Host "" -Write-Host "🔐 Installing app onto Ledger Flex..." -ForegroundColor Yellow -Write-Host "👉 Ensure:" -Write-Host " • Ledger connected via USB" -Write-Host " • Device unlocked" -Write-Host " • Developer Mode enabled" -Write-Host "" - -ledgerctl install $appJson.FullName - -Write-Host "" -Write-Host "✅ Minotari Ledger Wallet installed successfully!" -ForegroundColor Green +& $Python.Source $Installer @InstallerArgs +exit $LASTEXITCODE diff --git a/applications/minotari_ledger_wallet/wallet/install_scripts/flex/install_minotari_ledger_flex.sh b/applications/minotari_ledger_wallet/wallet/install_scripts/flex/install_minotari_ledger_flex.sh old mode 100644 new mode 100755 index eb9891de17..dc2fb9d972 --- a/applications/minotari_ledger_wallet/wallet/install_scripts/flex/install_minotari_ledger_flex.sh +++ b/applications/minotari_ledger_wallet/wallet/install_scripts/flex/install_minotari_ledger_flex.sh @@ -1,148 +1,17 @@ -# To run -# chmod +x install_minotari_ledger_flex.sh -# ./install_minotari_ledger_flex.sh - - #!/usr/bin/env bash set -euo pipefail -# ------------------------- -# CLI options -# ------------------------- - -RELEASE_TAG="" - -usage() { - cat </dev/null 2>&1; then - echo "❌ Homebrew is not installed. Install it first from https://brew.sh" - exit 1 -fi - -echo "🔧 Installing system dependencies..." -brew install virtualenv wget jq - -# ------------------------- -# Project setup -# ------------------------- - -PROJECT_DIR="$HOME/src/tari" -VENV_DIR="$PROJECT_DIR/tari-ledger-live" -DOWNLOAD_DIR="$VENV_DIR/tari-downloads" - -echo "📁 Setting up project directory at $PROJECT_DIR" -mkdir -p "$PROJECT_DIR" -cd "$PROJECT_DIR" - -if [[ ! -d "$VENV_DIR" ]]; then - echo "🐍 Creating Python virtual environment..." - python3 -m venv "$VENV_DIR" -fi - -cd "$VENV_DIR" -# shellcheck disable=SC1091 -source bin/activate - -echo "📦 Installing Python dependencies..." -pip3 install --upgrade pip -pip3 install protobuf setuptools ecdsa ledgerwallet - -# ------------------------- -# Auto-install ledgerctl -# ------------------------- +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +INSTALLER="${SCRIPT_DIR}/../install_minotari_ledger.py" -if ! command -v ledgerctl >/dev/null 2>&1; then - echo "🔐 ledgerctl not found — installing..." - pip3 install ledgerctl +if command -v python3 >/dev/null 2>&1; then + PYTHON_BIN="python3" +elif command -v python >/dev/null 2>&1; then + PYTHON_BIN="python" else - echo "✅ ledgerctl already installed" -fi - -mkdir -p "$DOWNLOAD_DIR" -cd "$DOWNLOAD_DIR" - -# ------------------------- -# Download latest release -# ------------------------- - -if [[ -n "$RELEASE_TAG" ]]; then - echo "🌐 Fetching Minotari Ledger release info for tag '$RELEASE_TAG'..." - RELEASE_API="https://api.github.com/repos/tari-project/tari/releases/tags/${RELEASE_TAG}" -else - echo "🌐 Fetching latest Minotari Ledger release info..." - RELEASE_API="https://api.github.com/repos/tari-project/tari/releases/latest" -fi - -ASSET_URL=$(curl -fsSL "$RELEASE_API" \ - | jq -r ' - .assets[] - | select(.name | test("minotari_ledger_wallet-flex.*\\.zip$")) - | .browser_download_url - ') - -if [[ -z "$ASSET_URL" || "$ASSET_URL" == "null" ]]; then - echo "❌ Could not find flex release asset." + echo "Python 3 is required to run the Minotari Ledger installer." >&2 exit 1 fi -echo "⬇️ Downloading:" -echo " $ASSET_URL" - -wget -q --show-progress "$ASSET_URL" - -ZIP_FILE=$(basename "$ASSET_URL") - -echo "📦 Unzipping $ZIP_FILE" -unzip -o "$ZIP_FILE" - -# ------------------------- -# Install onto Ledger -# ------------------------- - -APP_JSON=$(find . -name "app_flex.json" | head -n 1) - -if [[ -z "$APP_JSON" ]]; then - echo "❌ app_flex.json not found after unzip." - exit 1 -fi - -echo -echo "🔐 Installing app onto Ledger Flex..." -echo "👉 Ensure:" -echo " • Ledger connected via USB" -echo " • Device unlocked" -echo " • Developer Mode enabled" -echo - -ledgerctl install "$APP_JSON" - -echo -echo "✅ Minotari Ledger Wallet installed successfully!" +exec "${PYTHON_BIN}" "${INSTALLER}" "$@" diff --git a/applications/minotari_ledger_wallet/wallet/install_scripts/install_minotari_ledger.py b/applications/minotari_ledger_wallet/wallet/install_scripts/install_minotari_ledger.py new file mode 100755 index 0000000000..27fb46c1eb --- /dev/null +++ b/applications/minotari_ledger_wallet/wallet/install_scripts/install_minotari_ledger.py @@ -0,0 +1,479 @@ +#!/usr/bin/env python3 +""" +Unified installer for the Minotari Ledger Wallet application. + +The installer detects the connected Ledger model, downloads the matching Tari +release artifact, verifies it, and installs it. It supports Nano S Plus, Nano X, +Stax, and Flex. The original Nano S is intentionally unsupported. +""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import os +import re +import subprocess +import sys +import tempfile +import urllib.error +import urllib.parse +import urllib.request +import venv +import zipfile +from dataclasses import dataclass +from pathlib import Path, PurePosixPath +from typing import Iterable, Optional, Sequence + +GITHUB_REPO = "tari-project/tari" +GITHUB_API = f"https://api.github.com/repos/{GITHUB_REPO}" +USER_AGENT = "minotari-ledger-installer/1.0" +BOOTSTRAP_ENV = "MINOTARI_LEDGER_INSTALLER_BOOTSTRAPPED" + + +@dataclass(frozen=True) +class LedgerModel: + slug: str + display_name: str + target_id: int + + +SUPPORTED_MODELS = { + "nanosplus": LedgerModel("nanosplus", "Ledger Nano S Plus", 0x33100004), + "nanox": LedgerModel("nanox", "Ledger Nano X", 0x33000004), + "stax": LedgerModel("stax", "Ledger Stax", 0x33200004), + "flex": LedgerModel("flex", "Ledger Flex", 0x33300004), +} + +MODEL_BY_TARGET_ID = {model.target_id: model for model in SUPPORTED_MODELS.values()} + +UNSUPPORTED_TARGET_IDS = { + 0x31100002: "Ledger Nano S", + 0x31100003: "Ledger Nano S", + 0x31100004: "Ledger Nano S", +} + + +@dataclass(frozen=True) +class ReleaseAsset: + tag_name: str + asset_name: str + download_url: str + checksum_url: str + + +class InstallerError(Exception): + """Expected installer failure with a user-facing message.""" + + +def print_step(message: str) -> None: + print(f"==> {message}") + + +def print_info(message: str) -> None: + print(f" {message}") + + +def cache_dir() -> Path: + if sys.platform == "win32": + root = Path(os.environ.get("LOCALAPPDATA", Path.home() / "AppData" / "Local")) + elif sys.platform == "darwin": + root = Path.home() / "Library" / "Caches" + else: + root = Path(os.environ.get("XDG_CACHE_HOME", Path.home() / ".cache")) + return root / "minotari-ledger-installer" + + +def venv_python_path(venv_dir: Path) -> Path: + if sys.platform == "win32": + return venv_dir / "Scripts" / "python.exe" + return venv_dir / "bin" / "python" + + +def module_available(python: Path, module: str) -> bool: + result = subprocess.run( + [str(python), "-c", f"import {module}"], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + check=False, + ) + return result.returncode == 0 + + +def ensure_bootstrapped() -> None: + if os.environ.get(BOOTSTRAP_ENV) == "1": + return + + venv_dir = cache_dir() / f"venv-py{sys.version_info.major}{sys.version_info.minor}" + python = venv_python_path(venv_dir) + + try: + if not python.exists(): + print_step(f"Creating isolated Python environment at {venv_dir}") + venv.EnvBuilder(with_pip=True).create(venv_dir) + + missing_modules = [ + module + for module in ("ledgerwallet", "ledgerblue") + if not module_available(python, module) + ] + if missing_modules: + print_step("Installing Ledger tooling into isolated environment") + subprocess.check_call([str(python), "-m", "pip", "install", "--upgrade", "pip"]) + subprocess.check_call([str(python), "-m", "pip", "install", *missing_modules]) + + env = os.environ.copy() + env[BOOTSTRAP_ENV] = "1" + args = [str(python), str(Path(__file__).resolve()), *sys.argv[1:]] + if sys.platform == "win32": + sys.exit(subprocess.call(args, env=env)) + os.execve(str(python), args, env) + except (OSError, subprocess.CalledProcessError) as error: + raise InstallerError( + f"Failed to prepare isolated Ledger tooling environment at {venv_dir}." + ) from error + + +def parse_args(argv: Sequence[str]) -> argparse.Namespace: + parser = argparse.ArgumentParser( + description="Install the Minotari Ledger Wallet app for the connected Ledger device.", + ) + parser.add_argument( + "-t", + "--tag", + help="Install a specific Tari release tag, e.g. v5.4.0-pre.1.", + ) + return parser.parse_args(argv) + + +def github_request(url: str) -> urllib.request.Request: + return urllib.request.Request( + url, + headers={ + "Accept": "application/vnd.github+json", + "User-Agent": USER_AGENT, + }, + ) + + +def fetch_json(url: str): + try: + with urllib.request.urlopen(github_request(url), timeout=30) as response: + return json.loads(response.read().decode("utf-8")) + except urllib.error.HTTPError as error: + raise InstallerError(f"GitHub request failed: HTTP {error.code} for {url}") from error + except urllib.error.URLError as error: + raise InstallerError(f"Network error while contacting GitHub: {error.reason}") from error + except json.JSONDecodeError as error: + raise InstallerError("GitHub returned invalid JSON") from error + + +def asset_matches_model(asset_name: str, model: str) -> bool: + pattern = rf"^minotari_ledger_wallet-{re.escape(model)}-.+\.zip$" + return re.match(pattern, asset_name) is not None + + +def find_asset_for_model(release: dict, model: str) -> Optional[ReleaseAsset]: + assets = release.get("assets") or [] + checksum_assets = {asset.get("name", ""): asset for asset in assets} + for zip_asset in assets: + name = zip_asset.get("name", "") + if not asset_matches_model(name, model): + continue + + checksum_asset = checksum_assets.get(f"{name}.sha256") + if checksum_asset is None: + continue + + return ReleaseAsset( + tag_name=release.get("tag_name", "unknown"), + asset_name=name, + download_url=zip_asset["browser_download_url"], + checksum_url=checksum_asset["browser_download_url"], + ) + + return None + + +def select_asset_from_releases(releases: Iterable[dict], model: str) -> ReleaseAsset: + for release in releases: + if release.get("draft"): + continue + asset = find_asset_for_model(release, model) + if asset is not None: + return asset + raise InstallerError( + f"No non-draft Tari release contains a Minotari Ledger artifact for model '{model}'." + ) + + +def fetch_release_asset(model: str, tag: Optional[str]) -> ReleaseAsset: + if tag: + release = fetch_json(f"{GITHUB_API}/releases/tags/{urllib.parse.quote(tag)}") + if release.get("draft"): + raise InstallerError(f"Release {tag} is a draft and cannot be installed.") + asset = find_asset_for_model(release, model) + if asset is None: + available = [ + asset.get("name", "") + for asset in release.get("assets", []) + if "minotari_ledger_wallet" in asset.get("name", "") + ] + raise InstallerError( + f"Release {tag} has no verified Ledger artifact for model '{model}'. " + f"Available Ledger assets: {available or 'none'}" + ) + return asset + + for page in range(1, 4): + releases = fetch_json(f"{GITHUB_API}/releases?per_page=30&page={page}") + if not releases: + break + try: + return select_asset_from_releases(releases, model) + except InstallerError: + continue + + raise InstallerError( + f"No recent Tari release contains a verified Minotari Ledger artifact for model '{model}'." + ) + + +def download_file(url: str, destination: Path) -> None: + try: + with urllib.request.urlopen(github_request(url), timeout=120) as response: + try: + total = int(response.headers.get("Content-Length") or 0) + except (TypeError, ValueError): + total = 0 + downloaded = 0 + with destination.open("wb") as output: + while True: + chunk = response.read(1024 * 64) + if not chunk: + break + output.write(chunk) + downloaded += len(chunk) + if total: + pct = min(100, downloaded * 100 // total) + print(f"\r Downloaded {pct:3d}%", end="", flush=True) + if total: + print() + except urllib.error.URLError as error: + raise InstallerError(f"Download failed: {error.reason}") from error + except OSError as error: + raise InstallerError(f"Download failed: {error}") from error + + +def parse_sha256_file(text: str, expected_filename: str) -> str: + digests = [] + found_named_digest = False + for line in text.splitlines(): + parts = line.strip().split() + if not parts: + continue + digest = parts[0].removeprefix("sha256:") + if not re.fullmatch(r"[0-9a-fA-F]{64}", digest): + continue + filename = parts[-1].lstrip("*") if len(parts) > 1 else None + if filename == expected_filename: + return digest.lower() + if filename is not None: + found_named_digest = True + continue + digests.append(digest.lower()) + if len(digests) == 1 and not found_named_digest: + return digests[0] + if digests or found_named_digest: + raise InstallerError(f"Checksum file did not contain a digest for {expected_filename}.") + raise InstallerError("Checksum file did not contain a SHA256 digest.") + + +def sha256_file(path: Path) -> str: + digest = hashlib.sha256() + with path.open("rb") as file: + for chunk in iter(lambda: file.read(1024 * 1024), b""): + digest.update(chunk) + return digest.hexdigest() + + +def verify_sha256(path: Path, expected_digest: str) -> None: + try: + actual = sha256_file(path) + except OSError as error: + raise InstallerError(f"Could not read downloaded archive {path.name}: {error}") from error + if actual.lower() != expected_digest.lower(): + raise InstallerError( + f"Checksum mismatch for {path.name}: expected {expected_digest}, got {actual}." + ) + + +def safe_extract(zip_path: Path, extract_dir: Path) -> None: + root = extract_dir.resolve() + try: + with zipfile.ZipFile(zip_path, "r") as archive: + for member in archive.infolist(): + name = member.filename + normalized = name.replace("\\", "/") + path = PurePosixPath(normalized) + if ( + not name + or "\0" in name + or ":" in name + or path.is_absolute() + or ".." in path.parts + ): + raise InstallerError(f"Unsafe path in archive: {name}") + + target = (root / Path(*path.parts)).resolve() + if os.path.commonpath([str(root), str(target)]) != str(root): + raise InstallerError(f"Unsafe path in archive: {name}") + + archive.extractall(root) + except zipfile.BadZipFile as error: + raise InstallerError(f"Downloaded archive is not a valid zip file: {zip_path.name}") from error + except (RuntimeError, NotImplementedError) as error: + raise InstallerError(f"Could not extract archive {zip_path.name}: {error}") from error + except OSError as error: + raise InstallerError(f"Could not extract archive {zip_path.name}: {error}") from error + + +def find_install_artifact(extract_dir: Path) -> Path: + apdu_files = sorted(extract_dir.rglob("minotari_ledger_wallet.apdu")) + if apdu_files: + return apdu_files[0] + + raise InstallerError("Archive did not contain minotari_ledger_wallet.apdu.") + + +def model_from_target_id(target_id: int) -> LedgerModel: + if target_id in MODEL_BY_TARGET_ID: + return MODEL_BY_TARGET_ID[target_id] + if target_id in UNSUPPORTED_TARGET_IDS: + raise InstallerError( + f"{UNSUPPORTED_TARGET_IDS[target_id]} is not supported by Minotari. " + "Use Nano S Plus, Nano X, Stax, or Flex." + ) + raise InstallerError(f"Unsupported Ledger target id: 0x{target_id:08x}.") + + +def detect_ledger_model() -> LedgerModel: + try: + from ledgerwallet.client import LedgerClient, NoLedgerDeviceException + except ImportError as error: + raise InstallerError("Ledger tooling is not installed.") from error + + client = None + try: + client = LedgerClient() + return model_from_target_id(client.target_id) + except NoLedgerDeviceException as error: + raise InstallerError("No Ledger device was detected. Connect and unlock the device.") from error + except InstallerError: + raise + except Exception as error: + raise InstallerError(f"Could not query Ledger device: {error}") from error + finally: + if client is not None: + client.close() + + +def install_apdu_file(apdu_path: Path, model: LedgerModel) -> None: + result = subprocess.run( + [ + sys.executable, + "-m", + "ledgerblue.runScript", + "--scp", + "--targetId", + f"0x{model.target_id:08x}", + "--fileName", + str(apdu_path), + ], + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + check=False, + ) + if result.returncode != 0: + output = result.stdout if isinstance(result.stdout, str) else "" + if "Invalid status 511f" in output or "OS version on your device does not seem compatible" in output: + raise InstallerError( + "The Ledger firmware is too old for this Minotari Ledger app artifact. " + "Update the Ledger device firmware in Ledger Live, unlock the device, and run this installer again." + ) + if output: + print(output, file=sys.stderr, end="" if output.endswith("\n") else "\n") + raise InstallerError(f"ledgerblue runScript failed with exit code {result.returncode}.") + + +def download_and_install(model: LedgerModel, tag: Optional[str]) -> None: + print_step(f"Resolving release artifact for {model.display_name}") + asset = fetch_release_asset(model.slug, tag) + print_info(f"Selected {asset.asset_name} from {asset.tag_name}") + + with tempfile.TemporaryDirectory(prefix="minotari-ledger-") as tmp: + tmp_dir = Path(tmp) + zip_path = tmp_dir / asset.asset_name + checksum_path = tmp_dir / f"{asset.asset_name}.sha256" + + print_step("Downloading firmware archive") + download_file(asset.download_url, zip_path) + download_file(asset.checksum_url, checksum_path) + + try: + checksum_text = checksum_path.read_text(encoding="utf-8") + except UnicodeDecodeError as error: + raise InstallerError(f"Checksum file for {asset.asset_name} is not valid UTF-8.") from error + except OSError as error: + raise InstallerError(f"Could not read checksum file for {asset.asset_name}: {error}") from error + + expected = parse_sha256_file(checksum_text, asset.asset_name) + verify_sha256(zip_path, expected) + print_info("Checksum verified") + + print_step("Extracting firmware archive") + extract_dir = tmp_dir / "extract" + extract_dir.mkdir() + safe_extract(zip_path, extract_dir) + apdu_path = find_install_artifact(extract_dir) + print_info(f"Found APDU artifact: {apdu_path.name}") + + print_step(f"Installing Minotari Wallet on {model.display_name}") + print_info("Keep the Ledger connected, unlocked, and approve prompts on the device.") + install_apdu_file(apdu_path, model) + + +def run(argv: Sequence[str]) -> int: + args = parse_args(argv) + + try: + if sys.version_info < (3, 9): + raise InstallerError( + f"Python 3.9+ is required; found {sys.version_info.major}.{sys.version_info.minor}." + ) + ensure_bootstrapped() + + print_step("Detecting connected Ledger model") + model = detect_ledger_model() + print_info(f"Detected {model.display_name}") + + download_and_install(model, args.tag) + except KeyboardInterrupt: + print("\nInstallation interrupted.", file=sys.stderr) + return 130 + except InstallerError as error: + print(f"Error: {error}", file=sys.stderr) + return 1 + + print_step("Minotari Ledger Wallet installed successfully") + return 0 + + +def main() -> None: + sys.exit(run(sys.argv[1:])) + + +if __name__ == "__main__": + main() diff --git a/applications/minotari_ledger_wallet/wallet/install_scripts/nanosplus/README.md b/applications/minotari_ledger_wallet/wallet/install_scripts/nanosplus/README.md index d76a727232..b5a895a2a7 100644 --- a/applications/minotari_ledger_wallet/wallet/install_scripts/nanosplus/README.md +++ b/applications/minotari_ledger_wallet/wallet/install_scripts/nanosplus/README.md @@ -1,100 +1,20 @@ -# Minotari Ledger Nano S Plus Installer (macOS) +# Minotari Ledger Nano S Plus Installer -This script installs the **Minotari Ledger Wallet app** (`minotari_ledger_wallet-nanosplus`) onto a **Ledger Nano S Plus** on macOS. +This directory keeps compatibility entry points for Ledger Nano S Plus users. +Both scripts delegate to the auto-detecting unified installer in +`../install_minotari_ledger.py`. -It is fully automated and handles: -- System dependencies -- Python virtual environment setup -- `ledgerctl` installation -- Downloading the **latest** Minotari Ledger release -- Installing the app onto the Ledger device - ---- - -## Supported Platforms - -- **macOS** (Intel & Apple Silicon) -- **Ledger Nano S Plus** - ---- - -## What the Script Does - -1. Installs required tools via **Homebrew** -2. Creates a Python **virtual environment** -3. Installs required Python dependencies -4. Automatically installs `ledgerctl` (if missing) -5. Downloads the **latest** `minotari_ledger_wallet-nanosplus` release from GitHub -6. Unzips the release -7. Uploads the app to the Ledger device using `ledgerctl` - -All tooling is isolated inside the virtual environment to avoid polluting system Python. - ---- - -## Prerequisites - -### Homebrew - -```bash -brew --version -``` - -If not installed, see https://brew.sh - -### Ledger Device - -Ensure your **Ledger Nano S Plus** is: -- Connected via USB -- Unlocked -- Developer Mode enabled -- Not running another app - ---- - -## Installation +## macOS / Linux ```bash -chmod +x install_minotari_ledger_nanosplus.sh ./install_minotari_ledger_nanosplus.sh ``` ---- - -## Directory Layout +## Windows PowerShell -```text -~/src/tari/ -└── tari-ledger-live/ - ├── bin/ - ├── lib/ - └── tari-downloads/ +```powershell +.\install_ledger_win.ps1 ``` ---- - -## Re-running the Script - -The script is safe to re-run and will always fetch the latest release. - ---- - -## Troubleshooting - -- Ensure the Ledger is unlocked and Developer Mode is enabled -- Close Ledger Live before installing -- Use a data-capable USB cable - ---- - -## Security - -- Downloads are from the official Tari GitHub -- No keys or secrets are accessed -- Installation requires physical confirmation on the Ledger - ---- - -## License - -Provided as-is. Minotari Ledger app is licensed by the Tari Project. +Pass `--tag ` on macOS/Linux or `-Tag ` on Windows to install +a specific Tari release. diff --git a/applications/minotari_ledger_wallet/wallet/install_scripts/nanosplus/install_ledger_win.ps1 b/applications/minotari_ledger_wallet/wallet/install_scripts/nanosplus/install_ledger_win.ps1 index e7ba5a0e74..724b859897 100644 --- a/applications/minotari_ledger_wallet/wallet/install_scripts/nanosplus/install_ledger_win.ps1 +++ b/applications/minotari_ledger_wallet/wallet/install_scripts/nanosplus/install_ledger_win.ps1 @@ -1,113 +1,27 @@ [CmdletBinding()] param( - # Install a specific release tag (e.g. v5.2.0-pre.7), including pre-releases. - # If omitted, the latest published release is used. - [string]$Tag + [string]$Tag, + [Parameter(ValueFromRemainingArguments = $true)] + [string[]]$RemainingArgs ) $ErrorActionPreference = "Stop" -Write-Host "🚀 Installing Minotari Ledger Wallet (Nano S Plus)" -ForegroundColor Cyan +$ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path +$Installer = Join-Path (Split-Path -Parent $ScriptDir) "install_minotari_ledger.py" +$Python = Get-Command python -ErrorAction SilentlyContinue -# ------------------------- -# Prerequisites -# ------------------------- - -if (-not (Get-Command python -ErrorAction SilentlyContinue)) { - Write-Error "Python is not installed or not on PATH. Install Python 3 first." -} - -if (-not (Get-Command pip -ErrorAction SilentlyContinue)) { - Write-Error "pip not found. Ensure Python was installed with pip enabled." -} - -# ------------------------- -# Project setup -# ------------------------- - -$ProjectDir = "$env:USERPROFILE\src\tari" -$VenvDir = "$ProjectDir\tari-ledger-live" -$DownloadDir = "$VenvDir\tari-downloads" - -Write-Host "📁 Setting up project directory at $ProjectDir" -New-Item -ItemType Directory -Force -Path $ProjectDir | Out-Null -Set-Location $ProjectDir - -if (-not (Test-Path $VenvDir)) { - Write-Host "🐍 Creating Python virtual environment..." - python -m venv $VenvDir -} - -# Activate venv -& "$VenvDir\Scripts\Activate.ps1" - -Write-Host "📦 Installing Python dependencies..." -pip install --upgrade pip -pip install protobuf setuptools ecdsa ledgerwallet - -# ------------------------- -# Auto-install ledgerctl -# ------------------------- - -if (-not (Get-Command ledgerctl -ErrorAction SilentlyContinue)) { - Write-Host "🔐 ledgerctl not found — installing..." - pip install ledgerctl -} else { - Write-Host "✅ ledgerctl already installed" +if (-not $Python) { + Write-Error "Python 3 is required to run the Minotari Ledger installer." } -# ------------------------- -# Download latest release -# ------------------------- - +$InstallerArgs = @() if ($Tag) { - Write-Host "🌐 Fetching Minotari Ledger release info for tag '$Tag'..." - $ReleaseUri = "https://api.github.com/repos/tari-project/tari/releases/tags/$Tag" -} else { - Write-Host "🌐 Fetching latest Minotari Ledger release..." - $ReleaseUri = "https://api.github.com/repos/tari-project/tari/releases/latest" + $InstallerArgs += @("--tag", $Tag) } - -New-Item -ItemType Directory -Force -Path $DownloadDir | Out-Null -Set-Location $DownloadDir - -$Release = Invoke-RestMethod ` - -Uri $ReleaseUri ` - -Headers @{ "User-Agent" = "PowerShell" } - -$Asset = $Release.assets | - Where-Object { $_.name -match "minotari_ledger_wallet-nanosplus.*\.zip" } | - Select-Object -First 1 - -if (-not $Asset) { - Write-Error "Could not find nanosplus release asset." +if ($RemainingArgs) { + $InstallerArgs += $RemainingArgs } -Write-Host "⬇️ Downloading $($Asset.name)" -Invoke-WebRequest $Asset.browser_download_url -OutFile $Asset.name - -Write-Host "📦 Extracting archive..." -Expand-Archive -Path $Asset.name -DestinationPath . -Force - -# ------------------------- -# Install onto Ledger -# ------------------------- - -$appJson = Get-ChildItem -Recurse -Filter "app_nanosplus.json" | Select-Object -First 1 - -if (-not $appJson) { - Write-Error "app_nanosplus.json not found after extraction." -} - -Write-Host "" -Write-Host "🔐 Installing app onto Ledger Nano S Plus..." -ForegroundColor Yellow -Write-Host "👉 Ensure:" -Write-Host " • Ledger connected via USB" -Write-Host " • Device unlocked" -Write-Host " • Developer Mode enabled" -Write-Host "" - -ledgerctl install $appJson.FullName - -Write-Host "" -Write-Host "✅ Minotari Ledger Wallet installed successfully!" -ForegroundColor Green +& $Python.Source $Installer @InstallerArgs +exit $LASTEXITCODE diff --git a/applications/minotari_ledger_wallet/wallet/install_scripts/nanosplus/install_minotari_ledger_nanosplus.sh b/applications/minotari_ledger_wallet/wallet/install_scripts/nanosplus/install_minotari_ledger_nanosplus.sh old mode 100644 new mode 100755 index c1aec1a2b1..dc2fb9d972 --- a/applications/minotari_ledger_wallet/wallet/install_scripts/nanosplus/install_minotari_ledger_nanosplus.sh +++ b/applications/minotari_ledger_wallet/wallet/install_scripts/nanosplus/install_minotari_ledger_nanosplus.sh @@ -1,148 +1,17 @@ -# To run -# chmod +x install_minotari_ledger_nanosplus.sh -# ./install_minotari_ledger_nanosplus.sh - - #!/usr/bin/env bash set -euo pipefail -# ------------------------- -# CLI options -# ------------------------- - -RELEASE_TAG="" - -usage() { - cat </dev/null 2>&1; then - echo "❌ Homebrew is not installed. Install it first from https://brew.sh" - exit 1 -fi - -echo "🔧 Installing system dependencies..." -brew install virtualenv wget jq - -# ------------------------- -# Project setup -# ------------------------- - -PROJECT_DIR="$HOME/src/tari" -VENV_DIR="$PROJECT_DIR/tari-ledger-live" -DOWNLOAD_DIR="$VENV_DIR/tari-downloads" - -echo "📁 Setting up project directory at $PROJECT_DIR" -mkdir -p "$PROJECT_DIR" -cd "$PROJECT_DIR" - -if [[ ! -d "$VENV_DIR" ]]; then - echo "🐍 Creating Python virtual environment..." - python3 -m venv "$VENV_DIR" -fi - -cd "$VENV_DIR" -# shellcheck disable=SC1091 -source bin/activate - -echo "📦 Installing Python dependencies..." -pip3 install --upgrade pip -pip3 install protobuf setuptools ecdsa ledgerwallet - -# ------------------------- -# Auto-install ledgerctl -# ------------------------- +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +INSTALLER="${SCRIPT_DIR}/../install_minotari_ledger.py" -if ! command -v ledgerctl >/dev/null 2>&1; then - echo "🔐 ledgerctl not found — installing..." - pip3 install ledgerctl +if command -v python3 >/dev/null 2>&1; then + PYTHON_BIN="python3" +elif command -v python >/dev/null 2>&1; then + PYTHON_BIN="python" else - echo "✅ ledgerctl already installed" -fi - -mkdir -p "$DOWNLOAD_DIR" -cd "$DOWNLOAD_DIR" - -# ------------------------- -# Download latest release -# ------------------------- - -if [[ -n "$RELEASE_TAG" ]]; then - echo "🌐 Fetching Minotari Ledger release info for tag '$RELEASE_TAG'..." - RELEASE_API="https://api.github.com/repos/tari-project/tari/releases/tags/${RELEASE_TAG}" -else - echo "🌐 Fetching latest Minotari Ledger release info..." - RELEASE_API="https://api.github.com/repos/tari-project/tari/releases/latest" -fi - -ASSET_URL=$(curl -fsSL "$RELEASE_API" \ - | jq -r ' - .assets[] - | select(.name | test("minotari_ledger_wallet-nanosplus.*\\.zip$")) - | .browser_download_url - ') - -if [[ -z "$ASSET_URL" || "$ASSET_URL" == "null" ]]; then - echo "❌ Could not find nanosplus release asset." + echo "Python 3 is required to run the Minotari Ledger installer." >&2 exit 1 fi -echo "⬇️ Downloading:" -echo " $ASSET_URL" - -wget -q --show-progress "$ASSET_URL" - -ZIP_FILE=$(basename "$ASSET_URL") - -echo "📦 Unzipping $ZIP_FILE" -unzip -o "$ZIP_FILE" - -# ------------------------- -# Install onto Ledger -# ------------------------- - -APP_JSON=$(find . -name "app_nanosplus.json" | head -n 1) - -if [[ -z "$APP_JSON" ]]; then - echo "❌ app_nanosplus.json not found after unzip." - exit 1 -fi - -echo -echo "🔐 Installing app onto Ledger Nano S Plus..." -echo "👉 Ensure:" -echo " • Ledger connected via USB" -echo " • Device unlocked" -echo " • Developer Mode enabled" -echo - -ledgerctl install "$APP_JSON" - -echo -echo "✅ Minotari Ledger Wallet installed successfully!" +exec "${PYTHON_BIN}" "${INSTALLER}" "$@" diff --git a/applications/minotari_ledger_wallet/wallet/install_scripts/nanox/README.md b/applications/minotari_ledger_wallet/wallet/install_scripts/nanox/README.md index 974456cce7..fd84e1720d 100644 --- a/applications/minotari_ledger_wallet/wallet/install_scripts/nanox/README.md +++ b/applications/minotari_ledger_wallet/wallet/install_scripts/nanox/README.md @@ -1,100 +1,20 @@ -# Minotari Ledger Nano X Installer (macOS) +# Minotari Ledger Nano X Installer -This script installs the **Minotari Ledger Wallet app** (`minotari_ledger_wallet-nanox`) onto a **Ledger Nano X** on macOS. +This directory keeps compatibility entry points for Ledger Nano X users. +Both scripts delegate to the auto-detecting unified installer in +`../install_minotari_ledger.py`. -It is fully automated and handles: -- System dependencies -- Python virtual environment setup -- `ledgerctl` installation -- Downloading the **latest** Minotari Ledger release -- Installing the app onto the Ledger device - ---- - -## Supported Platforms - -- **macOS** (Intel & Apple Silicon) -- **Ledger Nano X** - ---- - -## What the Script Does - -1. Installs required tools via **Homebrew** -2. Creates a Python **virtual environment** -3. Installs required Python dependencies -4. Automatically installs `ledgerctl` (if missing) -5. Downloads the **latest** `minotari_ledger_wallet-nanox` release from GitHub -6. Unzips the release -7. Uploads the app to the Ledger device using `ledgerctl` - -All tooling is isolated inside the virtual environment to avoid polluting system Python. - ---- - -## Prerequisites - -### Homebrew - -```bash -brew --version -``` - -If not installed, see https://brew.sh - -### Ledger Device - -Ensure your **Ledger Nano X** is: -- Connected via USB -- Unlocked -- Developer Mode enabled -- Not running another app - ---- - -## Installation +## macOS / Linux ```bash -chmod +x install_minotari_ledger_nanox.sh ./install_minotari_ledger_nanox.sh ``` ---- - -## Directory Layout +## Windows PowerShell -```text -~/src/tari/ -└── tari-ledger-live/ - ├── bin/ - ├── lib/ - └── tari-downloads/ +```powershell +.\install_ledger_win.ps1 ``` ---- - -## Re-running the Script - -The script is safe to re-run and will always fetch the latest release. - ---- - -## Troubleshooting - -- Ensure the Ledger is unlocked and Developer Mode is enabled -- Close Ledger Live before installing -- Use a data-capable USB cable - ---- - -## Security - -- Downloads are from the official Tari GitHub -- No keys or secrets are accessed -- Installation requires physical confirmation on the Ledger - ---- - -## License - -Provided as-is. Minotari Ledger app is licensed by the Tari Project. +Pass `--tag ` on macOS/Linux or `-Tag ` on Windows to install +a specific Tari release. diff --git a/applications/minotari_ledger_wallet/wallet/install_scripts/nanox/install_ledger_win.ps1 b/applications/minotari_ledger_wallet/wallet/install_scripts/nanox/install_ledger_win.ps1 index a32e20079c..724b859897 100644 --- a/applications/minotari_ledger_wallet/wallet/install_scripts/nanox/install_ledger_win.ps1 +++ b/applications/minotari_ledger_wallet/wallet/install_scripts/nanox/install_ledger_win.ps1 @@ -1,113 +1,27 @@ [CmdletBinding()] param( - # Install a specific release tag (e.g. v5.2.0-pre.7), including pre-releases. - # If omitted, the latest published release is used. - [string]$Tag + [string]$Tag, + [Parameter(ValueFromRemainingArguments = $true)] + [string[]]$RemainingArgs ) $ErrorActionPreference = "Stop" -Write-Host "🚀 Installing Minotari Ledger Wallet (Nano X)" -ForegroundColor Cyan +$ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path +$Installer = Join-Path (Split-Path -Parent $ScriptDir) "install_minotari_ledger.py" +$Python = Get-Command python -ErrorAction SilentlyContinue -# ------------------------- -# Prerequisites -# ------------------------- - -if (-not (Get-Command python -ErrorAction SilentlyContinue)) { - Write-Error "Python is not installed or not on PATH. Install Python 3 first." -} - -if (-not (Get-Command pip -ErrorAction SilentlyContinue)) { - Write-Error "pip not found. Ensure Python was installed with pip enabled." -} - -# ------------------------- -# Project setup -# ------------------------- - -$ProjectDir = "$env:USERPROFILE\src\tari" -$VenvDir = "$ProjectDir\tari-ledger-live" -$DownloadDir = "$VenvDir\tari-downloads" - -Write-Host "📁 Setting up project directory at $ProjectDir" -New-Item -ItemType Directory -Force -Path $ProjectDir | Out-Null -Set-Location $ProjectDir - -if (-not (Test-Path $VenvDir)) { - Write-Host "🐍 Creating Python virtual environment..." - python -m venv $VenvDir -} - -# Activate venv -& "$VenvDir\Scripts\Activate.ps1" - -Write-Host "📦 Installing Python dependencies..." -pip install --upgrade pip -pip install protobuf setuptools ecdsa ledgerwallet - -# ------------------------- -# Auto-install ledgerctl -# ------------------------- - -if (-not (Get-Command ledgerctl -ErrorAction SilentlyContinue)) { - Write-Host "🔐 ledgerctl not found — installing..." - pip install ledgerctl -} else { - Write-Host "✅ ledgerctl already installed" +if (-not $Python) { + Write-Error "Python 3 is required to run the Minotari Ledger installer." } -# ------------------------- -# Download latest release -# ------------------------- - +$InstallerArgs = @() if ($Tag) { - Write-Host "🌐 Fetching Minotari Ledger release info for tag '$Tag'..." - $ReleaseUri = "https://api.github.com/repos/tari-project/tari/releases/tags/$Tag" -} else { - Write-Host "🌐 Fetching latest Minotari Ledger release..." - $ReleaseUri = "https://api.github.com/repos/tari-project/tari/releases/latest" + $InstallerArgs += @("--tag", $Tag) } - -New-Item -ItemType Directory -Force -Path $DownloadDir | Out-Null -Set-Location $DownloadDir - -$Release = Invoke-RestMethod ` - -Uri $ReleaseUri ` - -Headers @{ "User-Agent" = "PowerShell" } - -$Asset = $Release.assets | - Where-Object { $_.name -match "minotari_ledger_wallet-nanox.*\.zip" } | - Select-Object -First 1 - -if (-not $Asset) { - Write-Error "Could not find nanox release asset." +if ($RemainingArgs) { + $InstallerArgs += $RemainingArgs } -Write-Host "⬇️ Downloading $($Asset.name)" -Invoke-WebRequest $Asset.browser_download_url -OutFile $Asset.name - -Write-Host "📦 Extracting archive..." -Expand-Archive -Path $Asset.name -DestinationPath . -Force - -# ------------------------- -# Install onto Ledger -# ------------------------- - -$appJson = Get-ChildItem -Recurse -Filter "app_nanox.json" | Select-Object -First 1 - -if (-not $appJson) { - Write-Error "app_nanox.json not found after extraction." -} - -Write-Host "" -Write-Host "🔐 Installing app onto Ledger Nano X..." -ForegroundColor Yellow -Write-Host "👉 Ensure:" -Write-Host " • Ledger connected via USB" -Write-Host " • Device unlocked" -Write-Host " • Developer Mode enabled" -Write-Host "" - -ledgerctl install $appJson.FullName - -Write-Host "" -Write-Host "✅ Minotari Ledger Wallet installed successfully!" -ForegroundColor Green +& $Python.Source $Installer @InstallerArgs +exit $LASTEXITCODE diff --git a/applications/minotari_ledger_wallet/wallet/install_scripts/nanox/install_minotari_ledger_nanox.sh b/applications/minotari_ledger_wallet/wallet/install_scripts/nanox/install_minotari_ledger_nanox.sh old mode 100644 new mode 100755 index 4382797f7a..dc2fb9d972 --- a/applications/minotari_ledger_wallet/wallet/install_scripts/nanox/install_minotari_ledger_nanox.sh +++ b/applications/minotari_ledger_wallet/wallet/install_scripts/nanox/install_minotari_ledger_nanox.sh @@ -1,148 +1,17 @@ -# To run -# chmod +x install_minotari_ledger_nanox.sh -# ./install_minotari_ledger_nanox.sh - - #!/usr/bin/env bash set -euo pipefail -# ------------------------- -# CLI options -# ------------------------- - -RELEASE_TAG="" - -usage() { - cat </dev/null 2>&1; then - echo "❌ Homebrew is not installed. Install it first from https://brew.sh" - exit 1 -fi - -echo "🔧 Installing system dependencies..." -brew install virtualenv wget jq - -# ------------------------- -# Project setup -# ------------------------- - -PROJECT_DIR="$HOME/src/tari" -VENV_DIR="$PROJECT_DIR/tari-ledger-live" -DOWNLOAD_DIR="$VENV_DIR/tari-downloads" - -echo "📁 Setting up project directory at $PROJECT_DIR" -mkdir -p "$PROJECT_DIR" -cd "$PROJECT_DIR" - -if [[ ! -d "$VENV_DIR" ]]; then - echo "🐍 Creating Python virtual environment..." - python3 -m venv "$VENV_DIR" -fi - -cd "$VENV_DIR" -# shellcheck disable=SC1091 -source bin/activate - -echo "📦 Installing Python dependencies..." -pip3 install --upgrade pip -pip3 install protobuf setuptools ecdsa ledgerwallet - -# ------------------------- -# Auto-install ledgerctl -# ------------------------- +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +INSTALLER="${SCRIPT_DIR}/../install_minotari_ledger.py" -if ! command -v ledgerctl >/dev/null 2>&1; then - echo "🔐 ledgerctl not found — installing..." - pip3 install ledgerctl +if command -v python3 >/dev/null 2>&1; then + PYTHON_BIN="python3" +elif command -v python >/dev/null 2>&1; then + PYTHON_BIN="python" else - echo "✅ ledgerctl already installed" -fi - -mkdir -p "$DOWNLOAD_DIR" -cd "$DOWNLOAD_DIR" - -# ------------------------- -# Download latest release -# ------------------------- - -if [[ -n "$RELEASE_TAG" ]]; then - echo "🌐 Fetching Minotari Ledger release info for tag '$RELEASE_TAG'..." - RELEASE_API="https://api.github.com/repos/tari-project/tari/releases/tags/${RELEASE_TAG}" -else - echo "🌐 Fetching latest Minotari Ledger release info..." - RELEASE_API="https://api.github.com/repos/tari-project/tari/releases/latest" -fi - -ASSET_URL=$(curl -fsSL "$RELEASE_API" \ - | jq -r ' - .assets[] - | select(.name | test("minotari_ledger_wallet-nanox.*\\.zip$")) - | .browser_download_url - ') - -if [[ -z "$ASSET_URL" || "$ASSET_URL" == "null" ]]; then - echo "❌ Could not find nanox release asset." + echo "Python 3 is required to run the Minotari Ledger installer." >&2 exit 1 fi -echo "⬇️ Downloading:" -echo " $ASSET_URL" - -wget -q --show-progress "$ASSET_URL" - -ZIP_FILE=$(basename "$ASSET_URL") - -echo "📦 Unzipping $ZIP_FILE" -unzip -o "$ZIP_FILE" - -# ------------------------- -# Install onto Ledger -# ------------------------- - -APP_JSON=$(find . -name "app_nanox.json" | head -n 1) - -if [[ -z "$APP_JSON" ]]; then - echo "❌ app_nanox.json not found after unzip." - exit 1 -fi - -echo -echo "🔐 Installing app onto Ledger Nano X..." -echo "👉 Ensure:" -echo " • Ledger connected via USB" -echo " • Device unlocked" -echo " • Developer Mode enabled" -echo - -ledgerctl install "$APP_JSON" - -echo -echo "✅ Minotari Ledger Wallet installed successfully!" +exec "${PYTHON_BIN}" "${INSTALLER}" "$@" diff --git a/applications/minotari_ledger_wallet/wallet/install_scripts/stax/README.md b/applications/minotari_ledger_wallet/wallet/install_scripts/stax/README.md index 55a5da0493..03dc2c5a6c 100644 --- a/applications/minotari_ledger_wallet/wallet/install_scripts/stax/README.md +++ b/applications/minotari_ledger_wallet/wallet/install_scripts/stax/README.md @@ -1,100 +1,20 @@ -# Minotari Ledger Stax Installer (macOS) +# Minotari Ledger Stax Installer -This script installs the **Minotari Ledger Wallet app** (`minotari_ledger_wallet-stax`) onto a **Ledger Stax** on macOS. +This directory keeps compatibility entry points for Ledger Stax users. +Both scripts delegate to the auto-detecting unified installer in +`../install_minotari_ledger.py`. -It is fully automated and handles: -- System dependencies -- Python virtual environment setup -- `ledgerctl` installation -- Downloading the **latest** Minotari Ledger release -- Installing the app onto the Ledger device - ---- - -## Supported Platforms - -- **macOS** (Intel & Apple Silicon) -- **Ledger Stax** - ---- - -## What the Script Does - -1. Installs required tools via **Homebrew** -2. Creates a Python **virtual environment** -3. Installs required Python dependencies -4. Automatically installs `ledgerctl` (if missing) -5. Downloads the **latest** `minotari_ledger_wallet-stax` release from GitHub -6. Unzips the release -7. Uploads the app to the Ledger device using `ledgerctl` - -All tooling is isolated inside the virtual environment to avoid polluting system Python. - ---- - -## Prerequisites - -### Homebrew - -```bash -brew --version -``` - -If not installed, see https://brew.sh - -### Ledger Device - -Ensure your **Ledger Stax** is: -- Connected via USB -- Unlocked -- Developer Mode enabled -- Not running another app - ---- - -## Installation +## macOS / Linux ```bash -chmod +x install_minotari_ledger_stax.sh ./install_minotari_ledger_stax.sh ``` ---- - -## Directory Layout +## Windows PowerShell -```text -~/src/tari/ -└── tari-ledger-live/ - ├── bin/ - ├── lib/ - └── tari-downloads/ +```powershell +.\install_ledger_win.ps1 ``` ---- - -## Re-running the Script - -The script is safe to re-run and will always fetch the latest release. - ---- - -## Troubleshooting - -- Ensure the Ledger is unlocked and Developer Mode is enabled -- Close Ledger Live before installing -- Use a data-capable USB cable - ---- - -## Security - -- Downloads are from the official Tari GitHub -- No keys or secrets are accessed -- Installation requires physical confirmation on the Ledger - ---- - -## License - -Provided as-is. Minotari Ledger app is licensed by the Tari Project. +Pass `--tag ` on macOS/Linux or `-Tag ` on Windows to install +a specific Tari release. diff --git a/applications/minotari_ledger_wallet/wallet/install_scripts/stax/install_ledger_win.ps1 b/applications/minotari_ledger_wallet/wallet/install_scripts/stax/install_ledger_win.ps1 index b591990aa9..724b859897 100644 --- a/applications/minotari_ledger_wallet/wallet/install_scripts/stax/install_ledger_win.ps1 +++ b/applications/minotari_ledger_wallet/wallet/install_scripts/stax/install_ledger_win.ps1 @@ -1,113 +1,27 @@ [CmdletBinding()] param( - # Install a specific release tag (e.g. v5.2.0-pre.7), including pre-releases. - # If omitted, the latest published release is used. - [string]$Tag + [string]$Tag, + [Parameter(ValueFromRemainingArguments = $true)] + [string[]]$RemainingArgs ) $ErrorActionPreference = "Stop" -Write-Host "🚀 Installing Minotari Ledger Wallet (Stax)" -ForegroundColor Cyan +$ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path +$Installer = Join-Path (Split-Path -Parent $ScriptDir) "install_minotari_ledger.py" +$Python = Get-Command python -ErrorAction SilentlyContinue -# ------------------------- -# Prerequisites -# ------------------------- - -if (-not (Get-Command python -ErrorAction SilentlyContinue)) { - Write-Error "Python is not installed or not on PATH. Install Python 3 first." -} - -if (-not (Get-Command pip -ErrorAction SilentlyContinue)) { - Write-Error "pip not found. Ensure Python was installed with pip enabled." -} - -# ------------------------- -# Project setup -# ------------------------- - -$ProjectDir = "$env:USERPROFILE\src\tari" -$VenvDir = "$ProjectDir\tari-ledger-live" -$DownloadDir = "$VenvDir\tari-downloads" - -Write-Host "📁 Setting up project directory at $ProjectDir" -New-Item -ItemType Directory -Force -Path $ProjectDir | Out-Null -Set-Location $ProjectDir - -if (-not (Test-Path $VenvDir)) { - Write-Host "🐍 Creating Python virtual environment..." - python -m venv $VenvDir -} - -# Activate venv -& "$VenvDir\Scripts\Activate.ps1" - -Write-Host "📦 Installing Python dependencies..." -pip install --upgrade pip -pip install protobuf setuptools ecdsa ledgerwallet - -# ------------------------- -# Auto-install ledgerctl -# ------------------------- - -if (-not (Get-Command ledgerctl -ErrorAction SilentlyContinue)) { - Write-Host "🔐 ledgerctl not found — installing..." - pip install ledgerctl -} else { - Write-Host "✅ ledgerctl already installed" +if (-not $Python) { + Write-Error "Python 3 is required to run the Minotari Ledger installer." } -# ------------------------- -# Download latest release -# ------------------------- - +$InstallerArgs = @() if ($Tag) { - Write-Host "🌐 Fetching Minotari Ledger release info for tag '$Tag'..." - $ReleaseUri = "https://api.github.com/repos/tari-project/tari/releases/tags/$Tag" -} else { - Write-Host "🌐 Fetching latest Minotari Ledger release..." - $ReleaseUri = "https://api.github.com/repos/tari-project/tari/releases/latest" + $InstallerArgs += @("--tag", $Tag) } - -New-Item -ItemType Directory -Force -Path $DownloadDir | Out-Null -Set-Location $DownloadDir - -$Release = Invoke-RestMethod ` - -Uri $ReleaseUri ` - -Headers @{ "User-Agent" = "PowerShell" } - -$Asset = $Release.assets | - Where-Object { $_.name -match "minotari_ledger_wallet-stax.*\.zip" } | - Select-Object -First 1 - -if (-not $Asset) { - Write-Error "Could not find stax release asset." +if ($RemainingArgs) { + $InstallerArgs += $RemainingArgs } -Write-Host "⬇️ Downloading $($Asset.name)" -Invoke-WebRequest $Asset.browser_download_url -OutFile $Asset.name - -Write-Host "📦 Extracting archive..." -Expand-Archive -Path $Asset.name -DestinationPath . -Force - -# ------------------------- -# Install onto Ledger -# ------------------------- - -$appJson = Get-ChildItem -Recurse -Filter "app_stax.json" | Select-Object -First 1 - -if (-not $appJson) { - Write-Error "app_stax.json not found after extraction." -} - -Write-Host "" -Write-Host "🔐 Installing app onto Ledger Stax..." -ForegroundColor Yellow -Write-Host "👉 Ensure:" -Write-Host " • Ledger connected via USB" -Write-Host " • Device unlocked" -Write-Host " • Developer Mode enabled" -Write-Host "" - -ledgerctl install $appJson.FullName - -Write-Host "" -Write-Host "✅ Minotari Ledger Wallet installed successfully!" -ForegroundColor Green +& $Python.Source $Installer @InstallerArgs +exit $LASTEXITCODE diff --git a/applications/minotari_ledger_wallet/wallet/install_scripts/stax/install_minotari_ledger_stax.sh b/applications/minotari_ledger_wallet/wallet/install_scripts/stax/install_minotari_ledger_stax.sh old mode 100644 new mode 100755 index 05d4a105da..dc2fb9d972 --- a/applications/minotari_ledger_wallet/wallet/install_scripts/stax/install_minotari_ledger_stax.sh +++ b/applications/minotari_ledger_wallet/wallet/install_scripts/stax/install_minotari_ledger_stax.sh @@ -1,148 +1,17 @@ -# To run -# chmod +x install_minotari_ledger_stax.sh -# ./install_minotari_ledger_stax.sh - - #!/usr/bin/env bash set -euo pipefail -# ------------------------- -# CLI options -# ------------------------- - -RELEASE_TAG="" - -usage() { - cat </dev/null 2>&1; then - echo "❌ Homebrew is not installed. Install it first from https://brew.sh" - exit 1 -fi - -echo "🔧 Installing system dependencies..." -brew install virtualenv wget jq - -# ------------------------- -# Project setup -# ------------------------- - -PROJECT_DIR="$HOME/src/tari" -VENV_DIR="$PROJECT_DIR/tari-ledger-live" -DOWNLOAD_DIR="$VENV_DIR/tari-downloads" - -echo "📁 Setting up project directory at $PROJECT_DIR" -mkdir -p "$PROJECT_DIR" -cd "$PROJECT_DIR" - -if [[ ! -d "$VENV_DIR" ]]; then - echo "🐍 Creating Python virtual environment..." - python3 -m venv "$VENV_DIR" -fi - -cd "$VENV_DIR" -# shellcheck disable=SC1091 -source bin/activate - -echo "📦 Installing Python dependencies..." -pip3 install --upgrade pip -pip3 install protobuf setuptools ecdsa ledgerwallet - -# ------------------------- -# Auto-install ledgerctl -# ------------------------- +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +INSTALLER="${SCRIPT_DIR}/../install_minotari_ledger.py" -if ! command -v ledgerctl >/dev/null 2>&1; then - echo "🔐 ledgerctl not found — installing..." - pip3 install ledgerctl +if command -v python3 >/dev/null 2>&1; then + PYTHON_BIN="python3" +elif command -v python >/dev/null 2>&1; then + PYTHON_BIN="python" else - echo "✅ ledgerctl already installed" -fi - -mkdir -p "$DOWNLOAD_DIR" -cd "$DOWNLOAD_DIR" - -# ------------------------- -# Download latest release -# ------------------------- - -if [[ -n "$RELEASE_TAG" ]]; then - echo "🌐 Fetching Minotari Ledger release info for tag '$RELEASE_TAG'..." - RELEASE_API="https://api.github.com/repos/tari-project/tari/releases/tags/${RELEASE_TAG}" -else - echo "🌐 Fetching latest Minotari Ledger release info..." - RELEASE_API="https://api.github.com/repos/tari-project/tari/releases/latest" -fi - -ASSET_URL=$(curl -fsSL "$RELEASE_API" \ - | jq -r ' - .assets[] - | select(.name | test("minotari_ledger_wallet-stax.*\\.zip$")) - | .browser_download_url - ') - -if [[ -z "$ASSET_URL" || "$ASSET_URL" == "null" ]]; then - echo "❌ Could not find stax release asset." + echo "Python 3 is required to run the Minotari Ledger installer." >&2 exit 1 fi -echo "⬇️ Downloading:" -echo " $ASSET_URL" - -wget -q --show-progress "$ASSET_URL" - -ZIP_FILE=$(basename "$ASSET_URL") - -echo "📦 Unzipping $ZIP_FILE" -unzip -o "$ZIP_FILE" - -# ------------------------- -# Install onto Ledger -# ------------------------- - -APP_JSON=$(find . -name "app_stax.json" | head -n 1) - -if [[ -z "$APP_JSON" ]]; then - echo "❌ app_stax.json not found after unzip." - exit 1 -fi - -echo -echo "🔐 Installing app onto Ledger Stax..." -echo "👉 Ensure:" -echo " • Ledger connected via USB" -echo " • Device unlocked" -echo " • Developer Mode enabled" -echo - -ledgerctl install "$APP_JSON" - -echo -echo "✅ Minotari Ledger Wallet installed successfully!" +exec "${PYTHON_BIN}" "${INSTALLER}" "$@" diff --git a/applications/minotari_ledger_wallet/wallet/install_scripts/test_install_minotari_ledger.py b/applications/minotari_ledger_wallet/wallet/install_scripts/test_install_minotari_ledger.py new file mode 100644 index 0000000000..3bd82e927f --- /dev/null +++ b/applications/minotari_ledger_wallet/wallet/install_scripts/test_install_minotari_ledger.py @@ -0,0 +1,322 @@ +#!/usr/bin/env python3 + +import importlib.util +import sys +import tempfile +import unittest +import zipfile +from pathlib import Path +from unittest import mock + + +SCRIPT_PATH = Path(__file__).with_name("install_minotari_ledger.py") +SPEC = importlib.util.spec_from_file_location("install_minotari_ledger", SCRIPT_PATH) +installer = importlib.util.module_from_spec(SPEC) +assert SPEC.loader is not None +sys.modules["install_minotari_ledger"] = installer +SPEC.loader.exec_module(installer) + + +class TestBootstrapHandling(unittest.TestCase): + def test_windows_bootstrap_waits_for_child_process(self): + with tempfile.TemporaryDirectory() as temp_dir: + python = Path(temp_dir) / "python.exe" + python.touch() + with mock.patch.object(installer, "cache_dir", return_value=Path(temp_dir)), \ + mock.patch.object(installer, "venv_python_path", return_value=python), \ + mock.patch.object(installer, "module_available", return_value=True), \ + mock.patch.object(installer.sys, "platform", "win32"), \ + mock.patch.object(installer.sys, "argv", ["install_minotari_ledger.py", "--tag", "v.test"]), \ + mock.patch.object(installer.subprocess, "call", return_value=7) as call, \ + mock.patch.dict(installer.os.environ, {}, clear=True): + with self.assertRaises(SystemExit) as context: + installer.ensure_bootstrapped() + + self.assertEqual(context.exception.code, 7) + self.assertEqual(call.call_args.args[0], [str(python), str(SCRIPT_PATH.resolve()), "--tag", "v.test"]) + + def test_bootstrap_installs_missing_ledgerblue(self): + with tempfile.TemporaryDirectory() as temp_dir: + python = Path(temp_dir) / "python" + python.touch() + + def module_available(_python, module): + return module == "ledgerwallet" + + with mock.patch.object(installer, "cache_dir", return_value=Path(temp_dir)), \ + mock.patch.object(installer, "venv_python_path", return_value=python), \ + mock.patch.object(installer, "module_available", side_effect=module_available), \ + mock.patch.object(installer.subprocess, "check_call") as check_call, \ + mock.patch.object(installer.os, "execve", side_effect=SystemExit(0)), \ + mock.patch.object(installer.sys, "platform", "linux"), \ + mock.patch.object(installer.sys, "argv", ["install_minotari_ledger.py", "--tag", "v.test"]), \ + mock.patch.object(installer, "print_step"), \ + mock.patch.dict(installer.os.environ, {}, clear=True): + with self.assertRaises(SystemExit): + installer.ensure_bootstrapped() + + self.assertEqual( + check_call.call_args_list[-1].args[0], + [str(python), "-m", "pip", "install", "ledgerblue"], + ) + + +class TestModelMapping(unittest.TestCase): + def test_supported_target_ids(self): + self.assertEqual(installer.model_from_target_id(0x33100004).slug, "nanosplus") + self.assertEqual(installer.model_from_target_id(0x33000004).slug, "nanox") + self.assertEqual(installer.model_from_target_id(0x33200004).slug, "stax") + self.assertEqual(installer.model_from_target_id(0x33300004).slug, "flex") + + def test_original_nano_s_is_unsupported(self): + with self.assertRaisesRegex(installer.InstallerError, "Nano S is not supported"): + installer.model_from_target_id(0x31100004) + + +class TestReleaseSelection(unittest.TestCase): + def test_selects_matching_asset_and_checksum(self): + release = { + "tag_name": "v5.4.0-pre.1", + "assets": [ + { + "name": "minotari_ledger_wallet-nanosplus-v5.4.0-pre.1-abc1234.zip", + "browser_download_url": "https://example.com/nanosplus.zip", + }, + { + "name": "minotari_ledger_wallet-nanosplus-v5.4.0-pre.1-abc1234.zip.sha256", + "browser_download_url": "https://example.com/nanosplus.zip.sha256", + }, + ], + } + + asset = installer.find_asset_for_model(release, "nanosplus") + + self.assertIsNotNone(asset) + self.assertEqual(asset.tag_name, "v5.4.0-pre.1") + self.assertEqual(asset.download_url, "https://example.com/nanosplus.zip") + self.assertEqual(asset.checksum_url, "https://example.com/nanosplus.zip.sha256") + + def test_default_selection_skips_non_matching_newer_release(self): + stable_without_ledger_assets = { + "tag_name": "v5.3.1", + "draft": False, + "assets": [{"name": "tari_suite-v5.3.1-linux.zip"}], + } + prerelease_with_ledger_asset = { + "tag_name": "v5.4.0-pre.1", + "draft": False, + "assets": [ + { + "name": "minotari_ledger_wallet-flex-v5.4.0-pre.1-abc1234.zip", + "browser_download_url": "https://example.com/flex.zip", + }, + { + "name": "minotari_ledger_wallet-flex-v5.4.0-pre.1-abc1234.zip.sha256", + "browser_download_url": "https://example.com/flex.zip.sha256", + }, + ], + } + + asset = installer.select_asset_from_releases( + [stable_without_ledger_assets, prerelease_with_ledger_asset], + "flex", + ) + + self.assertEqual(asset.tag_name, "v5.4.0-pre.1") + + def test_missing_checksum_is_not_selectable(self): + release = { + "tag_name": "v5.4.0-pre.1", + "assets": [ + { + "name": "minotari_ledger_wallet-stax-v5.4.0-pre.1-abc1234.zip", + "browser_download_url": "https://example.com/stax.zip", + }, + ], + } + + self.assertIsNone(installer.find_asset_for_model(release, "stax")) + + def test_skips_matching_zip_without_checksum(self): + release = { + "tag_name": "v5.4.0-pre.1", + "assets": [ + { + "name": "minotari_ledger_wallet-flex-v5.4.0-pre.1-bad.zip", + "browser_download_url": "https://example.com/bad.zip", + }, + { + "name": "minotari_ledger_wallet-flex-v5.4.0-pre.1-good.zip", + "browser_download_url": "https://example.com/good.zip", + }, + { + "name": "minotari_ledger_wallet-flex-v5.4.0-pre.1-good.zip.sha256", + "browser_download_url": "https://example.com/good.zip.sha256", + }, + ], + } + + asset = installer.find_asset_for_model(release, "flex") + + self.assertIsNotNone(asset) + self.assertEqual(asset.asset_name, "minotari_ledger_wallet-flex-v5.4.0-pre.1-good.zip") + + +class TestChecksumHandling(unittest.TestCase): + def test_parse_sha256_accepts_single_bare_digest(self): + checksum = "a" * 64 + + self.assertEqual(installer.parse_sha256_file(f"{checksum}\n", "firmware.zip"), checksum) + + def test_parse_sha256_for_expected_filename(self): + checksum = "a" * 64 + text = f"{'b' * 64} other.zip\n{checksum} firmware.zip\n" + + self.assertEqual(installer.parse_sha256_file(text, "firmware.zip"), checksum) + + def test_parse_sha256_rejects_single_hash_for_wrong_filename(self): + text = f"{'a' * 64} other.zip\n" + + with self.assertRaisesRegex(installer.InstallerError, "firmware.zip"): + installer.parse_sha256_file(text, "firmware.zip") + + def test_parse_sha256_rejects_multiple_hashes_without_expected_filename(self): + text = f"{'a' * 64} one.zip\n{'b' * 64} two.zip\n" + + with self.assertRaisesRegex(installer.InstallerError, "firmware.zip"): + installer.parse_sha256_file(text, "firmware.zip") + + def test_parse_sha256_rejects_bare_digest_mixed_with_named_mismatch(self): + text = f"{'a' * 64} other.zip\n{'b' * 64}\n" + + with self.assertRaisesRegex(installer.InstallerError, "firmware.zip"): + installer.parse_sha256_file(text, "firmware.zip") + + def test_checksum_mismatch_raises(self): + with tempfile.TemporaryDirectory() as temp_dir: + path = Path(temp_dir) / "firmware.zip" + path.write_bytes(b"firmware") + + with self.assertRaisesRegex(installer.InstallerError, "Checksum mismatch"): + installer.verify_sha256(path, "0" * 64) + + +class FakeDownloadResponse: + def __init__(self, headers, chunks): + self.headers = headers + self.chunks = list(chunks) + + def __enter__(self): + return self + + def __exit__(self, *_args): + return False + + def read(self, _size=-1): + if not self.chunks: + return b"" + return self.chunks.pop(0) + + +class TestDownloadHandling(unittest.TestCase): + def test_download_file_ignores_invalid_content_length(self): + with tempfile.TemporaryDirectory() as temp_dir: + destination = Path(temp_dir) / "firmware.zip" + response = FakeDownloadResponse({"Content-Length": "not-a-number"}, [b"firm", b"ware"]) + + with mock.patch.object(installer.urllib.request, "urlopen", return_value=response): + installer.download_file("https://example.com/firmware.zip", destination) + + self.assertEqual(destination.read_bytes(), b"firmware") + + +class TestExtractionAndArtifactSelection(unittest.TestCase): + def test_safe_extract_accepts_normal_archive(self): + with tempfile.TemporaryDirectory() as temp_dir: + root = Path(temp_dir) + archive = root / "firmware.zip" + output = root / "out" + output.mkdir() + with zipfile.ZipFile(archive, "w") as zip_file: + zip_file.writestr("minotari_ledger_wallet.apdu", "e0000000009000\n") + + installer.safe_extract(archive, output) + + self.assertTrue((output / "minotari_ledger_wallet.apdu").exists()) + + def test_safe_extract_blocks_zip_slip(self): + with tempfile.TemporaryDirectory() as temp_dir: + root = Path(temp_dir) + archive = root / "firmware.zip" + output = root / "out" + output.mkdir() + with zipfile.ZipFile(archive, "w") as zip_file: + zip_file.writestr("../evil.txt", "bad") + + with self.assertRaisesRegex(installer.InstallerError, "Unsafe path"): + installer.safe_extract(archive, output) + + def test_apdu_artifact_is_found(self): + with tempfile.TemporaryDirectory() as temp_dir: + root = Path(temp_dir) + (root / "minotari_ledger_wallet.apdu").write_text("e0000000009000\n", encoding="utf-8") + + artifact = installer.find_install_artifact(root) + + self.assertEqual(artifact.name, "minotari_ledger_wallet.apdu") + + def test_missing_apdu_artifact_raises(self): + with tempfile.TemporaryDirectory() as temp_dir: + root = Path(temp_dir) + (root / "app_nanox.json").write_text("{}", encoding="utf-8") + + with self.assertRaisesRegex(installer.InstallerError, "minotari_ledger_wallet.apdu"): + installer.find_install_artifact(root) + + +class TestApduInstall(unittest.TestCase): + def test_apdu_install_uses_ledgerblue_secure_channel(self): + apdu = Path("minotari_ledger_wallet.apdu") + completed = mock.Mock(returncode=0) + + with mock.patch.object(installer.subprocess, "run", return_value=completed) as run: + installer.install_apdu_file(apdu, installer.SUPPORTED_MODELS["nanosplus"]) + + self.assertEqual( + run.call_args.args[0], + [ + installer.sys.executable, + "-m", + "ledgerblue.runScript", + "--scp", + "--targetId", + "0x33100004", + "--fileName", + str(apdu), + ], + ) + self.assertFalse(run.call_args.kwargs["check"]) + + def test_apdu_install_failure_is_user_facing(self): + completed = mock.Mock(returncode=7) + + with mock.patch.object(installer.subprocess, "run", return_value=completed): + with self.assertRaisesRegex(installer.InstallerError, "ledgerblue runScript failed"): + installer.install_apdu_file(Path("app.apdu"), installer.SUPPORTED_MODELS["flex"]) + + def test_apdu_install_reports_old_firmware(self): + completed = mock.Mock( + returncode=1, + stdout=( + "ledgerblue.commException.CommException: Exception : Invalid status 511f " + "(The OS version on your device does not seem compatible with the SDK version used to build the app)" + ), + ) + + with mock.patch.object(installer.subprocess, "run", return_value=completed): + with self.assertRaisesRegex(installer.InstallerError, "firmware is too old"): + installer.install_apdu_file(Path("app.apdu"), installer.SUPPORTED_MODELS["nanosplus"]) + + +if __name__ == "__main__": + unittest.main()