diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..0f565b9 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,10 @@ +# Signed manifests, signatures and pinned keys must be byte-exact: a CRLF +# conversion on a Windows checkout invalidates every signature over them. +crates/bootable-core/testdata/** -text +crates/bootable-core/keys/** -text +crates/bootable-core/assets/bios/** -text + +# Files parsed or executed by line must keep LF on every platform. +*.lang text eol=lf +*.sh text eol=lf +*.S text eol=lf diff --git a/.github/workflows/aur-publish.yml b/.github/workflows/aur-publish.yml new file mode 100644 index 0000000..7d0cb37 --- /dev/null +++ b/.github/workflows/aur-publish.yml @@ -0,0 +1,119 @@ +name: AUR publish + +# Keeps the AUR source packages bootable-tui and bootable-gui (one pkgbase, "bootable") in step +# with each stable release by bumping pkgver, the source checksum, and .SRCINFO. +# +# Disabled until the AUR_SSH_PRIVATE_KEY repository secret exists: without it the job reports a +# notice and succeeds without pushing anything. Use a key dedicated to this job, registered on the +# AUR account. This is separate from package-manifests.yml, which never pushes anywhere (it renders +# the bootable-bin manifests for manual submission). +# +# Triggers: +# workflow_run a manually dispatched Release workflow finished successfully. +# workflow_dispatch re-run for a version, for example after adding the secret. +on: + workflow_dispatch: + inputs: + version: + description: Stable version to publish, for example 0.1.5 (default is the Cargo.toml version) + required: false + type: string + workflow_run: + workflows: [Release] + types: [completed] + +permissions: + contents: read + +concurrency: + group: aur-publish + cancel-in-progress: false + +jobs: + publish: + name: Update AUR bootable package + runs-on: ubuntu-24.04 + container: archlinux:base-devel + if: >- + (github.event_name == 'workflow_dispatch') || + (github.event_name == 'workflow_run' && + github.event.workflow_run.conclusion == 'success' && + github.event.workflow_run.event == 'workflow_dispatch') + env: + AUR_SSH_PRIVATE_KEY: ${{ secrets.AUR_SSH_PRIVATE_KEY }} + steps: + - name: Check that publishing is configured + id: gate + shell: bash + run: | + if [ -z "$AUR_SSH_PRIVATE_KEY" ]; then + echo "::notice::AUR_SSH_PRIVATE_KEY is not set; skipping AUR publish." + echo "enabled=false" >> "$GITHUB_OUTPUT" + else + echo "enabled=true" >> "$GITHUB_OUTPUT" + fi + + - name: Install tools + if: steps.gate.outputs.enabled == 'true' + run: pacman -Syu --noconfirm --needed git openssh + + - uses: actions/checkout@v4 + if: steps.gate.outputs.enabled == 'true' + with: + ref: ${{ github.event.workflow_run.head_sha || github.sha }} + + - name: Resolve the release version + id: version + if: steps.gate.outputs.enabled == 'true' + shell: bash + env: + INPUT_VERSION: ${{ inputs.version }} + run: | + set -euo pipefail + version="${INPUT_VERSION#v}" + if [ -z "$version" ]; then + version="$(sed -n 's/^version = "\([^"]*\)"/\1/p' Cargo.toml | head -1)" + fi + if ! [[ "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo "Refusing to publish '$version': only stable MAJOR.MINOR.PATCH releases are supported." >&2 + exit 1 + fi + echo "version=$version" >> "$GITHUB_OUTPUT" + + - name: Update and push the AUR repository + if: steps.gate.outputs.enabled == 'true' + shell: bash + env: + VERSION: ${{ steps.version.outputs.version }} + run: | + set -euo pipefail + # makepkg refuses to run as root. + useradd -m builder + install -d -m 0700 -o builder -g builder /home/builder/.ssh + printf '%s\n' "$AUR_SSH_PRIVATE_KEY" > /home/builder/.ssh/aur + # Pinned AUR host key (SHA256:RFzBCUItH9LZS0cKB5UE6ceAYhBD5C8GeOBip8Z11+4). + echo 'aur.archlinux.org ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIEuBKrPzbawxA/k2g6NcyV5jmqwJ2s+zpgZGZ7tpLIcN' > /home/builder/.ssh/known_hosts + chmod 600 /home/builder/.ssh/aur + chown -R builder:builder /home/builder/.ssh + su builder -c "VERSION='$VERSION' bash -s" <<'BUILDER' + set -euo pipefail + export GIT_SSH_COMMAND='ssh -i ~/.ssh/aur -o IdentitiesOnly=yes -o StrictHostKeyChecking=yes -o BatchMode=yes' + git config --global user.name debpalash + git config --global user.email 4178343+debpalash@users.noreply.github.com + git clone ssh://aur@aur.archlinux.org/bootable.git ~/aur + cd ~/aur + current="$(sed -n 's/^pkgver=//p' PKGBUILD)" + if [ "$(printf '%s\n%s\n' "$current" "$VERSION" | sort -V | tail -1)" != "$VERSION" ] || [ "$current" = "$VERSION" ]; then + echo "::notice::AUR already at $current; nothing to do for $VERSION." + exit 0 + fi + sed -i "s/^pkgver=.*/pkgver=$VERSION/; s/^pkgrel=.*/pkgrel=1/; s/^sha256sums=.*/sha256sums=('SKIP')/" PKGBUILD + sum="$(curl -fsSL "https://github.com/debpalash/bootable/archive/refs/tags/v$VERSION.tar.gz" | sha256sum | cut -d' ' -f1)" + test "${#sum}" -eq 64 + sed -i "s/^sha256sums=.*/sha256sums=('$sum')/" PKGBUILD + makepkg --verifysource --skippgpcheck + makepkg --printsrcinfo > .SRCINFO + git add PKGBUILD .SRCINFO + git commit -m "Update to $VERSION" + git push origin master + BUILDER diff --git a/.github/workflows/package-manifests.yml b/.github/workflows/package-manifests.yml new file mode 100644 index 0000000..658408d --- /dev/null +++ b/.github/workflows/package-manifests.yml @@ -0,0 +1,92 @@ +name: Package manifests + +# Renders submit-ready package-manager manifests (winget, Scoop, Homebrew, Flatpak, AUR, Nix) +# for a published stable release and uploads them as a workflow artifact ONLY. +# +# This workflow never pushes to another repository, never opens pull requests, and uses no +# secrets. A maintainer downloads the artifact and submits each manifest by hand; see +# docs/package-managers.md. +# +# Triggers: +# workflow_dispatch the reliable path; defaults to the version in Cargo.toml. +# release: published only fires for releases NOT created with the default GITHUB_TOKEN +# (GitHub suppresses events from it), so it will not see releases that the +# Release workflow publishes itself. +# workflow_run fires when a manually dispatched Release workflow finishes successfully, +# which is how stable releases are actually published. +on: + workflow_dispatch: + inputs: + version: + description: Stable version to render, for example 0.1.4 (default is the Cargo.toml version) + required: false + type: string + release: + types: [published] + workflow_run: + workflows: [Release] + types: [completed] + +permissions: + contents: read + +concurrency: + group: package-manifests-${{ github.event.release.tag_name || github.event.workflow_run.id || inputs.version || github.run_id }} + cancel-in-progress: false + +jobs: + render: + name: Render package manifests + runs-on: ubuntu-24.04 + # Prereleases and release-candidate runs are never packaged. + if: >- + (github.event_name == 'workflow_dispatch') || + (github.event_name == 'release' && !github.event.release.prerelease) || + (github.event_name == 'workflow_run' && + github.event.workflow_run.conclusion == 'success' && + github.event.workflow_run.event == 'workflow_dispatch') + steps: + - uses: actions/checkout@v4 + + - name: Resolve the release version + id: version + shell: bash + env: + EVENT_NAME: ${{ github.event_name }} + INPUT_VERSION: ${{ inputs.version }} + RELEASE_TAG: ${{ github.event.release.tag_name }} + run: | + set -euo pipefail + case "$EVENT_NAME" in + release) version="${RELEASE_TAG#v}" ;; + workflow_dispatch) + version="${INPUT_VERSION#v}" + ;; + *) version="" ;; + esac + if [ -z "$version" ]; then + version="$(sed -n 's/^version = "\([^"]*\)"/\1/p' Cargo.toml | head -1)" + fi + if ! [[ "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo "Refusing to package '$version': only stable MAJOR.MINOR.PATCH releases are supported." >&2 + exit 1 + fi + echo "version=$version" >> "$GITHUB_OUTPUT" + + - name: Lint and self-test the renderer + shell: bash + run: | + shellcheck scripts/render-package-manifests.sh + scripts/render-package-manifests.sh --self-test + + - name: Render manifests from the published checksums + shell: bash + env: + VERSION: ${{ steps.version.outputs.version }} + run: scripts/render-package-manifests.sh "$VERSION" --out dist/package-manifests + + - uses: actions/upload-artifact@v4 + with: + name: package-manifests-${{ steps.version.outputs.version }} + path: dist/package-manifests + if-no-files-found: error diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index f70d9d4..9d76253 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -89,6 +89,8 @@ jobs: name: Package macOS Apple Silicon needs: metadata runs-on: macos-14 + outputs: + signed: ${{ steps.signing.outputs.enabled }} permissions: contents: read id-token: write @@ -107,8 +109,39 @@ jobs: - run: cargo build --release --workspace --target aarch64-apple-darwin - name: Build DMG and archive run: scripts/package-macos.sh "$BOOTABLE_VERSION" + # Signing is off unless every Apple secret of a credential set exists and + # the run is a manual stable release (see docs/signing.md). The secret + # values are scoped to these two steps so cargo build scripts never see them. + - name: Detect macOS signing configuration + id: signing + shell: bash + env: + APPLE_CERTIFICATE: ${{ github.event_name == 'workflow_dispatch' && secrets.APPLE_CERTIFICATE || '' }} + APPLE_CERTIFICATE_PASSWORD: ${{ github.event_name == 'workflow_dispatch' && secrets.APPLE_CERTIFICATE_PASSWORD || '' }} + APPLE_ID: ${{ github.event_name == 'workflow_dispatch' && secrets.APPLE_ID || '' }} + APPLE_TEAM_ID: ${{ github.event_name == 'workflow_dispatch' && secrets.APPLE_TEAM_ID || '' }} + APPLE_APP_PASSWORD: ${{ github.event_name == 'workflow_dispatch' && secrets.APPLE_APP_PASSWORD || '' }} + APPLE_API_KEY_P8: ${{ github.event_name == 'workflow_dispatch' && secrets.APPLE_API_KEY_P8 || '' }} + APPLE_API_KEY_ID: ${{ github.event_name == 'workflow_dispatch' && secrets.APPLE_API_KEY_ID || '' }} + APPLE_API_ISSUER_ID: ${{ github.event_name == 'workflow_dispatch' && secrets.APPLE_API_ISSUER_ID || '' }} + run: scripts/sign-detect.sh macos + - name: Developer ID sign, notarize, and staple + if: steps.signing.outputs.enabled == 'true' + env: + APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }} + APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }} + APPLE_ID: ${{ secrets.APPLE_ID }} + APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} + APPLE_APP_PASSWORD: ${{ secrets.APPLE_APP_PASSWORD }} + APPLE_API_KEY_P8: ${{ secrets.APPLE_API_KEY_P8 }} + APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY_ID }} + APPLE_API_ISSUER_ID: ${{ secrets.APPLE_API_ISSUER_ID }} + run: scripts/sign-macos.sh "$BOOTABLE_VERSION" - name: Verify macOS packages run: scripts/verify-macos-packages.sh "$BOOTABLE_VERSION" + - name: Verify macOS signatures and notarization + if: steps.signing.outputs.enabled == 'true' + run: scripts/sign-verify-macos.sh "$BOOTABLE_VERSION" - uses: actions/attest-build-provenance@v2 if: github.event_name == 'workflow_dispatch' with: @@ -123,6 +156,8 @@ jobs: name: Package Windows x86-64 needs: metadata runs-on: windows-2025 + outputs: + signed: ${{ steps.signing.outputs.enabled }} permissions: contents: read id-token: write @@ -142,12 +177,150 @@ jobs: run: cargo install cargo-packager --locked --version 0.11.8 - run: cargo test --workspace - run: cargo build --release --workspace --target x86_64-pc-windows-msvc + # Signing is off unless a complete provider configuration exists and the run + # is a manual stable release (see docs/signing.md). Azure Artifact Signing is + # the default; SignPath is the alternative. Secret values are scoped to the + # steps that need them so cargo build scripts never see them. + - name: Detect Windows signing configuration + id: signing + shell: bash + env: + AZURE_CLIENT_ID: ${{ github.event_name == 'workflow_dispatch' && secrets.AZURE_CLIENT_ID || '' }} + AZURE_TENANT_ID: ${{ github.event_name == 'workflow_dispatch' && secrets.AZURE_TENANT_ID || '' }} + AZURE_SUBSCRIPTION_ID: ${{ github.event_name == 'workflow_dispatch' && secrets.AZURE_SUBSCRIPTION_ID || '' }} + AZURE_SIGNING_ENDPOINT: ${{ github.event_name == 'workflow_dispatch' && vars.AZURE_SIGNING_ENDPOINT || '' }} + AZURE_SIGNING_ACCOUNT: ${{ github.event_name == 'workflow_dispatch' && vars.AZURE_SIGNING_ACCOUNT || '' }} + AZURE_SIGNING_PROFILE: ${{ github.event_name == 'workflow_dispatch' && vars.AZURE_SIGNING_PROFILE || '' }} + SIGNPATH_API_TOKEN: ${{ github.event_name == 'workflow_dispatch' && secrets.SIGNPATH_API_TOKEN || '' }} + SIGNPATH_ORGANIZATION_ID: ${{ github.event_name == 'workflow_dispatch' && vars.SIGNPATH_ORGANIZATION_ID || '' }} + SIGNPATH_PROJECT_SLUG: ${{ github.event_name == 'workflow_dispatch' && vars.SIGNPATH_PROJECT_SLUG || '' }} + SIGNPATH_SIGNING_POLICY_SLUG: ${{ github.event_name == 'workflow_dispatch' && vars.SIGNPATH_SIGNING_POLICY_SLUG || '' }} + run: scripts/sign-detect.sh windows + - name: Azure login for Artifact Signing + if: steps.signing.outputs.provider == 'azure' + uses: azure/login@v3 + with: + client-id: ${{ secrets.AZURE_CLIENT_ID }} + tenant-id: ${{ secrets.AZURE_TENANT_ID }} + subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }} + - name: Stage release executables for signing + id: stage_binaries + if: steps.signing.outputs.enabled == 'true' + shell: pwsh + run: scripts/sign-windows.ps1 -Action Stage -Phase binaries -Version $env:BOOTABLE_VERSION + - name: Sign binaries with Azure Artifact Signing + if: steps.signing.outputs.provider == 'azure' + uses: azure/artifact-signing-action@v2 + with: + endpoint: ${{ vars.AZURE_SIGNING_ENDPOINT }} + signing-account-name: ${{ vars.AZURE_SIGNING_ACCOUNT }} + certificate-profile-name: ${{ vars.AZURE_SIGNING_PROFILE }} + files-folder: ${{ steps.stage_binaries.outputs.unsigned_dir }} + files-folder-filter: exe + file-digest: SHA256 + timestamp-rfc3161: http://timestamp.acs.microsoft.com + timestamp-digest: SHA256 + exclude-environment-credential: true + exclude-workload-identity-credential: true + exclude-managed-identity-credential: true + exclude-shared-token-cache-credential: true + exclude-visual-studio-credential: true + exclude-visual-studio-code-credential: true + exclude-azure-cli-credential: false + exclude-azure-powershell-credential: true + exclude-azure-developer-cli-credential: true + exclude-interactive-browser-credential: true + - name: Upload binaries to SignPath + id: upload_binaries + if: steps.signing.outputs.provider == 'signpath' + uses: actions/upload-artifact@v4 + with: + name: signpath-unsigned-binaries + path: ${{ steps.stage_binaries.outputs.unsigned_dir }} + retention-days: 1 + if-no-files-found: error + - name: Sign binaries with SignPath + if: steps.signing.outputs.provider == 'signpath' + uses: signpath/github-action-submit-signing-request@v3 + with: + api-token: ${{ secrets.SIGNPATH_API_TOKEN }} + organization-id: ${{ vars.SIGNPATH_ORGANIZATION_ID }} + project-slug: ${{ vars.SIGNPATH_PROJECT_SLUG }} + signing-policy-slug: ${{ vars.SIGNPATH_SIGNING_POLICY_SLUG }} + artifact-configuration-slug: ${{ vars.SIGNPATH_ARTIFACT_CONFIGURATION_SLUG }} + github-artifact-id: ${{ steps.upload_binaries.outputs.artifact-id }} + wait-for-completion: true + output-artifact-directory: ${{ steps.stage_binaries.outputs.signed_dir }} + - name: Apply signed release executables + if: steps.signing.outputs.enabled == 'true' + shell: pwsh + env: + SIGNED_DIRECTORY: ${{ steps.signing.outputs.provider == 'azure' && steps.stage_binaries.outputs.unsigned_dir || steps.stage_binaries.outputs.signed_dir }} + run: scripts/sign-windows.ps1 -Action Apply -Phase binaries -Version $env:BOOTABLE_VERSION -SignedDirectory $env:SIGNED_DIRECTORY - name: Build MSI, NSIS installer, portable executables, and archive shell: pwsh run: scripts/package-windows.ps1 -Version $env:BOOTABLE_VERSION + - name: Stage installers for signing + id: stage_installers + if: steps.signing.outputs.enabled == 'true' + shell: pwsh + run: scripts/sign-windows.ps1 -Action Stage -Phase installers -Version $env:BOOTABLE_VERSION + - name: Sign installers with Azure Artifact Signing + if: steps.signing.outputs.provider == 'azure' + uses: azure/artifact-signing-action@v2 + with: + endpoint: ${{ vars.AZURE_SIGNING_ENDPOINT }} + signing-account-name: ${{ vars.AZURE_SIGNING_ACCOUNT }} + certificate-profile-name: ${{ vars.AZURE_SIGNING_PROFILE }} + files-folder: ${{ steps.stage_installers.outputs.unsigned_dir }} + files-folder-filter: exe,msi + file-digest: SHA256 + timestamp-rfc3161: http://timestamp.acs.microsoft.com + timestamp-digest: SHA256 + exclude-environment-credential: true + exclude-workload-identity-credential: true + exclude-managed-identity-credential: true + exclude-shared-token-cache-credential: true + exclude-visual-studio-credential: true + exclude-visual-studio-code-credential: true + exclude-azure-cli-credential: false + exclude-azure-powershell-credential: true + exclude-azure-developer-cli-credential: true + exclude-interactive-browser-credential: true + - name: Upload installers to SignPath + id: upload_installers + if: steps.signing.outputs.provider == 'signpath' + uses: actions/upload-artifact@v4 + with: + name: signpath-unsigned-installers + path: ${{ steps.stage_installers.outputs.unsigned_dir }} + retention-days: 1 + if-no-files-found: error + - name: Sign installers with SignPath + if: steps.signing.outputs.provider == 'signpath' + uses: signpath/github-action-submit-signing-request@v3 + with: + api-token: ${{ secrets.SIGNPATH_API_TOKEN }} + organization-id: ${{ vars.SIGNPATH_ORGANIZATION_ID }} + project-slug: ${{ vars.SIGNPATH_PROJECT_SLUG }} + signing-policy-slug: ${{ vars.SIGNPATH_SIGNING_POLICY_SLUG }} + artifact-configuration-slug: ${{ vars.SIGNPATH_ARTIFACT_CONFIGURATION_SLUG }} + github-artifact-id: ${{ steps.upload_installers.outputs.artifact-id }} + wait-for-completion: true + output-artifact-directory: ${{ steps.stage_installers.outputs.signed_dir }} + - name: Apply signed installers + if: steps.signing.outputs.enabled == 'true' + shell: pwsh + env: + SIGNED_DIRECTORY: ${{ steps.signing.outputs.provider == 'azure' && steps.stage_installers.outputs.unsigned_dir || steps.stage_installers.outputs.signed_dir }} + run: scripts/sign-windows.ps1 -Action Apply -Phase installers -Version $env:BOOTABLE_VERSION -SignedDirectory $env:SIGNED_DIRECTORY - name: Verify Windows packages shell: pwsh run: scripts/verify-windows-packages.ps1 -Version $env:BOOTABLE_VERSION + - name: Verify Authenticode signatures + if: steps.signing.outputs.enabled == 'true' + shell: pwsh + run: scripts/sign-verify-windows.ps1 -Version $env:BOOTABLE_VERSION - uses: actions/attest-build-provenance@v2 if: github.event_name == 'workflow_dispatch' with: @@ -238,12 +411,62 @@ jobs: - Windows 10+ x86-64: MSI, setup EXE, portable GUI/TUI EXEs, and ZIP - macOS Apple Silicon: DMG and tar.gz - Integrity: SHA-256 sidecars for every asset + - Signing: release candidates are never code-signed or notarized; only stable releases can be - Safety: interactive apps remain unelevated; approved removable-media writes use the fixed helper files: dist/* + # Optional keyless Sigstore signature over one SHA256SUMS file covering every + # release asset. Needs no secret: enable it by setting the repository variable + # SIGN_CHECKSUMS to "true" (see docs/signing.md). Disabled, this job only reports. + checksums: + name: Sign release checksums + needs: [metadata, linux, macos, windows] + if: github.event_name == 'workflow_dispatch' + runs-on: ubuntu-24.04 + permissions: + contents: read + id-token: write + outputs: + signed: ${{ steps.report.outputs.signed }} + env: + SIGN_CHECKSUMS: ${{ vars.SIGN_CHECKSUMS }} + steps: + - id: report + name: Report checksum signing state + run: echo "signed=$([ "$SIGN_CHECKSUMS" = true ] && echo true || echo false)" >> "$GITHUB_OUTPUT" + - uses: actions/download-artifact@v4 + if: env.SIGN_CHECKSUMS == 'true' + with: + pattern: release-* + merge-multiple: true + path: dist + - uses: sigstore/cosign-installer@v4 + if: env.SIGN_CHECKSUMS == 'true' + - name: Write and sign SHA256SUMS + if: env.SIGN_CHECKSUMS == 'true' + shell: bash + run: | + test "$(find dist -maxdepth 1 -type f | wc -l)" -eq 22 + (cd dist && sha256sum --check ./*.sha256) + mkdir checksums + (cd dist && find . -maxdepth 1 -type f ! -name '*.sha256' -printf '%f\n' | + LC_ALL=C sort | xargs -d '\n' sha256sum) > checksums/SHA256SUMS + cosign sign-blob --yes --bundle checksums/SHA256SUMS.sigstore.json checksums/SHA256SUMS + cosign verify-blob \ + --bundle checksums/SHA256SUMS.sigstore.json \ + --certificate-identity "https://github.com/${GITHUB_WORKFLOW_REF}" \ + --certificate-oidc-issuer https://token.actions.githubusercontent.com \ + checksums/SHA256SUMS + - uses: actions/upload-artifact@v4 + if: env.SIGN_CHECKSUMS == 'true' + with: + name: release-checksums + path: checksums/* + if-no-files-found: error + publish: name: Publish stable release - needs: [metadata, linux, macos, windows] + needs: [metadata, linux, macos, windows, checksums] if: github.event_name == 'workflow_dispatch' runs-on: ubuntu-24.04 permissions: @@ -254,6 +477,20 @@ jobs: pattern: release-* merge-multiple: true path: dist + - id: signing + name: Describe signing status + env: + WINDOWS_SIGNED: ${{ needs.windows.outputs.signed }} + MACOS_SIGNED: ${{ needs.macos.outputs.signed }} + CHECKSUMS_SIGNED: ${{ needs.checksums.outputs.signed }} + run: | + windows='Windows packages are unsigned' + macos='the macOS app is ad-hoc signed, not notarized' + checksums='' + [ "$WINDOWS_SIGNED" = true ] && windows='Windows executables and installers are Authenticode-signed' + [ "$MACOS_SIGNED" = true ] && macos='the macOS app and DMG are Developer ID signed, notarized, and stapled' + [ "$CHECKSUMS_SIGNED" = true ] && checksums=' SHA256SUMS carries a keyless Sigstore signature (SHA256SUMS.sigstore.json).' + echo "text=${windows} and ${macos}.${checksums}" >> "$GITHUB_OUTPUT" - uses: softprops/action-gh-release@v2 with: tag_name: ${{ needs.metadata.outputs.tag }} @@ -264,13 +501,15 @@ jobs: ## Bootable ${{ needs.metadata.outputs.tag }} Bootable ships native installers and portable builds for its matching GUI and TUI. - Packages are unsigned and the macOS app is ad-hoc signed, not notarized. Use expendable - USB or SD media and check the physical target before approving erasure. + ${{ steps.signing.outputs.text }} Linux packages rely on checksums and build + provenance. Use expendable USB or SD media and check the physical target before + approving erasure. - Linux x86-64: AppImage, DEB, RPM, and tar.gz - Windows 10+ x86-64: MSI, setup EXE, portable GUI/TUI EXEs, and ZIP - macOS Apple Silicon: DMG and tar.gz - Integrity: SHA-256 sidecars and GitHub build provenance for every asset + - Verification: see docs/signing.md for how to check signatures on each platform - Privilege: interactive apps remain unelevated; a fixed helper performs approved writes The DEB/RPM and Windows installers place the protected helper automatically. The macOS diff --git a/.gitignore b/.gitignore index 6acfc19..ed62d37 100644 --- a/.gitignore +++ b/.gitignore @@ -15,3 +15,4 @@ site/public/omarchy-plugin.png site/public/tui-*.png site/public/tui-*.gif site/public/install.sh +dist/ diff --git a/CHANGELOG.md b/CHANGELOG.md index c5cb6fc..093394f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,43 @@ All notable changes to Bootable are documented here. +## Unreleased + +- Added remembered preferences shared by the GUI and TUI: last image folder, checksum algorithm, and + the four most recent images, reusable in one click (GUI) or with keys `1`–`4` (TUI). Target + drives and destructive options are never remembered. +- Added selected-drive details in both interfaces: connection, masked serial tail, and mounted + volumes that will be unmounted before writing. +- Added a shared Guide (`?` button, F1 or `?`) listing every action and its key in each interface, + plus desktop shortcuts: Ctrl/Cmd+O image, G discover, R refresh drives, P review plan, Esc close. +- Added OpenPGP and minisign verification of publisher checksum manifests against pinned keys + (Ubuntu, Debian, Linux Mint, Kali, Fedora, AlmaLinux). A bad signature refuses the download; a + missing signature or unknown key falls back to checksum-only and says so. See `docs/signatures.md`. +- Added headless CLI `bootable flash ` (still requires the exact + `--confirm` phrase), `bootable completions `, and stable exit codes. See `docs/cli.md`. + `write` without `--confirm` now exits 3 instead of 1. +- Added package-manager manifest templates (winget, Scoop, Homebrew, AUR, Nix, Flatpak) and a + renderer script; nothing is published automatically. See `docs/package-managers.md`. +- Made the release workflow signing-ready for macOS notarization, Windows Authenticode, and + cosign-signed checksums. Builds stay unsigned until secrets are configured (`docs/signing.md`). +- Added comparison and roadmap pages to the website. + +## 0.1.4 — 2026-10-02 + +- Added a safe progress protocol for Omarchy and other external clients, streaming catalog + download and write progress as JSON, plus removable-media status in both interfaces. +- Polished shared GUI and TUI discovery, keeping their capabilities and information hierarchy in + step. +- Fixed the Linux release job so the packaged GUI is smoke-tested under a software Vulkan device. +- Published to the AUR as `bootable-tui` and `bootable-gui` (source builds from this release). +- Added localization: a shared message catalog with English, Spanish, French, German, Brazilian + Portuguese, Russian, Japanese, and Simplified Chinese (machine-quality drafts that need native + review), a saved language setting, a header language select (GUI) and the `L` key (TUI). Only + text produced by the shared core is translated so far; most app-owned UI text is still English. +- Added an experimental "BIOS + UEFI (CSM)" boot-firmware option for MBR Windows installer media on + Linux (GUI select, TUI `f` key, `--windows-boot-firmware` CLI flag). It is tested against a stub + `bootmgr` in QEMU only and has not been verified with a real Windows installer or real hardware. + ## 0.1.1 — 2026-08-27 - Added native Linux AppImage, DEB, and RPM packages, each containing the desktop app, TUI, and diff --git a/Cargo.lock b/Cargo.lock index bbcd400..c0aff89 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -8,6 +8,17 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" +[[package]] +name = "aead" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" +dependencies = [ + "bytes", + "crypto-common 0.1.7", + "generic-array", +] + [[package]] name = "aes" version = "0.8.4" @@ -20,6 +31,29 @@ dependencies = [ "zeroize", ] +[[package]] +name = "aes-gcm" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "831010a0f742e1209b3bcea8fab6a8e149051ba6099432c8cb2cc117dec3ead1" +dependencies = [ + "aead", + "aes", + "cipher", + "ctr", + "ghash", + "subtle", +] + +[[package]] +name = "aes-kw" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69fa2b352dcefb5f7f3a5fb840e02665d311d878955380515e4fd50095dd3d8c" +dependencies = [ + "aes", +] + [[package]] name = "ahash" version = "0.8.12" @@ -178,6 +212,19 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "argon2" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072" +dependencies = [ + "base64ct", + "blake2", + "cpufeatures 0.2.17", + "password-hash", + "zeroize", +] + [[package]] name = "arrayref" version = "0.3.9" @@ -566,6 +613,12 @@ dependencies = [ "arrayvec", ] +[[package]] +name = "base16ct" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf" + [[package]] name = "base62" version = "2.2.4" @@ -588,6 +641,12 @@ dependencies = [ "vsimd", ] +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + [[package]] name = "bindgen" version = "0.71.1" @@ -644,6 +703,27 @@ version = "0.10.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e4b40c7323adcfc0a41c4b88143ed58346ff65a288fc144329c5c45e05d70c6" +[[package]] +name = "bitfields" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef6e59298da389bc0649c7463856b34c6e17fe542f88939426ede4436c6b1195" +dependencies = [ + "bitfields-impl", +] + +[[package]] +name = "bitfields-impl" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2c044f98f86f15414668d6c8187c7e4fadab1ad2b31680f648703e0fe07c555" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "thiserror 2.0.20", +] + [[package]] name = "bitflags" version = "1.3.2" @@ -735,6 +815,15 @@ dependencies = [ "profiling", ] +[[package]] +name = "blake2" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe" +dependencies = [ + "digest 0.10.7", +] + [[package]] name = "block" version = "0.1.6" @@ -790,15 +879,30 @@ dependencies = [ "piper", ] +[[package]] +name = "blowfish" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e412e2cd0f2b2d93e02543ceae7917b3c70331573df19ee046bcbc35e45e87d7" +dependencies = [ + "byteorder", + "cipher", +] + [[package]] name = "bootable-core" version = "0.1.4" dependencies = [ + "blake2", "bzip2", + "ed25519-dalek", "flate2", "fs2", "getrandom 0.3.4", "md-5", + "minisign-verify", + "pgp", + "rand 0.8.7", "reqwest", "roxmltree", "scraper", @@ -844,12 +948,14 @@ dependencies = [ "anyhow", "bootable-core", "clap", + "clap_complete", "crossterm", "image", "ratatui", "ratatui-image", "rfd", "serde_json", + "unicode-width", ] [[package]] @@ -862,6 +968,15 @@ dependencies = [ "serde_core", ] +[[package]] +name = "buffer-redux" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "431a9cc8d7efa49bc326729264537f5e60affce816c66edf434350778c9f4f54" +dependencies = [ + "memchr", +] + [[package]] name = "built" version = "0.8.1" @@ -953,6 +1068,25 @@ dependencies = [ "wayland-client", ] +[[package]] +name = "camellia" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3264e2574e9ef2b53ce6f536dea83a69ac0bc600b762d1523ff83fe07230ce30" +dependencies = [ + "byteorder", + "cipher", +] + +[[package]] +name = "cast5" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26b07d673db1ccf000e90f54b819db9e75a8348d6eb056e9b8ab53231b7a9911" +dependencies = [ + "cipher", +] + [[package]] name = "castaway" version = "0.2.4" @@ -1010,6 +1144,15 @@ dependencies = [ "nom 7.1.3", ] +[[package]] +name = "cfb-mode" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "738b8d467867f80a71351933f70461f5b56f24d5c93e0cf216e59229c968d330" +dependencies = [ + "cipher", +] + [[package]] name = "cfg-if" version = "1.0.4" @@ -1099,6 +1242,15 @@ dependencies = [ "strsim", ] +[[package]] +name = "clap_complete" +version = "4.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037e2a1a92236d0aff7e845093f64661d6df4c02c9fcc61a60e9e1d736fa392f" +dependencies = [ + "clap", +] + [[package]] name = "clap_derive" version = "4.6.4" @@ -1117,6 +1269,17 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" +[[package]] +name = "cmac" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8543454e3c3f5126effff9cd44d562af4e31fb8ce1cc0d3dcd8f084515dbc1aa" +dependencies = [ + "cipher", + "dbl", + "digest 0.10.7", +] + [[package]] name = "cocoa" version = "0.25.0" @@ -1261,6 +1424,12 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + [[package]] name = "const-oid" version = "0.10.2" @@ -1478,6 +1647,12 @@ dependencies = [ "libc", ] +[[package]] +name = "crc24" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd121741cf3eb82c08dd3023eb55bf2665e5f60ec20f89760cf836ae4562e6a0" + [[package]] name = "crc32fast" version = "1.5.0" @@ -1560,6 +1735,18 @@ version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" +[[package]] +name = "crypto-bigint" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76" +dependencies = [ + "generic-array", + "rand_core 0.6.4", + "subtle", + "zeroize", +] + [[package]] name = "crypto-common" version = "0.1.7" @@ -1629,14 +1816,91 @@ version = "0.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e2931af7e13dc045d8e9d26afccc6fa115d64e115c9c84b1166288b46f6782c2" +[[package]] +name = "ctr" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835" +dependencies = [ + "cipher", +] + +[[package]] +name = "curve25519-dalek" +version = "4.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "curve25519-dalek-derive", + "digest 0.10.7", + "fiat-crypto", + "rustc_version", + "subtle", + "zeroize", +] + +[[package]] +name = "curve25519-dalek-derive" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "cx448" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4c0cf476284b03eb6c10e78787b21c7abb7d7d43cb2f02532ba6b831ed892fa" +dependencies = [ + "crypto-bigint", + "elliptic-curve", + "pkcs8", + "rand_core 0.6.4", + "serdect 0.3.0", + "sha3", + "signature", + "subtle", + "zeroize", +] + +[[package]] +name = "darling" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee" +dependencies = [ + "darling_core 0.20.11", + "darling_macro 0.20.11", +] + [[package]] name = "darling" version = "0.24.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "88490bf1b990d87eaaa7ac8aa887f629a08e7359765b4911faf63c3763347d23" dependencies = [ - "darling_core", - "darling_macro", + "darling_core 0.24.0", + "darling_macro 0.24.0", +] + +[[package]] +name = "darling_core" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d00b9596d185e565c2207a0b01f8bd1a135483d02d9b7b0a54b11da8d53412e" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 2.0.119", ] [[package]] @@ -1652,13 +1916,24 @@ dependencies = [ "syn 3.0.3", ] +[[package]] +name = "darling_macro" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" +dependencies = [ + "darling_core 0.20.11", + "quote", + "syn 2.0.119", +] + [[package]] name = "darling_macro" version = "0.24.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "68f5792fa0d41cd2325ce0ffa64f0a340eaebd4971a3a0c5e1ffd2cc488a355e" dependencies = [ - "darling_core", + "darling_core 0.24.0", "quote", "syn 3.0.3", ] @@ -1669,6 +1944,15 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "be1e0bca6c3637f992fc1cc7cbc52a78c1ef6db076dbf1059c4323d6a2048376" +[[package]] +name = "dbl" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd2735a791158376708f9347fe8faba9667589d82427ef3aed6794a8981de3d9" +dependencies = [ + "generic-array", +] + [[package]] name = "deflate64" version = "0.1.12" @@ -1681,6 +1965,17 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5729f5117e208430e437df2f4843f5e5952997175992d1414f94c57d61e270b4" +[[package]] +name = "der" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" +dependencies = [ + "const-oid 0.9.6", + "pem-rfc7468", + "zeroize", +] + [[package]] name = "deranged" version = "0.5.8" @@ -1698,6 +1993,37 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "derive_builder" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "507dfb09ea8b7fa618fcf76e953f4f5e192547945816d5358edffe39f6f94947" +dependencies = [ + "derive_builder_macro", +] + +[[package]] +name = "derive_builder_core" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d5bcf7b024d6835cfb3d473887cd966994907effbe9227e8c8219824d06c4e8" +dependencies = [ + "darling 0.20.11", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "derive_builder_macro" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab63b0e2bf4d5928aff72e83a7dace85d7bba5fe12dcc3c5a572d78caffd3f3c" +dependencies = [ + "derive_builder_core", + "syn 2.0.119", +] + [[package]] name = "derive_more" version = "0.99.20" @@ -1731,6 +2057,16 @@ dependencies = [ "quote", "rustc_version", "syn 2.0.119", + "unicode-xid", +] + +[[package]] +name = "des" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ffdd80ce8ce993de27e9f063a444a4d53ce8e8db4c1f00cc03af5ad5a9867a1e" +dependencies = [ + "cipher", ] [[package]] @@ -1740,6 +2076,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ "block-buffer 0.10.4", + "const-oid 0.9.6", "crypto-common 0.1.7", "subtle", ] @@ -1751,7 +2088,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" dependencies = [ "block-buffer 0.12.1", - "const-oid", + "const-oid 0.10.2", "crypto-common 0.2.2", ] @@ -1849,6 +2186,22 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2" +[[package]] +name = "dsa" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48bc224a9084ad760195584ce5abb3c2c34a225fa312a128ad245a6b412b7689" +dependencies = [ + "digest 0.10.7", + "num-bigint-dig", + "num-traits", + "pkcs8", + "rfc6979", + "sha2 0.10.9", + "signature", + "zeroize", +] + [[package]] name = "dtoa" version = "1.0.11" @@ -1903,6 +2256,58 @@ version = "1.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" +[[package]] +name = "eax" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9954fabd903b82b9d7a68f65f97dc96dd9ad368e40ccc907a7c19d53e6bfac28" +dependencies = [ + "aead", + "cipher", + "cmac", + "ctr", + "subtle", +] + +[[package]] +name = "ecdsa" +version = "0.16.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca" +dependencies = [ + "der", + "digest 0.10.7", + "elliptic-curve", + "rfc6979", + "signature", + "spki", +] + +[[package]] +name = "ed25519" +version = "2.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53" +dependencies = [ + "pkcs8", + "signature", +] + +[[package]] +name = "ed25519-dalek" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9" +dependencies = [ + "curve25519-dalek", + "ed25519", + "rand_core 0.6.4", + "serde", + "sha2 0.10.9", + "subtle", + "zeroize", +] + [[package]] name = "ego-tree" version = "0.10.0" @@ -1915,6 +2320,31 @@ version = "1.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9e5e8f6c15a24b9a3ee5efec809ccd006d3b30e8b3bb63c39af737c7f87daa1d" +[[package]] +name = "elliptic-curve" +version = "0.13.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47" +dependencies = [ + "base16ct", + "base64ct", + "crypto-bigint", + "digest 0.10.7", + "ff", + "generic-array", + "group", + "hkdf", + "pem-rfc7468", + "pkcs8", + "rand_core 0.6.4", + "sec1", + "serde_json", + "serdect 0.2.0", + "subtle", + "tap", + "zeroize", +] + [[package]] name = "embed-resource" version = "3.0.11" @@ -2134,6 +2564,23 @@ dependencies = [ "simd-adler32", ] +[[package]] +name = "ff" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0b50bfb653653f9ca9095b427bed08ab8d75a137839d9ad64eb11810d5b6393" +dependencies = [ + "bitvec", + "rand_core 0.6.4", + "subtle", +] + +[[package]] +name = "fiat-crypto" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" + [[package]] name = "filedescriptor" version = "0.8.3" @@ -2502,6 +2949,7 @@ checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" dependencies = [ "typenum", "version_check", + "zeroize", ] [[package]] @@ -2562,6 +3010,16 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "ghash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0d8a4362ccb29cb0b265253fb0a2728f592895ee6854fd9bc13f2ffda266ff1" +dependencies = [ + "opaque-debug", + "polyval", +] + [[package]] name = "gif" version = "0.14.2" @@ -2972,6 +3430,17 @@ version = "0.18.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "12101ecc8225ea6d675bc70263074eab6169079621c2186fe0c66590b2df9681" +[[package]] +name = "group" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63" +dependencies = [ + "ff", + "rand_core 0.6.4", + "subtle", +] + [[package]] name = "h2" version = "0.4.16" @@ -3359,6 +3828,15 @@ dependencies = [ "thiserror 2.0.20", ] +[[package]] +name = "idea" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "075557004419d7f2031b8bb7f44bb43e55a83ca7b63076a8fb8fe75753836477" +dependencies = [ + "cipher", +] + [[package]] name = "ident_case" version = "1.0.1" @@ -3505,7 +3983,7 @@ version = "0.3.13" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2bf84e73fa6f27f299dec58e13223cf70db80da872eb921d4f6138342a0eabc8" dependencies = [ - "darling", + "darling 0.24.0", "indoc", "proc-macro2", "quote", @@ -3680,11 +4158,25 @@ dependencies = [ name = "js-sys" version = "0.3.104" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0e0c1080212aad755ea003d18543e8768dd432c48819efd73a7bf1e39b7a5a3a" +checksum = "0e0c1080212aad755ea003d18543e8768dd432c48819efd73a7bf1e39b7a5a3a" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "k256" +version = "0.13.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6e3919bbaa2945715f0bb6d3934a173d1e9a59ac23767fbaaef277265a7411b" dependencies = [ "cfg-if", - "futures-util", - "wasm-bindgen", + "ecdsa", + "elliptic-curve", + "once_cell", + "sha2 0.10.9", + "signature", ] [[package]] @@ -3698,6 +4190,15 @@ dependencies = [ "thiserror 2.0.20", ] +[[package]] +name = "keccak" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb26cec98cce3a3d96cbb7bced3c4b16e3d13f27ec56dbd62cbc8f39cfb9d653" +dependencies = [ + "cpufeatures 0.2.17", +] + [[package]] name = "khronos-egl" version = "6.0.0" @@ -4152,6 +4653,12 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" +[[package]] +name = "minisign-verify" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "871285dc19d8d0ebe0eef3d0e99a205f2a71363b122632cbbfa4a6c370a960ce" + [[package]] name = "miniz_oxide" version = "0.8.9" @@ -4459,6 +4966,27 @@ dependencies = [ "libc", ] +[[package]] +name = "num_enum" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0bca838442ec211fa11de3a8b0e0e8f3a4522575b5c4c06ed722e005036f26" +dependencies = [ + "num_enum_derive", + "rustversion", +] + +[[package]] +name = "num_enum_derive" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "680998035259dcfcafe653688bf2aa6d3e2dc05e98be6ab46afb089dc84f1df8" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "num_threads" version = "0.1.7" @@ -4605,6 +5133,18 @@ dependencies = [ "memchr", ] +[[package]] +name = "ocb3" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c196e0276c471c843dd5777e7543a36a298a4be942a2a688d8111cd43390dedb" +dependencies = [ + "aead", + "cipher", + "ctr", + "subtle", +] + [[package]] name = "once_cell" version = "1.21.4" @@ -4652,6 +5192,12 @@ dependencies = [ "zvariant", ] +[[package]] +name = "opaque-debug" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" + [[package]] name = "open" version = "5.4.1" @@ -4708,6 +5254,44 @@ version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1a80800c0488c3a21695ea981a54918fbb37abf04f4d0720c453632255e2ff0e" +[[package]] +name = "p256" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9863ad85fa8f4460f9c48cb909d38a0d689dba1f6f6988a5e3e0d31071bcd4b" +dependencies = [ + "ecdsa", + "elliptic-curve", + "primeorder", + "sha2 0.10.9", +] + +[[package]] +name = "p384" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe42f1670a52a47d448f14b6a5c61dd78fce51856e68edaa38f7ae3a46b8d6b6" +dependencies = [ + "ecdsa", + "elliptic-curve", + "primeorder", + "sha2 0.10.9", +] + +[[package]] +name = "p521" +version = "0.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fc9e2161f1f215afdfce23677034ae137bbd45016a880c2eb3ba8eb95f085b2" +dependencies = [ + "base16ct", + "ecdsa", + "elliptic-curve", + "primeorder", + "rand_core 0.6.4", + "sha2 0.10.9", +] + [[package]] name = "palette" version = "0.7.7" @@ -4771,6 +5355,17 @@ dependencies = [ "windows-link 0.2.1", ] +[[package]] +name = "password-hash" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166" +dependencies = [ + "base64ct", + "rand_core 0.6.4", + "subtle", +] + [[package]] name = "paste" version = "1.0.15" @@ -4812,6 +5407,15 @@ dependencies = [ "hmac", ] +[[package]] +name = "pem-rfc7468" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412" +dependencies = [ + "base64ct", +] + [[package]] name = "percent-encoding" version = "2.3.2" @@ -4860,6 +5464,75 @@ dependencies = [ "pest", ] +[[package]] +name = "pgp" +version = "0.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae70f4d9325a391db30d115d6191a7bb67cec856aa0bba131290f0c4e09a532a" +dependencies = [ + "aead", + "aes", + "aes-gcm", + "aes-kw", + "argon2", + "base64", + "bitfields", + "block-padding", + "blowfish", + "buffer-redux", + "byteorder", + "bytes", + "camellia", + "cast5", + "cfb-mode", + "cipher", + "const-oid 0.9.6", + "crc24", + "curve25519-dalek", + "cx448", + "derive_builder", + "derive_more 2.1.1", + "des", + "digest 0.10.7", + "dsa", + "eax", + "ecdsa", + "ed25519-dalek", + "elliptic-curve", + "flate2", + "generic-array", + "hex", + "hkdf", + "idea", + "k256", + "log", + "md-5", + "memchr", + "nom 8.0.0", + "num-bigint-dig", + "num-traits", + "num_enum", + "ocb3", + "p256", + "p384", + "p521", + "rand 0.8.7", + "replace_with", + "ripemd", + "rsa", + "sha1", + "sha1-checked", + "sha2 0.10.9", + "sha3", + "signature", + "smallvec", + "snafu", + "subtle", + "twofish", + "x25519-dalek", + "zeroize", +] + [[package]] name = "phf" version = "0.11.3" @@ -4961,6 +5634,27 @@ dependencies = [ "futures-io", ] +[[package]] +name = "pkcs1" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8ffb9f10fa047879315e6625af03c164b16962a5368d724ed16323b68ace47f" +dependencies = [ + "der", + "pkcs8", + "spki", +] + +[[package]] +name = "pkcs8" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" +dependencies = [ + "der", + "spki", +] + [[package]] name = "pkg-config" version = "0.3.34" @@ -5019,6 +5713,18 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2f3a9f18d041e6d0e102a0a46750538147e5e8992d3b4873aaafee2520b00ce3" +[[package]] +name = "polyval" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "opaque-debug", + "universal-hash", +] + [[package]] name = "portable-atomic" version = "1.15.0" @@ -5082,6 +5788,15 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "primeorder" +version = "0.13.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "353e1ca18966c16d9deb1c69278edbc5f194139612772bd9537af60ac231e1e6" +dependencies = [ + "elliptic-curve", +] + [[package]] name = "proc-macro-crate" version = "3.5.0" @@ -5715,6 +6430,12 @@ version = "0.8.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" +[[package]] +name = "replace_with" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51743d3e274e2b18df81c4dc6caf8a5b8e15dbe799e0dca05c7617380094e884" + [[package]] name = "reqwest" version = "0.12.28" @@ -5769,6 +6490,16 @@ dependencies = [ "usvg", ] +[[package]] +name = "rfc6979" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dd2a808d456c4a54e300a23e9f5a67e122c3024119acbfd73e3bf664491cb2" +dependencies = [ + "hmac", + "subtle", +] + [[package]] name = "rfd" version = "0.15.4" @@ -5816,6 +6547,15 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "ripemd" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd124222d17ad93a644ed9d011a40f4fb64aa54275c08cc216524a9ea82fb09f" +dependencies = [ + "digest 0.10.7", +] + [[package]] name = "ropey" version = "2.0.0-beta.1" @@ -5831,6 +6571,26 @@ version = "0.20.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6c20b6793b5c2fa6553b250154b78d6d0db37e72700ae35fad9387a46f487c97" +[[package]] +name = "rsa" +version = "0.9.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8573f03f5883dcaebdfcf4725caa1ecb9c15b2ef50c43a07b816e06799bb12d" +dependencies = [ + "const-oid 0.9.6", + "digest 0.10.7", + "num-bigint-dig", + "num-integer", + "num-traits", + "pkcs1", + "pkcs8", + "rand_core 0.6.4", + "signature", + "spki", + "subtle", + "zeroize", +] + [[package]] name = "rust-embed" version = "8.12.0" @@ -6180,6 +6940,21 @@ version = "4.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1c107b6f4780854c8b126e228ea8869f4d7b71260f962fefb57b996b8959ba6b" +[[package]] +name = "sec1" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc" +dependencies = [ + "base16ct", + "der", + "generic-array", + "pkcs8", + "serdect 0.2.0", + "subtle", + "zeroize", +] + [[package]] name = "security-framework" version = "3.7.0" @@ -6365,6 +7140,26 @@ dependencies = [ "unsafe-libyaml", ] +[[package]] +name = "serdect" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a84f14a19e9a014bb9f4512488d9829a68e04ecabffb0f9904cd1ace94598177" +dependencies = [ + "base16ct", + "serde", +] + +[[package]] +name = "serdect" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f42f67da2385b51a5f9652db9c93d78aeaf7610bf5ec366080b6de810604af53" +dependencies = [ + "base16ct", + "serde", +] + [[package]] name = "servo_arc" version = "0.4.3" @@ -6385,6 +7180,17 @@ dependencies = [ "digest 0.10.7", ] +[[package]] +name = "sha1-checked" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89f599ac0c323ebb1c6082821a54962b839832b03984598375bff3975b804423" +dependencies = [ + "digest 0.10.7", + "sha1", + "zeroize", +] + [[package]] name = "sha1_smol" version = "1.0.1" @@ -6413,6 +7219,16 @@ dependencies = [ "digest 0.11.3", ] +[[package]] +name = "sha3" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77fd7028345d415a4034cf8777cd4f8ab1851274233b45f84e3d955502d93874" +dependencies = [ + "digest 0.10.7", + "keccak", +] + [[package]] name = "shlex" version = "1.3.0" @@ -6456,6 +7272,16 @@ dependencies = [ "libc", ] +[[package]] +name = "signature" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +dependencies = [ + "digest 0.10.7", + "rand_core 0.6.4", +] + [[package]] name = "simd-adler32" version = "0.3.10" @@ -6556,6 +7382,27 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dd538fb6910ac1099850255cf94a94df6551fbdd602454387d0adb2d1ca6dead" +[[package]] +name = "snafu" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e45cb604038abb7b926b679887b3226d8d0f23874b66623625a0454be425a4b7" +dependencies = [ + "snafu-derive", +] + +[[package]] +name = "snafu-derive" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "287f59010008f0d7cf5e3b03196d666c1acc46c8d3e9cf34c28a1a7157601e72" +dependencies = [ + "heck 0.5.0", + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "socket2" version = "0.6.5" @@ -6584,6 +7431,16 @@ dependencies = [ "bitflags 2.13.1", ] +[[package]] +name = "spki" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +dependencies = [ + "base64ct", + "der", +] + [[package]] name = "stable_deref_trait" version = "1.2.1" @@ -7521,6 +8378,15 @@ dependencies = [ "core_maths", ] +[[package]] +name = "twofish" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a78e83a30223c757c3947cd144a31014ff04298d8719ae10d03c31c0448c8013" +dependencies = [ + "cipher", +] + [[package]] name = "typeid" version = "1.0.3" @@ -7645,6 +8511,22 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "universal-hash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea" +dependencies = [ + "crypto-common 0.1.7", + "subtle", +] + [[package]] name = "unsafe-libyaml" version = "0.2.11" @@ -8857,6 +9739,18 @@ version = "0.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd" +[[package]] +name = "x25519-dalek" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7e468321c81fb07fa7f4c636c3972b9100f0346e5b6a9f2bd0603a52f7ed277" +dependencies = [ + "curve25519-dalek", + "rand_core 0.6.4", + "serde", + "zeroize", +] + [[package]] name = "xattr" version = "0.2.3" diff --git a/Cargo.toml b/Cargo.toml index c045744..e023c53 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -20,8 +20,11 @@ rust-version = "1.88" [workspace.dependencies] anyhow = "1.0" +blake2 = "0.10" clap = { version = "4.5", features = ["derive"] } +clap_complete = "4.5" crossterm = "0.29" +ed25519-dalek = "2" gpui = "0.2.2" gpui-component = "0.5.1" gpui_http_client = "0.2.2" @@ -29,6 +32,9 @@ image = { version = "0.25.10", default-features = false, features = ["gif", "jpe futures = "0.3" libc = "0.2" md-5 = "0.10" +minisign-verify = "0.3" +pgp = { version = "0.21", default-features = false } +rand = "0.8" ratatui = "0.30" ratatui-image = { version = "11.0.6", default-features = false, features = ["crossterm", "image-defaults"] } reqwest = { version = "0.12", default-features = false, features = ["blocking", "rustls-tls"] } diff --git a/README.md b/README.md index 95ef83a..d5a4e33 100644 --- a/README.md +++ b/README.md @@ -3,9 +3,9 @@

Create and verify bootable USB and SD drives.

- Linux AppImage · - Windows installer · - macOS DMG · + Linux AppImage · + Windows installer · + macOS DMG · All downloads and checksums

diff --git a/apps/bootable-desktop/src/main.rs b/apps/bootable-desktop/src/main.rs index 8fd04fb..eb7cb40 100644 --- a/apps/bootable-desktop/src/main.rs +++ b/apps/bootable-desktop/src/main.rs @@ -4,14 +4,15 @@ use std::sync::Arc; use std::time::Duration; use bootable_core::{ - BadBlockCheck, Bootable, CacheMode, CatalogFacet, CatalogState, ChecksumAlgorithm, Device, - DiscoverySession, DiscoverySource, DistributionBundle, DistributionDetails, - DistributionSummary, DownloadCompletion, DownloadLaunch, DownloadRequest, DownloadStatus, - ImageKind, ImageReport, IsoRelease, ManagedDownloadSession, OperationState, PiCatalog, PiImage, - Progress, QuickAccess, ReviewReadiness, ReviewedWriteSession, WindowsPartitionScheme, - WorkspaceStepState, WriteCompletion, WriteOptions, catalog_search_summary, - distribution_matches_query, format_bytes, removable_media_status, review_readiness, - target_eligibility_label, workspace_progress, + Bootable, CacheMode, CatalogFacet, CatalogState, ChecksumAlgorithm, Device, DiscoverySession, + DiscoverySource, DistributionBundle, DistributionDetails, DistributionSummary, + DownloadCompletion, DownloadLaunch, DownloadRequest, DownloadStatus, ImageKind, ImageReport, + IsoRelease, Locale, ManagedDownloadSession, Message, OperationState, PiCatalog, PiImage, + Preferences, Progress, ProgressPhase, QuickAccess, ReviewReadiness, ReviewedWriteSession, + Strings, WindowsBootFirmware, WindowsPartitionScheme, WorkspaceProgress, WorkspaceStepState, + WriteCompletion, WriteOptions, catalog_search_summary, device_details_in, + distribution_matches_query, format_bytes, help_intro, help_sections, removable_media_status_in, + review_readiness, target_eligibility_label_in, workspace_progress, }; use futures::{ AsyncReadExt, FutureExt, StreamExt, @@ -25,10 +26,23 @@ use gpui_component::{ checkbox::Checkbox, input::{Input, InputEvent, InputState}, scroll::ScrollableElement, - select::{Select, SelectEvent, SelectState}, + select::{Select, SelectEvent, SelectItem, SelectState}, }; use gpui_http_client::{AsyncBody, HttpClient, Url, http}; +actions!( + bootable, + [ + OpenImage, + ToggleDiscover, + RefreshDrives, + ReviewPlan, + ToggleHelp, + CloseOverlay + ] +); + +const KEY_CONTEXT: &str = "BootableWorkspace"; const DEVICE_SCAN_INTERVAL: Duration = Duration::from_secs(1); const DOWNLOAD_SCAN_INTERVAL: Duration = Duration::from_secs(5); const BRAND_MARK_SVG: &[u8] = include_bytes!("../../../assets/bootable-mark.svg"); @@ -238,14 +252,29 @@ struct BootableView { selected_pi_image: Option, catalog_search: Entity, windows_partition_scheme: Entity>>, + windows_boot_firmware: Entity>>, + /// Set when the firmware option was reset without a window at hand (a new + /// image arrived from a background task); render re-syncs the select. + firmware_select_stale: bool, + language_select: Entity>>, + locale: Locale, catalog_visible: usize, pi_visible: usize, _catalog_search_subscription: Subscription, _windows_partition_subscription: Subscription, + _windows_firmware_subscription: Subscription, + _language_subscription: Subscription, download_session: ManagedDownloadSession, downloads_open: bool, write_session: ReviewedWriteSession, + preferences: Preferences, + help_open: bool, + focus_handle: FocusHandle, status: String, + /// Set when core's final `Finished`-phase progress message has been shown + /// for the running download; the completion handler then keeps it instead + /// of writing a generic ready line. Replaces matching the English text. + download_final_shown: bool, } #[derive(Clone, Copy)] @@ -294,12 +323,62 @@ enum WriteUpdate { Finished(WriteCompletion), } +/// Applies a firmware choice; BIOS + UEFI forces MBR. Returns whether the +/// partition scheme changed. Core remains the final validator. +fn choose_boot_firmware(options: &mut WriteOptions, firmware: WindowsBootFirmware) -> bool { + options.windows_boot_firmware = firmware; + if firmware.includes_legacy_bios() + && options.windows_partition_scheme != WindowsPartitionScheme::Mbr + { + options.windows_partition_scheme = WindowsPartitionScheme::Mbr; + return true; + } + false +} + +/// Applies a scheme choice; GPT forces UEFI-only. Returns whether the firmware +/// changed. +fn choose_partition_scheme(options: &mut WriteOptions, scheme: WindowsPartitionScheme) -> bool { + options.windows_partition_scheme = scheme; + if scheme == WindowsPartitionScheme::Gpt + && options.windows_boot_firmware != WindowsBootFirmware::Uefi + { + options.windows_boot_firmware = WindowsBootFirmware::Uefi; + return true; + } + false +} + impl BootableView { + /// The catalog bound to the active language. Cheap (`Copy`); take it at the + /// top of every render function and thread it into helpers. + fn t(&self) -> Strings { + self.locale.strings() + } + + /// The status line after a Windows option is toggled: its own on/off text. + fn toggle_status(&self, checked: bool, on: Message, off: Message) -> String { + self.t().text(if checked { on } else { off }).into() + } + + /// The setup-options toggle caption (`compact` for the narrow header). + fn advanced_label(&self, compact: bool) -> &'static str { + let t = self.t(); + t.text(match (self.advanced, compact) { + (true, false) => Message::ActionHideOptions, + (false, false) => Message::ActionSetupOptions, + (true, true) => Message::ActionHideOptionsCompact, + (false, true) => Message::ActionSetupOptionsCompact, + }) + } + fn new(window: &mut Window, cx: &mut Context) -> Self { let engine = Bootable::native(); + let preferences = Preferences::load(); + let locale = preferences.locale(); let catalog_search = cx.new(|cx| { InputState::new(window, cx) - .placeholder("Search by name, slug, or base family…") + .placeholder(locale.strings().text(Message::DiscoverSearchPlaceholder)) .clean_on_escape() }); let search_subscription = cx.subscribe(&catalog_search, |view, input, event, cx| { @@ -318,26 +397,85 @@ impl BootableView { cx, ) }); - let windows_partition_subscription = - cx.subscribe(&windows_partition_scheme, |view, _, event, cx| { + let windows_boot_firmware = cx.new(|cx| { + SelectState::new( + WindowsBootFirmware::ALL + .iter() + .map(ToString::to_string) + .collect::>(), + Some(gpui_component::IndexPath::default().row(0)), + window, + cx, + ) + }); + let windows_partition_subscription = cx.subscribe_in( + &windows_partition_scheme, + window, + |view, _, event, window, cx| { if let SelectEvent::Confirm(Some(value)) = event { - view.options.windows_partition_scheme = if value.starts_with("MBR") { + let scheme = if value.starts_with("MBR") { WindowsPartitionScheme::Mbr } else { WindowsPartitionScheme::Gpt }; - view.status = format!( - "Windows partition scheme: {} · target firmware: UEFI", - view.options.windows_partition_scheme + if choose_partition_scheme(&mut view.options, scheme) { + view.sync_firmware_select(window, cx); + } + view.status = view.t().format( + Message::StatusWindowsScheme, + &[ + ("scheme", &view.options.windows_partition_scheme), + ("firmware", &view.options.windows_boot_firmware), + ], + ); + cx.notify(); + } + }, + ); + let windows_firmware_subscription = cx.subscribe_in( + &windows_boot_firmware, + window, + |view, _, event, window, cx| { + if let SelectEvent::Confirm(Some(value)) = event { + let Some(firmware) = WindowsBootFirmware::ALL + .into_iter() + .find(|firmware| firmware.to_string() == *value) + else { + return; + }; + if choose_boot_firmware(&mut view.options, firmware) { + view.sync_partition_select(window, cx); + } + view.status = view.t().format( + Message::StatusWindowsFirmware, + &[ + ("scheme", &view.options.windows_partition_scheme), + ("firmware", &view.options.windows_boot_firmware), + ], ); cx.notify(); } + }, + ); + let language_select = cx.new(|cx| { + SelectState::new( + language_choices(locale), + language_index(preferences.language), + window, + cx, + ) + }); + let language_subscription = + cx.subscribe_in(&language_select, window, |view, _, event, window, cx| { + if let SelectEvent::Confirm(Some(language)) = event { + view.set_language(*language, window, cx); + } }); let (devices, status) = match engine.discover_devices() { Ok(devices) => { - let status = format!( - "{} · choose an image to begin", - removable_media_status(&devices) + let status = locale.strings().format( + Message::StatusStartup, + &[("media", &removable_media_status_in(locale, &devices))], ); (devices, status) } @@ -345,16 +483,18 @@ impl BootableView { }; Self::schedule_device_scan(cx); Self::schedule_download_scan(cx); + let focus_handle = cx.focus_handle(); + window.focus(&focus_handle); let view = Self { engine, image: None, image_loading: false, devices, selected_device: None, - browse_directory: None, + browse_directory: preferences.image_directory(), options: WriteOptions::default(), advanced: false, - checksum_algorithm: ChecksumAlgorithm::Sha256, + checksum_algorithm: preferences.checksum_algorithm, catalog_open: false, discovery_session: DiscoverySession::default(), distributions: Vec::new(), @@ -371,14 +511,24 @@ impl BootableView { selected_pi_image: None, catalog_search, windows_partition_scheme, + windows_boot_firmware, + firmware_select_stale: false, + language_select, + locale, catalog_visible: 20, pi_visible: 20, _catalog_search_subscription: search_subscription, _windows_partition_subscription: windows_partition_subscription, + _windows_firmware_subscription: windows_firmware_subscription, + _language_subscription: language_subscription, download_session: ManagedDownloadSession::default(), downloads_open: false, write_session: ReviewedWriteSession::default(), + preferences, + help_open: false, + focus_handle, status, + download_final_shown: false, }; Self::schedule_initial_catalog_load(cx); view @@ -401,7 +551,13 @@ impl BootableView { fn toggle_catalog(&mut self, cx: &mut Context) { self.catalog_open = !self.catalog_open; if !self.catalog_open { - self.status = format!("Catalog closed • {}", self.review_readiness().guidance()); + self.status = self.t().format( + Message::StatusCatalogClosed, + &[( + "guidance", + &self.review_readiness().guidance_in(self.locale), + )], + ); cx.notify(); return; } @@ -409,8 +565,7 @@ impl BootableView { if self.distributions.is_empty() { self.load_catalog(cx); } else { - self.status = - "DistroWatch catalog ready • rankings indicate interest, not quality".into(); + self.status = self.t().text(Message::StatusCatalogReady).into(); cx.notify(); } } @@ -423,7 +578,10 @@ impl BootableView { if !self.discovery_session.begin(CatalogFacet::Popular) { return; } - self.status = "Loading DistroWatch six-month popularity…".into(); + self.status = self + .t() + .text(Message::StatusCatalogLoadingPopularity) + .into(); cx.notify(); let task = cx .background_executor() @@ -439,7 +597,7 @@ impl BootableView { &fetch, fetch.value.is_empty(), ); - let source = fetch.status_suffix(); + let source = fetch.status_suffix_in(view.locale); let distributions = fetch.value; let count = distributions.len(); view.popular_distributions = distributions.clone(); @@ -449,7 +607,11 @@ impl BootableView { if showing_popular { view.distributions = distributions; } - view.status = format!("{count} distributions · {source}"); + view.status = view.t().plural( + Message::StatusCatalogDistributionsLoaded, + count as u64, + &[("source", &source)], + ); if count > 0 && showing_popular { view.select_distribution(0, cx); } @@ -460,7 +622,10 @@ impl BootableView { view.status = view .discovery_session .state(CatalogFacet::Popular) - .short_label("distributions"); + .short_label_in( + view.locale, + view.t().text(Message::CatalogSubjectDistributions), + ); } } cx.notify(); @@ -480,7 +645,7 @@ impl BootableView { self.catalog_visible = 20; if query.trim().is_empty() { self.distributions = self.popular_distributions.clone(); - self.status = "Showing DistroWatch six-month popularity".into(); + self.status = self.t().text(Message::StatusCatalogPopularity).into(); cx.notify(); return; } @@ -503,7 +668,10 @@ impl BootableView { if !self.discovery_session.begin(CatalogFacet::Directory) { return; } - self.status = "Searching DistroWatch's full distribution directory…".into(); + self.status = self + .t() + .text(Message::StatusCatalogSearchingDirectory) + .into(); cx.notify(); let task = cx.background_executor().spawn(async move { Bootable::native().distribution_directory_cached(CacheMode::PreferCache) @@ -545,7 +713,10 @@ impl BootableView { view.status = view .discovery_session .state(CatalogFacet::Directory) - .short_label("search catalog"); + .short_label_in( + view.locale, + view.t().text(Message::CatalogSubjectSearchCatalog), + ); } } } @@ -566,11 +737,11 @@ impl BootableView { if (self.pi_catalog.is_some() && mode == CacheMode::PreferCache) || !self.discovery_session.begin(CatalogFacet::RaspberryPi) { - self.status = "Raspberry Pi image discovery selected".into(); + self.status = self.t().text(Message::StatusCatalogPiSelected).into(); cx.notify(); return; } - self.status = "Loading the official Raspberry Pi Imager catalog…".into(); + self.status = self.t().text(Message::StatusCatalogPiLoading).into(); cx.notify(); let task = cx .background_executor() @@ -586,14 +757,18 @@ impl BootableView { &fetch, fetch.value.images.is_empty(), ); - let source = fetch.status_suffix(); + let source = fetch.status_suffix_in(view.locale); let catalog = fetch.value; let count = catalog.images.len(); view.pi_catalog = Some(catalog); view.selected_pi_device = None; view.selected_pi_image = (count > 0).then_some(0); if view.discovery_session.source() == DiscoverySource::RaspberryPi { - view.status = format!("{count} Raspberry Pi images · {source}"); + view.status = view.t().plural( + Message::StatusCatalogPiImagesLoaded, + count as u64, + &[("source", &source)], + ); } } Err(error) => { @@ -603,7 +778,10 @@ impl BootableView { view.status = view .discovery_session .state(CatalogFacet::RaspberryPi) - .short_label("Raspberry Pi images"); + .short_label_in( + view.locale, + view.t().text(Message::CatalogSubjectPiImages), + ); } } } @@ -632,7 +810,7 @@ impl BootableView { match preset { QuickAccess::All => { self.distributions = self.popular_distributions.clone(); - self.status = "Showing DistroWatch six-month popularity".into(); + self.status = self.t().text(Message::StatusCatalogPopularity).into(); } QuickAccess::Arch | QuickAccess::Debian => { let cached = if preset == QuickAccess::Arch { @@ -645,14 +823,17 @@ impl BootableView { return; } self.distributions = cached.clone(); - self.status = format!( - "Showing {} active {}-based distributions from DistroWatch", - cached.len(), - if preset == QuickAccess::Arch { - "Arch" - } else { - "Debian" - } + self.status = self.locale.strings().plural( + Message::StatusCatalogShowingBase, + cached.len() as u64, + &[( + "base", + &if preset == QuickAccess::Arch { + "Arch" + } else { + "Debian" + }, + )], ); } QuickAccess::Omarchy => { @@ -672,12 +853,11 @@ impl BootableView { logo_url: "https://distrowatch.com/images/icon-large/omarchy.png".into(), }); self.distributions = vec![omarchy]; - self.status = "Omarchy family · ISO releases are writable; installer-only derivatives are clearly marked".into(); + self.status = self.t().text(Message::StatusCatalogOmarchy).into(); } QuickAccess::Windows => { self.distributions.clear(); - self.status = - "Windows media tools · choose a Windows ISO to unlock setup options".into(); + self.status = self.t().text(Message::StatusCatalogWindowsTools).into(); } } cx.notify(); @@ -706,7 +886,9 @@ impl BootableView { } else { "Debian" }; - self.status = format!("Searching DistroWatch for active {base}-based distributions…"); + self.status = self + .t() + .format(Message::StatusCatalogLoadingBase, &[("base", &base)]); cx.notify(); let task = cx .background_executor() @@ -719,7 +901,7 @@ impl BootableView { Ok(fetch) => { view.discovery_session .complete(facet, &fetch, fetch.value.is_empty()); - let source = fetch.status_suffix(); + let source = fetch.status_suffix_in(view.locale); let distributions = fetch.value; let count = distributions.len(); if preset == QuickAccess::Arch { @@ -729,17 +911,24 @@ impl BootableView { } if view.discovery_session.quick_access() == preset { view.distributions = distributions; - view.status = - format!("{count} active {base}-based distributions · {source}"); + view.status = view.t().plural( + Message::StatusCatalogBaseLoaded, + count as u64, + &[("base", &base), ("source", &source)], + ); } } Err(error) => { view.discovery_session.fail(facet, error.to_string()); if view.discovery_session.quick_access() == preset { + let subject = view.t().format( + Message::CatalogSubjectBaseDistributions, + &[("base", &base)], + ); view.status = view .discovery_session .state(facet) - .short_label(&format!("{base}-based distributions")); + .short_label_in(view.locale, &subject); } } } @@ -754,9 +943,13 @@ impl BootableView { fn select_pi_device(&mut self, index: Option, cx: &mut Context) { self.selected_pi_device = index; self.selected_pi_image = self.visible_pi_images().first().map(|(index, _)| *index); + let t = self.t(); self.status = match index.and_then(|index| self.pi_catalog.as_ref()?.devices.get(index)) { - Some(device) => format!("Showing images compatible with {}", device.name), - None => "Showing every Raspberry Pi image".into(), + Some(device) => t.format( + Message::StatusCatalogPiCompatible, + &[("board", &device.name)], + ), + None => t.text(Message::StatusCatalogPiAll).into(), }; cx.notify(); } @@ -823,30 +1016,45 @@ impl BootableView { view.update(cx, |view, cx| { match update { DownloadUpdate::Progress(progress) => { + // Core text (stage names, integrity result): English for now. + view.download_final_shown = progress.phase == ProgressPhase::Finished; view.status = progress.message.clone(); view.download_session.apply_progress(progress); } DownloadUpdate::Finished(completion) => { match &completion { - DownloadCompletion::Ready { report, destination } => { - view.browse_directory = destination - .parent() - .map(std::path::PathBuf::from); - view.status = format!( - "Ready · downloaded, verified, and inspected {} · discovery remains open", - report.path.display() - ); + DownloadCompletion::Ready { + report, + destination, + } => { + view.browse_directory = + destination.parent().map(std::path::PathBuf::from); + // Core's final progress message already names the integrity result + // (for example a verified signature); keep it instead of a generic line. + if !view.download_final_shown { + view.status = view.t().format( + Message::StatusDownloadReady, + &[("name", &report.path.display())], + ); + } view.image = Some(report.clone()); view.advanced = false; + view.reset_boot_firmware(); } DownloadCompletion::Cancelled => { - view.status = - "Download cancelled • temporary data cleaned up".into(); + view.status = view + .t() + .text(Message::StatusDownloadCancelledCleaned) + .into(); } DownloadCompletion::Failed(error) => { - view.status = format!("Download stopped · {error}"); + view.status = view.t().format( + Message::StatusDownloadStopped, + &[("error", error)], + ); } } + view.download_final_shown = false; view.download_session.finish(completion); view.refresh_download_jobs(cx); view.start_next_queued_download(cx); @@ -863,7 +1071,12 @@ impl BootableView { fn refresh_download_jobs(&mut self, cx: &mut Context) { match self.download_session.refresh(&self.engine) { Ok(_) => {} - Err(error) => self.status = format!("Download history unavailable · {error}"), + Err(error) => { + self.status = self.t().format( + Message::StatusDownloadHistoryUnavailable, + &[("error", &error)], + ) + } } cx.notify(); } @@ -872,9 +1085,10 @@ impl BootableView { self.downloads_open = !self.downloads_open; if self.downloads_open { self.refresh_download_jobs(cx); - self.status = format!( - "{} download job(s) in history", - self.download_session.jobs().len() + self.status = self.t().plural( + Message::DownloadsJobsInHistory, + self.download_session.jobs().len() as u64, + &[], ); } cx.notify(); @@ -889,7 +1103,7 @@ impl BootableView { ) { let DownloadRequest::Launch(launch) = self.download_session.request(id, destination, retry) else { - self.status = "Download queued · it will start when the active job finishes".into(); + self.status = self.t().text(Message::StatusDownloadQueued).into(); self.refresh_download_jobs(cx); return; }; @@ -897,11 +1111,15 @@ impl BootableView { } fn launch_download_worker(&mut self, launch: DownloadLaunch, cx: &mut Context) { - self.status = if launch.retry { - "Retrying download · preserved bytes will be resumed when supported".into() - } else { - "Starting managed download…".into() - }; + self.download_final_shown = false; + self.status = self + .t() + .text(if launch.retry { + Message::StatusDownloadRetrying + } else { + Message::StatusDownloadStarting + }) + .into(); let DownloadLaunch { id, destination, @@ -937,7 +1155,7 @@ impl BootableView { match self.download_session.retry(&self.engine, &id) { Ok(DownloadRequest::Launch(launch)) => self.launch_download_worker(launch, cx), Ok(DownloadRequest::Queued) => { - self.status = "Retry queued · it will start after the active download".into(); + self.status = self.t().text(Message::StatusDownloadRetryQueued).into(); cx.notify(); } Err(error) => { @@ -952,7 +1170,9 @@ impl BootableView { Ok(Some(launch)) => self.launch_download_worker(launch, cx), Ok(None) => {} Err(error) => { - self.status = format!("Could not start queued download · {error}"); + self.status = self + .t() + .format(Message::StatusDownloadStartFailed, &[("error", &error)]); cx.notify(); } } @@ -960,20 +1180,31 @@ impl BootableView { fn use_managed_download(&mut self, id: &str, cx: &mut Context) { let Some(job) = self.download_session.jobs().iter().find(|job| job.id == id) else { - self.status = "Download job no longer exists".into(); + self.status = self.t().text(Message::StatusDownloadJobGone).into(); cx.notify(); return; }; let destination = job.destination.clone(); match self.download_session.use_completed(&self.engine, id) { Ok(report) => { - self.browse_directory = destination.parent().map(std::path::PathBuf::from); + self.preferences.remember_image(&report); + self.save_preferences(); + self.browse_directory = self.preferences.image_directory(); self.image = Some(report); self.advanced = false; + self.reset_boot_firmware(); self.downloads_open = false; - self.status = format!("Using completed download {}", destination.display()); + self.status = self.t().format( + Message::StatusDownloadUsingCompleted, + &[("path", &destination.display())], + ); + } + Err(error) => { + self.status = self.t().format( + Message::StatusDownloadCompletedUnavailable, + &[("error", &error)], + ) } - Err(error) => self.status = format!("Downloaded image is unavailable · {error}"), } cx.notify(); } @@ -981,7 +1212,7 @@ impl BootableView { fn remove_managed_download(&mut self, id: &str, cx: &mut Context) { match self.download_session.remove(&self.engine, id) { Ok(()) => { - self.status = "Download history entry removed · completed image kept".into(); + self.status = self.t().text(Message::StatusDownloadHistoryRemoved).into(); self.refresh_download_jobs(cx); } Err(error) => { @@ -997,7 +1228,7 @@ impl BootableView { .and_then(|index| self.pi_catalog.as_ref()?.images.get(index)) .cloned() else { - self.status = "Choose a Raspberry Pi image to download".into(); + self.status = self.t().text(Message::StatusCatalogPiChoose).into(); cx.notify(); return; }; @@ -1006,7 +1237,7 @@ impl BootableView { dialog = dialog.set_directory(directory); } let Some(destination) = dialog.save_file() else { - self.status = "Raspberry Pi image download cancelled".into(); + self.status = self.t().text(Message::StatusDownloadPiCancelled).into(); cx.notify(); return; }; @@ -1033,7 +1264,10 @@ impl BootableView { self.catalog_releases.clear(); self.discovery_session .expect_details(distribution.slug.clone()); - self.status = format!("Resolving current {} ISO files…", distribution.name); + self.status = self.t().format( + Message::StatusCatalogLoadingReleases, + &[("name", &distribution.name)], + ); cx.notify(); let request_slug = distribution.slug.clone(); let fetch_slug = request_slug.clone(); @@ -1054,7 +1288,7 @@ impl BootableView { &fetch, fetch.value.releases.is_empty(), ); - let source = fetch.status_suffix(); + let source = fetch.status_suffix_in(view.locale); let DistributionBundle { details, releases, @@ -1064,21 +1298,37 @@ impl BootableView { view.selected_details = Some(details); view.catalog_releases = releases; view.selected_release = (count > 0).then_some(0); + let t = view.t(); + let loaded = t.plural( + Message::StatusCatalogReleasesLoaded, + count as u64, + &[("source", &source)], + ); view.status = if count == 0 && !warnings.is_empty() { - format!( - "Profile ready · no direct ISO found · {} source error(s)", - warnings.len() + t.plural( + Message::StatusCatalogProfileReadyErrors, + warnings.len() as u64, + &[], ) } else if count == 0 { - "Profile loaded • no direct ISO was resolved from its current links" - .into() + t.text(Message::StatusCatalogProfileReadyNoIso).into() } else if !warnings.is_empty() { - format!( - "{count} direct ISO release(s) · {source} · {} source warning(s)", - warnings.len() + t.format( + Message::StatusCatalogWithWarnings, + &[ + ("summary", &loaded), + ( + "warnings", + &t.plural( + Message::StatusCatalogSourceWarnings, + warnings.len() as u64, + &[], + ), + ), + ], ) } else { - format!("{count} direct ISO release(s) · {source}") + loaded }; } Err(error) => { @@ -1087,7 +1337,10 @@ impl BootableView { view.status = view .discovery_session .state(CatalogFacet::Details) - .short_label("ISO releases"); + .short_label_in( + view.locale, + view.t().text(Message::CatalogSubjectIsoReleases), + ); } } cx.notify(); @@ -1104,12 +1357,15 @@ impl BootableView { .and_then(|index| self.distributions.get(index)) .map(|distribution| distribution.page_url.clone()) else { - self.status = "Choose a distribution first".into(); + self.status = self + .t() + .text(Message::StatusCatalogChooseDistribution) + .into(); cx.notify(); return; }; self.status = match self.engine.open_distrowatch_page(&page_url) { - Ok(()) => "Opened the DistroWatch distribution page in your browser".into(), + Ok(()) => self.t().text(Message::StatusCatalogBrowserOpened).into(), Err(error) => error.to_string(), }; cx.notify(); @@ -1136,7 +1392,7 @@ impl BootableView { ); } QuickAccess::Windows => { - self.status = "Windows tools use the selected local ISO".into(); + self.status = self.t().text(Message::StatusCatalogWindowsUsesIso).into(); cx.notify(); } } @@ -1148,7 +1404,7 @@ impl BootableView { .and_then(|index| self.catalog_releases.get(index)) .cloned() else { - self.status = "Choose an ISO release to download".into(); + self.status = self.t().text(Message::StatusCatalogChooseRelease).into(); cx.notify(); return; }; @@ -1157,7 +1413,7 @@ impl BootableView { dialog = dialog.set_directory(directory); } let Some(destination) = dialog.save_file() else { - self.status = "ISO download cancelled".into(); + self.status = self.t().text(Message::StatusDownloadIsoCancelled).into(); cx.notify(); return; }; @@ -1173,9 +1429,11 @@ impl BootableView { fn toggle_download_pause(&mut self, cx: &mut Context) { match self.download_session.toggle_pause(&self.engine) { Ok(Some(OperationState::Paused)) => { - self.status = "Download paused • resume or cancel when ready".into() + self.status = self.t().text(Message::StatusDownloadPaused).into() + } + Ok(Some(OperationState::Running)) => { + self.status = self.t().text(Message::StatusDownloadResumed).into() } - Ok(Some(OperationState::Running)) => self.status = "Download resumed".into(), Ok(Some(OperationState::Cancelled) | None) => {} Err(error) => self.status = error.to_string(), } @@ -1184,7 +1442,7 @@ impl BootableView { fn cancel_download(&mut self, cx: &mut Context) { if self.download_session.cancel() { - self.status = "Cancelling download safely • cleaning temporary data…".into(); + self.status = self.t().text(Message::StatusDownloadCancelling).into(); cx.notify(); } } @@ -1232,12 +1490,12 @@ impl BootableView { fn choose_image(&mut self, cx: &mut Context) { if self.image_loading { - self.status = "Image inspection is already running".into(); + self.status = self.t().text(Message::StatusImageBusy).into(); cx.notify(); return; } let mut dialog = rfd::FileDialog::new().add_filter( - "Boot images", + self.t().text(Message::SourceDialogTitle), &[ "iso", "img", "raw", "xz", "gz", "gzip", "zst", "zstd", "bz2", "bzip2", ], @@ -1245,42 +1503,145 @@ impl BootableView { if let Some(directory) = &self.browse_directory { dialog = dialog.set_directory(directory); } - let selected = dialog.pick_file(); - if let Some(path) = selected { - self.image_loading = true; - self.status = - "Inspecting image • compressed sources are measured after expansion…".into(); + if let Some(path) = dialog.pick_file() { + self.inspect_image_path(path, cx); + } + } + + fn inspect_image_path(&mut self, path: std::path::PathBuf, cx: &mut Context) { + if self.image_loading { + self.status = self.t().text(Message::StatusImageBusy).into(); cx.notify(); - let inspected_path = path.clone(); - let task = cx.background_executor().spawn(async move { - Bootable::native() - .inspect_image(&inspected_path) - .map_err(|error| error.to_string()) - }); - cx.spawn(async move |view, cx| { - let result = task.await; - if let Some(view) = view.upgrade() { - view.update(cx, |view, cx| { - view.image_loading = false; - match result { - Ok(report) => { - view.status = format!("Recognized {}", report.kind); - view.browse_directory = path.parent().map(std::path::PathBuf::from); - view.image = Some(report); - view.advanced = false; - } - Err(error) => { - view.image = None; - view.advanced = false; - view.status = error; - } + return; + } + self.image_loading = true; + self.status = self.t().text(Message::StatusImageInspecting).into(); + cx.notify(); + let inspected_path = path.clone(); + let task = cx.background_executor().spawn(async move { + Bootable::native() + .inspect_image(&inspected_path) + .map_err(|error| error.to_string()) + }); + cx.spawn(async move |view, cx| { + let result = task.await; + if let Some(view) = view.upgrade() { + view.update(cx, |view, cx| { + view.image_loading = false; + match result { + Ok(report) => { + view.status = view + .t() + .format(Message::StatusImageRecognized, &[("kind", &report.kind)]); + view.preferences.remember_image(&report); + view.save_preferences(); + view.browse_directory = view.preferences.image_directory(); + view.image = Some(report); + view.advanced = false; + view.reset_boot_firmware(); } - cx.notify(); - }) - .ok(); - } - }) - .detach(); + Err(error) => { + view.preferences.forget_image(&path); + view.save_preferences(); + view.image = None; + view.advanced = false; + view.reset_boot_firmware(); + view.status = error; + } + } + cx.notify(); + }) + .ok(); + } + }) + .detach(); + } + + fn save_preferences(&mut self) { + if let Err(error) = self.preferences.save() { + self.status = self + .t() + .format(Message::StatusPrefsSaveFailed, &[("error", &error)]); + } + } + + /// Applies a language choice (`None` follows the system), persists it, and + /// refreshes everything that was cached from the previous language. + fn set_language( + &mut self, + language: Option, + window: &mut Window, + cx: &mut Context, + ) { + if self.preferences.language == language { + return; + } + self.preferences.language = language; + self.locale = self.preferences.locale(); + // Cached status text came from the previous language; replace it. + self.status = self.review_readiness().guidance_in(self.locale).into(); + self.save_preferences(); + let locale = self.locale; + self.catalog_search.update(cx, |input, cx| { + input.set_placeholder( + locale.strings().text(Message::DiscoverSearchPlaceholder), + window, + cx, + ); + }); + self.language_select.update(cx, |select, cx| { + select.set_items(language_choices(locale), window, cx); + select.set_selected_index(language_index(language), window, cx); + }); + cx.notify(); + } + + /// Legacy BIOS output is experimental and never remembered: any newly + /// chosen image starts back at UEFI-only. The select is re-synced on render. + fn reset_boot_firmware(&mut self) { + self.options.windows_boot_firmware = WindowsBootFirmware::default(); + self.firmware_select_stale = true; + } + + fn sync_firmware_select(&mut self, window: &mut Window, cx: &mut Context) { + let index = WindowsBootFirmware::ALL + .iter() + .position(|firmware| *firmware == self.options.windows_boot_firmware) + .unwrap_or(0); + self.windows_boot_firmware.update(cx, |select, cx| { + select.set_selected_index( + Some(gpui_component::IndexPath::default().row(index)), + window, + cx, + ); + }); + } + + fn sync_partition_select(&mut self, window: &mut Window, cx: &mut Context) { + let index = match self.options.windows_partition_scheme { + WindowsPartitionScheme::Gpt => 0, + WindowsPartitionScheme::Mbr => 1, + }; + self.windows_partition_scheme.update(cx, |select, cx| { + select.set_selected_index( + Some(gpui_component::IndexPath::default().row(index)), + window, + cx, + ); + }); + } + + fn toggle_help(&mut self, cx: &mut Context) { + self.help_open = !self.help_open; + cx.notify(); + } + + fn close_overlay(&mut self, cx: &mut Context) { + if self.help_open { + self.help_open = false; + cx.notify(); + } else if self.catalog_open && !self.write_session.confirmation_open() { + self.toggle_catalog(cx); } } @@ -1290,10 +1651,13 @@ impl BootableView { dialog = dialog.set_directory(directory); } if let Some(directory) = dialog.pick_folder() { - self.status = format!("Image browser folder: {}", directory.display()); + self.status = self.t().format( + Message::StatusImageFolder, + &[("path", &directory.display())], + ); self.browse_directory = Some(directory); } else { - self.status = "Folder selection cancelled".into(); + self.status = self.t().text(Message::StatusImageFolderCancelled).into(); } cx.notify(); } @@ -1304,23 +1668,29 @@ impl BootableView { .and_then(|index| self.devices.get(index)) .cloned() else { - self.status = "Choose a removable drive to back up".into(); + self.status = self.t().text(Message::StatusBackupChooseDrive).into(); cx.notify(); return; }; let mut dialog = rfd::FileDialog::new() - .add_filter("Raw drive image", &["img", "raw", "dd"]) + .add_filter( + self.t().text(Message::SourceDialogFilterBackup), + &["img", "raw", "dd"], + ) .set_file_name("bootable-backup.img"); if let Some(directory) = &self.browse_directory { dialog = dialog.set_directory(directory); } let Some(destination) = dialog.save_file() else { - self.status = "Drive backup cancelled".into(); + self.status = self.t().text(Message::StatusBackupCancelled).into(); cx.notify(); return; }; self.browse_directory = destination.parent().map(std::path::PathBuf::from); - self.status = format!("Backing up {} in the background…", device.display_name()); + self.status = self.t().format( + Message::StatusBackupRunning, + &[("drive", &device.display_name())], + ); cx.notify(); let device_id = device.id.to_string(); @@ -1335,9 +1705,10 @@ impl BootableView { if let Some(view) = view.upgrade() { view.update(cx, |view, cx| { view.status = match result { - Ok(destination) => { - format!("Drive image saved to {}", destination.display()) - } + Ok(destination) => view.t().format( + Message::StatusBackupDone, + &[("path", &destination.display())], + ), Err(error) => error.to_string(), }; cx.notify(); @@ -1350,7 +1721,7 @@ impl BootableView { fn checksum_image(&mut self, cx: &mut Context) { let Some(image) = &self.image else { - self.status = "Choose an image before computing its checksum".into(); + self.status = self.t().text(Message::StatusChecksumChooseImage).into(); cx.notify(); return; }; @@ -1366,35 +1737,37 @@ impl BootableView { fn cycle_checksum_algorithm(&mut self, cx: &mut Context) { self.checksum_algorithm = self.checksum_algorithm.next(); - self.status = format!("Checksum algorithm: {}", self.checksum_algorithm); + self.status = self.t().format( + Message::StatusChecksumAlgorithm, + &[("algorithm", &self.checksum_algorithm)], + ); + self.preferences.checksum_algorithm = self.checksum_algorithm; + self.save_preferences(); cx.notify(); } fn toggle_advanced(&mut self, cx: &mut Context) { if self.image.is_none() { self.advanced = false; - self.status = "Choose or download an image before opening media options".into(); + self.status = self.t().text(Message::StatusOptionsOpenNeedsImage).into(); cx.notify(); return; } self.advanced = !self.advanced; - self.status = if self.advanced { - "Advanced options expanded • every choice is included in the reviewed plan".into() - } else { - "Advanced options collapsed • configured values remain active".into() - }; + self.status = self + .t() + .text(if self.advanced { + Message::StatusOptionsExpanded + } else { + Message::StatusOptionsCollapsed + }) + .into(); cx.notify(); } fn cycle_bad_blocks(&mut self, cx: &mut Context) { self.options.bad_block_check = self.options.bad_block_check.next(); - self.status = match self.options.bad_block_check { - BadBlockCheck::Disabled => "Destructive bad-block check disabled".into(), - mode => format!( - "Bad-block check: {} destructive pattern(s) before writing", - mode.passes() - ), - }; + self.status = self.options.bad_block_check.status_in(self.locale); cx.notify(); } @@ -1405,8 +1778,7 @@ impl BootableView { fn scan_devices(&mut self, manual: bool, cx: &mut Context) { if self.write_session.active() { if manual { - self.status = - "Drive refresh is paused while writing • do not unplug the target".into(); + self.status = self.t().text(Message::StatusDrivesRefreshPaused).into(); cx.notify(); } return; @@ -1415,7 +1787,7 @@ impl BootableView { Ok(devices) => { if devices == self.devices { if manual { - self.status = "Drive list is up to date • automatic detection is on".into(); + self.status = self.t().text(Message::StatusDrivesUpToDate).into(); cx.notify(); } return; @@ -1436,7 +1808,7 @@ impl BootableView { self.selected_device = selected_id.and_then(|id| devices.iter().position(|device| device.id == id)); self.devices = devices; - self.status = device_change_message(added, removed); + self.status = device_change_message(self.t(), added, removed); } Err(error) => self.status = error.to_string(), } @@ -1445,7 +1817,7 @@ impl BootableView { fn preview_plan(&mut self, cx: &mut Context) { let Some(image) = self.image.clone() else { - self.status = "Choose an image first".into(); + self.status = self.t().text(Message::StatusImageChooseFirst).into(); cx.notify(); return; }; @@ -1454,7 +1826,7 @@ impl BootableView { .and_then(|index| self.devices.get(index)) .cloned() else { - self.status = "Choose a target drive first".into(); + self.status = self.t().text(Message::StatusTargetChooseFirst).into(); cx.notify(); return; }; @@ -1465,7 +1837,7 @@ impl BootableView { Ok(plan) => { self.catalog_open = false; self.write_session.open(plan); - "Reviewing the write plan • nothing has been written".into() + self.t().text(Message::StatusReviewOpen).into() } Err(error) => error.to_string(), }; @@ -1482,24 +1854,24 @@ impl BootableView { fn close_review(&mut self, cx: &mut Context) { if !self.write_session.close() { - self.status = "Writing is active • do not close the app or unplug the target".into(); + self.status = self.t().text(Message::StatusWriteActive).into(); cx.notify(); return; } - self.status = self.review_readiness().guidance().into(); + self.status = self.review_readiness().guidance_in(self.locale).into(); cx.notify(); } fn open_write_confirmation(&mut self, cx: &mut Context) { if self.write_session.open_confirmation() { - self.status = "Review the target changes and consequences before writing".into(); + self.status = self.t().text(Message::StatusReviewConsequences).into(); cx.notify(); } } fn close_write_confirmation(&mut self, cx: &mut Context) { self.write_session.close_confirmation(); - self.status = "Write cancelled before erasure • the target is unchanged".into(); + self.status = self.t().text(Message::StatusWriteCancelled).into(); cx.notify(); } @@ -1512,7 +1884,7 @@ impl BootableView { return; } }; - self.status = "Write started • do not unplug the target".into(); + self.status = self.t().text(Message::StatusWriteStarted).into(); cx.notify(); let (sender, receiver) = mpsc::unbounded(); @@ -1548,10 +1920,18 @@ impl BootableView { view.update(cx, |view, cx| { match update { WriteUpdate::Progress(progress) => { - view.status = view.write_session.apply_progress(progress); + // The phase name is localized here; the step text is + // produced by core and stays English for now. + view.status = format!( + "{} • {}", + progress.phase.label_in(view.locale), + progress.message + ); + view.write_session.apply_progress(progress); } WriteUpdate::Finished(completion) => { - view.status = view.write_session.finish(completion); + view.status = completion.status_in(view.locale); + view.write_session.finish(completion); } } cx.notify(); @@ -1564,14 +1944,13 @@ impl BootableView { fn cancel_write(&mut self, cx: &mut Context) { if self.write_session.cancel() { - self.status = - "Stopping safely • flushing completed writes; the media will remain incomplete" - .into(); + self.status = self.t().text(Message::StatusWriteStopping).into(); cx.notify(); } } fn review_card(&self, _cx: &mut Context) -> impl IntoElement { + let t = self.t(); let plan = self .write_session .plan() @@ -1596,9 +1975,14 @@ impl BootableView { .py_3() .rounded_lg() .bg(rgb(0x0d151f)) - .child(format!("{}. {}", index + 1, step.title)) + .child(div().flex_1().min_w(px(0.)).child(format!( + "{}. {}", + index + 1, + step.title + ))) .child( div() + .flex_shrink_0() .text_xs() .text_color(if step.destructive { rgb(0xe5b95f) @@ -1606,9 +1990,9 @@ impl BootableView { rgb(0x8fa4bd) }) .child(if step.destructive { - "ERASES DATA" + t.heading(Message::ReviewStepErases) } else { - "safe" + t.text(Message::ReviewStepSafe).to_string() }), ) }) @@ -1622,30 +2006,32 @@ impl BootableView { .border_1() .border_color(rgb(0x243244)) .bg(rgb(0x111923)) + .child( + div().flex().items_start().justify_between().gap_4().child( + div() + .flex() + .flex_col() + .gap_1() + .child( + div() + .text_xl() + .font_weight(FontWeight::BOLD) + .child(t.text(Message::ReviewTitle)), + ) + .child(div().text_sm().text_color(rgb(0x8fa4bd)).child(t.text( + if self.write_session.active() { + Message::ReviewSubtitleWriting + } else { + Message::ReviewSubtitle + }, + ))), + ), + ) .child( div() - .flex() - .items_start() - .justify_between() - .gap_4() - .child( - div() - .flex() - .flex_col() - .gap_1() - .child( - div() - .text_xl() - .font_weight(FontWeight::BOLD) - .child("Review write plan"), - ) - .child( - div() - .text_sm() - .text_color(rgb(0x8fa4bd)) - .child("Nothing is written until a separate destructive confirmation succeeds."), - ), - ), + .text_sm() + .font_weight(FontWeight::SEMIBOLD) + .child(t.text(Message::ReviewPlanSummary)), ) .child( div() @@ -1653,7 +2039,7 @@ impl BootableView { .grid_cols(2) .gap_3() .child(review_value( - "SOURCE", + t.heading(Message::ReviewFieldSource), format!( "{}\n{} • {}", plan.image.path.display(), @@ -1662,7 +2048,7 @@ impl BootableView { ), )) .child(review_value( - "TARGET", + t.heading(Message::ReviewFieldTarget), format!( "{}\n{} • {}", plan.target.path.display(), @@ -1670,11 +2056,13 @@ impl BootableView { format_bytes(plan.target.capacity) ), )) - .child(review_value("METHOD", plan.strategy.to_string())) .child(review_value( - "CONSEQUENCE", - "All existing data and partitions on the selected target will be erased" - .into(), + t.heading(Message::ReviewFieldMethod), + plan.strategy.to_string(), + )) + .child(review_value( + t.heading(Message::ReviewFieldConsequence), + t.text(Message::ReviewConsequence).into(), )), ) .child( @@ -1686,7 +2074,7 @@ impl BootableView { div() .text_sm() .font_weight(FontWeight::SEMIBOLD) - .child("Ordered operations"), + .child(t.text(Message::ReviewOrderedOperations)), ) .children(step_rows), ) @@ -1705,27 +2093,45 @@ impl BootableView { div() .flex() .flex_col() + .flex_1() + .min_w(px(0.)) .gap_1() + .child( + div() + .text_xs() + .font_weight(FontWeight::SEMIBOLD) + .text_color(rgb(0xb6a17a)) + .child(t.heading(Message::ReviewPermanentChanges)), + ) .child( div() .text_sm() .font_weight(FontWeight::BOLD) .text_color(rgb(0xe5b95f)) - .child(if self.write_session.active() { - "Writing and verification are active" + .child(t.text(if self.write_session.active() { + Message::ReviewStateWriting } else { - "One final confirmation is required" - }), + Message::ReviewStateFinalConfirm + })), ) - .child( - div() - .text_xs() - .text_color(rgb(0xb6a17a)) - .child(if self.write_session.active() { - "Do not close the app, power off, or unplug the target drive." - } else { - "Review the exact target changes and irreversible consequences before writing." - }), + .child(div().text_xs().text_color(rgb(0xb6a17a)).child(t.text( + if self.write_session.active() { + Message::ReviewWarningWriting + } else { + Message::ReviewWarningIdle + }, + ))) + .when( + !self.write_session.active() + && self.write_session.completion().is_none(), + |column| { + column.child( + div() + .text_xs() + .text_color(rgb(0xb6a17a)) + .child(t.text(Message::ReviewHintOpenConfirmation)), + ) + }, ), ), ) @@ -1748,12 +2154,19 @@ impl BootableView { .justify_between() .child( div() + .flex_1() + .min_w(px(0.)) .text_sm() .font_weight(FontWeight::BOLD) - .child(format!("{} · {}", progress.phase, progress.message)), + .child(format!( + "{} · {}", + progress.phase.label_in(self.locale), + progress.message + )), ) .child( div() + .flex_shrink_0() .text_xs() .text_color(rgb(0x8fa4bd)) .child(progress.metrics(elapsed)), @@ -1776,29 +2189,14 @@ impl BootableView { ) }) .when_some(self.write_session.completion(), |panel, completion| { - let (title, message, color) = match completion { - WriteCompletion::Succeeded => ( - "Write complete", - "The image was written and verified. The removable drive can now be safely removed." - .to_string(), - 0x5bd7c0, - ), - WriteCompletion::AuthenticationDenied => ( - "Write cancelled before erasure", - "Administrator authentication was cancelled or denied.".into(), - 0xf0cc7d, - ), - WriteCompletion::Cancelled => ( - "Write stopped safely", - "The media is incomplete and must be rewritten before use.".into(), - 0xf29a9a, - ), - WriteCompletion::Failed(error) => ( - "Write failed", - error.clone(), - 0xf29a9a, - ), + let color = match completion { + WriteCompletion::Succeeded => 0x5bd7c0, + WriteCompletion::AuthenticationDenied => 0xf0cc7d, + WriteCompletion::Cancelled | WriteCompletion::Failed(_) => 0xf29a9a, }; + // A failure body is core's error text, shown as received. + let title = completion.title_in(self.locale); + let message = completion.detail_in(self.locale); panel.child( div() .flex() @@ -1822,16 +2220,17 @@ impl BootableView { } fn review_footer(&self, cx: &mut Context) -> impl IntoElement { + let t = self.t(); let write_succeeded = self.write_session.succeeded(); - let action_label = if self.write_session.active() { - "Stop safely" + let action_label = t.text(if self.write_session.active() { + Message::ReviewActionStopSafely } else if write_succeeded { - "Written & verified" + Message::ReviewActionWritten } else if self.write_session.completion().is_some() { - "Review & retry" + Message::ReviewActionRetry } else { - "Review consequences" - }; + Message::ReviewActionConsequences + }); div() .flex() .items_center() @@ -1846,28 +2245,31 @@ impl BootableView { .child( div() .flex_1() + .min_w(px(0.)) .text_sm() .text_color(rgb(if self.write_session.active() { 0xe5b95f } else { 0xa9b8c9 })) - .child(if self.write_session.active() { - "Writing and verification are active · do not unplug the target" + .child(t.text(if self.write_session.active() { + Message::ReviewStatusWriting } else if write_succeeded { - "Complete · the written media passed byte verification" + Message::ReviewStatusComplete } else { - "Review the physical target and permanent changes before writing" - }), + Message::ReviewStatusReview + })), ) .child( div() .flex() + .flex_wrap() + .justify_end() .items_center() .gap_2() .child( Button::new("review-back") - .label("Back") + .label(t.text(Message::ActionBack)) .disabled(self.write_session.active()) .on_click(cx.listener(|this, _, _, cx| this.close_review(cx))), ) @@ -1888,6 +2290,7 @@ impl BootableView { } fn write_confirmation_modal(&self, cx: &mut Context) -> impl IntoElement { + let t = self.t(); let plan = self .write_session .plan() @@ -1918,40 +2321,48 @@ impl BootableView { .text_color(rgb(if step.destructive { 0xf29a9a } else { 0x5bd7c0 })) .child((index + 1).to_string()), ) - .child(div().flex_1().text_sm().child(step.title.clone())) .child( div() + .flex_1() + .min_w(px(0.)) + .text_sm() + .child(step.title.clone()), + ) + .child( + div() + .flex_shrink_0() .text_xs() .font_weight(FontWeight::SEMIBOLD) .text_color(rgb(if step.destructive { 0xf29a9a } else { 0x8fa4bd })) .child(if step.destructive { - "ERASES DATA" + t.heading(Message::ReviewStepErases) } else { - "verifies" + t.text(Message::ReviewStepVerifies).to_string() }), ) }) .collect::>(); let consequences = [ - "Every existing file and partition on this physical drive will be permanently erased.", - "Choosing the wrong drive destroys the data on that drive; confirm its model, path, and capacity below.", - "Power loss, closing the app, or unplugging during writing can leave incomplete and unbootable media.", - "Bootable rechecks the target identity immediately before erasure and verifies the result afterward.", + Message::ConfirmConsequenceErase, + Message::ConfirmConsequenceWrongDrive, + Message::ConfirmConsequenceInterrupted, + Message::ConfirmConsequenceRecheck, ] .into_iter() + .map(|message| t.text(message)) .map(|message| { div() .flex() .items_start() .gap_3() + .child(div().mt_1().size(px(7.)).rounded_full().bg(rgb(0xe5b95f))) .child( div() - .mt_1() - .size(px(7.)) - .rounded_full() - .bg(rgb(0xe5b95f)), + .flex_1() + .text_sm() + .text_color(rgb(0xc7b58f)) + .child(message), ) - .child(div().flex_1().text_sm().text_color(rgb(0xc7b58f)).child(message)) }) .collect::>(); @@ -1989,23 +2400,26 @@ impl BootableView { div() .flex() .flex_col() + .flex_1() + .min_w(px(0.)) .gap_1() .child( div() .text_xl() .font_weight(FontWeight::BOLD) .text_color(rgb(0xf0cc7d)) - .child("Confirm permanent changes"), + .child(t.text(Message::ConfirmTitle)), ) .child( div() .text_sm() .text_color(rgb(0x8fa4bd)) - .child("Review what Bootable will change and what can go wrong."), + .child(t.text(Message::ConfirmSubtitle)), ), ) .child( div() + .flex_shrink_0() .px_3() .py_1() .rounded_full() @@ -2013,7 +2427,7 @@ impl BootableView { .text_xs() .font_weight(FontWeight::BOLD) .text_color(rgb(0xf29a9a)) - .child("PERMANENT"), + .child(t.heading(Message::ConfirmBadge)), ), ) .child( @@ -2036,7 +2450,7 @@ impl BootableView { div() .text_xs() .text_color(rgb(0xb6a17a)) - .child("PHYSICAL TARGET"), + .child(t.heading(Message::ConfirmPhysicalTarget)), ) .child( div() @@ -2068,7 +2482,7 @@ impl BootableView { div() .text_sm() .font_weight(FontWeight::BOLD) - .child("Changes to this drive"), + .child(t.text(Message::ConfirmChanges)), ) .children(change_rows), ) @@ -2085,14 +2499,14 @@ impl BootableView { .text_sm() .font_weight(FontWeight::BOLD) .text_color(rgb(0xe5b95f)) - .child("Consequences"), + .child(t.text(Message::ConfirmConsequences)), ) .children(consequences), ) .child( Checkbox::new("acknowledge-write-consequences") .checked(self.write_session.acknowledged()) - .label("I checked the physical target and understand that all of its existing data will be permanently erased.") + .label(t.text(Message::ConfirmAck)) .on_click(cx.listener(|this, checked: &bool, _, cx| { this.write_session.set_acknowledged(*checked); cx.notify(); @@ -2101,12 +2515,13 @@ impl BootableView { .child( div() .flex() + .flex_wrap() .items_center() .justify_end() .gap_3() .child( Button::new("cancel-confirm-write") - .label("Cancel") + .label(t.text(Message::ActionCancel)) .on_click(cx.listener(|this, _, _, cx| { this.close_write_confirmation(cx) })), @@ -2114,7 +2529,11 @@ impl BootableView { .child( Button::new("confirm-write") .danger() - .label("Confirm erase & write") + .label(t.text(if self.write_session.can_confirm() { + Message::ConfirmSubmit + } else { + Message::ConfirmAcknowledgeFirst + })) .disabled(!self.write_session.can_confirm()) .on_click(cx.listener(|this, _, _, cx| this.start_write(cx))), ), @@ -2127,6 +2546,7 @@ impl BootableView { cx: &mut Context, layout: ViewportLayout, ) -> impl IntoElement { + let t = self.t(); let show_page_fallback = self.selected_distribution.is_some() && self.catalog_releases.is_empty() && !self @@ -2193,9 +2613,9 @@ impl BootableView { .child(div().text_xs().text_color(rgb(0x7890a8)).child( distribution.based_on.clone().unwrap_or_else(|| { if distribution.rank == 0 { - "DistroWatch directory".into() + t.text(Message::DiscoverItemDirectorySource).into() } else { - "Independent".into() + t.text(Message::DiscoverItemIndependent).into() } }), )), @@ -2208,9 +2628,12 @@ impl BootableView { .items_end() .child(div().text_xs().text_color(rgb(0x8fa4bd)).child( if distribution.rank == 0 { - "Directory".into() + t.text(Message::DiscoverItemDirectory).to_string() } else { - format!("{} / day", distribution.hits_per_day) + t.format( + Message::DiscoverItemHitsPerDay, + &[("hits", &distribution.hits_per_day)], + ) }, )) .child( @@ -2218,7 +2641,11 @@ impl BootableView { .text_xs() .font_weight(FontWeight::SEMIBOLD) .text_color(rgb(if selected { 0x5bd7c0 } else { 0x8fc7ff })) - .child(if selected { "Selected" } else { "Select →" }), + .child(if selected { + t.text(Message::ActionSelected).to_string() + } else { + format!("{} →", t.text(Message::ActionSelect)) + }), ), ) }) @@ -2233,12 +2660,18 @@ impl BootableView { let size = release .size .map(format_bytes) - .unwrap_or_else(|| "Size unknown".into()); - let integrity = release + .unwrap_or_else(|| t.text(Message::DiscoverItemSizeUnknown).into()); + let publisher_checksum = release .checksum_algorithm - .filter(|_| release.checksum.is_some() || release.checksum_url.is_some()) - .map(|algorithm| format!("Publisher {algorithm}")) - .unwrap_or_else(|| "No publisher checksum".into()); + .filter(|_| release.checksum.is_some() || release.checksum_url.is_some()); + let has_publisher_checksum = publisher_checksum.is_some(); + let integrity = match publisher_checksum { + Some(algorithm) => t.format( + Message::DiscoverItemPublisherChecksum, + &[("algorithm", &algorithm)], + ), + None => t.text(Message::DiscoverItemNoPublisherChecksum).into(), + }; div() .id(("release", index)) .flex() @@ -2254,14 +2687,18 @@ impl BootableView { .border_color(rgb(if selected { 0x3ebfa7 } else { 0x1f2c3c })) .on_click(cx.listener(move |this, _, _, cx| { this.selected_release = Some(index); - let has_checksum = this.catalog_releases.get(index).is_some_and(|release| { - release.checksum.is_some() || release.checksum_url.is_some() - }); - this.status = if has_checksum { - "ISO selected • publisher checksum will be verified before use".into() - } else { - "ISO selected • publisher checksum unavailable; HTTPS length and boot structure will be checked".into() - }; + let has_checksum = + this.catalog_releases.get(index).is_some_and(|release| { + release.checksum.is_some() || release.checksum_url.is_some() + }); + this.status = this + .t() + .text(if has_checksum { + Message::StatusCatalogIsoSelectedChecksum + } else { + Message::StatusCatalogIsoSelectedHttps + }) + .into(); cx.notify(); })) .child( @@ -2280,7 +2717,7 @@ impl BootableView { .child( div() .text_xs() - .text_color(rgb(if integrity.starts_with("Publisher") { + .text_color(rgb(if has_publisher_checksum { 0x5bd7c0 } else { 0x7890a8 @@ -2306,27 +2743,28 @@ impl BootableView { ) { catalog_search_summary(&query, 0) } else { - distribution_state.short_label("distributions") + distribution_state + .short_label_in(self.locale, t.text(Message::CatalogSubjectDistributions)) }; let release_message = if self.selected_distribution.is_none() && matches!( self.discovery_session.state(CatalogFacet::Details), CatalogState::Idle ) { - "Choose a distribution to resolve its current ISO files".into() + t.text(Message::DiscoverDetailEmpty).into() } else { self.discovery_session .state(CatalogFacet::Details) - .short_label("ISO releases") + .short_label_in(self.locale, t.text(Message::CatalogSubjectIsoReleases)) }; let distribution_heading = if !query.is_empty() { - "SEARCH RESULTS" + t.heading(Message::DiscoverSectionSearch) } else { match self.discovery_session.quick_access() { - QuickAccess::Arch => "ARCH-BASED", - QuickAccess::Debian => "DEBIAN-BASED", - QuickAccess::Omarchy => "OMARCHY", - _ => "POPULAR · SIX MONTHS", + QuickAccess::Arch => t.heading(Message::DiscoverSectionArch), + QuickAccess::Debian => t.heading(Message::DiscoverSectionDebian), + QuickAccess::Omarchy => "OMARCHY".to_string(), + _ => t.heading(Message::DiscoverSectionPopular), } }; let refresh_label = if distribution_state.is_failed() @@ -2335,9 +2773,9 @@ impl BootableView { .state(CatalogFacet::Details) .is_failed() { - "Retry" + t.text(Message::ActionRetry) } else { - "Refresh" + t.text(Message::ActionRefresh) }; div() @@ -2354,9 +2792,12 @@ impl BootableView { .flex() .items_center() .justify_between() + .gap_3() .child( div() .flex() + .flex_1() + .min_w(px(0.)) .items_center() .gap_3() .child(Icon::empty().path("ui/discover.svg")) @@ -2364,19 +2805,19 @@ impl BootableView { div() .flex() .flex_col() + .flex_1() + .min_w(px(0.)) .child( div() .text_base() .font_weight(FontWeight::SEMIBOLD) - .child("Discover distributions"), + .child(t.text(Message::DiscoverTitle)), ) .child( div() .text_xs() .text_color(rgb(0x7890a8)) - .child( - "DistroWatch page-hit ranking measures interest—not quality or market share", - ), + .child(t.text(Message::DiscoverDisclaimer)), ), ), ) @@ -2385,7 +2826,7 @@ impl BootableView { .compact() .icon(Icon::empty().path("ui/refresh.svg")) .label(refresh_label) - .tooltip("Refresh DistroWatch data") + .tooltip(t.text(Message::TooltipRefreshDistrowatch)) .on_click(cx.listener(|this, _, _, cx| { this.retry_discovery(cx); })), @@ -2463,7 +2904,10 @@ impl BootableView { .path("ui/image.svg") .size(px(18.)), ) - .child(format!("{} screenshot", details.name)), + .child(t.format( + Message::DiscoverDetailScreenshotOf, + &[("name", &details.name)], + )), ) .child( img(url.clone()) @@ -2516,15 +2960,15 @@ impl BootableView { details .os_type .as_deref() - .unwrap_or("Unknown OS"), + .unwrap_or(t.text(Message::DiscoverDetailUnknownOs)), details .origin .as_deref() - .unwrap_or("Unknown origin"), + .unwrap_or(t.text(Message::DiscoverDetailUnknownOrigin)), details .status .as_deref() - .unwrap_or("Unknown status") + .unwrap_or(t.text(Message::DiscoverDetailUnknownStatus)) )), ) .when_some( @@ -2534,11 +2978,13 @@ impl BootableView { div() .text_xs() .text_color(rgb(0x5bd7c0)) - .child(format!( - "★ {rating}/10 · {} reviews", + .child(t.plural( + Message::DiscoverDetailRating, details .visitor_review_count .unwrap_or_default() + as u64, + &[("rating", rating)], )), ) }, @@ -2560,9 +3006,17 @@ impl BootableView { .text_xs() .text_color(rgb(0x7890a8)) .child(format!( - "Architecture: {} · Desktop: {}", - compact_list(&details.architectures, 4), - compact_list(&details.desktops, 4) + "{} · {}", + labeled( + t, + Message::DiscoverDetailArchitecture, + &compact_list(t, &details.architectures, 4) + ), + labeled( + t, + Message::DiscoverDetailDesktop, + &compact_list(t, &details.desktops, 4) + ) )), ) }) @@ -2576,7 +3030,7 @@ impl BootableView { .text_xs() .font_weight(FontWeight::SEMIBOLD) .text_color(rgb(0x8fa4bd)) - .child("DIRECT ISO FILES"), + .child(t.heading(Message::DiscoverDetailDirectIsos)), ) .child( div() @@ -2587,9 +3041,12 @@ impl BootableView { .state(CatalogFacet::Details) .is_loading() { - "Loading…".into() + t.text(Message::DiscoverDetailLoading).into() } else { - format!("{} found", self.catalog_releases.len()) + t.format( + Message::DiscoverDetailFound, + &[("count", &self.catalog_releases.len())], + ) }), ), ) @@ -2618,7 +3075,7 @@ impl BootableView { Button::new("open-distrowatch-page") .primary() .icon(Icon::empty().path("ui/discover.svg")) - .label("Open DistroWatch download page") + .label(t.text(Message::DiscoverDetailOpenPage)) .on_click(cx.listener(|this, _, _, cx| { this.open_selected_distrowatch_page(cx) })), @@ -2630,7 +3087,7 @@ impl BootableView { .primary() .disabled(self.selected_release.is_none()) .icon(Icon::empty().path("ui/download.svg")) - .label("Download & use ISO") + .label(t.text(Message::DiscoverDetailDownloadUse)) .on_click(cx.listener(|this, _, _, cx| { this.download_catalog_release(cx) })), @@ -2641,11 +3098,8 @@ impl BootableView { } fn catalog_card(&self, cx: &mut Context, layout: ViewportLayout) -> impl IntoElement { - let advanced_label = if self.advanced { - "Hide options" - } else { - "Setup options" - }; + let t = self.t(); + let advanced_label = self.advanced_label(false); let content = if self.discovery_session.quick_access() == QuickAccess::Windows { self.windows_quick_card(cx).into_any_element() } else if self.discovery_session.quick_access() == QuickAccess::Omarchy { @@ -2667,6 +3121,7 @@ impl BootableView { .flex() .items_center() .gap_3() + .when(!layout.compact, |toolbar| toolbar.flex_wrap()) .when(layout.compact, |toolbar| toolbar.flex_col().items_start()) .child( div() @@ -2679,7 +3134,7 @@ impl BootableView { div() .text_base() .font_weight(FontWeight::BOLD) - .child("Discover"), + .child(t.text(Message::ActionDiscover)), ), ) .child( @@ -2696,7 +3151,7 @@ impl BootableView { == DiscoverySource::DistroWatch, |button| button.primary(), ) - .label("All") + .label(t.text(Message::DiscoverQuickAll)) .on_click(cx.listener(|this, _, window, cx| { this.show_quick_access(QuickAccess::All, window, cx) })), @@ -2789,9 +3244,9 @@ impl BootableView { Button::new("downloads") .compact() .icon(Icon::empty().path("ui/download.svg")) - .label(format!( - "Downloads · {}", - self.download_session.jobs().len() + .label(t.format( + Message::ActionDownloadsCount, + &[("count", &self.download_session.jobs().len())], )) .when(self.downloads_open, |button| button.primary()) .on_click( @@ -2802,7 +3257,7 @@ impl BootableView { Button::new("catalog") .compact() .icon(Icon::empty().path("ui/discover.svg")) - .label("Close catalog") + .label(t.text(Message::ActionCatalogClose)) .on_click( cx.listener(|this, _, _, cx| this.toggle_catalog(cx)), ), @@ -2822,7 +3277,7 @@ impl BootableView { Button::new("refresh") .compact() .icon(Icon::empty().path("ui/refresh.svg")) - .tooltip("Refresh removable drives") + .tooltip(t.text(Message::TooltipRefreshDrives)) .on_click( cx.listener(|this, _, _, cx| this.refresh_devices(cx)), ), @@ -2888,6 +3343,7 @@ impl BootableView { } fn windows_quick_card(&self, cx: &mut Context) -> impl IntoElement { + let t = self.t(); let windows_image = self .image .as_ref() @@ -2938,7 +3394,7 @@ impl BootableView { div() .text_base() .font_weight(FontWeight::BOLD) - .child("Windows installer media"), + .child(t.text(Message::OptionsWindowsHeading)), ) .child( div() @@ -2951,11 +3407,11 @@ impl BootableView { Button::new("windows-choose-iso") .primary() .icon(Icon::empty().path("ui/image.svg")) - .label(if windows_image { - "Replace Windows ISO" + .label(t.text(if windows_image { + Message::OptionsWindowsReplaceIso } else { - "Choose Windows ISO" - }) + Message::OptionsWindowsChooseIso + })) .on_click(cx.listener(|this, _, _, cx| this.choose_image(cx))), ), ) @@ -3009,10 +3465,24 @@ impl BootableView { div() .text_xs() .text_color(rgb(0x806f55)) - .child("Unavailable items are intentionally not clickable. Silent installation can erase the first disk Windows Setup detects and requires a separate high-friction safety design."), + .child(format!( + "{} {}", + t.text(Message::OptionsWindowsUnavailableNote), + t.text(Message::OptionsWindowsSilentInstallWarning) + )), ), ) - .when(windows_image, |panel| panel.child(self.advanced_card(cx))) + .when(windows_image, |panel| { + panel + .child( + div() + .text_sm() + .font_weight(FontWeight::SEMIBOLD) + .text_color(rgb(0x5bd7c0)) + .child(t.text(Message::OptionsWindowsInstallerReady)), + ) + .child(self.advanced_card(cx)) + }) .when(!windows_image, |panel| { panel.child( div() @@ -3021,12 +3491,13 @@ impl BootableView { .bg(rgb(0x0f1925)) .text_sm() .text_color(rgb(0x8fa4bd)) - .child("Choose an inspected Windows installer ISO to reveal the independent Windows setup checkboxes. No Windows option is applied silently."), + .child(t.text(Message::OptionsWindowsInstallerLocked)), ) }) } fn pi_catalog_card(&self, cx: &mut Context) -> impl IntoElement { + let t = self.t(); let query = self.catalog_search_query(cx); let devices = self .pi_catalog @@ -3119,9 +3590,7 @@ impl BootableView { .border_color(rgb(if selected { 0x3ebfa7 } else { 0x1f2c3c })) .on_click(cx.listener(move |this, _, _, cx| { this.selected_pi_image = Some(index); - this.status = - "Raspberry Pi image selected • download will be extracted and verified" - .into(); + this.status = this.t().text(Message::StatusCatalogPiSelectedVerify).into(); cx.notify(); })) .when_some(image.icon_url.as_ref(), |row, icon| { @@ -3144,7 +3613,10 @@ impl BootableView { ) .child(div().text_xs().text_color(rgb(0x7890a8)).child(format!( "{} · {}", - image.release_date.as_deref().unwrap_or("Date unknown"), + image + .release_date + .as_deref() + .unwrap_or(t.text(Message::PiDateUnknown)), image.download_size.map(format_bytes).unwrap_or_default() ))), ) @@ -3156,11 +3628,11 @@ impl BootableView { let pi_message = self .discovery_session .state(CatalogFacet::RaspberryPi) - .short_label("Raspberry Pi images"); + .short_label_in(self.locale, t.text(Message::CatalogSubjectPiImages)); let image_message = if self.pi_catalog.is_some() && !query.is_empty() { - format!("No Raspberry Pi images match “{query}”") + t.format(Message::PiEmptyQuery, &[("query", &query)]) } else if self.pi_catalog.is_some() { - "No compatible Raspberry Pi images found".into() + t.text(Message::PiEmpty).into() } else { pi_message.clone() }; @@ -3187,11 +3659,14 @@ impl BootableView { div() .text_base() .font_weight(FontWeight::SEMIBOLD) - .child("Official Raspberry Pi Imager catalog"), + .child(t.text(Message::PiTitle)), ) - .child(div().text_xs().text_color(rgb(0x7890a8)).child( - "Board compatibility, compressed and extracted checksums included", - )), + .child( + div() + .text_xs() + .text_color(rgb(0x7890a8)) + .child(t.text(Message::PiSubtitle)), + ), ) .child( Button::new("reload-pi-catalog") @@ -3203,12 +3678,12 @@ impl BootableView { .state(CatalogFacet::RaspberryPi) .is_failed() { - "Retry" + t.text(Message::ActionRetry) } else { - "Refresh" + t.text(Message::ActionRefresh) }, ) - .tooltip("Refresh Raspberry Pi catalog") + .tooltip(t.text(Message::TooltipRefreshPi)) .on_click(cx.listener(|this, _, _, cx| { this.show_raspberry_pi_with(CacheMode::Refresh, cx); })), @@ -3229,14 +3704,14 @@ impl BootableView { .text_xs() .font_weight(FontWeight::SEMIBOLD) .text_color(rgb(0x8fa4bd)) - .child("BOARD FILTER"), + .child(t.heading(Message::PiBoardFilter)), ) .child( Button::new("pi-device-all") .when(self.selected_pi_device.is_none(), |button| { button.primary() }) - .label("All images") + .label(t.text(Message::PiAllImages)) .on_click(cx.listener(|this, _, _, cx| { this.select_pi_device(None, cx) })), @@ -3274,7 +3749,7 @@ impl BootableView { .text_xs() .font_weight(FontWeight::SEMIBOLD) .text_color(rgb(0x8fa4bd)) - .child("COMPATIBLE IMAGES"), + .child(t.heading(Message::PiCompatibleImages)), ) .child( div() @@ -3325,40 +3800,64 @@ impl BootableView { .child(description.clone()), ) }) - .child(div().text_xs().text_color(rgb(0x7890a8)).child( - format!( - "Download {} · Expanded {}\nReleased {}\n{}", - image - .download_size - .map(format_bytes) - .unwrap_or_default(), - image - .extracted_size - .map(format_bytes) - .unwrap_or_default(), - image.release_date.as_deref().unwrap_or("unknown"), - image - .category - .as_deref() - .unwrap_or("Raspberry Pi image") + .child( + div().text_xs().text_color(rgb(0x7890a8)).child( + t.format( + Message::PiDetails, + &[ + ( + "download", + &image + .download_size + .map(format_bytes) + .unwrap_or_default(), + ), + ( + "expanded", + &image + .extracted_size + .map(format_bytes) + .unwrap_or_default(), + ), + ( + "date", + &image.release_date.as_deref().unwrap_or( + t.text(Message::PiDateUnknown), + ), + ), + ( + "description", + &image.category.as_deref().unwrap_or( + t.text(Message::PiDefaultCategory), + ), + ), + ], + ), ), - )) + ) }) .child( Button::new("download-pi-image") .primary() .disabled(selected.is_none()) .icon(Icon::empty().path("ui/download.svg")) - .label("Download, verify & use") + .label(t.text(Message::PiDownloadUse)) .on_click(cx.listener(|this, _, _, cx| { this.download_pi_catalog_image(cx) })), ), ), ) + .child( + div() + .text_xs() + .text_color(rgb(0x7890a8)) + .child(t.text(Message::PiHint)), + ) } fn source_card(&self, cx: &mut Context) -> impl IntoElement { + let t = self.t(); let details = self .image .as_ref() @@ -3370,7 +3869,7 @@ impl BootableView { format_bytes(image.size) ) }) - .unwrap_or_else(|| "ISO, IMG, RAW, or compressed disk image".into()); + .unwrap_or_else(|| t.text(Message::SourceFormats).into()); div() .flex() .flex_1() @@ -3408,7 +3907,7 @@ impl BootableView { .font_weight(FontWeight::SEMIBOLD) .text_color(rgb(0xe8f0f8)) .child(Icon::empty().path("ui/image.svg")) - .child("Choose an image"), + .child(t.text(Message::SourceTitle)), ), ) .child( @@ -3427,6 +3926,8 @@ impl BootableView { div() .flex() .flex_col() + .flex_1() + .min_w(px(0.)) .gap_2() .child(div().text_sm().text_color(rgb(0xa9b8c9)).child(details)) .when(self.image.is_some(), |details| { @@ -3435,14 +3936,14 @@ impl BootableView { .text_xs() .font_weight(FontWeight::SEMIBOLD) .text_color(rgb(0x5bd7c0)) - .child("✓ Inspected"), + .child(format!("✓ {}", t.text(Message::SourceInspected))), ) }) .child( div() .text_xs() .text_color(rgb(0x6f8299)) - .child("The image is inspected before any write is allowed"), + .child(t.text(Message::SourceHint)), ), ) .child( @@ -3450,19 +3951,97 @@ impl BootableView { .primary() .disabled(self.image_loading) .icon(Icon::empty().path("ui/image.svg")) - .label(if self.image_loading { - "Inspecting…" + .label(t.text(if self.image_loading { + Message::ActionInspecting } else if self.image.is_some() { - "Change" + Message::ActionChange } else { - "Browse" - }) + Message::ActionBrowse + })) .on_click(cx.listener(|this, _, _, cx| this.choose_image(cx))), ), ) + .child(self.recent_images_row(cx)) + } + + fn recent_images_row(&self, cx: &mut Context) -> impl IntoElement { + let t = self.t(); + let recents = self.preferences.recent_images(); + div() + .flex() + .flex_col() + .gap_1() + .when(recents.is_empty(), |row| { + row.child( + div() + .text_xs() + .text_color(rgb(0x6f8299)) + .child(t.text(Message::SourceRecentEmpty)), + ) + }) + .when(!recents.is_empty(), |row| { + row.child( + div() + .text_xs() + .font_weight(FontWeight::SEMIBOLD) + .text_color(rgb(0x8fa4bd)) + .child(t.heading(Message::SourceRecentTitle)), + ) + .children( + recents + .into_iter() + .take(3) + .enumerate() + .map(|(index, recent)| { + let path = recent.path.clone(); + let current = self + .image + .as_ref() + .is_some_and(|image| image.path == recent.path); + div() + .id(("recent-image", index)) + .flex() + .items_center() + .justify_between() + .gap_3() + .px_3() + .py_1() + .rounded_md() + .border_1() + .border_color(rgb(if current { 0x36d3b4 } else { 0x1f2c3c })) + .bg(rgb(0x0d151f)) + .when(!self.image_loading, |item| { + item.cursor_pointer().on_click(cx.listener( + move |this, _, _, cx| { + this.inspect_image_path(path.clone(), cx) + }, + )) + }) + .child( + div() + .flex_1() + .overflow_hidden() + .text_sm() + .text_color(rgb(0xc4d2e2)) + .child(recent.file_name()), + ) + .child( + div() + .text_xs() + .text_color(rgb(if current { 0x5bd7c0 } else { 0x8fa4bd })) + .child(if current { + t.text(Message::SourceRecentInUse).to_string() + } else { + format_bytes(recent.size) + }), + ) + }), + ) + }) } fn advanced_card(&self, cx: &mut Context) -> impl IntoElement { + let t = self.t(); let windows_available = self .image .as_ref() @@ -3507,14 +4086,25 @@ impl BootableView { .text_base() .font_weight(FontWeight::SEMIBOLD) .child(Icon::empty().path("ui/settings.svg")) - .child("Windows installer options"), + .child(t.text(Message::OptionsWindowsTitle)), ) + .child(div().text_sm().text_color(rgb(0x7890a8)).child(t.format( + Message::OptionsSelectedCount, + &[("count", &selected_count)], + ))), + ) + .child( + div() + .flex() + .flex_col() + .gap_1() .child( div() - .text_sm() + .text_xs() .text_color(rgb(0x7890a8)) - .child(format!("{selected_count} selected")), - ), + .child(t.text(Message::OptionsWindowsPartitionScheme)), + ) + .child(Select::new(&self.windows_partition_scheme).w_full()), ) .child( div() @@ -3525,9 +4115,15 @@ impl BootableView { div() .text_xs() .text_color(rgb(0x7890a8)) - .child("Partition scheme · target firmware"), + .child(t.text(Message::OptionsWindowsBootFirmwareExperimental)), ) - .child(Select::new(&self.windows_partition_scheme).w_full()), + .child(Select::new(&self.windows_boot_firmware).w_full()) + .child( + div() + .text_xs() + .text_color(rgb(0x7890a8)) + .child(t.text(Message::OptionsWindowsBootFirmwareHint)), + ), ) .child( div() @@ -3537,113 +4133,172 @@ impl BootableView { .child( Checkbox::new("windows-requirements") .checked(self.options.windows.bypass_hardware_requirements) - .label("Bypass TPM, Secure Boot and RAM checks") + .label(t.text(Message::OptionsWindowsBypassHardwareLabel)) .on_click(cx.listener(|this, checked: &bool, _, cx| { this.options.windows.bypass_hardware_requirements = *checked; - this.status = "Windows installer selections updated".into(); + this.status = this.toggle_status( + *checked, + Message::OptionsWindowsBypassHardwareOn, + Message::OptionsWindowsBypassHardwareOff, + ); cx.notify(); })), ) .child( Checkbox::new("windows-offline-account") .checked(self.options.windows.allow_offline_account) - .label("Expose local/offline account setup") + .label(t.text(Message::OptionsWindowsOfflineAccountLabel)) .on_click(cx.listener(|this, checked: &bool, _, cx| { this.options.windows.allow_offline_account = *checked; - this.status = "Windows installer selections updated".into(); + this.status = this.toggle_status( + *checked, + Message::OptionsWindowsOfflineAccountOn, + Message::OptionsWindowsOfflineAccountOff, + ); cx.notify(); })), ) .child( Checkbox::new("windows-local-account") .checked(self.options.windows.local_account.is_some()) - .label(format!( - "Create local account: {}", - self.options - .windows - .local_account - .clone() - .or_else(bootable_core::suggested_account_name) - .unwrap_or_else(|| "User".into()) - )) + .label( + t.format( + Message::OptionsWindowsNamedAccountLabel, + &[( + "name", + &self + .options + .windows + .local_account + .clone() + .or_else(bootable_core::suggested_account_name) + .unwrap_or_else(|| "User".into()), + )], + ), + ) .on_click(cx.listener(|this, checked: &bool, _, cx| { this.options.windows.local_account = checked.then(|| { bootable_core::suggested_account_name() .unwrap_or_else(|| "User".into()) }); - this.status = "Windows installer selections updated".into(); + let t = this.t(); + this.status = match &this.options.windows.local_account { + Some(account) => t.format( + Message::OptionsWindowsNamedAccountOn, + &[("account", account)], + ), + None => t + .text(Message::OptionsWindowsNamedAccountOff) + .into(), + }; cx.notify(); })), ) .child( Checkbox::new("windows-regional") .checked(self.options.windows.regional.is_some()) - .label("Copy this computer's locale and time zone") + .label(t.text(Message::OptionsWindowsHostRegionLabel)) .on_click(cx.listener(|this, checked: &bool, _, cx| { - this.options.windows.regional = checked - .then(bootable_core::host_regional_options); - this.status = "Windows installer selections updated".into(); + this.options.windows.regional = + checked.then(bootable_core::host_regional_options); + let t = this.t(); + this.status = match &this.options.windows.regional { + Some(regional) => t.format( + Message::OptionsWindowsHostRegionOn, + &[ + ("locale", ®ional.user_locale), + ("zone", ®ional.time_zone), + ], + ), + None => { + t.text(Message::OptionsWindowsHostRegionOff).into() + } + }; cx.notify(); })), ) .child( Checkbox::new("windows-privacy") .checked(self.options.windows.minimize_data_collection) - .label("Apply privacy-focused OOBE defaults") + .label(t.text(Message::OptionsWindowsPrivacyLabel)) .on_click(cx.listener(|this, checked: &bool, _, cx| { this.options.windows.minimize_data_collection = *checked; - this.status = "Windows installer selections updated".into(); + this.status = this.toggle_status( + *checked, + Message::OptionsWindowsPrivacyOn, + Message::OptionsWindowsPrivacyOff, + ); cx.notify(); })), ) .child( Checkbox::new("windows-bitlocker") .checked(self.options.windows.disable_bitlocker) - .label("Disable automatic BitLocker encryption") + .label(t.text(Message::OptionsWindowsBitlockerLabel)) .on_click(cx.listener(|this, checked: &bool, _, cx| { this.options.windows.disable_bitlocker = *checked; - this.status = "Windows installer selections updated".into(); + this.status = this.toggle_status( + *checked, + Message::OptionsWindowsBitlockerOn, + Message::OptionsWindowsBitlockerOff, + ); cx.notify(); })), ) .child( Checkbox::new("windows-qol") .checked(self.options.windows.quality_of_life) - .label("QoL: reduce Copilot, OneDrive, Teams, suggestions, and Fast Startup") + .label(t.text(Message::OptionsWindowsQolLabel)) .on_click(cx.listener(|this, checked: &bool, _, cx| { this.options.windows.quality_of_life = *checked; - this.status = "Windows installer selections updated".into(); + this.status = this.toggle_status( + *checked, + Message::OptionsWindowsQolOn, + Message::OptionsWindowsQolOff, + ); cx.notify(); })), ) .child( Checkbox::new("windows-ca-2023") .checked(self.options.windows.use_windows_ca_2023) - .label("Use Windows UEFI CA 2023 signed bootloaders") + .label(t.text(Message::OptionsWindowsCa2023Label)) .on_click(cx.listener(|this, checked: &bool, _, cx| { this.options.windows.use_windows_ca_2023 = *checked; - this.status = "CA 2023 media requires updated Secure Boot firmware certificates".into(); + this.status = this.toggle_status( + *checked, + Message::OptionsWindowsCa2023On, + Message::OptionsWindowsCa2023Off, + ); cx.notify(); })), ) .child( Checkbox::new("windows-skusi-policy") .checked(self.options.windows.apply_skusi_policy) - .label("Apply SkuSiPolicy.p7b Secure Boot revocations") + .label(t.text(Message::OptionsWindowsSkusipolicyLabel)) .on_click(cx.listener(|this, checked: &bool, _, cx| { this.options.windows.apply_skusi_policy = *checked; - this.status = "Windows installer selections updated".into(); + this.status = this.toggle_status( + *checked, + Message::OptionsWindowsSkusipolicyOn, + Message::OptionsWindowsSkusipolicyOff, + ); cx.notify(); })), ) .child( Checkbox::new("windows-s-mode") .checked(self.options.windows.force_s_mode) - .label("Force Windows S Mode (expert)") + .label(t.text(Message::OptionsWindowsSmodeLabel)) .on_click(cx.listener(|this, checked: &bool, _, cx| { this.options.windows.force_s_mode = *checked; - this.status = "S Mode may remain enforced after reinstall; review before writing".into(); + this.status = this.toggle_status( + *checked, + Message::OptionsWindowsSmodeOn, + Message::OptionsWindowsSmodeOff, + ); cx.notify(); })), ), @@ -3659,7 +4314,7 @@ impl BootableView { .text_base() .font_weight(FontWeight::SEMIBOLD) .child(Icon::empty().path("ui/settings.svg")) - .child("Linux / Unix boot media"), + .child(t.text(Message::OptionsLinuxTitle)), ) .child( div() @@ -3670,13 +4325,25 @@ impl BootableView { Checkbox::new("raw-image-write") .checked(true) .disabled(true) - .label("Preserve the complete bootable disk layout"), + .label(t.text(Message::OptionsLinuxLayout)), ) .child( Checkbox::new("raw-image-verify") .checked(true) .disabled(true) - .label("Verify the written bytes with SHA-256"), + .label(t.text(Message::OptionsLinuxVerify)), + ) + .child( + Checkbox::new("raw-image-boot-records") + .checked(true) + .disabled(true) + .label(t.text(Message::OptionsLinuxBootRecordsShort)), + ) + .child( + Checkbox::new("raw-image-unmount") + .checked(true) + .disabled(true) + .label(t.text(Message::OptionsLinuxUnmountShort)), ), ) }) @@ -3696,13 +4363,13 @@ impl BootableView { div() .text_sm() .font_weight(FontWeight::SEMIBOLD) - .child("Media tools"), + .child(t.text(Message::OptionsToolsTitle)), ) .child( div() .text_xs() .text_color(rgb(0x7890a8)) - .child("Verification and backup utilities"), + .child(t.text(Message::OptionsToolsSubtitle)), ), ) .child( @@ -3713,7 +4380,7 @@ impl BootableView { .child( Button::new("bad-block-check") .compact() - .label(format!("Bad blocks · {}", self.options.bad_block_check)) + .label(self.options.bad_block_check.label_in(self.locale)) .on_click( cx.listener(|this, _, _, cx| this.cycle_bad_blocks(cx)), ), @@ -3731,7 +4398,7 @@ impl BootableView { Button::new("checksum-image") .compact() .icon(Icon::empty().path("ui/hash.svg")) - .label("Verify image") + .label(t.text(Message::OptionsToolsVerifyImage)) .on_click( cx.listener(|this, _, _, cx| this.checksum_image(cx)), ), @@ -3740,14 +4407,14 @@ impl BootableView { Button::new("choose-folder") .compact() .icon(Icon::empty().path("ui/folder.svg")) - .label("Image folder") + .label(t.text(Message::OptionsToolsImageFolder)) .on_click(cx.listener(|this, _, _, cx| this.choose_folder(cx))), ) .child( Button::new("backup-device") .compact() .icon(Icon::empty().path("ui/backup.svg")) - .label("Back up drive") + .label(t.text(Message::OptionsToolsBackupDrive)) .on_click(cx.listener(|this, _, _, cx| this.backup_device(cx))), ), ), @@ -3755,6 +4422,7 @@ impl BootableView { } fn target_cards(&self, cx: &mut Context) -> impl IntoElement { + let t = self.t(); if self.devices.is_empty() { return div() .p_4() @@ -3764,7 +4432,7 @@ impl BootableView { .bg(rgb(0x0d151f)) .text_sm() .text_color(rgb(0x8fa4bd)) - .child("Connect a removable USB or SD drive, then refresh") + .child(t.text(Message::TargetEmpty)) .into_any_element(); } let cards = self @@ -3775,7 +4443,7 @@ impl BootableView { let selected = self.selected_device == Some(index); let blocked = !device.is_eligible_target(); let border = if selected { 0x36d3b4 } else { 0x283345 }; - let status = target_eligibility_label(device); + let status = target_eligibility_label_in(self.locale, device); div() .id(("device", index)) .flex() @@ -3791,14 +4459,17 @@ impl BootableView { .cursor_pointer() .on_click(cx.listener(move |this, _, _, cx| { this.selected_device = Some(index); - this.status = "Target selected · confirm the physical drive before reviewing the erase plan".into(); + this.status = this.t().text(Message::StatusTargetSelected).into(); cx.notify(); })) }) + .gap_3() .child( div() .flex() .flex_col() + .flex_1() + .min_w(px(0.)) .gap_1() .child( div() @@ -3818,6 +4489,7 @@ impl BootableView { div() .flex() .flex_col() + .flex_shrink_0() .items_end() .gap_1() .child( @@ -3833,11 +4505,11 @@ impl BootableView { .font_weight(FontWeight::SEMIBOLD) .text_color(rgb(if blocked { 0xf29a9a } else { 0x8fc7ff })) .child(if blocked { - "Blocked" + t.text(Message::ActionBlocked).to_string() } else if selected { - "Selected" + t.text(Message::ActionSelected).to_string() } else { - "Select →" + format!("{} →", t.text(Message::ActionSelect)) }), ), ) @@ -3852,7 +4524,172 @@ impl BootableView { .into_any_element() } + fn selected_device_details(&self) -> Option { + let device = self.devices.get(self.selected_device?)?; + let locale = self.locale; + let t = self.t(); + // Translated labels (German, Russian) run longer than the English ones. + let label_width = if locale.is_source() { 84. } else { 112. }; + let rows = device_details_in(locale, device) + .into_iter() + .map(|row| { + div() + .flex() + .gap_2() + .child( + div() + .w(px(label_width)) + .flex_shrink_0() + .text_xs() + .text_color(rgb(0x6f8299)) + .child(row.label), + ) + .child( + div() + .flex_1() + .text_xs() + .text_color(rgb(0xc4d2e2)) + .child(row.value), + ) + }) + .collect::>(); + Some( + div() + .flex() + .flex_col() + .gap_1() + .p_3() + .rounded_lg() + .border_1() + .border_color(rgb(0x1f2c3c)) + .bg(rgb(0x0d151f)) + .child( + div() + .text_xs() + .font_weight(FontWeight::SEMIBOLD) + .text_color(rgb(0x8fa4bd)) + .child(t.heading(Message::TargetSelectedDrive)), + ) + .children(rows), + ) + } + + fn help_overlay(&self, cx: &mut Context) -> impl IntoElement { + let t = self.t(); + let sections = help_sections(self.locale) + .iter() + .map(|section| { + div() + .flex() + .flex_col() + .gap_1() + .child( + div() + .text_xs() + .font_weight(FontWeight::SEMIBOLD) + .text_color(rgb(0x5bd7c0)) + .child(section.title.to_uppercase()), + ) + .children(section.entries.iter().map(|entry| { + div() + .flex() + .items_start() + .justify_between() + .gap_4() + .child( + div() + .flex() + .flex_col() + .flex_1() + .min_w(px(0.)) + .child(div().text_sm().child(entry.action)) + .child( + div() + .text_xs() + .text_color(rgb(0x8fa4bd)) + .child(entry.detail), + ), + ) + .child( + div() + .flex_shrink_0() + .text_sm() + .font_weight(FontWeight::SEMIBOLD) + .text_color(rgb(0xe5b95f)) + .child(entry.desktop), + ) + })) + }) + .collect::>(); + div() + .id("help-backdrop") + .absolute() + .size_full() + .flex() + .items_center() + .justify_center() + .p_6() + .bg(rgba(0x000000cc)) + .on_mouse_down( + MouseButton::Left, + cx.listener(|this, _, _, cx| { + this.help_open = false; + cx.notify(); + }), + ) + .child( + div() + .id("help-panel") + .w_full() + .max_w(px(640.)) + .max_h(relative(0.92)) + .overflow_y_scrollbar() + .flex() + .flex_col() + .gap_3() + .p_5() + .rounded_xl() + .border_1() + .border_color(rgb(0x243244)) + .bg(rgb(0x111923)) + .on_mouse_down(MouseButton::Left, |_, _, cx| cx.stop_propagation()) + .child( + div() + .flex() + .items_center() + .justify_between() + .child( + div() + .text_xl() + .font_weight(FontWeight::BOLD) + .child(t.text(Message::GuideTitle)), + ) + .child( + Button::new("close-help") + .label(t.text(Message::ActionClose)) + .on_click(cx.listener(|this, _, _, cx| { + this.help_open = false; + cx.notify(); + })), + ), + ) + .child( + div() + .text_sm() + .text_color(rgb(0xa9b8c9)) + .child(help_intro(self.locale)), + ) + .children(sections), + ) + } + fn download_history_card(&self, cx: &mut Context) -> impl IntoElement { + let t = self.t(); + let any_interrupted = self + .download_session + .jobs() + .iter() + .any(|job| job.status == DownloadStatus::Interrupted); let rows = self .download_session .jobs() @@ -3889,6 +4726,7 @@ impl BootableView { div() .flex() .flex_col() + .flex_1() .min_w(px(0.)) .child( div() @@ -3906,6 +4744,8 @@ impl BootableView { .child( div() .flex() + .flex_wrap() + .justify_end() .items_center() .gap_2() .child( @@ -3913,13 +4753,13 @@ impl BootableView { .text_xs() .font_weight(FontWeight::BOLD) .text_color(rgb(status_color)) - .child(job.status.to_string()), + .child(job.status.label_in(self.locale)), ) .when(job.status.can_retry(), |actions| { actions.child( Button::new(("retry-download", index)) .compact() - .label("Retry") + .label(t.text(Message::DownloadsActionRetryResume)) .on_click(cx.listener(move |this, _, _, cx| { this.retry_managed_download( retry_id.clone(), @@ -3933,7 +4773,7 @@ impl BootableView { Button::new(("use-download", index)) .compact() .primary() - .label("Use") + .label(t.text(Message::DownloadsActionUseImage)) .on_click(cx.listener(move |this, _, _, cx| { this.use_managed_download(&use_id, cx) })), @@ -3948,7 +4788,7 @@ impl BootableView { actions.child( Button::new(("remove-download", index)) .compact() - .label("Remove") + .label(t.text(Message::DownloadsActionRemove)) .on_click(cx.listener( move |this, _, _, cx| { this.remove_managed_download( @@ -3981,7 +4821,12 @@ impl BootableView { div() .text_xs() .text_color(rgb(0x8fa4bd)) - .child(job.error.clone().unwrap_or_else(|| job.message.clone())), + // The job message and error are core text, shown as received. + .child(format!( + "{} · {}", + job.kind.label_in(self.locale), + job.error.clone().unwrap_or_else(|| job.message.clone()) + )), ) }) .collect::>(); @@ -4005,14 +4850,18 @@ impl BootableView { .items_center() .gap_2() .font_weight(FontWeight::SEMIBOLD) + .flex_shrink_0() .child(Icon::empty().path("ui/download.svg")) - .child("Downloads"), + .child(t.text(Message::ActionDownloads)), ) .child( div() + .flex_1() + .min_w(px(0.)) + .text_right() .text_xs() .text_color(rgb(0x7890a8)) - .child("Persistent history · interrupted transfers can resume"), + .child(t.text(Message::DownloadsSubtitle)), ), ) .child( @@ -4028,24 +4877,43 @@ impl BootableView { .p_3() .text_sm() .text_color(rgb(0x7890a8)) - .child("No managed downloads yet"), + .child(t.text(Message::DownloadsEmpty)), ) }) .children(rows), ) + .when(any_interrupted, |card| { + card.child( + div() + .text_xs() + .text_color(rgb(0x7890a8)) + .child(t.text(Message::DownloadsInterruptedNote)), + ) + }) + } + + /// The header language picker: "Language: " or "Language: System + /// default ()". Mirrors the terminal's `L` key, same order and labels. + fn language_control(&self) -> impl IntoElement { + div().flex_shrink_0().w(px(310.)).max_w_full().child( + Select::new(&self.language_select) + .menu_width(px(310.)) + .placeholder(language_display(self.locale, self.preferences.language)), + ) } fn header_bar(&self, cx: &mut Context, compact: bool) -> impl IntoElement { - let advanced_label = if self.advanced { - "Hide options" - } else { - "Setup options" - }; + let t = self.t(); + let advanced_label = self.advanced_label(compact); + let job_count = self.download_session.jobs().len(); div() .flex() .items_center() .justify_between() .gap_3() + // Translated labels and the language picker can exceed one row; + // let the actions drop below the brand instead of being clipped. + .when(!compact, |header| header.flex_wrap()) .when(compact, |header| { header.flex_col().items_start().justify_start().gap_2() }) @@ -4087,7 +4955,7 @@ impl BootableView { div() .text_xs() .text_color(rgb(0x7890a8)) - .child("Boot media, written deliberately."), + .child(t.text(Message::HeaderTagline)), ) }), ) @@ -4100,18 +4968,18 @@ impl BootableView { .flex_col() .gap_1() .child(div().text_lg().font_weight(FontWeight::SEMIBOLD).child( - if self.write_session.is_reviewing() { - "Review write plan" + t.text(if self.write_session.is_reviewing() { + Message::ReviewTitle } else { - "Create boot media" - }, + Message::HeaderTitleCreate + }), )) .child(div().text_xs().text_color(rgb(0x8fa4bd)).child( - if self.write_session.is_reviewing() { - "Inspect every operation before confirmation." + t.text(if self.write_session.is_reviewing() { + Message::HeaderSubtitleReview } else { - "Image → removable drive → verified result" - }, + Message::HeaderSubtitleCreate + }), )), ) }), @@ -4121,14 +4989,17 @@ impl BootableView { .flex() .items_center() .when(compact, |actions| actions.w_full().flex_wrap()) + .when(!compact, |actions| { + actions.flex_wrap().justify_end().ml_auto().max_w_full() + }) .gap_2() .child( Button::new("downloads") .icon(Icon::empty().path("ui/download.svg")) .label(if compact { - format!("Jobs · {}", self.download_session.jobs().len()) + format!("{} · {job_count}", t.text(Message::ActionDownloadsCompact)) } else { - format!("Downloads · {}", self.download_session.jobs().len()) + t.format(Message::ActionDownloadsCount, &[("count", &job_count)]) }) .when(self.downloads_open, |button| button.primary()) .on_click(cx.listener(|this, _, _, cx| this.toggle_downloads(cx))), @@ -4136,16 +5007,12 @@ impl BootableView { .child( Button::new("catalog") .icon(Icon::empty().path("ui/discover.svg")) - .label(if compact { - if self.catalog_open { - "Catalog ×" - } else { - "Discover images" - } + .label(if self.catalog_open && compact { + format!("{} ×", t.text(Message::ActionCatalogCloseCompact)) } else if self.catalog_open { - "Close catalog" + t.text(Message::ActionCatalogClose).to_string() } else { - "Discover images" + t.text(Message::ActionDiscover).to_string() }) .on_click(cx.listener(|this, _, _, cx| this.toggle_catalog(cx))), ) @@ -4153,15 +5020,24 @@ impl BootableView { actions.child( Button::new("advanced") .icon(Icon::empty().path("ui/settings.svg")) - .label(if compact { "Options" } else { advanced_label }) + .label(advanced_label) .on_click(cx.listener(|this, _, _, cx| this.toggle_advanced(cx))), ) }) + .child( + Button::new("guide") + .compact() + .label("?") + .tooltip(t.format(Message::TooltipGuide, &[("shortcut", &"F1")])) + .when(self.help_open, |button| button.primary()) + .on_click(cx.listener(|this, _, _, cx| this.toggle_help(cx))), + ) + .child(self.language_control()) .child( Button::new("refresh") .compact() .icon(Icon::empty().path("ui/refresh.svg")) - .tooltip("Refresh removable drives") + .tooltip(t.text(Message::TooltipRefreshDrives)) .on_click(cx.listener(|this, _, _, cx| this.refresh_devices(cx))), ), ) @@ -4173,6 +5049,7 @@ impl BootableView { self.selected_device .and_then(|index| self.devices.get(index)), ); + let titles = WorkspaceProgress::step_titles(self.locale); div() .flex() .items_center() @@ -4182,18 +5059,16 @@ impl BootableView { .border_1() .border_color(rgb(0x243244)) .bg(rgb(0x0f1925)) - .child(workspace_step("1", "Source", progress.source)) + .child(workspace_step("1", titles[0], progress.source)) .child(div().flex_1().h(px(1.)).bg(rgb(0x243244))) - .child(workspace_step("2", "Target", progress.target)) + .child(workspace_step("2", titles[1], progress.target)) .child(div().flex_1().h(px(1.)).bg(rgb(0x243244))) - .child(workspace_step("3", "Review & write", progress.review)) + .child(workspace_step("3", titles[2], progress.review)) } fn setup_summary(&self, cx: &mut Context) -> impl IntoElement { - let bad_blocks = match self.options.bad_block_check.passes() { - 0 => "Bad blocks off".into(), - passes => format!("Bad blocks {passes}x"), - }; + let t = self.t(); + let bad_blocks = self.options.bad_block_check.label_in(self.locale); div() .id("setup-summary") .flex() @@ -4214,18 +5089,26 @@ impl BootableView { .items_center() .gap_2() .font_weight(FontWeight::SEMIBOLD) + .flex_shrink_0() .child(Icon::empty().path("ui/settings.svg")) - .child("Setup options"), + .child(t.text(Message::ActionSetupOptions)), ) .child( div() + .flex_1() + .min_w(px(0.)) + .text_right() .text_sm() .text_color(rgb(0x8fa4bd)) - .child(format!("Verification on · {bad_blocks}")), + .child(t.format( + Message::OptionsSummaryVerification, + &[("bad_blocks", &bad_blocks)], + )), ) } fn discovery_summary(&self, cx: &mut Context) -> impl IntoElement { + let t = self.t(); div() .id("discovery-summary") .flex() @@ -4246,18 +5129,23 @@ impl BootableView { .items_center() .gap_2() .font_weight(FontWeight::SEMIBOLD) + .flex_shrink_0() .child(Icon::empty().path("ui/discover.svg")) - .child("Discover images"), + .child(t.text(Message::ActionDiscover)), ) .child( div() + .flex_1() + .min_w(px(0.)) + .text_right() .text_sm() .text_color(rgb(0x8fa4bd)) - .child("Browse trusted catalogs · Open →"), + .child(format!("{} →", t.text(Message::DiscoverCollapsedHint))), ) } fn target_panel(&self, cx: &mut Context) -> impl IntoElement { + let t = self.t(); div() .flex() .flex_1() @@ -4271,8 +5159,11 @@ impl BootableView { .child( div() .flex() + .flex_wrap() .items_center() .justify_between() + .gap_x_3() + .gap_y_1() .child( div() .flex() @@ -4283,6 +5174,7 @@ impl BootableView { .flex() .items_center() .justify_center() + .flex_shrink_0() .size(px(28.)) .rounded_full() .bg(rgb(0x183932)) @@ -4299,7 +5191,7 @@ impl BootableView { .text_base() .font_weight(FontWeight::SEMIBOLD) .child(Icon::empty().path("ui/usb.svg")) - .child("Choose a drive"), + .child(t.text(Message::TargetTitle)), ), ) .child( @@ -4307,7 +5199,7 @@ impl BootableView { .text_xs() .font_weight(FontWeight::SEMIBOLD) .text_color(rgb(0x5bd7c0)) - .child(removable_media_status(&self.devices)), + .child(removable_media_status_in(self.locale, &self.devices)), ), ) .child( @@ -4316,12 +5208,17 @@ impl BootableView { .overflow_y_scrollbar() .child(self.target_cards(cx)), ) - .child(div().text_xs().text_color(rgb(0x8fa4bd)).child( - "Confirm the physical drive before continuing · erasure starts only after review", - )) + .children(self.selected_device_details()) + .child( + div() + .text_xs() + .text_color(rgb(0x8fa4bd)) + .child(t.text(Message::TargetConfirmPhysical)), + ) } fn status_bar(&self, cx: &mut Context) -> impl IntoElement { + let t = self.t(); let readiness = self.review_readiness(); let download_state = self .download_session @@ -4375,7 +5272,7 @@ impl BootableView { self.selected_device .and_then(|index| self.devices.get(index)), ) - .status(), + .status_in(self.locale), ), ) .when_some( @@ -4414,11 +5311,11 @@ impl BootableView { actions .child( Button::new("pause-download") - .label(if state == OperationState::Paused { - "Resume" + .label(t.text(if state == OperationState::Paused { + Message::ActionResume } else { - "Pause" - }) + Message::ActionPause + })) .disabled(state == OperationState::Cancelled) .on_click( cx.listener(|this, _, _, cx| { @@ -4428,11 +5325,11 @@ impl BootableView { ) .child( Button::new("cancel-download") - .label(if state == OperationState::Cancelled { - "Cancelling…" + .label(t.text(if state == OperationState::Cancelled { + Message::ActionCancelling } else { - "Cancel" - }) + Message::DownloadsActionCancel + })) .disabled(state == OperationState::Cancelled) .on_click( cx.listener(|this, _, _, cx| this.cancel_download(cx)), @@ -4444,7 +5341,7 @@ impl BootableView { Button::new("preview") .primary() .icon(Icon::empty().path("ui/review.svg")) - .label(readiness.action_label()) + .label(readiness.action_label_in(self.locale)) .disabled(readiness != ReviewReadiness::Ready) .on_click(cx.listener(|this, _, _, cx| this.preview_plan(cx))), ) @@ -4455,11 +5352,36 @@ impl BootableView { impl Render for BootableView { fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { + if std::mem::take(&mut self.firmware_select_stale) { + self.sync_firmware_select(window, cx); + } let viewport = window.viewport_size(); let layout = ViewportLayout::new(viewport.width, viewport.height); div() .size_full() .relative() + .key_context(KEY_CONTEXT) + .track_focus(&self.focus_handle) + .on_action(cx.listener(|this, _: &OpenImage, _, cx| { + if !this.write_session.is_reviewing() { + this.choose_image(cx); + } + })) + .on_action(cx.listener(|this, _: &ToggleDiscover, _, cx| { + if !this.write_session.is_reviewing() { + this.toggle_catalog(cx); + } + })) + .on_action(cx.listener(|this, _: &RefreshDrives, _, cx| { + this.refresh_devices(cx); + })) + .on_action(cx.listener(|this, _: &ReviewPlan, _, cx| { + if !this.write_session.is_reviewing() { + this.preview_plan(cx); + } + })) + .on_action(cx.listener(|this, _: &ToggleHelp, _, cx| this.toggle_help(cx))) + .on_action(cx.listener(|this, _: &CloseOverlay, _, cx| this.close_overlay(cx))) .bg(rgb(0x0b1119)) .text_color(rgb(0xe8f0f8)) .flex() @@ -4587,7 +5509,77 @@ impl Render for BootableView { .when(self.write_session.confirmation_open(), |root| { root.child(self.write_confirmation_modal(cx)) }) + .when( + self.help_open && !self.write_session.confirmation_open(), + |root| root.child(self.help_overlay(cx)), + ) + } +} + +/// One entry of the language picker: `None` follows the system language. +#[derive(Clone)] +struct LanguageChoice { + language: Option, + /// Label in the dropdown list. + title: SharedString, + /// Text shown on the closed control. + display: SharedString, +} + +impl SelectItem for LanguageChoice { + type Value = Option; + + fn title(&self) -> SharedString { + self.title.clone() } + + fn display_title(&self) -> Option { + Some(self.display.clone().into_any_element()) + } + + fn value(&self) -> &Self::Value { + &self.language + } +} + +/// `Language: ` or `Language: System default ()`. +fn language_display(active: Locale, language: Option) -> String { + let label = Message::LanguageLabel.text(active); + format!("{label}: {}", language_title(active, language)) +} + +fn language_title(active: Locale, language: Option) -> String { + match language { + Some(locale) => locale.native_name().to_string(), + None => format!( + "{} ({})", + Message::LanguageSystemDefault.text(active), + active.native_name() + ), + } +} + +/// System default first, then every translated locale by its own name. +fn language_choices(active: Locale) -> Vec { + std::iter::once(None) + .chain(Locale::available().iter().copied().map(Some)) + .map(|language| LanguageChoice { + language, + title: language_title(active, language).into(), + display: language_display(active, language).into(), + }) + .collect() +} + +fn language_index(language: Option) -> Option { + let row = match language { + None => Some(0), + Some(locale) => Locale::available() + .iter() + .position(|candidate| *candidate == locale) + .map(|position| position + 1), + }?; + Some(gpui_component::IndexPath::default().row(row)) } fn workspace_step( @@ -4632,7 +5624,7 @@ fn workspace_step( ) } -fn review_value(label: &'static str, value: String) -> impl IntoElement { +fn review_value(label: String, value: String) -> impl IntoElement { div() .flex() .flex_col() @@ -4646,9 +5638,17 @@ fn review_value(label: &'static str, value: String) -> impl IntoElement { .child(div().text_sm().child(value)) } -fn compact_list(values: &[String], limit: usize) -> String { +/// `label: value` through the catalog, so spacing follows the language. +fn labeled(t: Strings, label: Message, value: &str) -> String { + t.format( + Message::CommonLabeled, + &[("label", &t.text(label)), ("value", &value)], + ) +} + +fn compact_list(t: Strings, values: &[String], limit: usize) -> String { if values.is_empty() { - return "Not listed".into(); + return t.text(Message::DiscoverDetailNotListed).into(); } let mut result = values .iter() @@ -4662,14 +5662,15 @@ fn compact_list(values: &[String], limit: usize) -> String { result } -fn device_change_message(added: usize, removed: usize) -> String { +fn device_change_message(t: Strings, added: usize, removed: usize) -> String { match (added, removed) { - (0, 0) => "Drive details changed • list updated automatically".into(), - (added, 0) => format!("Detected {added} new drive(s) • list updated automatically"), - (0, removed) => format!("Removed {removed} drive(s) • list updated automatically"), - (added, removed) => { - format!("Drive list changed: {added} added, {removed} removed • updated automatically") - } + (0, 0) => t.text(Message::StatusDrivesChanged).into(), + (added, 0) => t.plural(Message::StatusDrivesAdded, added as u64, &[]), + (0, removed) => t.plural(Message::StatusDrivesRemoved, removed as u64, &[]), + (added, removed) => t.format( + Message::StatusDrivesAddedRemoved, + &[("added", &added), ("removed", &removed)], + ), } } @@ -4681,6 +5682,15 @@ fn main() { cx.set_http_client(Arc::new(client)); } gpui_component::init(cx); + cx.bind_keys([ + KeyBinding::new("secondary-o", OpenImage, Some(KEY_CONTEXT)), + KeyBinding::new("secondary-g", ToggleDiscover, Some(KEY_CONTEXT)), + KeyBinding::new("secondary-r", RefreshDrives, Some(KEY_CONTEXT)), + KeyBinding::new("secondary-p", ReviewPlan, Some(KEY_CONTEXT)), + KeyBinding::new("f1", ToggleHelp, Some(KEY_CONTEXT)), + KeyBinding::new("secondary-/", ToggleHelp, Some(KEY_CONTEXT)), + KeyBinding::new("escape", CloseOverlay, Some(KEY_CONTEXT)), + ]); gpui_component::Theme::change(gpui_component::ThemeMode::Dark, None, cx); { let theme = gpui_component::Theme::global_mut(cx); @@ -4727,9 +5737,7 @@ fn main() { control.cancel(); view.status = "Stopping write safely • close again after completed writes are flushed".into(); } else { - view.status = - "Writing is active • do not close the app or unplug the target" - .into(); + view.status = view.t().text(Message::StatusWriteActive).into(); } cx.notify(); }); @@ -4745,9 +5753,137 @@ fn main() { #[cfg(test)] mod tests { - use super::{BootableAssets, ViewportLayout}; + use super::{ + BootableAssets, ViewportLayout, choose_boot_firmware, choose_partition_scheme, + compact_list, device_change_message, labeled, + }; + use bootable_core::{ + Bootable, Device, DeviceId, ImageKind, ImageReport, Locale, Message, WindowsBootFirmware, + WindowsPartitionScheme, WriteOptions, + }; use gpui::{AssetSource, px}; + fn sample_plan() -> bootable_core::WritePlan { + let device = Device { + id: DeviceId::new("serial:ABC123456"), + path: "/dev/sdz".into(), + vendor: Some("SanDisk".into()), + model: Some("Ultra".into()), + serial: Some("ABC123456".into()), + transport: Some("usb".into()), + capacity: 32 * 1024 * 1024 * 1024, + removable: true, + read_only: false, + system_disk: false, + mounts: Vec::new(), + }; + let image = ImageReport { + path: "linux.iso".into(), + size: 1024 * 1024 * 1024, + kind: ImageKind::HybridIso, + volume_label: None, + warnings: Vec::new(), + }; + Bootable::native() + .plan_with_options(image, device, WriteOptions::default()) + .expect("a removable target gets a plan") + } + + /// The window text around the destructive step is translated; the erase + /// phrase is a protocol token that is compared verbatim, so it must never + /// come from, or appear in, any locale's catalog. + #[test] + fn erase_confirmation_phrase_is_identical_in_every_locale() { + let plan = sample_plan(); + let phrase = plan.confirmation_phrase.clone(); + assert_eq!(phrase, "ERASE /dev/sdz BC123456"); + for &locale in Locale::ALL { + assert!(plan.confirmation_matches(&phrase), "{locale}"); + let t = locale.strings(); + for &message in Message::ALL { + let text = t.text(message); + assert!( + !text.contains(&phrase) && !text.contains("ERASE "), + "{locale}: {} carries the erase phrase", + message.key() + ); + } + } + } + + #[test] + fn english_status_helpers_keep_the_documented_wording() { + let en = Locale::En.strings(); + assert_eq!( + device_change_message(en, 0, 0), + "Drive details changed • list updated automatically" + ); + assert_eq!( + device_change_message(en, 1, 0), + "Detected 1 new drive • list updated automatically" + ); + assert_eq!( + device_change_message(en, 0, 2), + "Removed 2 drives • list updated automatically" + ); + assert_eq!( + device_change_message(en, 1, 2), + "Drive list changed: 1 added, 2 removed • updated automatically" + ); + assert_eq!( + labeled(en, Message::DiscoverDetailArchitecture, "x86_64"), + "Architecture: x86_64" + ); + assert_eq!(compact_list(en, &[], 4), "Not listed"); + let many = ["a", "b", "c", "d", "e", "f"].map(String::from); + assert_eq!(compact_list(en, &many, 4), "a, b, c, d +2"); + } + + #[test] + fn status_helpers_follow_the_active_language() { + let de = Locale::De.strings(); + assert_ne!( + device_change_message(de, 0, 0), + device_change_message(Locale::En.strings(), 0, 0) + ); + assert_eq!(compact_list(de, &[], 4), "Nicht aufgeführt"); + } + + #[test] + fn firmware_and_scheme_choices_stay_coupled() { + let mut options = WriteOptions::default(); + assert_eq!(options.windows_boot_firmware, WindowsBootFirmware::Uefi); + assert!(choose_boot_firmware( + &mut options, + WindowsBootFirmware::BiosAndUefi + )); + assert_eq!( + options.windows_partition_scheme, + WindowsPartitionScheme::Mbr + ); + assert!(!choose_boot_firmware( + &mut options, + WindowsBootFirmware::BiosAndUefi + )); + assert!(!choose_partition_scheme( + &mut options, + WindowsPartitionScheme::Mbr + )); + assert_eq!( + options.windows_boot_firmware, + WindowsBootFirmware::BiosAndUefi + ); + assert!(choose_partition_scheme( + &mut options, + WindowsPartitionScheme::Gpt + )); + assert_eq!(options.windows_boot_firmware, WindowsBootFirmware::Uefi); + assert!(!choose_partition_scheme( + &mut options, + WindowsPartitionScheme::Gpt + )); + } + #[test] fn embeds_every_svg_asset() { let assets = BootableAssets; diff --git a/apps/bootable-tui/Cargo.toml b/apps/bootable-tui/Cargo.toml index 9e70b52..28de8ba 100644 --- a/apps/bootable-tui/Cargo.toml +++ b/apps/bootable-tui/Cargo.toml @@ -13,6 +13,7 @@ path = "src/main.rs" [dependencies] anyhow.workspace = true clap.workspace = true +clap_complete.workspace = true crossterm.workspace = true bootable-core = { path = "../../crates/bootable-core" } image.workspace = true @@ -20,6 +21,7 @@ ratatui.workspace = true ratatui-image.workspace = true rfd = "0.15" serde_json.workspace = true +unicode-width = "0.2" [lints] workspace = true diff --git a/apps/bootable-tui/src/main.rs b/apps/bootable-tui/src/main.rs index adfbcaf..74e515c 100644 --- a/apps/bootable-tui/src/main.rs +++ b/apps/bootable-tui/src/main.rs @@ -1,5 +1,7 @@ +use std::fmt; use std::io::{self, IsTerminal}; -use std::path::PathBuf; +use std::path::{Path, PathBuf}; +use std::process::ExitCode; use std::sync::mpsc::{self, Receiver}; use std::time::{Duration, Instant}; @@ -8,13 +10,16 @@ use bootable_core::{ BadBlockCheck, Bootable, CacheMode, CatalogFacet, CatalogFetch, CatalogState, ChecksumAlgorithm, Device, DiscoverySession, DiscoverySource, DistributionBundle, DistributionDetails, DistributionSummary, DownloadCompletion, DownloadLaunch, DownloadRequest, - DownloadStatus, ImageReport, IsoRelease, ManagedDownloadSession, OperationState, PiCatalog, - Progress, ProgressPhase, QuickAccess, ReviewReadiness, ReviewedWriteSession, + DownloadStatus, ImageReport, IntegrityState, IsoRelease, Locale, ManagedDownloadSession, + Message, OperationControl, OperationState, PiCatalog, Preferences, Progress, ProgressPhase, + QuickAccess, ReviewReadiness, ReviewedWriteSession, Strings, WorkspaceProgress, WorkspaceStepState, WriteCompletion, WriteOptions, WritePlan, catalog_search_summary, - distribution_matches_query, format_bytes, removable_media_status, review_readiness, - target_eligibility_label, workspace_progress, + device_details_in, distribution_matches_query, format_bytes, help_intro, help_sections, + removable_media_status_in, review_readiness, target_eligibility_label, + target_eligibility_label_in, workspace_progress, }; -use clap::{Args, Parser, Subcommand}; +use clap::{Args, CommandFactory, Parser, Subcommand}; +use clap_complete::Shell; use crossterm::event::{ self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode, KeyEventKind, KeyModifiers, MouseButton, MouseEvent, MouseEventKind, @@ -32,6 +37,7 @@ use ratatui::widgets::{ Block, BorderType, Borders, Clear, Gauge, List, ListItem, ListState, Paragraph, Wrap, }; use ratatui_image::{Resize, StatefulImage, picker::Picker, protocol::StatefulProtocol}; +use unicode_width::{UnicodeWidthChar, UnicodeWidthStr}; const DEVICE_SCAN_INTERVAL: Duration = Duration::from_secs(1); const DOWNLOAD_SCAN_INTERVAL: Duration = Duration::from_secs(5); @@ -81,6 +87,10 @@ enum Commands { /// Emit newline-delimited JSON progress for graphical clients. #[arg(long)] json_progress: bool, + /// Refuse the download unless the publisher's checksum manifest carries + /// a verified signature from a key Bootable pins (exit 4 otherwise). + #[arg(long)] + require_signature: bool, }, /// List official Raspberry Pi Imager images. PiImages { @@ -140,12 +150,55 @@ enum Commands { #[arg(long, default_value = "off", value_name = "off|1|2|4")] bad_block_check: BadBlockCheck, }, + /// One step: fetch (if a catalog slug), plan, then write and verify. + /// + /// The target must be named explicitly and must pass the same eligibility + /// checks as `plan` and `write`. Nothing is written unless --confirm + /// repeats the exact phrase printed by the plan. + Flash { + /// Local image path, or a catalog slug (see `bootable catalog`). + #[arg(value_name = "SLUG_OR_IMAGE")] + source: String, + /// Removable device id or path (see `bootable devices`). + target: String, + /// Release index to download when SOURCE is a catalog slug. + #[arg(long, default_value_t = 0)] + index: usize, + /// Where to save a downloaded catalog image. + #[arg(long, value_name = "ISO_FILE")] + output: Option, + /// Refuse a catalog download unless the publisher's checksum manifest + /// carries a verified signature from a pinned key (exit 4 otherwise). + /// Only valid with a catalog slug; a local image has no publisher + /// signature to check, so combining the two is a usage error. + #[arg(long)] + require_signature: bool, + #[arg(long, value_name = "EXACT_PHRASE")] + confirm: Option, + /// Emit newline-delimited JSON progress events for trusted clients. + #[arg(long)] + json_progress: bool, + #[command(flatten)] + windows: WindowsArgs, + #[arg(long, default_value = "off", value_name = "off|1|2|4")] + bad_block_check: BadBlockCheck, + }, + /// Print a shell completion script to stdout. + Completions { + #[arg(value_enum)] + shell: Shell, + }, } #[derive(Debug, Args)] struct WindowsArgs { #[arg(long, default_value = "gpt", value_name = "gpt|mbr")] windows_partition_scheme: bootable_core::WindowsPartitionScheme, + /// Experimental: also boot Windows installer media on legacy BIOS (CSM) + /// machines. Needs --windows-partition-scheme mbr; core refuses + /// unsupported combinations. + #[arg(long, default_value = "uefi", value_name = "uefi|bios-uefi")] + windows_boot_firmware: bootable_core::WindowsBootFirmware, #[arg(long)] bypass_windows_11_requirements: bool, #[arg(long)] @@ -168,9 +221,141 @@ struct WindowsArgs { force_windows_s_mode: bool, } -fn main() -> Result<()> { +/// Stable process exit codes. Documented in `docs/cli.md`; do not renumber. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum ExitStatus { + Ok = 0, + Error = 1, + Usage = 2, + Confirmation = 3, + Verification = 4, +} + +impl ExitStatus { + fn code(self) -> u8 { + self as u8 + } + + fn name(self) -> &'static str { + match self { + Self::Ok => "ok", + Self::Error => "error", + Self::Usage => "usage", + Self::Confirmation => "confirmation_required", + Self::Verification => "verification_failed", + } + } +} + +/// CLI-level failures that carry their own exit status. +#[derive(Debug)] +enum CliError { + Usage(String), + ConfirmationRequired { phrase: String }, +} + +impl fmt::Display for CliError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Usage(message) => formatter.write_str(message), + Self::ConfirmationRequired { phrase } => write!( + formatter, + "nothing was written; repeat with --confirm '{phrase}'" + ), + } + } +} + +impl std::error::Error for CliError {} + +fn core_exit_status(error: &bootable_core::Error) -> ExitStatus { + use bootable_core::Error; + match error { + Error::ConfirmationMismatch { .. } | Error::UnsafeTarget(_) => ExitStatus::Confirmation, + Error::StalePlan(message) + | Error::PrivilegedWriteFailed(message) + | Error::InvalidDownload(message) + if is_verification_message(message) => + { + ExitStatus::Verification + } + _ => ExitStatus::Error, + } +} + +/// Core reports verification failures as message text on a few variants. +fn is_verification_message(message: &str) -> bool { + let message = message.to_ascii_lowercase(); + message.contains("verification failed") + || message.contains("mismatch") + || message.contains("signature is required") +} + +fn exit_status(error: &anyhow::Error) -> ExitStatus { + if let Some(error) = error.downcast_ref::() { + return match error { + CliError::Usage(_) => ExitStatus::Usage, + CliError::ConfirmationRequired { .. } => ExitStatus::Confirmation, + }; + } + error + .downcast_ref::() + .map_or(ExitStatus::Error, core_exit_status) +} + +fn error_json(message: &str, status: ExitStatus) -> serde_json::Value { + serde_json::json!({ + "error": { "kind": status.name(), "exit_code": status.code(), "message": message } + }) +} + +/// Commands whose success output is JSON also report failures as JSON on stderr. +fn wants_json_errors(command: &Commands) -> bool { + match command { + Commands::Catalog { json, .. } + | Commands::Releases { json, .. } + | Commands::PiImages { json, .. } + | Commands::Devices { json } + | Commands::Inspect { json, .. } + | Commands::Checksum { json, .. } + | Commands::Plan { json, .. } => *json, + _ => false, + } +} + +fn main() -> ExitCode { + // clap exits with status 2 on usage errors and 0 for --help/--version. let cli = Cli::parse(); - let engine = Bootable::native(); + let json_errors = cli.command.as_ref().is_some_and(wants_json_errors); + match run(cli) { + Ok(()) => ExitCode::from(ExitStatus::Ok.code()), + Err(error) => { + let status = exit_status(&error); + let message = error.to_string(); + if json_errors { + eprintln!("{}", error_json(&message, status)); + } else { + eprintln!("error: {message}"); + } + ExitCode::from(status.code()) + } + } +} + +fn run(cli: Cli) -> Result<()> { + let require_signature = matches!( + &cli.command, + Some( + Commands::Download { + require_signature: true, + .. + } | Commands::Flash { + require_signature: true, + .. + } + ) + ); + let engine = Bootable::native().require_signature(require_signature); match cli.command { Some(Commands::Catalog { limit, json }) => print_catalog(&engine, limit, json), Some(Commands::Releases { slug, json }) => print_releases(&engine, &slug, json), @@ -179,6 +364,7 @@ fn main() -> Result<()> { index, output, json_progress, + require_signature: _, }) => download_release(&engine, &slug, index, output, json_progress), Some(Commands::PiImages { device, @@ -226,11 +412,45 @@ fn main() -> Result<()> { json_progress, write_options(windows, bad_block_check), ), + Some(Commands::Flash { + source, + target, + index, + output, + require_signature, + confirm, + json_progress, + windows, + bad_block_check, + }) => flash_image( + &engine, + FlashRequest { + source, + target, + index, + output, + require_signature, + confirm, + json_progress, + options: write_options(windows, bad_block_check), + }, + ), + Some(Commands::Completions { shell }) => { + print_completions(shell); + Ok(()) + } None if io::stdout().is_terminal() => run_tui(engine, cli.image), - None => bail!("interactive mode needs a terminal; use `bootable --help`"), + None => Err(CliError::Usage( + "interactive mode needs a terminal; use `bootable --help`".into(), + ) + .into()), } } +fn print_completions(shell: Shell) { + clap_complete::generate(shell, &mut Cli::command(), "bootable", &mut io::stdout()); +} + fn print_catalog(engine: &Bootable, limit: usize, json: bool) -> Result<()> { let distributions = engine.popular_distributions(limit.clamp(1, 100))?; if json { @@ -298,36 +518,71 @@ fn download_release( json_progress: bool, ) -> Result<()> { let mut reporter = ProgressReporter::new(json_progress); - let result = (|| { - let details = engine.distribution_details(slug)?; - let releases = resolve_releases(engine, &details)?; - let release = releases - .get(index) - .with_context(|| format!("release index {index} is out of range"))?; - let destination = output.unwrap_or_else(|| PathBuf::from(&release.name)); - engine - .download_iso(release, &destination, |progress| reporter.print(progress)) - .map_err(anyhow::Error::from) - })(); + let result = fetch_catalog_image(engine, slug, index, output, &mut |progress| { + reporter.print(progress) + }); match result { - Ok(report) => { + Ok(FetchedImage { report, integrity }) => { if json_progress { + reporter.integrity(&integrity); reporter.finished(); } else { println!("Ready to write: {}", report.path.display()); println!("Kind: {}", report.kind); println!("Size: {}", format_bytes(report.size)); + reporter.integrity(&integrity); } Ok(()) } Err(error) => { - reporter.failed(&error.to_string()); + reporter.failed(&error.to_string(), exit_status(&error)); Err(error) } } } +/// A downloaded catalog image and how well it was authenticated. +struct FetchedImage { + report: ImageReport, + integrity: IntegrityState, +} + +/// The machine-readable integrity summary shared by every JSON surface. +fn integrity_json(integrity: &IntegrityState) -> serde_json::Value { + serde_json::json!({ + "label": integrity.label(), + "signature_verified": integrity.is_signature_verified(), + "signature_expected_but_unverified": integrity.signature_expected_but_unverified(), + }) +} + +/// Resolve a catalog slug to a release, then download and verify it. +fn fetch_catalog_image( + engine: &Bootable, + slug: &str, + index: usize, + output: Option, + progress: &mut dyn FnMut(Progress), +) -> Result { + let details = engine.distribution_details(slug)?; + let releases = resolve_releases(engine, &details)?; + let release = releases.get(index).ok_or_else(|| { + CliError::Usage(format!( + "release index {index} is out of range ({} available)", + releases.len() + )) + })?; + let destination = output.unwrap_or_else(|| PathBuf::from(&release.name)); + let (report, integrity) = engine.download_iso_with_integrity( + release, + &destination, + &OperationControl::new(), + progress, + )?; + Ok(FetchedImage { report, integrity }) +} + fn resolve_releases(engine: &Bootable, details: &DistributionDetails) -> Result> { let mut releases = Vec::new(); let mut last_error = None; @@ -474,40 +729,234 @@ fn print_plan( Ok(()) } +/// The engine operations the write commands depend on. `Bootable` is the real +/// implementation; tests substitute a fake so no device is ever touched. +trait WriteBackend { + fn check_target(&self, target: &str) -> Result<()>; + fn fetch( + &self, + slug: &str, + index: usize, + output: Option, + progress: &mut dyn FnMut(Progress), + ) -> Result; + fn prepare(&self, image: PathBuf, target: &str, options: WriteOptions) -> Result; + fn write( + &self, + plan: &WritePlan, + confirmation: &str, + progress: &mut dyn FnMut(Progress), + ) -> Result<()>; +} + +impl WriteBackend for Bootable { + fn check_target(&self, target: &str) -> Result<()> { + let device = self + .discover_devices()? + .into_iter() + .find(|device| device.id.as_str() == target || device.path.to_string_lossy() == target) + .ok_or_else(|| bootable_core::Error::DeviceNotFound(target.into()))?; + if !device.is_eligible_target() { + return Err(bootable_core::Error::UnsafeTarget(format!( + "{}: {}", + device.path.display(), + target_eligibility_label(&device) + )) + .into()); + } + Ok(()) + } + + fn fetch( + &self, + slug: &str, + index: usize, + output: Option, + progress: &mut dyn FnMut(Progress), + ) -> Result { + fetch_catalog_image(self, slug, index, output, progress) + } + + fn prepare(&self, image: PathBuf, target: &str, options: WriteOptions) -> Result { + Ok(self.prepare_with_options(image, target, options)?) + } + + fn write( + &self, + plan: &WritePlan, + confirmation: &str, + progress: &mut dyn FnMut(Progress), + ) -> Result<()> { + Ok(self.write_with_privilege(plan, confirmation, progress)?) + } +} + fn write_image( - engine: &Bootable, + backend: &impl WriteBackend, image: PathBuf, target: &str, confirmation: Option, json_progress: bool, options: WriteOptions, ) -> Result<()> { - let plan = engine.prepare_with_options(image, target, options)?; + let plan = backend + .prepare(image, target, options) + .map_err(|error| report_failure(json_progress, error))?; + confirmed_write(backend, &plan, confirmation, json_progress) +} + +/// Emit the terminal `failed` event (JSON mode only) and hand the error back, +/// so early failures obey the same one-terminal-event contract as later ones. +fn report_failure(json_progress: bool, error: anyhow::Error) -> anyhow::Error { + ProgressReporter::new(json_progress).failed(&error.to_string(), exit_status(&error)); + error +} + +/// The single confirmation gate shared by `write` and `flash`: nothing is +/// written unless the caller repeats the plan's exact phrase. +fn confirmed_write( + backend: &impl WriteBackend, + plan: &WritePlan, + confirmation: Option, + json_progress: bool, +) -> Result<()> { + let mut reporter = ProgressReporter::new(json_progress); let Some(confirmation) = confirmation else { - render_plan_text(&plan); - bail!( - "nothing was written; repeat with --confirm '{}'", - plan.confirmation_phrase - ); + if json_progress { + emit_line( + serde_json::json!({ + "event": "confirmation_required", + "data": { + "confirmation_phrase": plan.confirmation_phrase, + "plan": plan, + }, + }) + .to_string(), + ); + } else { + render_plan_text(plan); + } + return Err(CliError::ConfirmationRequired { + phrase: plan.confirmation_phrase.clone(), + } + .into()); }; - let mut reporter = ProgressReporter::new(json_progress); - let result = - engine.write_with_privilege(&plan, &confirmation, |progress| reporter.print(progress)); - match result { + if !plan.confirmation_matches(&confirmation) { + let error: anyhow::Error = bootable_core::Error::ConfirmationMismatch { + expected: plan.confirmation_phrase.clone(), + } + .into(); + reporter.failed(&error.to_string(), exit_status(&error)); + return Err(error); + } + match backend.write(plan, &confirmation, &mut |progress| { + reporter.print(progress) + }) { Ok(()) => { reporter.finished(); Ok(()) } Err(error) => { - reporter.failed(&error.to_string()); - Err(error.into()) + reporter.failed(&error.to_string(), exit_status(&error)); + Err(error) + } + } +} + +struct FlashRequest { + source: String, + target: String, + index: usize, + output: Option, + require_signature: bool, + confirm: Option, + json_progress: bool, + options: WriteOptions, +} + +#[derive(Debug, PartialEq, Eq)] +enum FlashSource { + Image(PathBuf), + Catalog(String), +} + +/// An existing file, or anything that looks like a path, is a local image; +/// a bare word is a catalog slug. +fn classify_source(source: &str, exists: bool) -> FlashSource { + let path_like = source.contains(['/', '\\', '.']) || source.starts_with('~'); + if exists || path_like { + FlashSource::Image(PathBuf::from(source)) + } else { + FlashSource::Catalog(source.to_owned()) + } +} + +fn flash_image(backend: &impl WriteBackend, request: FlashRequest) -> Result<()> { + let FlashRequest { + source, + target, + index, + output, + require_signature, + confirm, + json_progress, + options, + } = request; + let mut reporter = ProgressReporter::new(json_progress); + // Every failure before the write gate reports exactly one terminal event. + let image = match classify_source(&source, Path::new(&source).exists()) { + FlashSource::Image(path) => { + if require_signature { + return Err(report_failure( + json_progress, + CliError::Usage( + "--require-signature only applies to catalog downloads; a local \ + image has no publisher signature to check" + .into(), + ) + .into(), + )); + } + path + } + FlashSource::Catalog(slug) => { + // Refuse a missing or ineligible target before a large download. + backend + .check_target(&target) + .map_err(|error| report_failure(json_progress, error))?; + let FetchedImage { report, integrity } = backend + .fetch(&slug, index, output, &mut |progress| { + reporter.print(progress) + }) + .map_err(|error| report_failure(json_progress, error))?; + reporter.integrity(&integrity); + if confirm.is_none() && !json_progress { + eprintln!( + "Image kept at {}; pass that path instead of the slug to skip the download.", + report.path.display() + ); + } + report.path } + }; + let plan = backend + .prepare(image, &target, options) + .map_err(|error| report_failure(json_progress, error))?; + confirmed_write(backend, &plan, confirm, json_progress)?; + if !json_progress { + println!( + "Done: {} written and verified on {}", + plan.image.path.display(), + plan.target.path.display() + ); } + Ok(()) } fn write_options(windows: WindowsArgs, bad_block_check: BadBlockCheck) -> WriteOptions { WriteOptions { windows_partition_scheme: windows.windows_partition_scheme, + windows_boot_firmware: windows.windows_boot_firmware, windows: bootable_core::WindowsExperienceOptions { bypass_hardware_requirements: windows.bypass_windows_11_requirements, allow_offline_account: windows.allow_windows_offline_account, @@ -553,7 +1002,7 @@ impl ProgressReporter { return; } if self.json { - println!("{}", progress_event_json(&progress)); + emit_line(progress_event_json(&progress)); let _ = io::Write::flush(&mut io::stdout()); self.phase = Some(progress.phase); self.percentage = percentage; @@ -569,20 +1018,57 @@ impl ProgressReporter { fn finished(&self) { if self.json { - println!("{{\"event\":\"finished\"}}"); + emit_line("{\"event\":\"finished\"}".into()); } } - fn failed(&self, message: &str) { + /// One line (or `integrity` event) saying how the image was authenticated. + fn integrity(&self, integrity: &IntegrityState) { if self.json { - println!( - "{}", - serde_json::json!({ "event": "failed", "data": { "message": message } }) + emit_line( + serde_json::json!({ "event": "integrity", "data": integrity_json(integrity) }) + .to_string(), + ); + } else { + println!("Integrity: {}", integrity.label()); + } + } + + fn failed(&self, message: &str, status: ExitStatus) { + if self.json { + emit_line( + serde_json::json!({ + "event": "failed", + "data": { + "message": message, + "kind": status.name(), + "exit_code": status.code(), + }, + }) + .to_string(), ); } } } +/// Write one newline-delimited JSON event to stdout. Tests record the events +/// per thread instead so they can assert on the exact stream. +#[cfg(not(test))] +fn emit_line(line: String) { + println!("{line}"); +} + +#[cfg(test)] +fn emit_line(line: String) { + CAPTURED_EVENTS.with(|events| events.borrow_mut().push(line)); +} + +#[cfg(test)] +thread_local! { + static CAPTURED_EVENTS: std::cell::RefCell> = + const { std::cell::RefCell::new(Vec::new()) }; +} + fn progress_event_json(progress: &Progress) -> String { serde_json::json!({ "event": "progress", "data": progress }).to_string() } @@ -606,12 +1092,14 @@ fn render_plan_text(plan: &WritePlan) { println!("Confirmation: {}", plan.confirmation_phrase); } +type ImageInspection = std::result::Result<(ImageReport, PathBuf), (String, PathBuf)>; + struct App { engine: Bootable, devices: Vec, image: Option, image_loading: bool, - image_receiver: Option>>, + image_receiver: Option>, initial_image: Option, selected: Option, status: String, @@ -652,6 +1140,13 @@ struct App { write_receiver: Option>, hit_regions: HitRegions, workspace_focus: WorkspaceFocus, + preferences: Preferences, + locale: Locale, + help_open: bool, + /// True once core's final (`Finished`) download progress message has been + /// shown as the status. Replaces comparing the status text, which breaks + /// as soon as any part of it is localized. + download_final_message: bool, } enum DownloadUpdate { @@ -729,6 +1224,9 @@ impl WorkspaceFocus { #[derive(Default)] struct HitRegions { open_image: Option, + guide: Option, + language: Option, + recent_rows: Vec<(Rect, usize)>, discover: Option, choose_folder: Option, windows_options: Option, @@ -742,6 +1240,7 @@ struct HitRegions { windows_skusi_policy: Option, windows_s_mode: Option, windows_partition_scheme: Option, + windows_boot_firmware: Option, advanced: Option, checksum_algorithm: Option, bad_blocks: Option, @@ -780,13 +1279,21 @@ struct HitRegions { } impl App { + /// The active locale bound to the shared catalog. `Copy`, so it can be held + /// across `&mut self` calls. + fn t(&self) -> Strings { + self.locale.strings() + } + fn load(engine: Bootable, image_path: Option, artwork_picker: Picker) -> Self { + let preferences = Preferences::load(); + let locale = preferences.locale(); let devices_result = engine.discover_devices(); let (devices, status) = match devices_result { Ok(devices) => { - let status = format!( - "{} · choose an image to begin", - removable_media_status(&devices) + let status = locale.strings().format( + Message::StatusStartup, + &[("media", &removable_media_status_in(locale, &devices))], ); (devices, status) } @@ -806,8 +1313,8 @@ impl App { status, options: WriteOptions::default(), advanced: false, - checksum_algorithm: ChecksumAlgorithm::Sha256, - browse_directory: None, + checksum_algorithm: preferences.checksum_algorithm, + browse_directory: preferences.image_directory(), catalog_open: false, discovery_session: DiscoverySession::default(), distributions: Vec::new(), @@ -841,9 +1348,49 @@ impl App { write_receiver: None, hit_regions: HitRegions::default(), workspace_focus: WorkspaceFocus::Source, + preferences, + locale, + help_open: false, + download_final_message: false, + } + } + + fn save_preferences(&mut self) { + if let Err(error) = self.preferences.save() { + self.status = self + .t() + .format(Message::StatusPrefsSaveFailed, &[("error", &error)]); + } + } + + fn remember_image(&mut self, image: &ImageReport) { + self.preferences.remember_image(image); + self.save_preferences(); + self.browse_directory = self.preferences.image_directory(); + } + + fn use_recent_image(&mut self, index: usize) { + match self.preferences.recent_images().get(index) { + Some(recent) => self.inspect_image_path(recent.path.clone()), + None => self.status = self.t().text(Message::StatusImageNoRecent).into(), } } + fn toggle_help(&mut self) { + self.help_open = !self.help_open; + } + + /// Steps System default -> each available language -> System default, + /// persists the choice, and re-renders in the new language. + fn cycle_language(&mut self) { + self.preferences.language = next_language(self.preferences.language); + self.locale = self.preferences.locale(); + // Cached status text was produced in the previous language; replace it + // with the current guidance, exactly as the desktop app does. + self.status = self.review_readiness().guidance_in(self.locale).into(); + self.save_preferences(); + } + fn load_distrowatch(&mut self) { self.load_distrowatch_with(CacheMode::PreferCache); } @@ -853,9 +1400,9 @@ impl App { self.catalog_focus = CatalogFocus::Distributions; if !self.popular_distributions.is_empty() && mode == CacheMode::PreferCache { self.distributions = self.popular_distributions.clone(); - self.status = "DistroWatch discovery selected".into(); + self.status = self.t().text(Message::StatusCatalogPopularity).into(); } else if self.discovery_session.begin(CatalogFacet::Popular) { - self.status = "Loading distributions…".into(); + self.status = self.t().text(Message::StatusCatalogLoading).into(); let sender = self.catalog_sender.clone(); std::thread::spawn(move || { let result = Bootable::native() @@ -895,13 +1442,13 @@ impl App { self.discovery_session.show_raspberry_pi(); self.catalog_focus = CatalogFocus::Distributions; if self.pi_catalog.is_some() && mode == CacheMode::PreferCache { - self.status = "Raspberry Pi image discovery selected".into(); + self.status = self.t().text(Message::StatusCatalogPiSelected).into(); return; } if !self.discovery_session.begin(CatalogFacet::RaspberryPi) { return; } - self.status = "Loading Raspberry Pi images…".into(); + self.status = self.t().text(Message::StatusCatalogPiLoading).into(); let sender = self.catalog_sender.clone(); std::thread::spawn(move || { let result = Bootable::native() @@ -923,7 +1470,7 @@ impl App { match preset { QuickAccess::All => { self.distributions = self.popular_distributions.clone(); - self.status = "Showing DistroWatch six-month popularity".into(); + self.status = self.t().text(Message::StatusCatalogPopularity).into(); } QuickAccess::Arch | QuickAccess::Debian => { let cached = if preset == QuickAccess::Arch { @@ -947,15 +1494,14 @@ impl App { .cloned() { self.distributions = vec![omarchy]; - self.status = "Omarchy quick access · press Enter to resolve ISOs".into(); + self.status = self.t().text(Message::StatusCatalogOmarchy).into(); } else { - self.status = - "Omarchy is missing from the current DistroWatch directory".into(); + self.status = self.t().text(Message::StatusCatalogOmarchyMissing).into(); } } QuickAccess::Windows => { self.distributions.clear(); - self.status = "Windows media tools · press o to choose a Windows ISO".into(); + self.status = self.t().text(Message::StatusCatalogWindowsTools).into(); } } self.catalog_selected = 0; @@ -975,7 +1521,9 @@ impl App { if !self.discovery_session.begin(facet) { return; } - self.status = format!("Loading {base}-based distributions…"); + self.status = self + .t() + .format(Message::StatusCatalogLoadingBase, &[("base", &base)]); let sender = self.catalog_sender.clone(); std::thread::spawn(move || { let result = Bootable::native() @@ -989,10 +1537,32 @@ impl App { }); } + /// The status after an ISO release is picked: whether the publisher's + /// checksum can be verified or only HTTPS length and boot structure. + fn iso_selected_status(&self) -> String { + let has_checksum = self + .catalog_releases + .get(self.release_selected) + .is_some_and(|release| release.checksum.is_some() || release.checksum_url.is_some()); + self.t() + .text(if has_checksum { + Message::StatusCatalogIsoSelectedChecksum + } else { + Message::StatusCatalogIsoSelectedHttps + }) + .into() + } + fn toggle_catalog(&mut self) { if self.catalog_open { self.catalog_open = false; - self.status = format!("Catalog closed • {}", self.review_readiness().guidance()); + self.status = self.t().format( + Message::StatusCatalogClosed, + &[( + "guidance", + &self.review_readiness().guidance_in(self.locale), + )], + ); return; } self.catalog_open = true; @@ -1016,7 +1586,10 @@ impl App { self.catalog_releases.clear(); self.discovery_session .expect_details(distribution.slug.clone()); - self.status = format!("Loading {} releases…", distribution.name); + self.status = self.t().format( + Message::StatusCatalogLoadingReleases, + &[("name", &distribution.name)], + ); let slug = distribution.slug; let request_slug = slug.clone(); let sender = self.catalog_sender.clone(); @@ -1055,7 +1628,7 @@ impl App { self.load_quick_base(self.discovery_session.quick_access(), CacheMode::Refresh); } QuickAccess::Windows => { - self.status = "Windows tools use the selected local ISO".into(); + self.status = self.t().text(Message::StatusCatalogWindowsUsesIso).into(); } } } @@ -1117,16 +1690,18 @@ impl App { } fn poll_catalog(&mut self) { + let locale = self.locale; + let t = self.t(); while let Ok(update) = self.catalog_receiver.try_recv() { match update { CatalogUpdate::Popular(result) => match result { Ok(fetch) => { + let source = fetch.source_label_in(locale); self.discovery_session.complete( CatalogFacet::Popular, &fetch, fetch.value.is_empty(), ); - let source = fetch.status_suffix(); let distributions = fetch.value; let count = distributions.len(); self.popular_distributions = distributions.clone(); @@ -1136,7 +1711,11 @@ impl App { { self.distributions = distributions; self.catalog_selected = 0; - self.status = format!("{count} distributions · {source}"); + self.status = t.plural( + Message::StatusCatalogDistributionsLoaded, + count as u64, + &[("source", &source)], + ); if count > 0 { self.select_catalog_distribution(0); } @@ -1148,7 +1727,7 @@ impl App { self.status = self .discovery_session .state(CatalogFacet::Popular) - .short_label("distributions"); + .short_label_in(locale, t.text(Message::CatalogSubjectDistributions)); } }, CatalogUpdate::Directory(result) => match result { @@ -1176,7 +1755,10 @@ impl App { self.status = self .discovery_session .state(CatalogFacet::Directory) - .short_label("search catalog"); + .short_label_in( + locale, + t.text(Message::CatalogSubjectSearchCatalog), + ); } } }, @@ -1187,14 +1769,18 @@ impl App { &fetch, fetch.value.images.is_empty(), ); - let source = fetch.status_suffix(); + let source = fetch.source_label_in(locale); let catalog = fetch.value; let count = catalog.images.len(); self.pi_catalog = Some(catalog); self.pi_device_selected = 0; self.pi_image_selected = 0; if self.discovery_session.source() == DiscoverySource::RaspberryPi { - self.status = format!("{count} Raspberry Pi images · {source}"); + self.status = t.plural( + Message::StatusCatalogPiImagesLoaded, + count as u64, + &[("source", &source)], + ); } } Err(error) @@ -1205,7 +1791,7 @@ impl App { self.status = self .discovery_session .state(CatalogFacet::RaspberryPi) - .short_label("Raspberry Pi images"); + .short_label_in(locale, t.text(Message::CatalogSubjectPiImages)); } Err(error) => self .discovery_session @@ -1224,7 +1810,7 @@ impl App { }; self.discovery_session .complete(facet, &fetch, fetch.value.is_empty()); - let source = fetch.status_suffix(); + let source = fetch.source_label_in(locale); let distributions = fetch.value; let count = distributions.len(); if preset == QuickAccess::Arch { @@ -1235,7 +1821,11 @@ impl App { if self.discovery_session.quick_access() == preset { self.distributions = distributions; self.catalog_selected = 0; - self.status = format!("{count} {base}-based distributions · {source}"); + self.status = t.plural( + Message::StatusCatalogBaseLoaded, + count as u64, + &[("base", &base), ("source", &source)], + ); } } Err(error) => { @@ -1246,10 +1836,13 @@ impl App { }; self.discovery_session.fail(facet, error); if self.discovery_session.quick_access() == preset { - self.status = self - .discovery_session - .state(facet) - .short_label(&format!("{base}-based distributions")); + self.status = self.discovery_session.state(facet).short_label_in( + locale, + &t.format( + Message::CatalogSubjectBaseDistributions, + &[("base", &base)], + ), + ); } } }, @@ -1264,7 +1857,7 @@ impl App { &fetch, fetch.value.releases.is_empty(), ); - let source = fetch.status_suffix(); + let source = fetch.source_label_in(locale); let DistributionBundle { details, releases, @@ -1275,20 +1868,36 @@ impl App { self.catalog_releases = releases; self.release_selected = 0; self.catalog_focus = CatalogFocus::Releases; + let releases_summary = t.plural( + Message::StatusCatalogReleasesLoaded, + count as u64, + &[("source", &source)], + ); self.status = if count == 0 && !warnings.is_empty() { - format!( - "Profile ready · no direct ISO found · {} source error(s)", - warnings.len() + t.plural( + Message::StatusCatalogProfileReadyErrors, + warnings.len() as u64, + &[], ) } else if count == 0 { - "Profile ready · no direct ISO found".into() + t.text(Message::StatusCatalogProfileReadyNoIso).into() } else if !warnings.is_empty() { - format!( - "{count} ISO release(s) · {source} · {} source warning(s)", - warnings.len() + t.format( + Message::StatusCatalogWithWarnings, + &[ + ("summary", &releases_summary), + ( + "warnings", + &t.plural( + Message::StatusCatalogSourceWarnings, + warnings.len() as u64, + &[], + ), + ), + ], ) } else { - format!("{count} ISO release(s) · {source}") + releases_summary }; } Err(error) => { @@ -1296,7 +1905,7 @@ impl App { self.status = self .discovery_session .state(CatalogFacet::Details) - .short_label("ISO releases"); + .short_label_in(locale, t.text(Message::CatalogSubjectIsoReleases)); } } } @@ -1312,8 +1921,10 @@ impl App { self.artwork_error = None; } Err(error) => { - self.artwork_error = - Some(format!("Could not decode catalog artwork: {error}")); + self.artwork_error = Some(t.format( + Message::StatusCatalogArtworkError, + &[("error", &error)], + )); } }, Err(error) => self.artwork_error = Some(error), @@ -1328,7 +1939,12 @@ impl App { Ok(jobs) => { self.download_selected = self.download_selected.min(jobs.len().saturating_sub(1)); } - Err(error) => self.status = format!("Download history unavailable · {error}"), + Err(error) => { + self.status = self.t().format( + Message::StatusDownloadHistoryUnavailable, + &[("error", &error)], + ); + } } } @@ -1336,9 +1952,10 @@ impl App { self.downloads_open = !self.downloads_open; if self.downloads_open { self.refresh_download_jobs(); - self.status = format!( - "{} managed download job(s)", - self.download_session.jobs().len() + self.status = self.t().plural( + Message::DownloadsJobsInHistory, + self.download_session.jobs().len() as u64, + &[], ); } } @@ -1346,7 +1963,7 @@ impl App { fn launch_download_job(&mut self, id: String, destination: PathBuf, retry: bool) { let DownloadRequest::Launch(launch) = self.download_session.request(id, destination, retry) else { - self.status = "Download queued · it starts when the active job finishes".into(); + self.status = self.t().text(Message::StatusDownloadQueued).into(); self.refresh_download_jobs(); return; }; @@ -1354,11 +1971,15 @@ impl App { } fn launch_download_worker(&mut self, launch: DownloadLaunch) { - self.status = if launch.retry { - "Retrying download · preserved bytes resume when supported".into() - } else { - "Starting managed download…".into() - }; + self.download_final_message = false; + self.status = self + .t() + .text(if launch.retry { + Message::StatusDownloadRetrying + } else { + Message::StatusDownloadStarting + }) + .into(); let DownloadLaunch { id, destination, @@ -1389,14 +2010,14 @@ impl App { fn retry_selected_download(&mut self) { let Some(job) = self.download_session.jobs().get(self.download_selected) else { - self.status = "Choose a download job first".into(); + self.status = self.t().text(Message::StatusDownloadChooseJob).into(); return; }; let id = job.id.clone(); match self.download_session.retry(&self.engine, &id) { Ok(DownloadRequest::Launch(launch)) => self.launch_download_worker(launch), Ok(DownloadRequest::Queued) => { - self.status = "Retry queued · it starts after the active download".into() + self.status = self.t().text(Message::StatusDownloadRetryQueued).into() } Err(error) => self.status = error.to_string(), } @@ -1406,38 +2027,51 @@ impl App { match self.download_session.next_queued(&self.engine) { Ok(Some(launch)) => self.launch_download_worker(launch), Ok(None) => {} - Err(error) => self.status = format!("Could not start queued download · {error}"), + Err(error) => { + self.status = self + .t() + .format(Message::StatusDownloadStartFailed, &[("error", &error)]); + } } } fn use_selected_download(&mut self) { let Some(job) = self.download_session.jobs().get(self.download_selected) else { - self.status = "Choose a download job first".into(); + self.status = self.t().text(Message::StatusDownloadChooseJob).into(); return; }; let id = job.id.clone(); let destination = job.destination.clone(); match self.download_session.use_completed(&self.engine, &id) { Ok(report) => { - self.browse_directory = destination.parent().map(PathBuf::from); + self.remember_image(&report); + self.reset_image_scoped_options(); self.image = Some(report); self.advanced = false; self.downloads_open = false; - self.status = format!("Using completed download {}", destination.display()); + self.status = self.t().format( + Message::StatusDownloadUsingCompleted, + &[("path", &destination.display())], + ); + } + Err(error) => { + self.status = self.t().format( + Message::StatusDownloadCompletedUnavailable, + &[("error", &error)], + ); } - Err(error) => self.status = format!("Downloaded image is unavailable · {error}"), } } fn remove_selected_download(&mut self) { let Some(job) = self.download_session.jobs().get(self.download_selected) else { - self.status = "Choose a download job first".into(); + self.status = self.t().text(Message::StatusDownloadChooseJob).into(); return; }; let id = job.id.clone(); match self.download_session.remove(&self.engine, &id) { Ok(()) => { - self.status = "History entry removed · completed image kept".into(); + self.status = self.t().text(Message::StatusDownloadHistoryRemoved).into(); self.refresh_download_jobs(); } Err(error) => self.status = error.to_string(), @@ -1463,17 +2097,17 @@ impl App { fn download_catalog_release(&mut self) { let Some(release) = self.catalog_releases.get(self.release_selected).cloned() else { - self.status = "Choose an ISO release first".into(); + self.status = self.t().text(Message::StatusCatalogChooseRelease).into(); return; }; let mut dialog = rfd::FileDialog::new() - .add_filter("ISO images", &["iso"]) + .add_filter(self.t().text(Message::SourceDialogFilterIso), &["iso"]) .set_file_name(&release.name); if let Some(directory) = &self.browse_directory { dialog = dialog.set_directory(directory); } let Some(destination) = dialog.save_file() else { - self.status = "ISO download cancelled".into(); + self.status = self.t().text(Message::StatusDownloadIsoCancelled).into(); return; }; match self.engine.enqueue_iso_download(&release, &destination) { @@ -1488,11 +2122,14 @@ impl App { .get(self.catalog_selected) .map(|distribution| distribution.page_url.clone()) else { - self.status = "Choose a distribution first".into(); + self.status = self + .t() + .text(Message::StatusCatalogChooseDistribution) + .into(); return; }; self.status = match self.engine.open_distrowatch_page(&page_url) { - Ok(()) => "Opened the DistroWatch distribution page in your browser".into(), + Ok(()) => self.t().text(Message::StatusCatalogBrowserOpened).into(), Err(error) => error.to_string(), }; } @@ -1504,7 +2141,7 @@ impl App { .and_then(|catalog| catalog.images.get(self.pi_image_selected)) .cloned() else { - self.status = "Choose a Raspberry Pi image first".into(); + self.status = self.t().text(Message::StatusCatalogPiChoose).into(); return; }; let mut dialog = rfd::FileDialog::new().set_file_name(&image.suggested_filename); @@ -1512,7 +2149,7 @@ impl App { dialog = dialog.set_directory(directory); } let Some(destination) = dialog.save_file() else { - self.status = "Raspberry Pi image download cancelled".into(); + self.status = self.t().text(Message::StatusDownloadPiCancelled).into(); return; }; match self.engine.enqueue_pi_download(&image, &destination) { @@ -1521,6 +2158,27 @@ impl App { } } + /// Shows core's progress message as the status. Core's last message + /// (phase `Finished`) names the integrity result, for example a verified + /// signature; remember that it is showing so the generic ready line does + /// not replace it. This is an explicit flag, not a comparison of the + /// (localizable) status text. + fn show_download_progress(&mut self, progress: &Progress) { + self.download_final_message = progress.phase == ProgressPhase::Finished; + self.status = progress.message.clone(); + } + + /// The download finished and its image is in use: keep core's final + /// message when it was shown, otherwise the shared ready line. + fn show_download_ready(&mut self, path: &Path) { + if !self.download_final_message { + self.status = self + .t() + .format(Message::StatusDownloadReady, &[("name", &path.display())]); + } + self.download_final_message = false; + } + fn poll_download(&mut self) { let Some(receiver) = self.download_receiver.take() else { return; @@ -1529,7 +2187,7 @@ impl App { while let Ok(update) = receiver.try_recv() { match update { DownloadUpdate::Progress(progress) => { - self.status = progress.message.clone(); + self.show_download_progress(&progress); self.download_session.apply_progress(progress); } DownloadUpdate::Finished(completion) => { @@ -1540,18 +2198,23 @@ impl App { destination, } => { self.browse_directory = destination.parent().map(PathBuf::from); - self.status = format!( - "Ready · downloaded, verified, and inspected {} · discovery remains open", - report.path.display() - ); + self.show_download_ready(&report.path); + self.reset_image_scoped_options(); self.image = Some(report.clone()); self.advanced = false; } DownloadCompletion::Cancelled => { - self.status = "Download cancelled • temporary data cleaned up".into(); + self.download_final_message = false; + self.status = self + .t() + .text(Message::StatusDownloadCancelledCleaned) + .into(); } DownloadCompletion::Failed(error) => { - self.status = format!("Download stopped · {error}") + self.download_final_message = false; + self.status = self + .t() + .format(Message::StatusDownloadStopped, &[("error", error)]); } } self.download_session.finish(completion); @@ -1568,9 +2231,12 @@ impl App { fn toggle_download_pause(&mut self) { match self.download_session.toggle_pause(&self.engine) { Ok(Some(OperationState::Paused)) => { - self.status = "Download paused • press p to resume or x to cancel".into(); + // The shared line has no key legend; the TUI appends its own. + self.status = format!("{} (p / x)", self.t().text(Message::StatusDownloadPaused)); + } + Ok(Some(OperationState::Running)) => { + self.status = self.t().text(Message::StatusDownloadResumed).into(); } - Ok(Some(OperationState::Running)) => self.status = "Download resumed".into(), Ok(Some(OperationState::Cancelled) | None) => {} Err(error) => self.status = error.to_string(), } @@ -1578,7 +2244,7 @@ impl App { fn cancel_download(&mut self) { if self.download_session.cancel() { - self.status = "Cancelling download safely • cleaning temporary data…".into(); + self.status = self.t().text(Message::StatusDownloadCancelling).into(); } } @@ -1598,6 +2264,7 @@ impl App { KeyCode::Char('k') => self.toggle_windows_skusi_policy(), KeyCode::Char('s') => self.toggle_windows_s_mode(), KeyCode::Char('p') => self.cycle_windows_partition_scheme(), + KeyCode::Char('f') => self.cycle_windows_boot_firmware(), KeyCode::Char('1') => self.show_quick_access(QuickAccess::All), KeyCode::Char('2') => self.show_quick_access(QuickAccess::Arch), KeyCode::Char('3') => self.show_quick_access(QuickAccess::Debian), @@ -1612,7 +2279,7 @@ impl App { KeyCode::Esc | KeyCode::Enter => { self.catalog_searching = false; self.status = if self.catalog_query.is_empty() { - "Search closed · showing DistroWatch six-month popularity".into() + self.t().text(Message::StatusCatalogSearchClosed).into() } else { catalog_search_summary( &self.catalog_query, @@ -1638,7 +2305,10 @@ impl App { } if code == KeyCode::Char('/') { self.catalog_searching = true; - self.status = "Type to search · results update live · Esc leaves search".into(); + self.status = format!( + "{} · Esc", + self.t().text(Message::DiscoverSearchPlaceholder) + ); return; } if code == KeyCode::Char('r') { @@ -1760,7 +2430,10 @@ impl App { .as_ref() .and_then(|catalog| catalog.devices.get(self.pi_device_selected)) { - self.status = format!("Showing images compatible with {}", device.name); + self.status = self.t().format( + Message::StatusCatalogPiCompatible, + &[("board", &device.name)], + ); } } } @@ -1886,11 +2559,11 @@ impl App { fn choose_image(&mut self) { if self.image_loading { - self.status = "Image inspection is already running".into(); + self.status = self.t().text(Message::StatusImageBusy).into(); return; } let mut dialog = rfd::FileDialog::new().add_filter( - "Boot images", + self.t().text(Message::SourceDialogTitle), &[ "iso", "img", "raw", "xz", "gz", "gzip", "zst", "zstd", "bz2", "bzip2", ], @@ -1899,7 +2572,7 @@ impl App { dialog = dialog.set_directory(directory); } let Some(path) = dialog.pick_file() else { - self.status = "Image selection cancelled".into(); + self.status = self.t().text(Message::StatusImageCancelled).into(); return; }; self.inspect_image_path(path); @@ -1907,17 +2580,17 @@ impl App { fn inspect_image_path(&mut self, path: PathBuf) { if self.image_loading { - self.status = "Image inspection is already running".into(); + self.status = self.t().text(Message::StatusImageBusy).into(); return; } self.image_loading = true; - self.status = "Inspecting image • compressed sources are measured after expansion…".into(); + self.status = self.t().text(Message::StatusImageInspecting).into(); let (sender, receiver) = mpsc::channel(); std::thread::spawn(move || { - let result = Bootable::native() - .inspect_image(&path) - .map(|report| (report, path)) - .map_err(|error| error.to_string()); + let result = match Bootable::native().inspect_image(&path) { + Ok(report) => Ok((report, path)), + Err(error) => Err((error.to_string(), path)), + }; let _ = sender.send(result); }); self.image_receiver = Some(receiver); @@ -1928,15 +2601,21 @@ impl App { return; }; match receiver.try_recv() { - Ok(Ok((image, path))) => { + Ok(Ok((image, _))) => { self.image_loading = false; - self.status = format!("Recognized {}", image.kind); - self.browse_directory = path.parent().map(PathBuf::from); + self.status = self + .t() + .format(Message::StatusImageRecognized, &[("kind", &image.kind)]); + self.remember_image(&image); + self.reset_image_scoped_options(); self.image = Some(image); self.advanced = false; } - Ok(Err(error)) => { + Ok(Err((error, path))) => { self.image_loading = false; + self.preferences.forget_image(&path); + self.save_preferences(); + self.reset_image_scoped_options(); self.image = None; self.advanced = false; self.status = error; @@ -1944,7 +2623,7 @@ impl App { Err(mpsc::TryRecvError::Empty) => self.image_receiver = Some(receiver), Err(mpsc::TryRecvError::Disconnected) => { self.image_loading = false; - self.status = "Image inspection stopped unexpectedly".into(); + self.status = self.t().text(Message::StatusImageStopped).into(); } } } @@ -1955,10 +2634,13 @@ impl App { dialog = dialog.set_directory(directory); } if let Some(directory) = dialog.pick_folder() { - self.status = format!("Image browser folder: {}", directory.display()); + self.status = self.t().format( + Message::StatusImageFolder, + &[("path", &directory.display())], + ); self.browse_directory = Some(directory); } else { - self.status = "Folder selection cancelled".into(); + self.status = self.t().text(Message::StatusImageFolderCancelled).into(); } } @@ -1971,7 +2653,7 @@ impl App { .collect::>(); if eligible.is_empty() { self.selected = None; - self.status = "No eligible removable drive is available".into(); + self.status = self.t().text(Message::StatusTargetNoneEligible).into(); return; } let position = self @@ -1984,8 +2666,7 @@ impl App { }; self.selected = eligible.get(next).copied(); self.workspace_focus = WorkspaceFocus::Target; - self.status = - "Target selected · confirm the physical drive before reviewing the erase plan".into(); + self.status = self.t().text(Message::StatusTargetSelected).into(); } fn move_workspace_focus(&mut self, backwards: bool) { @@ -1994,15 +2675,17 @@ impl App { } else { self.workspace_focus.next(self.image.is_some()) }; - self.status = match self.workspace_focus { - WorkspaceFocus::Source => "Source · choose or change the image", - WorkspaceFocus::Target => "Target · choose an eligible removable drive", - WorkspaceFocus::Setup => "Setup options · configure image-specific choices", - WorkspaceFocus::Review => "Review & write · inspect the plan before erasure", - WorkspaceFocus::Discover => "Discover images · browse trusted catalogs", - WorkspaceFocus::Refresh => "Refresh drives · rescan removable media", - } - .into(); + self.status = self + .t() + .text(match self.workspace_focus { + WorkspaceFocus::Source => Message::FocusSource, + WorkspaceFocus::Target => Message::FocusTarget, + WorkspaceFocus::Setup => Message::FocusSetup, + WorkspaceFocus::Review => Message::FocusReview, + WorkspaceFocus::Discover => Message::FocusDiscover, + WorkspaceFocus::Refresh => Message::FocusRefresh, + }) + .into(); } fn activate_workspace_focus(&mut self) { @@ -2019,8 +2702,7 @@ impl App { fn refresh(&mut self, manual: bool) { if self.write_session.active() { if manual { - self.status = - "Drive refresh is paused while writing • do not unplug the target".into(); + self.status = self.t().text(Message::StatusDrivesRefreshPaused).into(); } return; } @@ -2028,7 +2710,7 @@ impl App { Ok(devices) => { if devices == self.devices { if manual { - self.status = "Drive list is up to date • automatic detection is on".into(); + self.status = self.t().text(Message::StatusDrivesUpToDate).into(); } return; } @@ -2048,7 +2730,7 @@ impl App { self.selected = selected_id.and_then(|id| devices.iter().position(|device| device.id == id)); self.devices = devices; - self.status = device_change_message(added, removed); + self.status = device_change_message(self.t(), added, removed); } Err(error) => self.status = error.to_string(), } @@ -2056,7 +2738,7 @@ impl App { fn preview(&mut self) { let Some(image) = self.image.clone() else { - self.status = "Start with --image /path/to/image.iso to create a plan".into(); + self.status = self.t().text(Message::StatusImageChooseFirst).into(); return; }; let Some(target) = self @@ -2064,7 +2746,7 @@ impl App { .and_then(|index| self.devices.get(index)) .cloned() else { - self.status = "No target device is selected".into(); + self.status = self.t().text(Message::StatusTargetChooseFirst).into(); return; }; match self @@ -2073,7 +2755,7 @@ impl App { { Ok(plan) => { self.catalog_open = false; - self.status = "Reviewing the write plan • nothing has been written".into(); + self.status = self.t().text(Message::StatusReviewOpen).into(); self.write_session.open(plan); self.write_receiver = None; } @@ -2093,21 +2775,21 @@ impl App { fn close_review(&mut self) { if !self.write_session.close() { - self.status = "Writing is active • do not close the app or unplug the target".into(); + self.status = self.t().text(Message::StatusWriteActive).into(); return; } - self.status = self.review_readiness().guidance().into(); + self.status = self.review_readiness().guidance_in(self.locale).into(); } fn open_write_confirmation(&mut self) { if self.write_session.open_confirmation() { - self.status = "Review the target changes and consequences before writing".into(); + self.status = self.t().text(Message::StatusReviewConsequences).into(); } } fn close_write_confirmation(&mut self) { self.write_session.close_confirmation(); - self.status = "Write cancelled before erasure • the target is unchanged".into(); + self.status = self.t().text(Message::StatusWriteCancelled).into(); } fn start_write(&mut self) { @@ -2118,7 +2800,7 @@ impl App { return; } }; - self.status = "Write started • do not unplug the target".into(); + self.status = self.t().text(Message::StatusWriteStarted).into(); let (sender, receiver) = mpsc::channel(); std::thread::spawn(move || { @@ -2149,7 +2831,9 @@ impl App { } WriteUpdate::Finished(completion) => { finished = true; - self.status = self.write_session.finish(completion); + let status = completion.status_in(self.locale); + self.write_session.finish(completion); + self.status = status; } } } @@ -2160,69 +2844,80 @@ impl App { fn cancel_write(&mut self) { if self.write_session.cancel() { - self.status = - "Stopping safely • flushing completed writes; media will remain incomplete".into(); + self.status = self.t().text(Message::StatusWriteStopping).into(); } } fn toggle_windows_requirements(&mut self) { let Some(image) = &self.image else { - self.status = "Choose a Windows image before changing Windows options".into(); + self.status = self.t().text(Message::StatusWindowsChooseInstaller).into(); return; }; if !matches!( image.kind, bootable_core::ImageKind::WindowsInstaller { .. } ) { - self.status = "Windows setup options apply only to Windows installer images".into(); + self.status = self.t().text(Message::StatusWindowsNotWindows).into(); return; } - let enabled = &mut self.options.windows.bypass_hardware_requirements; - *enabled = !*enabled; - self.status = if *enabled { - "Windows 11 TPM, Secure Boot, and RAM checks will be bypassed".into() - } else { - "Windows 11 hardware checks use Microsoft defaults".into() - }; + let enabled = !self.options.windows.bypass_hardware_requirements; + self.options.windows.bypass_hardware_requirements = enabled; + self.status = self + .t() + .text(if enabled { + Message::OptionsWindowsBypassHardwareOn + } else { + Message::OptionsWindowsBypassHardwareOff + }) + .into(); } fn toggle_windows_offline_account(&mut self) { if !self.windows_options_available() { return; } - let enabled = &mut self.options.windows.allow_offline_account; - *enabled = !*enabled; - self.status = if *enabled { - "Windows OOBE will expose the offline/local-account path".into() - } else { - "Windows OOBE will use its standard account flow".into() - }; + let enabled = !self.options.windows.allow_offline_account; + self.options.windows.allow_offline_account = enabled; + self.status = self + .t() + .text(if enabled { + Message::OptionsWindowsOfflineAccountOn + } else { + Message::OptionsWindowsOfflineAccountOff + }) + .into(); } fn toggle_windows_privacy(&mut self) { if !self.windows_options_available() { return; } - let enabled = &mut self.options.windows.minimize_data_collection; - *enabled = !*enabled; - self.status = if *enabled { - "Windows OOBE will use privacy-focused defaults".into() - } else { - "Windows OOBE privacy questions will remain at their defaults".into() - }; + let enabled = !self.options.windows.minimize_data_collection; + self.options.windows.minimize_data_collection = enabled; + self.status = self + .t() + .text(if enabled { + Message::OptionsWindowsPrivacyOn + } else { + Message::OptionsWindowsPrivacyOff + }) + .into(); } fn toggle_windows_bitlocker(&mut self) { if !self.windows_options_available() { return; } - let enabled = &mut self.options.windows.disable_bitlocker; - *enabled = !*enabled; - self.status = if *enabled { - "Automatic Windows device encryption will be disabled".into() - } else { - "Windows may automatically enable device encryption".into() - }; + let enabled = !self.options.windows.disable_bitlocker; + self.options.windows.disable_bitlocker = enabled; + self.status = self + .t() + .text(if enabled { + Message::OptionsWindowsBitlockerOn + } else { + Message::OptionsWindowsBitlockerOff + }) + .into(); } fn toggle_windows_named_account(&mut self) { @@ -2231,12 +2926,15 @@ impl App { } if self.options.windows.local_account.is_some() { self.options.windows.local_account = None; - self.status = "Automatic local-account creation disabled".into(); + self.status = self.t().text(Message::OptionsWindowsNamedAccountOff).into(); } else { let account = bootable_core::suggested_account_name().unwrap_or_else(|| "User".into()); self.options.windows.local_account = Some(account.clone()); self.options.windows.allow_offline_account = true; - self.status = format!("Windows will create local administrator account `{account}`"); + self.status = self.t().format( + Message::OptionsWindowsNamedAccountOn, + &[("account", &account)], + ); } } @@ -2246,12 +2944,15 @@ impl App { } if self.options.windows.regional.is_some() { self.options.windows.regional = None; - self.status = "Windows Setup will ask for regional options".into(); + self.status = self.t().text(Message::OptionsWindowsHostRegionOff).into(); } else { let regional = bootable_core::host_regional_options(); - self.status = format!( - "Windows will use locale {} and time zone {}", - regional.user_locale, regional.time_zone + self.status = self.t().format( + Message::OptionsWindowsHostRegionOn, + &[ + ("locale", ®ional.user_locale), + ("zone", ®ional.time_zone), + ], ); self.options.windows.regional = Some(regional); } @@ -2259,30 +2960,61 @@ impl App { fn toggle_windows_qol(&mut self) { if self.windows_options_available() { - self.options.windows.quality_of_life = !self.options.windows.quality_of_life; - self.status = "Windows QoL policy selection updated".into(); + let enabled = !self.options.windows.quality_of_life; + self.options.windows.quality_of_life = enabled; + self.status = self + .t() + .text(if enabled { + Message::OptionsWindowsQolOn + } else { + Message::OptionsWindowsQolOff + }) + .into(); } } fn toggle_windows_ca_2023(&mut self) { if self.windows_options_available() { - self.options.windows.use_windows_ca_2023 = !self.options.windows.use_windows_ca_2023; - self.status = "CA 2023 boot media requires updated Secure Boot certificates".into(); + let enabled = !self.options.windows.use_windows_ca_2023; + self.options.windows.use_windows_ca_2023 = enabled; + self.status = self + .t() + .text(if enabled { + Message::OptionsWindowsCa2023On + } else { + Message::OptionsWindowsCa2023Off + }) + .into(); } } fn toggle_windows_skusi_policy(&mut self) { if self.windows_options_available() { - self.options.windows.apply_skusi_policy = !self.options.windows.apply_skusi_policy; - self.status = "SkuSiPolicy.p7b selection updated".into(); + let enabled = !self.options.windows.apply_skusi_policy; + self.options.windows.apply_skusi_policy = enabled; + self.status = self + .t() + .text(if enabled { + Message::OptionsWindowsSkusipolicyOn + } else { + Message::OptionsWindowsSkusipolicyOff + }) + .into(); } } fn toggle_windows_s_mode(&mut self) { if self.windows_options_available() { - self.options.windows.force_s_mode = !self.options.windows.force_s_mode; - self.status = - "S Mode may remain enforced after reinstall; review the plan carefully".into(); + let enabled = !self.options.windows.force_s_mode; + self.options.windows.force_s_mode = enabled; + self.status = self + .t() + .text(if enabled { + Message::OptionsWindowsSmodeOn + } else { + Message::OptionsWindowsSmodeOff + }) + .into(); } } @@ -2290,20 +3022,36 @@ impl App { if !self.windows_options_available() { return; } - self.options.windows_partition_scheme = match self.options.windows_partition_scheme { - bootable_core::WindowsPartitionScheme::Gpt => { - bootable_core::WindowsPartitionScheme::Mbr - } - bootable_core::WindowsPartitionScheme::Mbr => { - bootable_core::WindowsPartitionScheme::Gpt - } - }; - self.status = format!( - "Windows partition scheme: {} · target firmware: UEFI", - self.options.windows_partition_scheme + cycle_partition_scheme(&mut self.options); + self.status = self.t().format( + Message::StatusWindowsScheme, + &[ + ("scheme", &self.options.windows_partition_scheme), + ("firmware", &self.options.windows_boot_firmware), + ], + ); + } + + fn cycle_windows_boot_firmware(&mut self) { + if !self.windows_options_available() { + return; + } + cycle_boot_firmware(&mut self.options); + self.status = self.t().format( + Message::StatusWindowsFirmware, + &[ + ("firmware", &self.options.windows_boot_firmware), + ("scheme", &self.options.windows_partition_scheme), + ], ); } + /// Destructive or advanced choices tied to one image are never carried to + /// the next image or persisted in preferences. + fn reset_image_scoped_options(&mut self) { + self.options.windows_boot_firmware = bootable_core::WindowsBootFirmware::default(); + } + fn windows_options_available(&mut self) -> bool { let available = self.image.as_ref().is_some_and(|image| { matches!( @@ -2312,7 +3060,7 @@ impl App { ) }); if !available { - self.status = "Choose a Windows installer image before changing Windows options".into(); + self.status = self.t().text(Message::StatusWindowsChooseInstaller).into(); } available } @@ -2320,36 +3068,38 @@ impl App { fn toggle_advanced(&mut self) { if self.image.is_none() { self.advanced = false; - self.status = "Choose or download an image before opening media options".into(); + self.status = self.t().text(Message::StatusOptionsOpenNeedsImage).into(); return; } self.advanced = !self.advanced; - self.status = if self.advanced { - "Advanced options expanded • every option is included in the reviewed plan".into() - } else { - "Advanced options collapsed • configured values remain active".into() - }; + self.status = self + .t() + .text(if self.advanced { + Message::StatusOptionsExpanded + } else { + Message::StatusOptionsCollapsed + }) + .into(); } fn cycle_checksum_algorithm(&mut self) { self.checksum_algorithm = self.checksum_algorithm.next(); - self.status = format!("Checksum algorithm: {}", self.checksum_algorithm); + self.status = self.t().format( + Message::StatusChecksumAlgorithm, + &[("algorithm", &self.checksum_algorithm)], + ); + self.preferences.checksum_algorithm = self.checksum_algorithm; + self.save_preferences(); } fn cycle_bad_blocks(&mut self) { self.options.bad_block_check = self.options.bad_block_check.next(); - self.status = match self.options.bad_block_check { - BadBlockCheck::Disabled => "Destructive bad-block check disabled".into(), - mode => format!( - "Bad-block check: {} destructive pattern(s) before writing", - mode.passes() - ), - }; + self.status = self.options.bad_block_check.status_in(self.locale); } fn checksum(&mut self) { let Some(image) = &self.image else { - self.status = "Choose an image before computing its checksum".into(); + self.status = self.t().text(Message::StatusChecksumChooseImage).into(); return; }; self.status = match self @@ -2367,20 +3117,26 @@ impl App { .and_then(|index| self.devices.get(index)) .cloned() else { - self.status = "Choose a removable drive to back up".into(); + self.status = self.t().text(Message::StatusBackupChooseDrive).into(); return; }; let mut dialog = rfd::FileDialog::new() - .add_filter("Raw drive image", &["img", "raw", "dd"]) + .add_filter( + self.t().text(Message::SourceDialogFilterBackup), + &["img", "raw", "dd"], + ) .set_file_name("bootable-backup.img"); if let Some(directory) = &self.browse_directory { dialog = dialog.set_directory(directory); } let Some(destination) = dialog.save_file() else { - self.status = "Drive backup cancelled".into(); + self.status = self.t().text(Message::StatusBackupCancelled).into(); return; }; - self.status = format!("Backing up {}…", device.display_name()); + self.status = self.t().format( + Message::StatusBackupRunning, + &[("drive", &device.display_name())], + ); let mut latest = self.status.clone(); let result = self .engine @@ -2388,8 +3144,14 @@ impl App { latest = progress.message; }); self.status = match result { - Ok(()) => format!("Drive image saved to {}", destination.display()), - Err(error) => format!("{error} • last step: {latest}"), + Ok(()) => self.t().format( + Message::StatusBackupDone, + &[("path", &destination.display())], + ), + Err(error) => self.t().format( + Message::StatusBackupFailed, + &[("error", &error), ("step", &latest)], + ), }; } @@ -2400,6 +3162,14 @@ impl App { self.cancel_download(); } else if contains(self.hit_regions.open_image, point) { self.choose_image(); + } else if let Some(index) = self + .hit_regions + .recent_rows + .iter() + .find(|(area, _)| area.contains(point.into())) + .map(|(_, index)| *index) + { + self.use_recent_image(index); } else if contains(self.hit_regions.advanced, point) { self.toggle_advanced(); } else if contains(self.hit_regions.choose_folder, point) { @@ -2426,6 +3196,8 @@ impl App { self.toggle_windows_s_mode(); } else if contains(self.hit_regions.windows_partition_scheme, point) { self.cycle_windows_partition_scheme(); + } else if contains(self.hit_regions.windows_boot_firmware, point) { + self.cycle_windows_boot_firmware(); } else if contains(self.hit_regions.bad_blocks, point) { self.cycle_bad_blocks(); } else if contains(self.hit_regions.checksum_algorithm, point) { @@ -2452,17 +3224,21 @@ impl App { { self.selected = Some(index); self.workspace_focus = WorkspaceFocus::Target; - self.status = - "Target selected · confirm the physical drive before reviewing the erase plan" - .into(); + self.status = self.t().text(Message::StatusTargetSelected).into(); } else { - self.status = "That drive is blocked and cannot be selected".into(); + self.status = self.t().text(Message::StatusTargetBlocked).into(); } } Some(false) } fn handle_mouse(&mut self, mouse: MouseEvent) -> bool { + if self.help_open { + if let MouseEventKind::Down(MouseButton::Left) = mouse.kind { + self.help_open = false; + } + return false; + } if self.write_session.confirmation_open() { if let MouseEventKind::Down(MouseButton::Left) = mouse.kind { let point = (mouse.column, mouse.row); @@ -2479,7 +3255,9 @@ impl App { if self.write_session.is_reviewing() { if let MouseEventKind::Down(MouseButton::Left) = mouse.kind { let point = (mouse.column, mouse.row); - if contains(self.hit_regions.review_back, point) { + if contains(self.hit_regions.language, point) { + self.cycle_language(); + } else if contains(self.hit_regions.review_back, point) { self.close_review(); } else if contains(self.hit_regions.review_write, point) { if self.write_session.active() { @@ -2489,8 +3267,7 @@ impl App { } } else if contains(self.hit_regions.quit, point) { if self.write_session.active() { - self.status = - "Writing is active • do not close the app or unplug the target".into(); + self.status = self.t().text(Message::StatusWriteActive).into(); } else { return true; } @@ -2500,6 +3277,14 @@ impl App { } if let MouseEventKind::Down(MouseButton::Left) = mouse.kind { let point = (mouse.column, mouse.row); + if contains(self.hit_regions.guide, point) { + self.toggle_help(); + return false; + } + if contains(self.hit_regions.language, point) { + self.cycle_language(); + return false; + } if contains(self.hit_regions.downloads, point) { self.toggle_downloads(); return false; @@ -2592,8 +3377,10 @@ impl App { && contains(self.hit_regions.catalog_search, point) { self.catalog_searching = true; - self.status = - "Type to search · results update live · Esc leaves search".into(); + self.status = format!( + "{} · Esc", + self.t().text(Message::DiscoverSearchPlaceholder) + ); } else if contains(self.hit_regions.source_distrowatch, point) { self.show_quick_access(QuickAccess::All); } else if contains(self.hit_regions.source_arch, point) { @@ -2637,8 +3424,7 @@ impl App { { self.pi_image_selected = *index; self.catalog_focus = CatalogFocus::Releases; - self.status = - "Raspberry Pi image selected • download will be verified".into(); + self.status = self.t().text(Message::StatusCatalogPiSelectedVerify).into(); } else if let Some((_, index)) = self .hit_regions .distribution_rows @@ -2656,7 +3442,7 @@ impl App { { self.catalog_focus = CatalogFocus::Releases; self.release_selected = *index; - self.status = "ISO selected • choose Download & use ISO".into(); + self.status = self.iso_selected_status(); } } _ => {} @@ -2730,6 +3516,29 @@ fn event_loop(terminal: &mut Terminal>, app: &mut A if event::poll(Duration::from_millis(250))? { match event::read()? { Event::Key(key) if key.kind == KeyEventKind::Press => { + if app.help_open { + if matches!( + key.code, + KeyCode::Esc | KeyCode::Enter | KeyCode::Char('?' | 'q') + ) { + app.help_open = false; + } + continue; + } + if key.code == KeyCode::Char('?') + && !app.write_session.confirmation_open() + && !app.catalog_searching + { + app.toggle_help(); + continue; + } + if key.code == KeyCode::Char('L') + && !app.write_session.confirmation_open() + && !app.catalog_searching + { + app.cycle_language(); + continue; + } if app.download_session.is_active() { match key.code { KeyCode::Char('p') => { @@ -2755,8 +3564,7 @@ fn event_loop(terminal: &mut Terminal>, app: &mut A } KeyCode::Enter => { app.status = - "Acknowledge the consequences before confirming the write" - .into(); + app.t().text(Message::StatusReviewAckRequired).into(); } _ => {} } @@ -2780,7 +3588,9 @@ fn event_loop(terminal: &mut Terminal>, app: &mut A app.open_write_confirmation(); } _ if app.write_session.active() => { - app.status = "Writing is active • press x to stop safely; do not unplug the target".into(); + // The shared line has no key legend; the TUI appends its own. + app.status = + format!("{} (x)", app.t().text(Message::StatusWriteActive)); } _ => {} } @@ -2836,8 +3646,8 @@ fn event_loop(terminal: &mut Terminal>, app: &mut A KeyCode::Char('p') => app.preview(), KeyCode::Char('h') => app.checksum(), KeyCode::Char('u') => app.backup(), - KeyCode::Char('?') => { - app.status = "Keyboard: Tab / Shift+Tab moves focus · Enter activates · arrows choose a target · o image · g discover · a setup · p review · r refresh · q quit".into(); + KeyCode::Char(digit @ '1'..='4') => { + app.use_recent_image(usize::from(digit as u8 - b'1')); } KeyCode::Up | KeyCode::Char('k') => { app.move_target_selection(-1); @@ -2865,6 +3675,83 @@ fn event_loop(terminal: &mut Terminal>, app: &mut A } fn draw(frame: &mut ratatui::Frame<'_>, app: &mut App) { + draw_screen(frame, app); + if app.help_open && !app.write_session.confirmation_open() { + draw_help(frame, frame.area(), app.locale); + } +} + +fn draw_help(frame: &mut ratatui::Frame<'_>, area: Rect, locale: Locale) { + let width = area.width.saturating_sub(4).min(110); + let text_width = usize::from(width.saturating_sub(2)); + let sections = help_sections(locale); + let key_style = Style::default().fg(Color::Rgb(229, 185, 95)); + let white = Style::default().fg(Color::White); + let muted = Style::default().fg(MUTED); + // Key chords are never translated, but size the column from the data + // rather than assuming the English widths. + let key_column = sections + .iter() + .flat_map(|section| §ion.entries) + .map(|entry| display_width(entry.terminal) + 2) + .max() + .unwrap_or(0) + .max(12); + let indent = 2 + key_column; + let mut lines = wrap_styled(&[(help_intro(locale), white)], text_width) + .into_iter() + .map(Line::from) + .collect::>(); + lines.push(Line::raw("")); + for section in §ions { + lines.push(Line::from(Span::styled( + section.title.to_uppercase(), + Style::default().fg(ACCENT).add_modifier(Modifier::BOLD), + ))); + for entry in §ion.entries { + let detail = format!(" · {}", entry.detail); + let body = wrap_styled( + &[(entry.action, white), (detail.as_str(), muted)], + text_width.saturating_sub(indent), + ); + for (index, mut spans) in body.into_iter().enumerate() { + let prefix = if index == 0 { + Span::styled( + format!(" {}", pad_display(entry.terminal, key_column)), + key_style, + ) + } else { + Span::raw(" ".repeat(indent)) + }; + spans.insert(0, prefix); + lines.push(Line::from(spans)); + } + } + } + lines.push(Line::raw("")); + lines.extend( + wrap_styled(&[("Press Esc, ? or click to close", muted)], text_width) + .into_iter() + .map(Line::from), + ); + let height = (lines.len() as u16 + 2).min(area.height.saturating_sub(2)); + let modal = Rect::new( + area.x + area.width.saturating_sub(width) / 2, + area.y + area.height.saturating_sub(height) / 2, + width, + height, + ); + frame.render_widget(Clear, modal); + let guide_title = format!(" {} ", locale.strings().text(Message::GuideTitle)); + frame.render_widget( + Paragraph::new(lines) + .wrap(Wrap { trim: false }) + .block(panel_block(&guide_title).style(Style::default().bg(PANEL))), + modal, + ); +} + +fn draw_screen(frame: &mut ratatui::Frame<'_>, app: &mut App) { app.hit_regions = HitRegions::default(); frame.render_widget( Block::default().style(Style::default().bg(BG)), @@ -2922,6 +3809,13 @@ fn draw(frame: &mut ratatui::Frame<'_>, app: &mut App) { return; } + if show_options && content.height < workspace_height.saturating_add(options_height + 1) { + // Too short for the workspace and the options together: the options + // the user asked for take the whole content area. + draw_advanced(frame, app, content); + return; + } + let show_setup_toggle = setup_available && content.height >= workspace_height.saturating_add(4); let show_discovery_toggle = content.height >= workspace_height @@ -2953,14 +3847,18 @@ fn draw(frame: &mut ratatui::Frame<'_>, app: &mut App) { } fn draw_collapsed_setup(frame: &mut ratatui::Frame<'_>, app: &mut App, area: Rect) { - let bad_blocks = match app.options.bad_block_check.passes() { - 0 => "Bad blocks off".into(), - passes => format!("Bad blocks {passes}x"), - }; + let t = app.t(); + let summary = t.format( + Message::OptionsSummaryVerification, + &[( + "bad_blocks", + &app.options.bad_block_check.label_in(app.locale), + )], + ); render_button( frame, area, - &format!("+ Setup options · Verification on · {bad_blocks}"), + &format!("+ {} · {summary}", t.text(Message::ActionSetupOptions)), app.workspace_focus == WorkspaceFocus::Setup, ); app.hit_regions.advanced = Some(area); @@ -2970,7 +3868,11 @@ fn draw_collapsed_discovery(frame: &mut ratatui::Frame<'_>, app: &mut App, area: render_button( frame, area, - "+ Discover images · Browse trusted catalogs · Open →", + &format!( + "+ {} · {} →", + app.t().text(Message::ActionDiscover), + app.t().text(Message::DiscoverCollapsedHint) + ), app.workspace_focus == WorkspaceFocus::Discover, ); app.hit_regions.discover = Some(area); @@ -2999,15 +3901,16 @@ fn draw_review(frame: &mut ratatui::Frame<'_>, app: &mut App, area: Rect) { format_bytes(plan.target.capacity) ); let method = plan.strategy.to_string(); + let t = app.t(); let steps = plan .steps .iter() .enumerate() .map(|(index, step)| { let marker = if step.destructive { - "ERASES DATA" + t.heading(Message::ReviewStepErases) } else { - "safe" + t.text(Message::ReviewStepSafe).to_string() }; ListItem::new(format!("{}. {} · {marker}", index + 1, step.title)).style( Style::default().fg(if step.destructive { @@ -3019,6 +3922,32 @@ fn draw_review(frame: &mut ratatui::Frame<'_>, app: &mut App, area: Rect) { }) .collect::>(); + let panel_text_width = usize::from(area.width.saturating_sub(2)); + let mut permanent_lines = wrapped_lines( + t.text(Message::ReviewConsequence), + Style::default() + .fg(Color::Yellow) + .add_modifier(Modifier::BOLD), + panel_text_width, + ); + if app.write_session.active() { + permanent_lines.extend(wrapped_lines( + t.text(Message::ReviewWarningWriting), + Style::default().fg(Color::Yellow), + panel_text_width, + )); + } else { + for message in [Message::ReviewSubtitle, Message::ReviewHintOpenConfirmation] { + permanent_lines.extend(wrapped_lines( + t.text(message), + Style::default().fg(MUTED), + panel_text_width, + )); + } + } + // Longer wording (German, Russian) wraps onto more lines; give the panel + // the rows it needs instead of clipping a safety sentence. + let permanent_height = (permanent_lines.len() as u16 + 2).clamp(6, if compact { 7 } else { 9 }); let mut constraints = vec![ Constraint::Length(if compact { 3 } else { 4 }), Constraint::Length(if compact { 5 } else { 6 }), @@ -3029,7 +3958,7 @@ fn draw_review(frame: &mut ratatui::Frame<'_>, app: &mut App, area: Rect) { constraints.push(Constraint::Min(0)); } if show_confirmation { - constraints.push(Constraint::Length(6)); + constraints.push(Constraint::Length(permanent_height)); } if show_progress { constraints.push(Constraint::Length(5)); @@ -3040,63 +3969,79 @@ fn draw_review(frame: &mut ratatui::Frame<'_>, app: &mut App, area: Rect) { constraints.push(Constraint::Length(3)); let rows = Layout::vertical(constraints).spacing(1).split(area); let mut row = 0; + { + // The brand lockup uses at most two lines; the last line of the + // header carries the language hint. + let header = rows[row]; + let hint_row = Rect::new(header.x, header.bottom().saturating_sub(1), header.width, 1); + draw_language_hint(frame, app, hint_row, usize::from(header.width)); + } frame.render_widget( Paragraph::new(brand_lockup( area.width >= 60, - "Review write plan", - if app.write_session.active() { - "Writing and verification are active • do not unplug the target." + t.text(Message::ReviewTitle), + t.text(if app.write_session.active() { + Message::ReviewSubtitleWriting } else { - "Nothing is written until the consequences are reviewed and acknowledged." - }, + Message::HeaderSubtitleReview + }), + t.text(Message::HeaderTagline), + usize::from(rows[row].width), )) .style(Style::default().bg(BG)), rows[row], ); row += 1; + let summary_labels = [ + t.heading(Message::ReviewFieldSource), + t.heading(Message::ReviewFieldTarget), + t.heading(Message::ReviewFieldMethod), + ]; + let label_width = summary_labels + .iter() + .map(|label| display_width(label)) + .max() + .unwrap_or(0) + + 2; + let summary_label = |index: usize| { + Span::styled( + pad_display(&summary_labels[index], label_width), + Style::default().fg(MUTED), + ) + }; + let plan_summary_title = format!(" {} ", t.text(Message::ReviewPlanSummary)); frame.render_widget( Paragraph::new(vec![ Line::from(vec![ - Span::styled("SOURCE ", Style::default().fg(MUTED)), + summary_label(0), Span::styled(source, Style::default().fg(Color::White)), ]), Line::from(vec![ - Span::styled("TARGET ", Style::default().fg(MUTED)), + summary_label(1), Span::styled(target, Style::default().fg(Color::White)), ]), Line::from(vec![ - Span::styled("METHOD ", Style::default().fg(MUTED)), + summary_label(2), Span::styled(method, Style::default().fg(ACCENT)), ]), ]) .wrap(Wrap { trim: true }) - .block(panel_block(" Plan summary ")), + .block(panel_block(&plan_summary_title)), rows[row], ); row += 1; if show_steps { + let operations_title = format!(" {} ", t.text(Message::ReviewOrderedOperations)); frame.render_widget( - List::new(steps).block(panel_block(" Ordered operations ")), + List::new(steps).block(panel_block(&operations_title)), rows[row], ); } row += 1; if show_confirmation { + let permanent_title = format!(" {} ", t.text(Message::ReviewPermanentChanges)); frame.render_widget( - Paragraph::new(vec![ - Line::styled( - "All existing data and partitions on the selected target will be erased.", - Style::default() - .fg(Color::Yellow) - .add_modifier(Modifier::BOLD), - ), - Line::styled( - "Open the confirmation to review changes, consequences, and the physical target.", - Style::default().fg(MUTED), - ), - ]) - .wrap(Wrap { trim: true }) - .block(panel_block(" Permanent changes ")), + Paragraph::new(permanent_lines).block(panel_block(&permanent_title)), rows[row], ); row += 1; @@ -3107,7 +4052,11 @@ fn draw_review(frame: &mut ratatui::Frame<'_>, app: &mut App, area: Rect) { .started_at() .map(|started| started.elapsed()) .unwrap_or_default(); - let progress_title = format!(" {} · {} ", progress.phase, progress.message); + let progress_title = format!( + " {} · {} ", + progress.phase.label_in(app.locale), + progress.message + ); frame.render_widget( Gauge::default() .block(panel_block(&progress_title)) @@ -3127,30 +4076,19 @@ fn draw_review(frame: &mut ratatui::Frame<'_>, app: &mut App, area: Rect) { row += 1; } if let Some(completion) = app.write_session.completion() { - let (title, message, color) = match completion { - WriteCompletion::Succeeded => ( - " Write complete ", - "Image written and verified. The removable drive can now be safely removed." - .to_string(), - ACCENT, - ), - WriteCompletion::AuthenticationDenied => ( - " Write cancelled before erasure ", - "Administrator authentication was cancelled or denied.".into(), - Color::Yellow, - ), - WriteCompletion::Cancelled => ( - " Write stopped safely ", - "The media is incomplete and must be rewritten before use.".into(), - Color::LightRed, - ), - WriteCompletion::Failed(error) => (" Write failed ", error.clone(), Color::LightRed), + let color = match completion { + WriteCompletion::Succeeded => ACCENT, + WriteCompletion::AuthenticationDenied => Color::Yellow, + WriteCompletion::Cancelled | WriteCompletion::Failed(_) => Color::LightRed, }; + let result_title = format!(" {} ", completion.title_in(app.locale)); frame.render_widget( - Paragraph::new(message) - .style(Style::default().fg(color)) - .wrap(Wrap { trim: true }) - .block(panel_block(title)), + Paragraph::new(wrapped_lines( + &completion.detail_in(app.locale), + Style::default().fg(color), + usize::from(rows[row].width.saturating_sub(2)), + )) + .block(panel_block(&result_title)), rows[row], ); row += 1; @@ -3162,30 +4100,32 @@ fn draw_review(frame: &mut ratatui::Frame<'_>, app: &mut App, area: Rect) { ]) .spacing(1) .split(rows[row]); + let back_label = format!("← {}", t.text(Message::ActionBack)); if app.write_session.active() { - render_disabled_button(frame, actions[0], "← Back locked"); + render_disabled_button(frame, actions[0], &back_label); } else { - render_button(frame, actions[0], "← Back to selection", false); + render_button(frame, actions[0], &back_label, false); } let write_enabled = !write_succeeded; let write_label = if app.write_session.active() { - "■ Stop safely" + format!("■ {}", t.text(Message::ReviewActionStopSafely)) } else if write_succeeded { - "✓ Written & verified" + format!("✓ {}", t.text(Message::ReviewActionWritten)) } else if app.write_session.completion().is_some() { - "! Review & retry" + format!("! {}", t.text(Message::ReviewActionRetry)) } else { - "! Review consequences" + format!("! {}", t.text(Message::ReviewActionConsequences)) }; if write_enabled { - render_button(frame, actions[1], write_label, true); + render_button(frame, actions[1], &write_label, true); } else { - render_disabled_button(frame, actions[1], write_label); + render_disabled_button(frame, actions[1], &write_label); } + let quit_label = format!("× {}", t.text(Message::ActionQuit)); if app.write_session.active() { - render_disabled_button(frame, actions[2], "× Quit locked"); + render_disabled_button(frame, actions[2], &quit_label); } else { - render_button(frame, actions[2], "× Quit", false); + render_button(frame, actions[2], &quit_label, false); } app.hit_regions.review_back = (!app.write_session.active()).then_some(actions[0]); app.hit_regions.review_write = write_enabled.then_some(actions[1]); @@ -3196,6 +4136,7 @@ fn draw_review(frame: &mut ratatui::Frame<'_>, app: &mut App, area: Rect) { } fn draw_write_confirmation_modal(frame: &mut ratatui::Frame<'_>, app: &mut App, area: Rect) { + let t = app.t(); let Some(plan) = app.write_session.plan() else { return; }; @@ -3207,7 +4148,12 @@ fn draw_write_confirmation_modal(frame: &mut ratatui::Frame<'_>, app: &mut App, width, height, ); - let compact = modal.height < 28; + let inner = modal.inner(ratatui::layout::Margin { + horizontal: 1, + vertical: 1, + }); + // Text columns inside a bordered panel in the modal. + let text_width = usize::from(inner.width.saturating_sub(2)); let target = format!( "{} • {}\n{} • {}", plan.target.display_name(), @@ -3221,9 +4167,9 @@ fn draw_write_confirmation_modal(frame: &mut ratatui::Frame<'_>, app: &mut App, .enumerate() .map(|(index, step)| { let marker = if step.destructive { - "ERASES DATA" + t.heading(Message::ReviewStepErases) } else { - "verifies" + t.text(Message::ReviewStepVerifies).to_string() }; ListItem::new(format!("{}. {} · {marker}", index + 1, step.title)).style( Style::default().fg(if step.destructive { @@ -3234,11 +4180,52 @@ fn draw_write_confirmation_modal(frame: &mut ratatui::Frame<'_>, app: &mut App, ) }) .collect::>(); + let bullets = [ + (Message::ConfirmConsequenceErase, Color::LightRed), + (Message::ConfirmConsequenceWrongDrive, Color::Yellow), + (Message::ConfirmConsequenceInterrupted, Color::Yellow), + (Message::ConfirmConsequenceRecheck, MUTED), + ]; + let bullet_lines = |count: usize| { + bullets + .iter() + .take(count) + .flat_map(|(message, color)| { + wrapped_lines( + &format!("• {}", t.text(*message)), + Style::default().fg(*color), + text_width, + ) + }) + .collect::>() + }; + let bullet_height = |count: usize| bullet_lines(count).len() as u16 + 2; + // The acknowledgement must always be fully visible; size its row to it. + let acknowledged = app.write_session.acknowledged(); + let acknowledgment = wrapped_lines( + &format!( + "{} {}", + if acknowledged { "■" } else { "□" }, + t.text(Message::ConfirmAck) + ), + Style::default().fg(if acknowledged { ACCENT } else { Color::White }), + text_width, + ); + let acknowledgment_height = acknowledgment.len() as u16 + 2; + // Full layout: target (4), changes (>= 5), consequences, acknowledgement, + // buttons (3) and four gaps. + let full_height = 4 + 5 + bullet_height(4) + acknowledgment_height + 3 + 4; + let compact = inner.height < full_height; frame.render_widget(Clear, modal); + let modal_title = format!( + " {} · {} ", + t.text(Message::ConfirmTitle), + t.heading(Message::ConfirmBadge) + ); frame.render_widget( Block::default() - .title(" Confirm permanent changes · PERMANENT ") + .title(modal_title) .title_style( Style::default() .fg(Color::Yellow) @@ -3250,15 +4237,11 @@ fn draw_write_confirmation_modal(frame: &mut ratatui::Frame<'_>, app: &mut App, .style(Style::default().bg(PANEL)), modal, ); - let inner = modal.inner(ratatui::layout::Margin { - horizontal: 1, - vertical: 1, - }); let rows = if compact { Layout::vertical([ Constraint::Length(4), Constraint::Min(4), - Constraint::Length(3), + Constraint::Length(acknowledgment_height), Constraint::Length(3), ]) .spacing(1) @@ -3266,96 +4249,68 @@ fn draw_write_confirmation_modal(frame: &mut ratatui::Frame<'_>, app: &mut App, } else { Layout::vertical([ Constraint::Length(4), - Constraint::Min(7), - Constraint::Length(7), - Constraint::Length(3), + Constraint::Min(5), + Constraint::Length(bullet_height(4)), + Constraint::Length(acknowledgment_height), Constraint::Length(3), ]) .spacing(1) .split(inner) }; + let physical_title = format!(" {} ", t.text(Message::ConfirmPhysicalTarget)); frame.render_widget( Paragraph::new(target) .wrap(Wrap { trim: true }) - .block(panel_block(" Physical target · check carefully ")), + .block(panel_block(&physical_title)), rows[0], ); + let consequences_title = format!(" {} ", t.text(Message::ConfirmConsequences)); if compact { + // Narrow terminals show the two consequences that matter most; the + // full list appears on a taller screen. frame.render_widget( - Paragraph::new(vec![ - Line::styled( - "• All existing files and partitions on this drive will be permanently erased.", - Style::default().fg(Color::LightRed), - ), - Line::styled( - "• Choosing the wrong physical drive destroys its data.", - Style::default().fg(Color::Yellow), - ), - Line::styled( - "• Do not close, power off, or unplug until verification finishes.", - Style::default().fg(Color::Yellow), - ), - ]) - .wrap(Wrap { trim: true }) - .block(panel_block(" Changes and consequences ")), + Paragraph::new(bullet_lines(2)).block(panel_block(&consequences_title)), rows[1], ); } else { + let changes_title = format!(" {} ", t.text(Message::ConfirmChanges)); frame.render_widget( - List::new(changes).block(panel_block(" Changes to this drive ")), + List::new(changes).block(panel_block(&changes_title)), rows[1], ); frame.render_widget( - Paragraph::new(vec![ - Line::styled( - "• Every existing file and partition on this physical drive becomes unrecoverable without a separate backup.", - Style::default().fg(Color::LightRed), - ), - Line::styled( - "• Selecting the wrong drive destroys the data on that drive.", - Style::default().fg(Color::Yellow), - ), - Line::styled( - "• Power loss, closing, or unplugging can leave incomplete and unbootable media.", - Style::default().fg(Color::Yellow), - ), - Line::styled( - "• Bootable rechecks target identity before erasure and verifies the result afterward.", - Style::default().fg(MUTED), - ), - ]) - .wrap(Wrap { trim: true }) - .block(panel_block(" Consequences ")), + Paragraph::new(bullet_lines(4)).block(panel_block(&consequences_title)), rows[2], ); } let acknowledgment_row = if compact { rows[2] } else { rows[3] }; let actions_row = if compact { rows[3] } else { rows[4] }; - let acknowledgment = if app.write_session.acknowledged() { - "■ I checked the physical target and understand its existing data will be permanently erased." - } else { - "□ I checked the physical target and understand its existing data will be permanently erased." - }; frame.render_widget( - Paragraph::new(acknowledgment) - .style(Style::default().fg(if app.write_session.acknowledged() { - ACCENT - } else { - Color::White - })) - .wrap(Wrap { trim: true }) - .block(panel_block(" Space/click to acknowledge ")), + Paragraph::new(acknowledgment).block(panel_block(" Space/click to acknowledge ")), acknowledgment_row, ); let actions = Layout::horizontal([Constraint::Ratio(1, 2), Constraint::Ratio(1, 2)]) .spacing(1) .split(actions_row); - render_button(frame, actions[0], "← Cancel · target unchanged", false); + render_button( + frame, + actions[0], + &format!("← {}", t.text(Message::ActionCancel)), + false, + ); let confirm_ready = app.write_session.can_confirm(); if confirm_ready { - render_danger_button(frame, actions[1], "! Confirm erase & write"); + render_danger_button( + frame, + actions[1], + &format!("! {}", t.text(Message::ConfirmSubmit)), + ); } else { - render_disabled_button(frame, actions[1], "□ Acknowledge first"); + render_disabled_button( + frame, + actions[1], + &format!("□ {}", t.text(Message::ConfirmAcknowledgeFirst)), + ); } app.hit_regions.confirm_acknowledge = Some(acknowledgment_row); app.hit_regions.confirm_cancel = Some(actions[0]); @@ -3385,23 +4340,71 @@ fn draw_header(frame: &mut ratatui::Frame<'_>, app: &mut App, area: Rect) { .split(area); let area = header_rows[0]; let wide = area.width >= 82; + let t = app.t(); let action_count = if app.image.is_some() { 4 } else { 3 }; + // Every button must at least fit its compact caption in this language + // (glyph, space, text, borders and a column of padding). + let caption = [ + Message::ActionDownloadsCompact, + Message::ActionCatalogCloseCompact, + Message::ActionDiscoverCompact, + Message::ActionSetupOptionsCompact, + Message::ActionHideOptionsCompact, + Message::ActionRefresh, + ] + .iter() + .map(|message| display_width(t.text(*message))) + .max() + .unwrap_or(0) as u16 + + 5; + let needed = caption * action_count as u16 + (action_count as u16 - 1); + // With room to spare the buttons grow to show the full wording, as long + // as the brand lockup keeps its width. + let full_caption = [ + Message::ActionDownloads, + Message::ActionCatalogClose, + Message::ActionDiscover, + Message::ActionSetupOptions, + Message::ActionHideOptions, + Message::ActionRefreshDrives, + ] + .iter() + .map(|message| display_width(t.text(*message))) + .max() + .unwrap_or(0) as u16 + + 5; + // The lockup needs its title line and its subtitle line. + let brand_need = (display_width(t.text(Message::HeaderSubtitleCreate)) + 6).max( + 4 + display_width(&format!(" BOOTABLE v{}", env!("CARGO_PKG_VERSION"))) + + display_width(&format!(" · {}", t.text(Message::HeaderTitleCreate))), + ) as u16; + let preferred = (full_caption * action_count as u16 + (action_count as u16 - 1)) + .min(area.width.saturating_sub(7 + brand_need)); let action_width = if wide { - if action_count == 4 { 64 } else { 48 } + if action_count == 4 { 64 } else { 48 }.max(preferred) } else { area.width.saturating_sub(13) - }; + } + .max(needed) + .min(area.width.saturating_sub(if wide { 13 + 24 } else { 13 })); let columns = Layout::horizontal([ Constraint::Min(if wide { 24 } else { 12 }), - Constraint::Length(action_width), + Constraint::Length(action_width + if wide { 6 } else { 0 }), ]) .spacing(1) .split(area); + let action_columns = Layout::horizontal([Constraint::Min(0), Constraint::Length(5)]) + .spacing(1) + .split(columns[1]); + render_button(frame, action_columns[1], "?", app.help_open); + app.hit_regions.guide = Some(action_columns[1]); frame.render_widget( Paragraph::new(brand_lockup( wide, - "Create boot media", - "One deliberate path from image to removable drive.", + t.text(Message::HeaderTitleCreate), + t.text(Message::HeaderSubtitleCreate), + t.text(Message::HeaderTagline), + usize::from(columns[0].width), )) .style(Style::default().bg(BG)), columns[0], @@ -3411,20 +4414,38 @@ fn draw_header(frame: &mut ratatui::Frame<'_>, app: &mut App, area: Rect) { action_count ]) .spacing(1) - .split(columns[1]); + .split(action_columns[0]); render_button( frame, actions[0], - if wide { "⇩ Downloads" } else { "⇩ Jobs" }, + &glyph_label( + actions[0], + !wide, + "⇩", + t.text(Message::ActionDownloads), + t.text(Message::ActionDownloadsCompact), + ), app.downloads_open, ); render_button( frame, actions[1], - if app.catalog_open { - if wide { "× Catalog" } else { "× Cat" } + &if app.catalog_open { + glyph_label( + actions[1], + !wide, + "×", + t.text(Message::ActionCatalogClose), + t.text(Message::ActionCatalogCloseCompact), + ) } else { - if wide { "⌄ Discover" } else { "⌄ Find" } + glyph_label( + actions[1], + !wide, + "⌄", + t.text(Message::ActionDiscover), + t.text(Message::ActionDiscoverCompact), + ) }, app.workspace_focus == WorkspaceFocus::Discover, ); @@ -3432,14 +4453,22 @@ fn draw_header(frame: &mut ratatui::Frame<'_>, app: &mut App, area: Rect) { render_button( frame, actions[2], - if app.advanced { - if wide { "⚙ Hide options" } else { "⚙ Hide" } + &if app.advanced { + glyph_label( + actions[2], + !wide, + "⚙", + t.text(Message::ActionHideOptions), + t.text(Message::ActionHideOptionsCompact), + ) } else { - if wide { - "⚙ Setup options" - } else { - "⚙ Setup" - } + glyph_label( + actions[2], + !wide, + "⚙", + t.text(Message::ActionSetupOptions), + t.text(Message::ActionSetupOptionsCompact), + ) }, false, ); @@ -3451,7 +4480,13 @@ fn draw_header(frame: &mut ratatui::Frame<'_>, app: &mut App, area: Rect) { render_button( frame, actions[refresh_index], - if wide { "↻ Refresh" } else { "↻ USB" }, + &glyph_label( + actions[refresh_index], + !wide, + "↻", + t.text(Message::ActionRefreshDrives), + t.text(Message::ActionRefresh), + ), app.workspace_focus == WorkspaceFocus::Refresh, ); app.hit_regions.downloads = Some(actions[0]); @@ -3460,22 +4495,34 @@ fn draw_header(frame: &mut ratatui::Frame<'_>, app: &mut App, area: Rect) { draw_workspace_steps(frame, app, header_rows[1]); } -fn draw_workspace_steps(frame: &mut ratatui::Frame<'_>, app: &App, area: Rect) { +fn draw_workspace_steps(frame: &mut ratatui::Frame<'_>, app: &mut App, area: Rect) { let progress = workspace_progress( app.image.as_ref(), app.selected.and_then(|index| app.devices.get(index)), ); + let titles = WorkspaceProgress::step_titles(app.locale); let line = Line::from(vec![ - step_span("1 Source", progress.source), + step_span(format!("1 {}", titles[0]), progress.source), Span::styled(" ───── ", Style::default().fg(BORDER)), - step_span("2 Target", progress.target), + step_span(format!("2 {}", titles[1]), progress.target), Span::styled(" ───── ", Style::default().fg(BORDER)), - step_span("3 Review & write", progress.review), + step_span(format!("3 {}", titles[2]), progress.review), ]); - frame.render_widget(Paragraph::new(line).alignment(Alignment::Center), area); + let room = usize::from(area.width).saturating_sub(line.width() + 1); + let taken = draw_language_hint(frame, app, area, room); + let steps_area = Rect::new( + area.x, + area.y, + area.width.saturating_sub(taken), + area.height, + ); + frame.render_widget( + Paragraph::new(line).alignment(Alignment::Center), + steps_area, + ); } -fn step_span(label: &'static str, state: WorkspaceStepState) -> Span<'static> { +fn step_span(label: String, state: WorkspaceStepState) -> Span<'static> { let (marker, style) = match state { WorkspaceStepState::Complete => ( "✓", @@ -3493,7 +4540,15 @@ fn step_span(label: &'static str, state: WorkspaceStepState) -> Span<'static> { Span::styled(format!(" {marker} {label} "), style) } -fn brand_lockup<'a>(wide: bool, context: &'a str, subtitle: &'a str) -> Vec> { +/// The brand lockup. `tagline` is shown after the context only when `room` +/// terminal columns leave space for it (the same rule the desktop header uses). +fn brand_lockup<'a>( + wide: bool, + context: &'a str, + subtitle: &'a str, + tagline: &'a str, + room: usize, +) -> Vec> { if !wide { return vec![Line::from(vec![ Span::styled( @@ -3506,6 +4561,11 @@ fn brand_lockup<'a>(wide: bool, context: &'a str, subtitle: &'a str) -> Vec(wide: bool, context: &'a str, subtitle: &'a str) -> Vec(wide: bool, context: &'a str, subtitle: &'a str) -> Vec, app: &mut App, area: Rect) { + let t = app.t(); let source = app .image .as_ref() .map(|image| { format!( - "{}\n{} • {} · ✓ Inspected", + "{}\n{} • {} · ✓ {}", image.path.display(), image.kind, - format_bytes(image.size) + format_bytes(image.size), + t.text(Message::SourceInspected) ) }) .unwrap_or_else(|| { - "ISO, IMG, RAW, or compressed disk image\nInspected before writing".into() + format!( + "{}\n{}", + t.text(Message::SourceFormats), + t.text(Message::SourceHint) + ) }); - let source_block = focused_panel_block( - " 1 Source · choose an image ", - app.workspace_focus == WorkspaceFocus::Source, - ); + let source_title = panel_heading(t, 1, Message::SourceTitle); + let source_block = + focused_panel_block(&source_title, app.workspace_focus == WorkspaceFocus::Source); let source_inner = source_block.inner(area); frame.render_widget(source_block, area); - let source_columns = Layout::horizontal([Constraint::Min(16), Constraint::Length(14)]) - .spacing(1) + let recents = app.preferences.recent_images(); + let recent_lines = if source_inner.height >= 7 { + (recents.len().max(1) + 1).min(usize::from(source_inner.height).saturating_sub(4)) + } else { + 0 + } as u16; + let source_rows = Layout::vertical([Constraint::Min(3), Constraint::Length(recent_lines)]) .split(source_inner); + draw_recent_images(frame, app, &recents, source_rows[1]); + // The button column grows to fit the longest caption in this language. + let button_columns = [ + Message::ActionBrowse, + Message::ActionChange, + Message::ActionInspecting, + ] + .iter() + .map(|message| display_width(t.text(*message)) + 5) + .max() + .unwrap_or(14) + .clamp(14, usize::from(source_rows[0].width / 2).max(14)) as u16; + // Narrow panels stack the button under the text instead of squeezing the + // text into a few columns. + let stacked = source_rows[0].width < 50 && source_rows[0].height >= 6; + let source_columns = if stacked { + let stack = + Layout::vertical([Constraint::Min(2), Constraint::Length(3)]).split(source_rows[0]); + let button = Layout::horizontal([Constraint::Min(0), Constraint::Length(button_columns)]) + .split(stack[1]); + [stack[0], button[1]] + } else { + let columns = Layout::horizontal([Constraint::Min(16), Constraint::Length(button_columns)]) + .spacing(1) + .split(source_rows[0]); + [columns[0], columns[1]] + }; frame.render_widget( Paragraph::new(source) .style(Style::default().fg(Color::White)) @@ -3559,24 +4660,95 @@ fn draw_source(frame: &mut ratatui::Frame<'_>, app: &mut App, area: Rect) { ); let button_area = centered_button_area(source_columns[1]); if app.image_loading { - render_disabled_button(frame, button_area, "… Inspecting"); + render_disabled_button(frame, button_area, t.text(Message::ActionInspecting)); app.hit_regions.open_image = None; } else { render_button( frame, button_area, - if app.image.is_some() { - "▣ Change" - } else { - "▣ Browse" - }, + &format!( + "▣ {}", + t.text(if app.image.is_some() { + Message::ActionChange + } else { + Message::ActionBrowse + }) + ), true, ); app.hit_regions.open_image = Some(button_area); } } +fn draw_recent_images( + frame: &mut ratatui::Frame<'_>, + app: &mut App, + recents: &[bootable_core::RecentImage], + area: Rect, +) { + app.hit_regions.recent_rows.clear(); + if area.height == 0 { + return; + } + if recents.is_empty() { + frame.render_widget( + Paragraph::new(truncate_end( + app.t().text(Message::SourceRecentEmpty), + usize::from(area.width), + )) + .style(Style::default().fg(MUTED)), + Rect::new(area.x, area.y, area.width, 1), + ); + return; + } + frame.render_widget( + Paragraph::new(format!( + "{} · 1-4", + app.t().heading(Message::SourceRecentTitle) + )) + .style(Style::default().fg(MUTED)), + Rect::new(area.x, area.y, area.width, 1), + ); + for (index, recent) in recents + .iter() + .enumerate() + .take(usize::from(area.height) - 1) + { + let row = Rect::new(area.x, area.y + 1 + index as u16, area.width, 1); + let current = app + .image + .as_ref() + .is_some_and(|image| image.path == recent.path); + let size = if current { + app.t().text(Message::SourceRecentInUse).to_string() + } else { + format_bytes(recent.size) + }; + let name_width = usize::from(row.width).saturating_sub(display_width(&size) + 5); + frame.render_widget( + Paragraph::new(Line::from(vec![ + Span::styled(format!("{} ", index + 1), Style::default().fg(ACCENT)), + Span::styled( + pad_display( + &truncate_middle(&recent.file_name(), name_width), + name_width, + ), + Style::default().fg(Color::White), + ), + Span::styled( + format!(" {size}"), + Style::default().fg(if current { ACCENT } else { MUTED }), + ), + ])), + row, + ); + app.hit_regions.recent_rows.push((row, index)); + } +} + fn draw_download_manager(frame: &mut ratatui::Frame<'_>, app: &mut App, area: Rect) { + let t = app.t(); + let locale = app.locale; let rows = Layout::vertical([ Constraint::Min(5), Constraint::Length(4), @@ -3585,11 +4757,16 @@ fn draw_download_manager(frame: &mut ratatui::Frame<'_>, app: &mut App, area: Re .spacing(1) .split(area); let items = if app.download_session.jobs().is_empty() { - vec![ - ListItem::new("No managed downloads yet · choose an image from Discover to begin") - .style(Style::default().fg(MUTED)), - ] + vec![ListItem::new(t.text(Message::DownloadsEmpty)).style(Style::default().fg(MUTED))] } else { + let status_width = app + .download_session + .jobs() + .iter() + .map(|job| display_width(job.status.label_in(locale))) + .max() + .unwrap_or(0) + .max(11); app.download_session .jobs() .iter() @@ -3599,9 +4776,9 @@ fn draw_download_manager(frame: &mut ratatui::Frame<'_>, app: &mut App, area: Re .map(|ratio| format!(" · {:>5.1}%", ratio * 100.)) .unwrap_or_default(); ListItem::new(format!( - "{:<11} {:<28} {}{}", - job.status, - truncate_middle(&job.label, 28), + "{} {} {}{}", + pad_display(job.status.label_in(locale), status_width), + pad_display(&truncate_middle(&job.label, 28), 28), job.destination.display(), progress )) @@ -3616,11 +4793,17 @@ fn draw_download_manager(frame: &mut ratatui::Frame<'_>, app: &mut App, area: Re }; let mut state = ListState::default() .with_selected((!app.download_session.jobs().is_empty()).then_some(app.download_selected)); + let downloads_title = format!( + " {} · {} ", + t.text(Message::ActionDownloads), + t.text(Message::DownloadsSubtitle) + ); frame.render_stateful_widget( List::new(items) - .block(panel_block( - " Downloads · persistent history · ↑/↓ select · m closes ", - )) + .block( + panel_block(&downloads_title) + .title_bottom(Line::from(" ↑/↓ select · m closes ").right_aligned()), + ) .highlight_symbol("› ") .highlight_style( Style::default() @@ -3636,21 +4819,22 @@ fn draw_download_manager(frame: &mut ratatui::Frame<'_>, app: &mut App, area: Re .jobs() .get(app.download_selected) .map_or_else( - || "Interrupted transfers retain only owned partial files; explicit cancellation removes them.".into(), - |job| { - format!( - "{} · {}\n{}", - job.kind, - job.error.as_deref().unwrap_or(&job.message), - job.destination.display() - ) - }, - ); + || t.text(Message::DownloadsInterruptedNote).to_string(), + |job| { + format!( + "{} · {}\n{}", + job.kind.label_in(locale), + job.error.as_deref().unwrap_or(&job.message), + job.destination.display() + ) + }, + ); + let selected_download_title = format!(" {} ", t.text(Message::DownloadsSelected)); frame.render_widget( Paragraph::new(details) .style(Style::default().fg(MUTED)) .wrap(Wrap { trim: true }) - .block(panel_block(" Selected download ")), + .block(panel_block(&selected_download_title)), rows[1], ); let actions = Layout::horizontal([ @@ -3665,20 +4849,23 @@ fn draw_download_manager(frame: &mut ratatui::Frame<'_>, app: &mut App, area: Re let can_use = selected.is_some_and(|job| job.status == DownloadStatus::Completed); let can_remove = selected .is_some_and(|job| !matches!(job.status, DownloadStatus::Running | DownloadStatus::Paused)); + let retry_label = format!("↻ {}", t.text(Message::DownloadsActionRetryResume)); + let use_label = format!("✓ {}", t.text(Message::DownloadsActionUseImage)); + let remove_label = format!("× {}", t.text(Message::DownloadsActionRemove)); if can_retry { - render_button(frame, actions[0], "↻ Retry / resume", true); + render_button(frame, actions[0], &retry_label, true); } else { - render_disabled_button(frame, actions[0], "↻ Retry / resume"); + render_disabled_button(frame, actions[0], &retry_label); } if can_use { - render_button(frame, actions[1], "✓ Use image", true); + render_button(frame, actions[1], &use_label, true); } else { - render_disabled_button(frame, actions[1], "✓ Use image"); + render_disabled_button(frame, actions[1], &use_label); } if can_remove { - render_button(frame, actions[2], "× Remove entry", false); + render_button(frame, actions[2], &remove_label, false); } else { - render_disabled_button(frame, actions[2], "× Remove entry"); + render_disabled_button(frame, actions[2], &remove_label); } app.hit_regions.download_rows = catalog_row_regions(rows[0], app.download_session.jobs().len()); app.hit_regions.download_retry = can_retry.then_some(actions[0]); @@ -3686,26 +4873,258 @@ fn draw_download_manager(frame: &mut ratatui::Frame<'_>, app: &mut App, area: Re app.hit_regions.download_remove = can_remove.then_some(actions[2]); } -fn truncate_middle(value: &str, limit: usize) -> String { - if value.chars().count() <= limit { - return value.into(); - } - let side = limit.saturating_sub(1) / 2; - let start = value.chars().take(side).collect::(); - let end = value - .chars() - .rev() - .take(side) - .collect::() +/// Terminal columns `value` occupies. East Asian glyphs take two, so +/// `chars().count()` is wrong for them. +fn display_width(value: &str) -> usize { + UnicodeWidthStr::width(value) +} + +/// The first of `variants` (longest first) that fits in `columns` terminal +/// columns, else the last one. +fn fit_variant(columns: usize, variants: &[String]) -> String { + variants + .iter() + .find(|variant| display_width(variant) <= columns) + .or(variants.last()) + .cloned() + .unwrap_or_default() +} + +/// A button caption: `glyph` (drawn by the adapter, never part of the +/// translated text) plus the long wording, or the compact wording when the +/// layout is narrow or the long form would not fit inside `area`. +fn glyph_label(area: Rect, narrow: bool, glyph: &str, long: &str, compact: &str) -> String { + let compact = format!("{glyph} {compact}"); + if narrow { + return compact; + } + fit_variant( + usize::from(area.width.saturating_sub(2)), + &[format!("{glyph} {long}"), compact], + ) +} + +/// `text` wrapped to `width` terminal columns, ready to render without +/// `Wrap`. The widget wrapper only breaks at spaces, which strands a whole +/// Japanese sentence on its own row and makes the row count unpredictable; +/// this one measures columns and may break between wide characters. +fn wrapped_lines(text: &str, style: Style, width: usize) -> Vec> { + wrap_styled(&[(text, style)], width) + .into_iter() + .map(Line::from) + .collect() +} + +/// Pads `value` with spaces to `width` terminal columns (never truncates). +fn pad_display(value: &str, width: usize) -> String { + let padding = width.saturating_sub(display_width(value)); + format!("{value}{}", " ".repeat(padding)) +} + +/// The longest prefix of `value` that fits in `columns` terminal columns. +fn take_columns(value: impl Iterator, columns: usize) -> String { + let mut used = 0; + let mut taken = String::new(); + for character in value { + let width = UnicodeWidthChar::width(character).unwrap_or(0); + if used + width > columns { + break; + } + used += width; + taken.push(character); + } + taken +} + +/// One list row: `prefix`, then `name` padded or ellipsized to whatever the +/// row has left, then `suffix`. The suffix (the action) is never clipped. +fn fit_row(room: usize, prefix: &str, name: &str, suffix: &str) -> String { + let name_room = room + .saturating_sub(display_width(prefix) + display_width(suffix)) + .max(6); + format!( + "{prefix}{}{suffix}", + pad_display(&truncate_end(name, name_room), name_room) + ) +} + +/// `value` with its first character upper-cased (a no-op for scripts without +/// case). +fn capitalize_first(value: &str) -> String { + let mut characters = value.chars(); + characters + .next() + .map(|first| first.to_uppercase().chain(characters).collect()) + .unwrap_or_default() +} + +/// `value` cut to `limit` terminal columns with a trailing ellipsis. +fn truncate_end(value: &str, limit: usize) -> String { + if display_width(value) <= limit { + return value.into(); + } + format!("{}…", take_columns(value.chars(), limit.saturating_sub(1))) +} + +fn truncate_middle(value: &str, limit: usize) -> String { + if display_width(value) <= limit { + return value.into(); + } + let side = limit.saturating_sub(1) / 2; + let start = take_columns(value.chars(), side); + let end = take_columns(value.chars().rev(), side) .chars() .rev() .collect::(); format!("{start}…{end}") } +/// Greedy word wrap that measures terminal columns, preserves the style of +/// each segment, and may break between any two wide (CJK) characters because +/// those scripts have no word spacing. +fn wrap_styled(segments: &[(&str, Style)], width: usize) -> Vec>> { + let width = width.max(1); + let cells = segments + .iter() + .flat_map(|(text, style)| text.chars().map(move |character| (character, *style))) + .collect::>(); + let mut lines = Vec::new(); + let mut start = 0; + while start < cells.len() { + if start > 0 { + while start < cells.len() && cells[start].0 == ' ' { + start += 1; + } + if start >= cells.len() { + break; + } + } + let (mut columns, mut end, mut last_break) = (0, start, None); + while end < cells.len() { + let glyph = UnicodeWidthChar::width(cells[end].0).unwrap_or(0); + if columns + glyph > width && end > start { + break; + } + columns += glyph; + end += 1; + if cells[end - 1].0 == ' ' || glyph == 2 { + last_break = Some(end); + } + } + let cut = if end >= cells.len() { + end + } else { + last_break + .filter(|position| *position > start) + .unwrap_or(end) + }; + let mut line = &cells[start..cut]; + while line.last().is_some_and(|(character, _)| *character == ' ') { + line = &line[..line.len() - 1]; + } + let mut spans: Vec> = Vec::new(); + let mut run = String::new(); + let mut run_style: Option